Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TASK064 — TUI 对齐 Claude Code 下一批差距(M8 规划)

> 状态:**M8-1..M8-6 已 implemented**(2026-10-22;P0 三连 689f830b3→423187e9d→aef776136,M8-4/5/6 并行子代理批次 f59a6a99e);M8-7/M8-8 + P3 池待做(引擎侧调研已完成:context_usage 小-中 / background_task 快照+终态 中)
> 状态:**M8-1..M8-8 全部 implemented(2026-10-22)**;提交链:P0 三连 689f830b3→423187e9d→aef776136,M8-4/5/6 并行批次 f59a6a99e,M8-7 643a400e5,M8-8 7946994c3。剩余 P3 池不排期随机会动;已登记遗留=confirm/prompt 交互模态、evictAfter 终态驱逐、停止交互(x 键)、fork 快照描述字段、方案 B 块级增量 Markdown
> 状态:proposed(2026-10-22;PR #446 合并后双调研挖掘——native_tui 现状侧 + 基准源码侧)
> 来源:用户指令"挖掘还有什么没对齐 Claude Code TUI 功能的,构建新规划"
> 双调研证据:①native_tui 逐文件实证(InProcessEventConsumer/ReplShell/ReplBridgeDriver 等)②基准源码逐项核实(claude-code-rev-main/src,文件:行号 见各项)
Expand Down Expand Up @@ -118,6 +118,21 @@

**验收**:导航态 c/p 写入剪贴板可粘贴验证;操作条渲染像素对齐(上边框+间隔符)。

**✅ M8-7 实施收口(2026-10-22,643a400e5)**:

| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
| 引擎侧:IEventConsumer.OnContextUsage DIM + ContextUsage 协议事件 + BridgeEventConsumer 显式实现 | SessionController.ProcessEvent onDone 发射(ChatUsageProcessor 在中间件链外且 Singleton 作用域错配弃) | ✅ | ✅ host.tests 41(发射 3 + 映射 + JSON 契约) |
| TUI 侧:ContextWarning 独立槽位 + ResolveContextWarning 三色(≤10 error/≤25 warning/>25 清空) | FooterBar 行 2 告警优先渲染 | ✅ | ✅ 13 新测试(路由 8 + 渲染 4 + 映射 1) |

**✅ M8-8 实施收口(2026-10-22,7946994c3)**:

| 基建实现 | 消费点 | 已实现 | 已验收 |
|---------|--------|--------|--------|
| 引擎侧:BackgroundTaskEventPump(订阅 ForkCompleted/TeammateCompleted 绕开回合作用域通道 + 回合终态快照) | BridgeServeLoop 可选参接线 + EngineTurnClient Consumer 出口(主收编) | ✅ | ✅ 泵 12 + consumer/契约 |
| TUI 侧:BackgroundTaskPanel 常驻面板(upsert/终态驻留/空面板零渲染/上限10窗口/三态色) | 排队面板上方渲染 | ✅ | ✅ 面板 15 + 路由 4 + 映射 1 |
| 批次回归 | — | ✅ | ✅ Core 919 + Host.Tests 76 + host.tests 1401 + selftest 真跑 EXIT=0 |

### M8-7 上下文告警(P1,依赖:引擎侧 token 余量事件——需先调研引擎 contextWindow 消费链)

- footer Notification 槽位真数据化:`N% 直到自动压缩`(dim) / `上下文不足 (N%) · 运行 /compact`(warning/error 色)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { }
/// <param name="percentLeft">剩余百分比 0-100(引擎侧 ICompactService.CalculateWarningState 权威算法;
/// 阈值标志不进接口 — TUI 侧只显示百分比+文案,UI 自己判颜色)</param>
void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { }

/// <summary>后台任务状态更新(M8-8 — 后台 fork/teammate 代理生命周期,TUI 常驻任务面板显示)。
/// kind: fork|teammate;state: running|completed|failed|cancelled;upsert 语义按 (kind,taskId)。</summary>
void OnBackgroundTask(string kind, string taskId, string description, string state) { }
}

