Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TASK064 — TUI 对齐 Claude Code 下一批差距(M8 规划)

> 状态:**M8-1/2/3(P0 三连)已 implemented**(2026-10-22,提交链 689f830b3 → 423187e9d → aef776136);M8-4..M8-8 + P3 池待做
> 状态:**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 快照+终态 中)
> 状态: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 @@ -85,6 +85,15 @@
| / 搜索 | ITextMatchable 七消息项 + Enter 跳匹配(环形)+ Esc 清 | ✅ | ✅ 查询累积/footer 显示/跳转 ScrollTop 断言/Esc 清除/退模态 |
| 语义演进回归 | ctrl+o 单条→全局(旧测试同步全局断言) | ✅ | ✅ Core 853 全绿(含 StructuredMessageTests 更新) |

**✅ M8-4/5/6 并行子代理批次实施收口(2026-10-22,f59a6a99e)**:三子代理文件面互斥分工 + 主智能体收编 ReplShell 落地件。

| 批次 | 基建实现 | 已实现 | 已验收 |
|------|---------|--------|--------|
| M8-4 | CollapsedToolItem 替换式聚合(连续 Read/Grep/Glob 第二次起原位折叠 + 最新操作⎿提示行)+ DiffResultItem ToggleExpand + transcript verbose 态 Diff 全展开 | ✅ | ✅ 20 新测试(聚合 9 + 折叠 4 + 渲染 7)|
| M8-5 | TuiCommandCallbacks 延迟绑定容器 + clearScreen/onExitRequested 全链 + ReplShell 清屏/退出公开入口 + RenderAndFlush 2J+3J 消费块 + Csi.EraseSavedLines | ✅ | ✅ 6 新测试(回调触发/反馈上屏/降级契约);confirm/prompt 登记遗留(TUI 命令确认模态独立任务)|
| M8-6 | 导航态 c 复制全文/p 复制主参数 + MessageActionsBar 底部操作条(滚动区尾部常驻项模式)| ✅ | ✅ 16 新测试 |
| 批次回归 | — | ✅ | ✅ Core 889 + Host.Tests 74 + host.tests 1382 + selftest 真跑 EXIT=0 |

### M8-4 工具折叠交互统一 + Read N files 聚合(P1,依赖:M8-3——展开消费 transcript/局部展开双通道)

- Ctrl+O 展开路由统一:thinking/diff/Bash/Write 折叠共享统一展开入口(消息级 ToggleExpand,不再只路由最后一条 thinking)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ void OnSubagentToolUse(string subagentId, string toolName, string summary, int t
/// 完成态一次性校正优于逐活动增量 — 引擎数据流中 token 仅回合/完成粒度存在(RecordTokenUsage 亦完成时调用);
/// 0 = 无 usage 数据(FinalizePending 等兜底路径),消费方保持原累计值)</param>
void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { }

/// <summary>
/// 上下文窗口余量上报(M8-7 — 每回合 Done 后消费侧 footer 告警显示;默认空实现,
/// 既有实现方零改动,默认实现须经接口类型调用才可见)
/// </summary>
/// <param name="promptTokens">当前上下文窗口占用(回合 Done 权威 usage.PromptTokens 快照)</param>
/// <param name="contextWindowTokens">上下文窗口大小(IContextWindowResolver 实时解析,缺省 200K)</param>
/// <param name="percentLeft">剩余百分比 0-100(引擎侧 ICompactService.CalculateWarningState 权威算法;
/// 阈值标志不进接口 — TUI 侧只显示百分比+文案,UI 自己判颜色)</param>
void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { }
}

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions modules/apps/engine/adapters/BridgeEventConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal)
WriteEvent(new Cli.Bridge.BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal));
}

/// <summary>上下文余量 → context_usage(M8-7 — 每回合 Done 后 footer 告警;显式实现:DIM 默认实现经类类型调用不可见)。</summary>
public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) {
WriteEvent(new Cli.Bridge.BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft));
}

/// <summary>思考结束结算(进入产出或回合结束时调用,幂等)。</summary>
private void EndThinkingIfActive() {
if (_thinkingStartMs is not long start)
Expand Down
23 changes: 23 additions & 0 deletions modules/apps/engine/adapters/SessionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,33 @@ private void DrainSubAgentEvents(SubAgentEventChannel channel, StringBuilder ful
onDone: (usage, mid) => {
modelId = mid;
_consumer.OnDone(usage, mid);
EmitContextUsage(usage);
});
return modelId;
}

/// <summary>缺省上下文窗口兜底(对齐 ChatContextManager.DefaultContextWindowResolver 的 200K)。</summary>
private const int DefaultContextWindowTokens = 200_000;