/// <summary>
Expand Down
16 changes: 15 additions & 1 deletion modules/apps/cli/core/commands/core/BridgeServeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ private static async Task<int> ExecuteBridgeAsync(string? vendor, string? model,
var fastMode = engine.Services.GetService<IFastModeService>();
WriteEvent(stdout, BuildSessionInfo(permissionChecker, fastMode, options));

// M8-8 后台任务事件泵 — 订阅 ForkCompleted/TeammateCompleted 直推 consumer(绕开回合作用域通道),
// 每回合终态后全量快照 upsert;两查询面均可选(引擎未注册时泵为 null,零行为变化)
var backgroundPump = CreateBackgroundTaskPump(engine.Services);

var mainAgent = EngineAgentFactory.CreateMainAgent(engine.Services, new Session().ObjectId);
SessionController? controller = null;

Expand All @@ -61,12 +65,22 @@ private static async Task<int> ExecuteBridgeAsync(string? vendor, string? model,
engine.ChatService, consumer, new TurnDiffService(), engine.SessionId, engine.Services,
mainAgent: mainAgent);
return RunTurnAsync(controller, input, turnCt);
}, ct, BuildPermissionModeApplier(permissionChecker)).ConfigureAwait(false);
}, ct, BuildPermissionModeApplier(permissionChecker), backgroundPump).ConfigureAwait(false);
} finally {
Console.SetOut(originalOut);
}
}

/// <summary>构造后台任务事件泵(M8-8)— 解析 fork/teammate 查询面(均引擎单例注册,可选),
/// 任一可用即建泵;两者皆缺返回 null(协议无 background_task 事件流,TUI 面板空置)。</summary>
private static BackgroundTaskEventPump? CreateBackgroundTaskPump(IServiceProvider services) {
var forkManager = services.GetService<IForkSubAgentManager>();
var teammateExecutor = services.GetService<IInProcessTeammateTaskExecutor>();
return forkManager is not null || teammateExecutor is not null
? new BackgroundTaskEventPump(forkManager, teammateExecutor)
: null;
}