/// <summary>
/// 发射上下文余量事件(M8-7 — Done 权威 usage 快照口径):PromptTokens 即当前上下文窗口占用;
/// 窗口优先 IContextWindowResolver(不可达兜底 200K),percentLeft 走 ICompactService.CalculateWarningState
/// 权威阈值算法(不可达时朴素窗口百分比兜底)→ _consumer.OnContextUsage。
/// usage 为 null 或 PromptTokens=0(mock/无计量路径)不发射,防消费侧噪音。
/// </summary>
private void EmitContextUsage(TokenUsage? usage) {
if (usage is not { PromptTokens: > 0 })
return;

var promptTokens = usage.PromptTokens;
var contextWindowTokens = _serviceProvider?.GetService<IContextWindowResolver>()?.ResolveCurrentContextWindow()
?? DefaultContextWindowTokens;
var percentLeft = _serviceProvider?.GetService<ICompactService>() is { } compact
? compact.CalculateWarningState(promptTokens, contextWindowTokens).PercentLeft
: Math.Max(0, (int)Math.Round((contextWindowTokens - promptTokens) * 100.0 / contextWindowTokens));
_consumer.OnContextUsage(promptTokens, contextWindowTokens, percentLeft);
}

/// <summary>
/// 子代理活动事件前置分流(DSG041 批1 — AgentId 非 null 即子代理事件,路由到三子代理回调):
/// AgentStarted → OnSubagentStarted(身份三元组)、AgentFinished → OnSubagentFinished(成败)、
Expand Down
8 changes: 8 additions & 0 deletions modules/apps/engine/bridge/BridgeProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public sealed record Quit() : BridgeRequest;
[JsonDerivedType(typeof(BridgeEvent.SubagentStarted), "subagent_started")]
[JsonDerivedType(typeof(BridgeEvent.SubagentToolUse), "subagent_tool_use")]
[JsonDerivedType(typeof(BridgeEvent.SubagentFinished), "subagent_finished")]
[JsonDerivedType(typeof(BridgeEvent.ContextUsage), "context_usage")]
[JsonDerivedType(typeof(BridgeEvent.TurnCompleted), "turn_completed")]
[JsonDerivedType(typeof(BridgeEvent.TurnInterrupted), "turn_interrupted")]
[JsonDerivedType(typeof(BridgeEvent.Error), "error")]
Expand Down Expand Up @@ -132,6 +133,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string
/// 完成态权威校正 TASK062 P7 — 与 native_tui 侧逐字段镜像)。</summary>
public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent;

/// <summary>
/// 上下文窗口余量(M8-7 — 每回合 Done 后 TUI footer 告警显示;与 native_tui 侧镜像)。
/// PromptTokens = 回合权威 usage.PromptTokens;PercentLeft 0-100(引擎侧阈值算法口径),
/// 阈值标志不进协议 — UI 侧自判颜色文案。
/// </summary>
public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent;