/// <summary>解析引擎权限检查器(等模式/安全闸初始化完成;未注册返回 null — 协议降级为仅展示默认值)。</summary>
private static async Task<PermissionChecker?> ResolvePermissionCheckerAsync(IServiceProvider services) {
if (services.GetService<IPermissionChecker>() is not PermissionChecker checker)
Expand Down
26 changes: 21 additions & 5 deletions modules/apps/cli/core/commands/core/BridgeServeLoop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,23 @@ internal static class BridgeServeLoop {
/// <summary>支持的退出码 — 正常退出(quit/Eof/外部取消均收尾后返回 0)。</summary>
private const int ExitOk = 0;

/// <summary>运行协议主循环直到 quit/Eof/外部取消(applyPermissionMode 为 null 时 set_permission_mode 回 error 诱导提示)。</summary>
/// <summary>运行协议主循环直到 quit/Eof/外部取消(applyPermissionMode 为 null 时 set_permission_mode 回 error 诱导提示)。
/// backgroundPump 可选 — 组装层(BridgeServeCommand)注入时:consumer 构造后 Bind+Start(订阅后台 fork/teammate
/// 完成事件直推 background_task 行),每回合终态后 PublishSnapshotAsync(TUI 面板与引擎状态对齐),循环退出前 StopAsync。</summary>
public static async Task<int> RunAsync(
TextReader stdin,
TextWriter stdout,
TextWriter stderr,
TurnExecutor executeTurn,
CancellationToken ct,
PermissionModeApplier? applyPermissionMode = null) {
PermissionModeApplier? applyPermissionMode = null,
BackgroundTaskEventPump? backgroundPump = null) {
var synced = TextWriter.Synchronized(stdout);
var consumer = new BridgeEventConsumer(synced);
if (backgroundPump is not null) {
backgroundPump.Bind(consumer);
await backgroundPump.StartAsync().ConfigureAwait(false);
}
CancellationTokenSource? turnCts = null;
Task? turnTask = null;
var exit = false;
Expand All @@ -56,7 +63,7 @@ public static async Task<int> RunAsync(
break;
default:
if (request is not null)
(exit, turnCts, turnTask) = DispatchRequest(request, synced, consumer, executeTurn, applyPermissionMode, turnCts, turnTask);
(exit, turnCts, turnTask) = DispatchRequest(request, synced, consumer, executeTurn, applyPermissionMode, backgroundPump, turnCts, turnTask);
break;
}
}
Expand All @@ -65,6 +72,8 @@ public static async Task<int> RunAsync(
if (turnTask is not null)
await turnTask.ConfigureAwait(false);
turnCts?.Dispose();
if (backgroundPump is not null)
await backgroundPump.StopAsync().ConfigureAwait(false);
return ExitOk;
}

Expand All @@ -75,6 +84,7 @@ private static (bool Exit, CancellationTokenSource? TurnCts, Task? TurnTask) Dis
BridgeEventConsumer consumer,
TurnExecutor executeTurn,
PermissionModeApplier? applyPermissionMode,
BackgroundTaskEventPump? backgroundPump,
CancellationTokenSource? turnCts,
Task? turnTask) {
switch (request) {
Expand All @@ -87,7 +97,7 @@ private static (bool Exit, CancellationTokenSource? TurnCts, Task? TurnTask) Dis

turnCts?.Dispose();
var newCts = new CancellationTokenSource();
var newTask = RunTurnAsync(stdout, consumer, executeTurn, text, newCts.Token);
var newTask = RunTurnAsync(stdout, consumer, executeTurn, backgroundPump, text, newCts.Token);
return (false, newCts, newTask);
case Cli.Bridge.BridgeRequest.Interrupt:
turnCts?.Cancel();
Expand Down Expand Up @@ -117,11 +127,14 @@ private static void ApplyPermissionMode(TextWriter stdout, PermissionModeApplier
$"权限模式 '{mode}' 无法应用(未知值或引擎未提供权限系统)。合法值:{ValidPermissionModes}。bypass 受 settings.json 的 disableBypassPermissionsMode 安全闸管控,被拦截时保持原模式。"));
}

/// <summary>执行一个回合:turn_started → executor(事件实时推送)→ completed/interrupted/error。</summary>
/// <summary>执行一个回合:turn_started → executor(事件实时推送)→ completed/interrupted/error
/// → 后台任务快照全量 upsert(回合结束边界 — idle 期 TUI 面板与引擎后台任务状态对齐;
/// 泵内部吞查询异常,快照失败不杀回合收尾)。</summary>
private static async Task RunTurnAsync(
TextWriter stdout,
BridgeEventConsumer consumer,
TurnExecutor executeTurn,
BackgroundTaskEventPump? backgroundPump,
string input,
CancellationToken ct) {
WriteEvent(stdout, new Cli.Bridge.BridgeEvent.TurnStarted());
Expand All @@ -133,6 +146,9 @@ private static async Task RunTurnAsync(
WriteEvent(stdout, new Cli.Bridge.BridgeEvent.TurnInterrupted());
} catch (Exception ex) {
WriteEvent(stdout, new Cli.Bridge.BridgeEvent.Error($"回合执行失败:{ex.Message}"));
} finally {
if (backgroundPump is not null)
await backgroundPump.PublishSnapshotAsync(CancellationToken.None).ConfigureAwait(false);
}
}

Expand Down
138 changes: 138 additions & 0 deletions modules/apps/engine/adapters/BackgroundTaskEventPump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace JoinCode.Adapters;

/// <summary>
/// 后台任务事件泵(M8-8)— 绕开回合作用域的 SubAgentEventChannel,把后台 fork/teammate 生命周期
/// 直推 <see cref="IEventConsumer.OnBackgroundTask"/>(TUI 常驻任务面板事件源):
/// <para>结构性动机:SubAgentEventChannel 是 AsyncLocal 回合作用域,idle 期到达的后台终态事件被死通道
/// 静默丢弃 — 泵改为进程级订阅 <see cref="IForkSubAgentManager.ForkCompleted"/> 与
/// <see cref="IInProcessTeammateTaskExecutor.TeammateCompleted"/>(两事件源全仓此前零订阅者,现成接入杠杆),
/// 事件到达即推 consumer,与回合边界解耦。</para>
/// <para>两查询面均为可选注入(引擎未注册时对应路径静默跳过,对齐 AgentForkMiddleware 可选依赖形态);
/// <see cref="Bind"/> 延迟绑定推送出口(消费方实例晚于泵构造 — BridgeServeLoop 内建 consumer 后绑定)。</para>
/// <para>线程安全:事件从 Actor 后台线程到达,consumer 推送安全由实现方自保(BridgeEventConsumer 走
/// TextWriter.Synchronized、InProcessEventConsumer 入队 FIFO),泵侧仅以 volatile 引用交接,不加锁。</para>
/// </summary>
public sealed class BackgroundTaskEventPump : IAsyncDisposable {
private readonly IForkSubAgentManager? _forkManager;
private readonly IInProcessTeammateTaskExecutor? _teammateExecutor;
private readonly ILogger<BackgroundTaskEventPump>? _logger;
private volatile IEventConsumer? _consumer;
private int _subscribed;

/// <summary>构造函数 — 注入 fork/teammate 查询与完成事件面(均可选)与可选日志。</summary>
/// <param name="forkManager">fork 管理器(ForkCompleted 终态事件 + GetActiveForksAsync 快照;null 时 fork 路径静默跳过)</param>
/// <param name="teammateExecutor">teammate 执行器(TeammateCompleted 终态事件 + GetActiveTeammateSnapshotsAsync 快照;null 时 teammate 路径静默跳过)</param>
/// <param name="logger">可选日志(快照查询异常降级记录)</param>
public BackgroundTaskEventPump(
IForkSubAgentManager? forkManager,
IInProcessTeammateTaskExecutor? teammateExecutor,
ILogger<BackgroundTaskEventPump>? logger = null) {
_forkManager = forkManager;
_teammateExecutor = teammateExecutor;
_logger = logger;
}

/// <summary>绑定推送出口(组装层在 consumer 就绪后调用;重绑覆盖 — consumer 全程单实例场景)。</summary>
/// <param name="consumer">事件消费出口(OnBackgroundTask 四参 upsert)</param>
public void Bind(IEventConsumer consumer) {
ArgumentNullException.ThrowIfNull(consumer);
_consumer = consumer;
}

/// <summary>订阅两完成事件面(幂等 — 重复调用零副作用;无异步等待,订阅即生效)。</summary>
public Task StartAsync() {
if (Interlocked.Exchange(ref _subscribed, 1) != 0)
return Task.CompletedTask;

if (_forkManager is not null)
_forkManager.ForkCompleted += OnForkCompleted;
if (_teammateExecutor is not null)
_teammateExecutor.TeammateCompleted += OnTeammateCompleted;
return Task.CompletedTask;
}

/// <summary>全量快照 upsert(回合结束边界调用)— fork 按快照实际 State 推(running 补发 + 终态幂等重发,
/// 避免把 TUI 已定格的终态条目拉回 running 的倒退);teammate 快照为活跃集合,全发 running。
/// 单查询面异常降级日志不中断(Actor 可能已释放,监控路径不得杀回合收尾)。</summary>
/// <param name="ct">取消令牌</param>
public async Task PublishSnapshotAsync(CancellationToken ct = default) {
var consumer = _consumer;
if (consumer is null)
return;

if (_forkManager is not null) {
try {
var forks = await _forkManager.GetActiveForksAsync(ct).ConfigureAwait(false);
foreach (var fork in forks)
consumer.OnBackgroundTask(BackgroundTaskKinds.Fork, fork.ForkId, string.Empty, MapForkState(fork.State));
} catch (Exception ex) {
_logger?.LogWarning(ex, "后台任务泵 fork 快照查询失败,跳过本批");
}
}

if (_teammateExecutor is not null) {
try {
var teammates = await _teammateExecutor.GetActiveTeammateSnapshotsAsync(ct).ConfigureAwait(false);
foreach (var teammate in teammates)
consumer.OnBackgroundTask(BackgroundTaskKinds.Teammate, teammate.TeammateId, teammate.Task, BackgroundTaskStates.Running);
} catch (Exception ex) {
_logger?.LogWarning(ex, "后台任务泵 teammate 快照查询失败,跳过本批");
}
}
}

/// <summary>退订两完成事件面(幂等 — 未订阅/重复退订零副作用;无超时无等待,退订即完成)。</summary>
public Task StopAsync() {
if (Interlocked.Exchange(ref _subscribed, 0) != 0) {
if (_forkManager is not null)
_forkManager.ForkCompleted -= OnForkCompleted;
if (_teammateExecutor is not null)
_teammateExecutor.TeammateCompleted -= OnTeammateCompleted;
}
return Task.CompletedTask;
}

/// <summary>释放 — 等价 StopAsync 退订(无资源需异步清理)。</summary>
public ValueTask DisposeAsync() => new(StopAsync());

/// <summary>ForkCompleted → OnBackgroundTask("fork", forkId, taskDescription, 终态)。
/// Bind 前到达的事件丢弃(组装顺序保证泵先 Bind 后 Start,仅测试直接触发可能越过)。</summary>
private void OnForkCompleted(object? sender, ForkCompletedEventArgs e) =>
_consumer?.OnBackgroundTask(BackgroundTaskKinds.Fork, e.ForkId, e.TaskDescription, MapForkState(e.State));

/// <summary>TeammateCompleted → OnBackgroundTask("teammate", teammateId, task, 终态)。
/// 事件参数只有 IsSuccess 二态 — 被终止/失败统一映射 failed(无 cancelled 信号源,见泵 XML 注释)。</summary>
private void OnTeammateCompleted(object? sender, TeammateCompletedEventArgs e) =>
_consumer?.OnBackgroundTask(
BackgroundTaskKinds.Teammate, e.TeammateId, e.Task,
e.IsSuccess ? BackgroundTaskStates.Completed : BackgroundTaskStates.Failed);

/// <summary>ForkState → 协议四值:[EnumValue] 唯一数据源取值,merged 折叠 completed
/// (合并即成功终态 — MergeFork 不发完成事件,快照路径亦过滤 merged,此处仅防御性折叠)。</summary>
private static string MapForkState(ForkState state) =>
state.ToValue() == "merged" ? BackgroundTaskStates.Completed : state.ToValue();
}

/// <summary>后台任务种类(M8-8 — 协议 kind 字段值;有限集合字符串常量,后续枚举化随面板扩展收编)。</summary>
public static class BackgroundTaskKinds {
/// <summary>后台 fork 子代理(AgentForkMiddleware 回退路径 — 继承父上下文分叉)。</summary>
public const string Fork = "fork";

/// <summary>后台 teammate 子代理(AgentForkMiddleware 优先路径 — ContinuousMode 消息驱动)。</summary>
public const string Teammate = "teammate";
}

/// <summary>后台任务状态(M8-8 — 协议 state 字段值;与 native_tui 侧约定四值)。</summary>
public static class BackgroundTaskStates {
/// <summary>运行中。</summary>
public const string Running = "running";

/// <summary>成功终态。</summary>
public const string Completed = "completed";

/// <summary>失败终态。</summary>
public const string Failed = "failed";

/// <summary>取消终态。</summary>
public const string Cancelled = "cancelled";
}
6 changes: 6 additions & 0 deletions modules/apps/engine/adapters/BridgeEventConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percen
WriteEvent(new Cli.Bridge.BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft));
}

/// <summary>后台任务状态更新 → background_task(M8-8 — 事件泵直推;显式实现:DIM 默认实现经类类型调用不可见,
/// 缺显式实现时泵经 IEventConsumer 调用会静默落入接口空实现 — DSG041 批3 先例)。</summary>
public void OnBackgroundTask(string kind, string taskId, string description, string state) {
WriteEvent(new Cli.Bridge.BridgeEvent.BackgroundTask(kind, taskId, description, state));
}

/// <summary>思考结束结算(进入产出或回合结束时调用,幂等)。</summary>
private void EndThinkingIfActive() {
if (_thinkingStartMs is not long start)
Expand Down
Loading