/// <summary>完整助手消息(追加滚动区)。</summary>
public sealed record AssistantMessage(string Text) : BridgeEvent;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ public async Task 诊断Sink_等待过长时收到告警() {
using var holder = await lk.TryLockAsync() ?? throw new System.TimeoutException($"锁 '{lk.Name}' 等待超时");
// TryLock 同步阻塞, 在另一线程调用以记录等待诊断
var waitTask = Task.Run(async () => await lk.TryLockAsync());
await Task.Delay(150);
// 确定性等待: LOCK-WAIT-START 诊断消息 = 等待者已进入排队的信号(AsyncLock 排队开始时发射)。
// 旧形态固定 Delay(150) 假设 Task.Run 在窗口内开始等待 — CI 线程池饥饿下等待者晚于释放才启动
// (等待 0ms 不超阈值 → 漏 LOCK-WAIT-SLOW 告警 flaky); 改等信号后排队的起止时序由测试编排保证
var startedWaiting = System.Threading.SpinWait.SpinUntil(
() => messages.Any(m => m.Contains("LOCK-WAIT-START") && m.Contains("wait-long-test")),
TimeSpan.FromSeconds(5));
startedWaiting.Should().BeTrue("等待者应在 5s 内进入等待状态(LOCK-WAIT-START 诊断信号,容忍CI调度延迟)");
await Task.Delay(100);
holder.Dispose();
var acquired = await waitTask;
acquired.Should().NotBeNull();
Expand Down
54 changes: 48 additions & 6 deletions native_tui/apps/NativeTui.Host/EngineSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ public sealed class EngineSession : IAsyncDisposable {
private readonly TuiPermissionBridge _permissionBridge;
private readonly PermissionChecker? _checker;
private readonly IFastModeService? _fastMode;
private readonly TuiCommandCallbacks _commandCallbacks;

private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode) {
private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode, TuiCommandCallbacks commandCallbacks) {
_engine = engine;
_client = client;
_permissionBridge = permissionBridge;
_checker = checker;
_fastMode = fastMode;
_commandCallbacks = commandCallbacks;
}

/// <summary>回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。</summary>
Expand All @@ -28,6 +30,9 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// <summary>权限确认桥 — Program 在 shell 构造后 Bind(引擎 PendingConfirmation → TUI 对话框闭环,M7-3)。</summary>
public TuiPermissionBridge PermissionBridge => _permissionBridge;

/// <summary>命令交互回调桥 — Program 在 shell 构造后 Bind(/clear 清滚动区、/exit 结束主循环 → ReplShell 能力,M8-5)。</summary>
public TuiCommandCallbacks CommandCallbacks => _commandCallbacks;

/// <summary>引擎服务容器 — 宿主组装层消费(命令清单/历史种子等只读解析)。</summary>
public IServiceProvider Services => _engine.Services;

Expand Down Expand Up @@ -55,13 +60,17 @@ public static async Task<EngineSession> CreateAsync(CancellationToken cancellati
var fastMode = engine.Services.GetService<IFastModeService>();
var mainAgent = EngineAgentFactory.CreateMainAgent(engine.Services, new Session().ObjectId);

// M8-5 命令交互回调桥 — 回调管道随回合执行器闭包传递(Bind 前回调为 null 安全降级,
// Program 在 shell 构造后 Bind:/clear → 清滚动区、/exit → 主循环结束)
var commandCallbacks = new TuiCommandCallbacks();

// 首次 submit 时绑定回合驱动器 — consumer 即进程内事件出口(client 构造,全程同一实例,对齐 BridgeServeCommand 先例)
SessionController? controller = null;
var client = new EngineTurnClient(
(input, consumer, turnCt) => {
// TASK064 斜杠命令分流(对齐 CliSession.ProcessInputAsync:/ 开头走命令执行不进 LLM)
if (input.StartsWith('/')) {
return DispatchSlashCommandAsync(engine.Services, input, consumer, turnCt);
return DispatchSlashCommandAsync(engine.Services, input, consumer, commandCallbacks, turnCt);
}

controller ??= new SessionController(
Expand All @@ -71,7 +80,7 @@ public static async Task<EngineSession> CreateAsync(CancellationToken cancellati
},
BuildPermissionModeApplier(checker));

var session = new EngineSession(engine, client, permissionBridge, checker, fastMode);
var session = new EngineSession(engine, client, permissionBridge, checker, fastMode, commandCallbacks);
client.Enqueue(BuildSessionInfoEvent(checker, fastMode, engine.Config));
return session;
} catch {
Expand Down Expand Up @@ -136,14 +145,20 @@ private static async Task RunTurnAsync(SessionController controller, string inpu
/// 输出经 AnsiStripper 剥离 SGR 色码(命令侧彩色输出与 TUI Cell/StyleId 渲染模型不兼容 —
/// ESC 零宽跳过后残骸显示为 "38;2;153;153;153m──" 形态)再经 consumer.OnText/OnDone 上屏
///(AssistantMessage Markdown 渲染);未知命令同样上屏诱导(/help 提示)— 不落 LLM
///(对齐"命令不触发 AI":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。</summary>
private static async Task DispatchSlashCommandAsync(
IServiceProvider services, string input, IEventConsumer consumer, CancellationToken ct) {
///(对齐"命令不触发 AI":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。
/// M8-5 交互回调接线:clearScreen/onExitRequested 经 TuiCommandCallbacks 透传(/clear 清 TUI 滚动区、
/// /exit 结束主循环;Bind 前为 null — 命令侧 ?.Invoke 安全降级对齐 Runner 可空语义);
/// confirm/prompt/readPassword 首版不接(TUI raw mode 无同步 Console 通道 — 模态对话框增强项遗留,
/// 命令侧降级:确认默认拒绝/输入 null)。internal static — 回调透传链测试面(fake DI 驱动真实命令注册链)。</summary>
internal static async Task DispatchSlashCommandAsync(
IServiceProvider services, string input, IEventConsumer consumer, TuiCommandCallbacks commandCallbacks, CancellationToken ct) {
var result = await SlashCommandRunner.RunAsync(
input, services,
clearScreen: commandCallbacks.ClearScreen,
confirm: null,
prompt: null,
readPassword: null,
onExitRequested: commandCallbacks.OnExitRequested,
cancellationToken: ct).ConfigureAwait(false);

var output = AnsiStripper.Strip(result.Output).TrimEnd();
Expand Down Expand Up @@ -173,3 +188,30 @@ public async ValueTask DisposeAsync() {
await _engine.Host.DisposeAsync().ConfigureAwait(false);
}
}

/// <summary>
/// TUI 命令交互回调桥(M8-5)— SlashCommandRunner 交互回调的延迟绑定容器:
/// EngineSession.CreateAsync 组装期创建并随回合执行器闭包传递(Bind 前回调为 null —
/// 命令侧 ?.Invoke 安全降级,对齐 Runner 可空语义:清屏跳过/退出信号丢弃/确认默认拒绝);
/// Program 在 shell 构造后 Bind:/clear → ReplShell 清滚动区、/exit → ReplShell 主循环结束。
/// 对齐 TuiPermissionBridge.Bind 延迟绑定形态(shell 构造晚于引擎会话,引擎回合线程并发读 — volatile)。
/// </summary>
public sealed class TuiCommandCallbacks {
private volatile Action? _clearScreen;
private volatile Action? _onExitRequested;

/// <summary>绑定 TUI 能力(Program 交互模式组装点 — shell 构造后、Run 前调用;重绑幂等覆盖)。</summary>
public void Bind(Action? clearScreen, Action? onExitRequested) {
_clearScreen = clearScreen;
_onExitRequested = onExitRequested;
}

/// <summary>清屏回调(/clear → 清 TUI 滚动区,保留 banner+spinner 常驻项;Bind 前为 null — 命令侧跳过)。</summary>
public Action? ClearScreen => _clearScreen;

/// <summary>退出请求回调(/exit ShouldContinue=false → 结束 TUI 主循环;Bind 前为 null — 信号降级丢弃)。</summary>
public Action? OnExitRequested => _onExitRequested;

/// <summary>已绑定任一回调(internal — Program 接线测试断言面)。</summary>
internal bool IsBound => _clearScreen is not null || _onExitRequested is not null;
}
6 changes: 6 additions & 0 deletions native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal)
_events.Enqueue(new BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal));
}

/// <summary>上下文余量 → ContextUsage(M8-7 TASK064 P1 — 引擎回调三参直传内存事件;
/// percentLeft 引擎侧算好,ReplBridgeDriver 路由 footer 告警。接口同步由引擎侧 IEventConsumer DIM 完成)。</summary>
public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) {
_events.Enqueue(new BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft));
}

/// <summary>排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。</summary>
public IReadOnlyList<BridgeEvent> DrainEvents() {
List<BridgeEvent>? drained = null;
Expand Down
5 changes: 5 additions & 0 deletions native_tui/apps/NativeTui.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ private static async Task<int> RunInteractiveAsync() {
pickerEngine.Attach(shell);
await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false);
session.PermissionBridge.Bind(shell);
// M8-5 命令交互回调接线(/clear 清 TUI 滚动区+scrollback、/exit 结束主循环)—
// ReplShell 公开入口已由主智能体收编落地(ClearMessagesExceptBannerAndSpinner/RequestExit)
session.CommandCallbacks.Bind(
clearScreen: shell.ClearMessagesExceptBannerAndSpinner,
onExitRequested: shell.RequestExit);
shell.Run();
shell.WriteExit();
}
Expand Down
3 changes: 3 additions & 0 deletions native_tui/src/NativeTui.Core/ansi/Csi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public static string CursorPosition(int row, int col) =>
/// <summary>全屏擦除。</summary>
public static string EraseScreen() => "\x1b[2J";

/// <summary>清滚动缓冲区(scrollback — CSI 3J,/clear 清对话历史滚入区)。</summary>
public static string EraseSavedLines() => "\x1b[3J";

/// <summary>擦除光标到行尾。</summary>
public static string EraseLineToEnd() => "\x1b[0K";

Expand Down
8 changes: 8 additions & 0 deletions native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace NativeTui.Core.Bridge;
[JsonDerivedType(typeof(SubagentStarted), "subagent_started")]
[JsonDerivedType(typeof(SubagentToolUse), "subagent_tool_use")]
[JsonDerivedType(typeof(SubagentFinished), "subagent_finished")]
[JsonDerivedType(typeof(ContextUsage), "context_usage")]
[JsonDerivedType(typeof(Error), "error")]
public abstract record BridgeEvent
{
Expand Down Expand Up @@ -103,6 +104,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string
/// <summary>子代理完成 — 条目定格(isError 聚合组头 ● 红;基准基准 agent 完成态 Done)。</summary>
public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent;

/// <summary>
/// 上下文余量(M8-7 TASK064 P1 — 引擎回合粒度上报,footer 告警数据源):
/// PromptTokens 当前回合已耗 / ContextWindowTokens 窗口总量 / PercentLeft 剩余百分比
/// (引擎侧算好 — TUI 侧零计算直路由:≤10% 告急 error、≤25% 不足 warning、&gt;25% 清空防噪音)。
/// </summary>
public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent;

/// <summary>错误(含诱导说明)。</summary>
public sealed record Error(string Message) : BridgeEvent;
}
Loading