diff --git "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" index e61cb47a7..1402a5336 100644 --- "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" +++ "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" @@ -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,文件:行号 见各项) @@ -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) diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs index 1068bdb32..35b87dbea 100644 --- a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs +++ b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs @@ -73,6 +73,16 @@ void OnSubagentToolUse(string subagentId, string toolName, string summary, int t /// 完成态一次性校正优于逐活动增量 — 引擎数据流中 token 仅回合/完成粒度存在(RecordTokenUsage 亦完成时调用); /// 0 = 无 usage 数据(FinalizePending 等兜底路径),消费方保持原累计值) void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { } + + /// + /// 上下文窗口余量上报(M8-7 — 每回合 Done 后消费侧 footer 告警显示;默认空实现, + /// 既有实现方零改动,默认实现须经接口类型调用才可见) + /// + /// 当前上下文窗口占用(回合 Done 权威 usage.PromptTokens 快照) + /// 上下文窗口大小(IContextWindowResolver 实时解析,缺省 200K) + /// 剩余百分比 0-100(引擎侧 ICompactService.CalculateWarningState 权威算法; + /// 阈值标志不进接口 — TUI 侧只显示百分比+文案,UI 自己判颜色) + void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { } } /// diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs index 4ea8fce8b..23d721dc6 100644 --- a/modules/apps/engine/adapters/BridgeEventConsumer.cs +++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs @@ -93,6 +93,11 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) WriteEvent(new Cli.Bridge.BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal)); } + /// 上下文余量 → context_usage(M8-7 — 每回合 Done 后 footer 告警;显式实现:DIM 默认实现经类类型调用不可见)。 + public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { + WriteEvent(new Cli.Bridge.BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft)); + } + /// 思考结束结算(进入产出或回合结束时调用,幂等)。 private void EndThinkingIfActive() { if (_thinkingStartMs is not long start) diff --git a/modules/apps/engine/adapters/SessionController.cs b/modules/apps/engine/adapters/SessionController.cs index 0ce87479d..7398c8667 100644 --- a/modules/apps/engine/adapters/SessionController.cs +++ b/modules/apps/engine/adapters/SessionController.cs @@ -223,10 +223,33 @@ private void DrainSubAgentEvents(SubAgentEventChannel channel, StringBuilder ful onDone: (usage, mid) => { modelId = mid; _consumer.OnDone(usage, mid); + EmitContextUsage(usage); }); return modelId; } + /// 缺省上下文窗口兜底(对齐 ChatContextManager.DefaultContextWindowResolver 的 200K)。 + private const int DefaultContextWindowTokens = 200_000; + + /// + /// 发射上下文余量事件(M8-7 — Done 权威 usage 快照口径):PromptTokens 即当前上下文窗口占用; + /// 窗口优先 IContextWindowResolver(不可达兜底 200K),percentLeft 走 ICompactService.CalculateWarningState + /// 权威阈值算法(不可达时朴素窗口百分比兜底)→ _consumer.OnContextUsage。 + /// usage 为 null 或 PromptTokens=0(mock/无计量路径)不发射,防消费侧噪音。 + /// + private void EmitContextUsage(TokenUsage? usage) { + if (usage is not { PromptTokens: > 0 }) + return; + + var promptTokens = usage.PromptTokens; + var contextWindowTokens = _serviceProvider?.GetService()?.ResolveCurrentContextWindow() + ?? DefaultContextWindowTokens; + var percentLeft = _serviceProvider?.GetService() is { } compact + ? compact.CalculateWarningState(promptTokens, contextWindowTokens).PercentLeft + : Math.Max(0, (int)Math.Round((contextWindowTokens - promptTokens) * 100.0 / contextWindowTokens)); + _consumer.OnContextUsage(promptTokens, contextWindowTokens, percentLeft); + } + /// /// 子代理活动事件前置分流(DSG041 批1 — AgentId 非 null 即子代理事件,路由到三子代理回调): /// AgentStarted → OnSubagentStarted(身份三元组)、AgentFinished → OnSubagentFinished(成败)、 diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs index 5fb52c949..d8738446a 100644 --- a/modules/apps/engine/bridge/BridgeProtocol.cs +++ b/modules/apps/engine/bridge/BridgeProtocol.cs @@ -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")] @@ -132,6 +133,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string /// 完成态权威校正 TASK062 P7 — 与 native_tui 侧逐字段镜像)。 public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent; + /// + /// 上下文窗口余量(M8-7 — 每回合 Done 后 TUI footer 告警显示;与 native_tui 侧镜像)。 + /// PromptTokens = 回合权威 usage.PromptTokens;PercentLeft 0-100(引擎侧阈值算法口径), + /// 阈值标志不进协议 — UI 侧自判颜色文案。 + /// + public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent; + /// 完整助手消息(追加滚动区)。 public sealed record AssistantMessage(string Text) : BridgeEvent; diff --git a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs index 55a66c9a3..c1039464b 100644 --- a/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs +++ b/modules/async_primitives/async_lock.tests/lock/AsyncLockDiagnosisTests.cs @@ -85,7 +85,14 @@ public void Dispose() { 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(); diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs index a6c910c42..9533d2672 100644 --- a/native_tui/apps/NativeTui.Host/EngineSession.cs +++ b/native_tui/apps/NativeTui.Host/EngineSession.cs @@ -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; } /// 回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。 @@ -28,6 +30,9 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien /// 权限确认桥 — Program 在 shell 构造后 Bind(引擎 PendingConfirmation → TUI 对话框闭环,M7-3)。 public TuiPermissionBridge PermissionBridge => _permissionBridge; + /// 命令交互回调桥 — Program 在 shell 构造后 Bind(/clear 清滚动区、/exit 结束主循环 → ReplShell 能力,M8-5)。 + public TuiCommandCallbacks CommandCallbacks => _commandCallbacks; + /// 引擎服务容器 — 宿主组装层消费(命令清单/历史种子等只读解析)。 public IServiceProvider Services => _engine.Services; @@ -55,13 +60,17 @@ public static async Task CreateAsync(CancellationToken cancellati var fastMode = engine.Services.GetService(); 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( @@ -71,7 +80,7 @@ public static async Task 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 { @@ -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":注册命令走命令执行,未知命令报错,两者都不消耗模型调用)。 - 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 驱动真实命令注册链)。 + 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(); @@ -173,3 +188,30 @@ public async ValueTask DisposeAsync() { await _engine.Host.DisposeAsync().ConfigureAwait(false); } } + +/// +/// TUI 命令交互回调桥(M8-5)— SlashCommandRunner 交互回调的延迟绑定容器: +/// EngineSession.CreateAsync 组装期创建并随回合执行器闭包传递(Bind 前回调为 null — +/// 命令侧 ?.Invoke 安全降级,对齐 Runner 可空语义:清屏跳过/退出信号丢弃/确认默认拒绝); +/// Program 在 shell 构造后 Bind:/clear → ReplShell 清滚动区、/exit → ReplShell 主循环结束。 +/// 对齐 TuiPermissionBridge.Bind 延迟绑定形态(shell 构造晚于引擎会话,引擎回合线程并发读 — volatile)。 +/// +public sealed class TuiCommandCallbacks { + private volatile Action? _clearScreen; + private volatile Action? _onExitRequested; + + /// 绑定 TUI 能力(Program 交互模式组装点 — shell 构造后、Run 前调用;重绑幂等覆盖)。 + public void Bind(Action? clearScreen, Action? onExitRequested) { + _clearScreen = clearScreen; + _onExitRequested = onExitRequested; + } + + /// 清屏回调(/clear → 清 TUI 滚动区,保留 banner+spinner 常驻项;Bind 前为 null — 命令侧跳过)。 + public Action? ClearScreen => _clearScreen; + + /// 退出请求回调(/exit ShouldContinue=false → 结束 TUI 主循环;Bind 前为 null — 信号降级丢弃)。 + public Action? OnExitRequested => _onExitRequested; + + /// 已绑定任一回调(internal — Program 接线测试断言面)。 + internal bool IsBound => _clearScreen is not null || _onExitRequested is not null; +} diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index a896b9f7c..a5deee01e 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -125,6 +125,12 @@ public void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) _events.Enqueue(new BridgeEvent.SubagentFinished(subagentId, isError, tokenTotal)); } + /// 上下文余量 → ContextUsage(M8-7 TASK064 P1 — 引擎回调三参直传内存事件; + /// percentLeft 引擎侧算好,ReplBridgeDriver 路由 footer 告警。接口同步由引擎侧 IEventConsumer DIM 完成)。 + public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { + _events.Enqueue(new BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft)); + } + /// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。 public IReadOnlyList DrainEvents() { List? drained = null; diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 20beeb097..106311893 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -164,6 +164,11 @@ private static async Task 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(); } diff --git a/native_tui/src/NativeTui.Core/ansi/Csi.cs b/native_tui/src/NativeTui.Core/ansi/Csi.cs index 5444539e5..f2ca905b8 100644 --- a/native_tui/src/NativeTui.Core/ansi/Csi.cs +++ b/native_tui/src/NativeTui.Core/ansi/Csi.cs @@ -26,6 +26,9 @@ public static string CursorPosition(int row, int col) => /// 全屏擦除。 public static string EraseScreen() => "\x1b[2J"; + /// 清滚动缓冲区(scrollback — CSI 3J,/clear 清对话历史滚入区)。 + public static string EraseSavedLines() => "\x1b[3J"; + /// 擦除光标到行尾。 public static string EraseLineToEnd() => "\x1b[0K"; diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index 85cde6f9a..186bccb4b 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -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 { @@ -103,6 +104,13 @@ public sealed record SubagentToolUse(string SubagentId, string ToolName, string /// 子代理完成 — 条目定格(isError 聚合组头 ● 红;基准基准 agent 完成态 Done)。 public sealed record SubagentFinished(string SubagentId, bool IsError, int TokenTotal) : BridgeEvent; + /// + /// 上下文余量(M8-7 TASK064 P1 — 引擎回合粒度上报,footer 告警数据源): + /// PromptTokens 当前回合已耗 / ContextWindowTokens 窗口总量 / PercentLeft 剩余百分比 + /// (引擎侧算好 — TUI 侧零计算直路由:≤10% 告急 error、≤25% 不足 warning、>25% 清空防噪音)。 + /// + public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent; + /// 错误(含诱导说明)。 public sealed record Error(string Message) : BridgeEvent; } diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index e6e30707b..a29ebfd6f 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -6,6 +6,8 @@ namespace NativeTui.Core.Bridge; /// 权限模式回执(permission_mode_changed 更新 footer)、 /// 回合生命周期驱动 Busy + SpinnerItem(BeginTurn/EndTurn),消息事件插入滚动区 /// (spinner 恒为末项),token_delta 累加 ResponseLength,错误追加 ErrorMessageItem; +/// 工具调用聚合(M8-4):连续同名可聚合调用(Read/Grep/Glob)合并为 CollapsedToolItem +/// (首次独立 ToolCallItem,第二次同名替换,后续 Record — 工具结果不断链,其他消息事件/回合边界断链); /// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态)。 /// 模拟路径与真实 bridge 路径共用同一套回合视图状态。 /// @@ -28,6 +30,31 @@ public sealed class ReplBridgeDriver /// 后续增量原地追加;AssistantMessage 原位替换为完整消息项;EndTurn 兜底定格/清理。 private StreamingTextItem? _streamingItem; + /// 活动工具调用聚合链(M8-4)— null = 无链(最近事件已断;详见 ToolCallChain)。 + private ToolCallChain? _activeToolChain; + + /// + /// 工具调用聚合链(M8-4)— Driver 私有状态:连续同名可聚合调用(Read/Grep/Glob)的合并追踪。 + /// 链生命周期:首次调用独立上屏 ToolCallItem(链头候选)→ 第二次同名到达原位替换为 CollapsedToolItem + /// (Count=2 起折叠形态)→ 后续同名 RecordCall 原地更新。断链条件:不同名/非聚合工具到达、 + /// 其他消息事件产生(流式正文/助手消息/错误/子代理组)、回合边界(BeginTurn/EndTurn); + /// 工具结果不断链(聚合仅统计 ToolCallItem — Read→Result→Read 仍聚合,结果照旧独立上屏)。 + /// + private sealed class ToolCallChain(string toolName, string firstArgs, ToolCallItem head) + { + /// 链工具名(同名合并键)。 + public string ToolName => toolName; + + /// 链首次调用参数(替换链头时构造聚合项的历史首条)。 + public string FirstArgs => firstArgs; + + /// 链头独立项(首次上屏的 ToolCallItem — 第二次同名到达时定位原位替换)。 + public ToolCallItem Head => head; + + /// 激活的聚合项(null = 尚未聚合 — 链头仍为独立 ToolCallItem)。 + public CollapsedToolItem? Collapsed { get; set; } + } + /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项; /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景); /// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用)。 @@ -81,7 +108,7 @@ public void Apply(BridgeEvent bridgeEvent) // durationMs 供引擎侧比对,视图侧模式切换由后续事件(tool_call/token_delta)驱动。 break; case BridgeEvent.ToolCall(var name, var args): - AddMessage(new ToolCallItem(name, args)); + ApplyToolCall(name, args); _spinner.Mode = SpinnerMode.ToolUse; _spinner.HasActiveTools = true; break; @@ -105,6 +132,7 @@ public void Apply(BridgeEvent bridgeEvent) if (text is { Length: > 0 }) { // M8-1 逐字流式 — 惰性建流式项(spinner 前)+ 原地追加(每帧全量重渲自然增长) if (_streamingItem is null) { + EndToolChain(); _streamingItem = new StreamingTextItem(); AddMessage(_streamingItem); } @@ -112,6 +140,7 @@ public void Apply(BridgeEvent bridgeEvent) } break; case BridgeEvent.AssistantMessage(var text, var markdown, var messageId, var thinkingDurationMs, var thinkingContent): + EndToolChain(); ReplaceStreamingWithFinal(markdown is not null ? new MarkdownMessageItem(markdown, _codeHighlighter, thinkingDurationMs, thinkingContent, messageId) : new AssistantMessageItem(text, thinkingDurationMs, thinkingContent, messageId)); @@ -135,8 +164,12 @@ public void Apply(BridgeEvent bridgeEvent) _spinner.HasActiveTools = false; } + break; + case BridgeEvent.ContextUsage(_, _, var percentLeft): + ApplyContextUsage(percentLeft); break; case BridgeEvent.Error(var message): + EndToolChain(); AddMessage(new ErrorMessageItem(message)); break; } @@ -145,6 +178,7 @@ public void Apply(BridgeEvent bridgeEvent) /// 子代理启动 — 惰性建组(组项落位 spinner 前)+ 条目追加 + spinner 切 ToolUse(子代理运行中主循环有活跃工具)。 private void ApplySubagentStarted(string subagentId, string agentType, string description) { + EndToolChain(); if (_activeSubagentGroup is null) { _activeSubagentGroup = new SubagentGroupCoordinator(); @@ -156,6 +190,68 @@ private void ApplySubagentStarted(string subagentId, string agentType, string de _spinner.HasActiveTools = true; } + /// 上下文余量路由(M8-7)— percentLeft 阈值形态写入 footer 告警槽 + /// (≤10% 告急 error / ≤25% 不足 warning / >25% 清空防噪音;独立于 Notification 临时通知槽)。 + private void ApplyContextUsage(int percentLeft) + { + var (text, level) = FooterState.ResolveContextWarning(percentLeft); + _view.Footer = _view.Footer with { ContextWarning = text, ContextWarningLevel = level }; + } + + /// ToolCall 路由(M8-4 聚合)— 同名可聚合链上合并(已聚合 Record / 未聚合替换链头), + /// 不同名或非聚合工具断链后独立上屏(单次调用保持 ToolCallItem 类型 — 视觉与断言面零变化)。 + private void ApplyToolCall(string name, string args) + { + if (_activeToolChain is { } chain && chain.ToolName == name) + { + ExtendChain(chain, args); + return; + } + + EndToolChain(); + if (CollapsedToolItem.IsAggregatable(name)) + { + var head = new ToolCallItem(name, args); + AddMessage(head); + _activeToolChain = new ToolCallChain(name, args, head); + return; + } + + AddMessage(new ToolCallItem(name, args)); + } + + /// 链上后续同名调用 — 已聚合则原地 Record(计数与最新提示行更新), + /// 未聚合则原位替换链头为聚合项(Count=2 起折叠形态 — 位置不变视觉无跳变)。 + private void ExtendChain(ToolCallChain chain, string args) + { + if (chain.Collapsed is { } collapsed) + { + collapsed.RecordCall(args); + return; + } + + var merged = new CollapsedToolItem(chain.ToolName, [chain.FirstArgs, args]); + ReplaceItem(chain.Head, merged); + chain.Collapsed = merged; + } + + /// 断链(幂等)— 不同名工具/其他消息事件/回合边界调用。 + private void EndToolChain() => _activeToolChain = null; + + /// 消息项原位替换(聚合链头 → 聚合项;位置不变视觉无跳变,未找到时退化为 AddMessage)。 + private void ReplaceItem(IScrollItem oldItem, IScrollItem newItem) + { + var index = _view.Messages.IndexOf(oldItem); + if (index < 0) + { + AddMessage(newItem); + return; + } + + _view.Messages.RemoveAt(index); + _view.Messages.Insert(index, newItem); + } + /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置";供应商 null → 空,banner 退化无前缀) /// + footer 初始模式 + bypass 可用性 + 登录门禁态(footer 未登录提示真实化 — TASK060)。 private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd, string? vendor, bool loggedIn) @@ -179,6 +275,7 @@ public void BeginTurn() { IsBusy = true; _activeSubagentGroup = null; + EndToolChain(); DiscardStreamingItem(); _view.Footer = _view.Footer with { Busy = true }; _spinner.Message = string.Empty; @@ -200,6 +297,7 @@ public void EndTurn() IsBusy = false; _activeSubagentGroup?.FinalizePending(); _activeSubagentGroup = null; + EndToolChain(); FinalizeStreamingItem(); _view.Footer = _view.Footer with { Busy = false }; _spinner.Message = string.Empty; diff --git a/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs b/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs new file mode 100644 index 000000000..464352422 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/CollapsedToolItem.cs @@ -0,0 +1,73 @@ +namespace NativeTui.Core.Repl; + +/// +/// 工具调用聚合项(M8-4)— 连续同名可聚合工具调用(Read/Grep/Glob)的单行摘要投影, +/// 对齐基准 CollapsedReadSearchContent 的 "Read N files" 折叠形态(中文文案): +/// 单次(Count=1)渲染与 ToolCallItem 像素一致(● bold 工具名 + (参数) — 单次视觉零变化); +/// 聚合态(Count≥2)摘要行 "● 读取 N 个文件"(● bullet + 动词 bold + 数量段默认样式)+ +/// 最新操作提示行 " ⎿ {LastArgs}" 整行 dim(基准组下方 latest file_path 行)。 +/// 高度随 Count 变化(2 → 3)由 ScrollBox 每帧全量重渲自动生效(无需失效通知)。 +/// 消费方:ReplBridgeDriver 聚合路由(第二次同名调用替换链头 ToolCallItem + 后续 RecordCall 原地更新)。 +/// +public sealed class CollapsedToolItem(string toolName, IReadOnlyList calls) : IScrollItem, ICursorNavigable, ITextMatchable +{ + /// 历史调用参数清单(全量保留 — transcript 搜索匹配任一,历史路径不丢)。 + private readonly List _calls = [.. calls]; + + /// 聚合的工具名(同名合并键 — Driver 链路由判定)。 + public string ToolName => toolName; + + /// 聚合调用次数。 + public int Count => _calls.Count; + + /// 最新一次调用参数(聚合态提示行正文)。 + public string LastArgs => _calls[^1]; + + /// 聚合摘要文案(断言面)— "读取 N 个文件" / "搜索 N 次" / "查找 N 个模式" / 兜底 "Tool × N"。 + public string SummaryText + { + get + { + var (verb, countPhrase) = Summarize(toolName, Count); + return $"{verb} {countPhrase}"; + } + } + + /// 是否可聚合工具 — Read/Grep/Glob(基准语义:高频批量读/搜/找才折叠;Edit/Bash/Write 等独立上屏)。 + public static bool IsAggregatable(string toolName) => toolName is "Read" or "Grep" or "Glob"; + + /// 后续同名调用到达 — 计数与最新提示行原地更新(下帧渲染自动反映)。 + public void RecordCall(string args) => _calls.Add(args); + + /// 内容高度 = margin 1 + 摘要 1 +(聚合态)最新提示行 1。 + public int MeasureHeight(int width) => _calls.Count > 1 ? 3 : 2; + + /// 渲染:margin 留空 → 单次 = ToolCall 形态 / 聚合态 = 摘要行 + 最新提示行。 + public void Render(Screen screen, Rect frame, Theme theme) + { + if (_calls.Count == 1) + { + MessageRenderer.RenderToolCall(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, toolName, _calls[0]); + return; + } + + var (verb, countPhrase) = Summarize(toolName, Count); + MessageRenderer.RenderCollapsedToolHeader(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, verb, countPhrase); + MessageRenderer.RenderCollapsedToolLastHint(screen, frame with { Y = frame.Y + 2, Height = 1 }, theme, LastArgs); + } + + /// 摘要分段派生 — 动词段(bold 渲染)+ 数量段(默认样式渲染);未知工具兜底 "Tool × N"(Driver 白名单外的防御形态)。 + private static (string Verb, string CountPhrase) Summarize(string toolName, int count) => toolName switch + { + "Read" => ("读取", $"{count} 个文件"), + "Grep" => ("搜索", $"{count} 次"), + "Glob" => ("查找", $"{count} 个模式"), + _ => (toolName, $"× {count}"), + }; + + /// 工具名 + 摘要 + 任一调用参数匹配(transcript 搜索 — 大小写不敏感)。 + public bool ContainsText(string query) => + toolName.Contains(query, StringComparison.OrdinalIgnoreCase) + || SummaryText.Contains(query, StringComparison.OrdinalIgnoreCase) + || _calls.Exists(args => args.Contains(query, StringComparison.OrdinalIgnoreCase)); +} diff --git a/native_tui/src/NativeTui.Core/repl/FooterBar.cs b/native_tui/src/NativeTui.Core/repl/FooterBar.cs index af135da76..a9198e137 100644 --- a/native_tui/src/NativeTui.Core/repl/FooterBar.cs +++ b/native_tui/src/NativeTui.Core/repl/FooterBar.cs @@ -6,25 +6,43 @@ namespace NativeTui.Core.Repl; /// + busy dim ` · esc 打断`(U+00B7)/ 非 busy 且有 agents 提示 dim ` · {AgentsHint}`; /// 标准模式(引擎默认 auto)dim `? 查看快捷键`(busy 时 `esc 打断`); /// 行 1 右侧 Notifications 登录错误(error 色右对齐); -/// 行 2 右侧通知(如 effort `◐ 中等 · /effort`,dim 右对齐)。 +/// 行 2 右侧通知(如 effort `◐ 中等 · /effort`,dim 右对齐)/ 上下文余量告警(M8-7 — +/// warning/error 色右对齐,与通知共存时告警优先)。 /// 注意:paddingX=2 留白由 REPL 组装层负责(frame.X 从 col 2 起),本组件只渲染内容。 /// public sealed class FooterBar { - /// footer 高度:Notification 非空时 2 行(行 2 右对齐通知),否则 1 行。 - public int MeasureHeight(FooterState state) => state.Notification is null ? 1 : 2; + /// footer 高度:Notification 或 ContextWarning 非空时 2 行(行 2 右对齐),否则 1 行。 + public int MeasureHeight(FooterState state) => state.Notification is null && state.ContextWarning is null ? 1 : 2; /// 渲染状态栏(frame 高度须 ≥ MeasureHeight)。 public void Render(Screen screen, Rect frame, Theme theme, FooterState state) { RenderLeftSide(screen, frame with { Height = 1 }, theme, state); - RenderRightAligned(screen, frame.Y, frame, theme, state.LoginError, error: true); - if (state.Notification is not null) + RenderRightAligned(screen, frame.Y, frame, state.LoginError, theme.Error); + RenderSecondRow(screen, frame, theme, state); + } + + /// 行 2 右对齐:上下文余量告警(M8-7 三色 — Critical error 色 / Warning warning 色)优先于一般通知(dim); + /// 共存时只渲染告警(持续告警语义 — 通知由 12s 超时自然消亡,告警持续到余量回升)。 + private static void RenderSecondRow(Screen screen, Rect frame, Theme theme, FooterState state) + { + if (state.ContextWarning is not null) { - RenderRightAligned(screen, frame.Y + 1, frame, theme, state.Notification, error: false); + RenderRightAligned(screen, frame.Y + 1, frame, state.ContextWarning, ContextWarningColor(theme, state.ContextWarningLevel)); + return; } + + RenderRightAligned(screen, frame.Y + 1, frame, state.Notification, null); } + /// 告警级别 → 前景色(None 防御性回落 warning 色 — Driver 保证 text null ⟺ level None)。 + private static Color ContextWarningColor(Theme theme, ContextAlertLevel level) => level switch + { + ContextAlertLevel.Critical => theme.Error, + _ => theme.Warning, + }; + /// 行 1 左侧:模式指示(标准模式快捷键提示 / 激活模式串 + 尾缀提示)。 private static void RenderLeftSide(Screen screen, Rect frame, Theme theme, FooterState state) { @@ -46,17 +64,17 @@ private static void RenderLeftSide(Screen screen, Rect frame, Theme theme, Foote } } - /// 右对齐写入一行文本(error 色或 dim,右端贴 frame.Right)。 - private static void RenderRightAligned(Screen screen, int row, Rect frame, Theme theme, string? text, bool error) + /// 右对齐写入一行文本(foreground 前景色 / null = dim,右端贴 frame.Right)。 + private static void RenderRightAligned(Screen screen, int row, Rect frame, string? text, Color? foreground) { if (text is null) { return; } - var style = error - ? screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Error }) - : screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var style = screen.Styles.GetOrAdd(foreground is { } color + ? new TextStyle { Foreground = color } + : new TextStyle { Dim = true }); var width = RuneWidth.OfText(text); var col = Math.Max(frame.X, frame.Right - width); screen.WriteText(row, col, text, style, frame.Right); diff --git a/native_tui/src/NativeTui.Core/repl/FooterState.cs b/native_tui/src/NativeTui.Core/repl/FooterState.cs index 7e0f79a27..ae1490e5b 100644 --- a/native_tui/src/NativeTui.Core/repl/FooterState.cs +++ b/native_tui/src/NativeTui.Core/repl/FooterState.cs @@ -23,4 +23,38 @@ public sealed record FooterState /// 右侧通知(行 2 右对齐 dim,如 "◐ medium · /effort";非空时 footer 占 2 行)。 public string? Notification { get; init; } + + /// + /// 上下文余量告警文本(M8-7 TASK064 P1 — 行 2 右对齐,色由 ContextWarningLevel 决定;null = 无告警)。 + /// 独立槽位于 Notification:通知是临时态(effort 12s 超时/transcript 提示,ReplShell 覆盖管理), + /// 告警是持续态(直到余量回升 >25% 或 /compact)— 互不覆盖;共存时告警优先渲染。 + /// + public string? ContextWarning { get; init; } + + /// 上下文余量告警级别(决定 ContextWarning 渲染色 — None 时不渲染)。 + public ContextAlertLevel ContextWarningLevel { get; init; } + + /// + /// percentLeft → 告警形态(TokenWarning.tsx:166-177 汉化):≤10% 告急(error 色)/ ≤25% 不足(warning 色)/ + /// >25% 清空(基准 auto-compact 常态显示 N% 简化为告警区才显示 — 防噪音)。 + /// + public static (string? Text, ContextAlertLevel Level) ResolveContextWarning(int percentLeft) => percentLeft switch + { + <= 10 => ($"上下文告急 ({percentLeft}%) · 运行 /compact", ContextAlertLevel.Critical), + <= 25 => ($"上下文不足 ({percentLeft}%) · 运行 /compact", ContextAlertLevel.Warning), + _ => (null, ContextAlertLevel.None), + }; +} + +/// 上下文余量告警级别(M8-7 — 三色形态选色键:告急 error / 警告 warning / 无 None)。 +public enum ContextAlertLevel +{ + /// 无告警(余量 >25% 安全区 — 不显示防噪音)。 + None = 0, + + /// 警告(余量 ≤25% — warning 色 `上下文不足 (N%) · 运行 /compact`)。 + Warning = 1, + + /// 告急(余量 ≤10% — error 色 `上下文告急 (N%) · 运行 /compact`)。 + Critical = 2, } diff --git a/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs b/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs new file mode 100644 index 000000000..3e3ffde6c --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/MessageActionsBar.cs @@ -0,0 +1,266 @@ +namespace NativeTui.Core.Repl; + +/// +/// 消息操作底部操作条(M8-6)— 消息光标导航态激活期间挂载于滚动区尾部 spinner 之前 +/// (闲时 spinner Hidden 高度 0,操作条即滚动区末行 = 视觉输入框上方;对齐基准 messageActions +/// 底部键位提示条):margin 1 + dim 全宽 ─ 上边框 + 1 空格缩进 dim 键位提示行(间隔符复用 +/// FooterBar 的 MiddotSeparator 先例)。非 ICursorNavigable(不参与导航、不被选中高亮投影); +/// ReplShell 挂载/卸载(BeginCursorNavigation 进入 / EscapeCursorNavigation 退出)。 +/// +public sealed class MessageActionsBar : IScrollItem +{ + /// 键位提示文案(c 复制全文 / p 复制主参数 — M8-6 新增两键 + 既有导航键全集)。 + internal const string HintsText = + $"enter 展开{Figures.MiddotSeparator}c 复制{Figures.MiddotSeparator}p 复制参数" + + $"{Figures.MiddotSeparator}{Figures.UpArrow}{Figures.DownArrow} 导航{Figures.MiddotSeparator}esc 返回"; + + /// 内容高度 = margin 1 + 上边框 1 + 键位提示行 1。 + public int MeasureHeight(int width) => 3; + + /// 渲染:margin 行留空 → dim 全宽 ─ 上边框 → 1 空格缩进 dim 键位提示行。 + public void Render(Screen screen, Rect frame, Theme theme) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + RenderTopBorder(screen, frame with { Y = frame.Y + 1, Height = 1 }, dim); + screen.WriteText(frame.Y + 2, frame.X, $" {HintsText}", dim, frame.Right); + } + + /// dim 全宽上边框(─ 逐格填充,对齐权限对话框边框形态)。 + private static void RenderTopBorder(Screen screen, Rect row, int dim) + { + for (var col = row.X; col < row.Right; col++) + { + screen[row.Y, col] = new Cell(new Rune('─'), dim, CellWidth.Default); + } + } +} + +/// +/// 消息复制文本提取器(M8-6)— 光标导航态 c(全文)/ p(主参数)按消息项类型取文本 +/// (基准 PRIMARY_INPUT 按工具名映射 path/command/pattern 的本地简化): +/// 用户/助手/错误/工具结果取 Text;diff 取 Summary(p)/ Summary + 补丁全量(c); +/// 工具调用经无损渲染行提取(toolName 与 args 无公开属性 — MessageItems.cs 并行冻结禁改, +/// 超宽单行屏渲染后剥前缀:c = "工具名(参数)"、p = 括号内参数);Markdown 递归块拼接。 +/// 不可提取类型(子代理分组/权限对话框等)返回 null(调用方静默降级 no-op)。 +/// +internal static class MessageActionsCopy +{ + /// 工具调用渲染提取屏宽度(参数无损上限 — 超长截断对齐渲染裁剪语义)。 + private const int ExtractWidth = 4096; + + /// 按消息项类型提取复制文本(primaryOnly=true 取主参数 p / false 取全文 c;null = 不可复制)。 + public static string? TryGetText(IScrollItem item, bool primaryOnly) => item switch + { + UserMessageItem user => user.Text, + AssistantMessageItem assistant => assistant.Text, + ErrorMessageItem error => error.Text, + ToolResultItem result => result.Text, + DiffResultItem diff => primaryOnly ? diff.Summary : DiffFullText(diff), + ToolCallItem tool => primaryOnly ? ToolCallArgs(tool) : ToolCallLine(tool), + MarkdownMessageItem markdown => MarkdownFullText(markdown), + _ => null, + }; + + /// diff 全文 = Summary + 各 hunk(头 + 前缀行 — 渲染同构文本形态)。 + private static string DiffFullText(DiffResultItem diff) + { + var builder = new StringBuilder(diff.Summary); + foreach (var hunk in diff.Patch) + { + builder.Append('\n').Append(hunk.Header); + foreach (var line in hunk.Lines) + { + builder.Append('\n').Append(line.Kind switch + { + DiffLineKind.Added => "+", + DiffLineKind.Removed => "-", + _ => " ", + }).Append(line.Content); + } + } + + return builder.ToString(); + } + + /// 工具调用主参数 — 渲染行剥 ● 前缀 + 工具名 + 括号(格式异常防御回退整行参数段)。 + private static string? ToolCallArgs(ToolCallItem item) + { + var line = ToolCallLine(item); + if (line is null) + { + return null; + } + + var open = line.IndexOf('('); + if (open <= 0 || line[^1] != ')') + { + return line; + } + + return line[(open + 1)..^1]; + } + + /// 工具调用渲染行提取("● 工具名(参数)" — 剥 ● 前缀后全文形态)。 + private static string? ToolCallLine(ToolCallItem item) + { + var rendered = RenderToolCallRow(item); + return rendered is { Length: > 2 } && rendered.StartsWith($"{Figures.MessageBullet} ", StringComparison.Ordinal) + ? rendered[2..] + : rendered; + } + + /// 无损渲染工具调用行 — 超宽两行屏(参数不截断;行 0 = item 自带 margin 留空, + /// 行 1 = 正文)渲染后提取正文行纯文本(样式无关:零宽字符跳过/宽字符占位尾格跳过由 + /// WriteText 与提取共同保证)。 + private static string? RenderToolCallRow(ToolCallItem item) + { + using var screen = new Screen(ExtractWidth, 2); + item.Render(screen, new Rect(0, 0, ExtractWidth, 2), Theme.Dark); + return RowPlainText(screen, 1); + } + + /// 行纯文本提取 — 尾部空格列截断 + 宽字符占位尾格跳过;全空行返回 null。 + private static string? RowPlainText(Screen screen, int row) + { + var last = -1; + for (var col = screen.Cols - 1; col >= 0; col--) + { + if (screen[row, col].Value != new Rune(' ')) + { + last = col; + break; + } + } + + if (last < 0) + { + return null; + } + + var builder = new StringBuilder(); + for (var col = 0; col <= last; col++) + { + var cell = screen[row, col]; + if (cell.Width == CellWidth.SpacerTail) + { + continue; + } + + builder.Append(cell.Value.ToString()); + } + + return builder.ToString(); + } + + /// Markdown 全文 — 块递归拼接(段/标题段序列、代码原文、引用与列表项递归、 + /// 分隔线、表格单元格 | 连接),顶层块间换行。 + private static string? MarkdownFullText(MarkdownMessageItem item) + { + var builder = new StringBuilder(); + foreach (var block in item.Document.Blocks) + { + AppendBlock(builder, block); + builder.Append('\n'); + } + + if (builder.Length == 0) + { + return null; + } + + var text = builder.ToString().TrimEnd('\n'); + return text.Length == 0 ? null : text; + } + + /// 单块文本追加(嵌套块递归)。 + private static void AppendBlock(StringBuilder builder, MarkdownBlock block) + { + switch (block) + { + case MarkdownParagraph paragraph: + AppendSegments(builder, paragraph.Segments); + break; + case MarkdownHeading heading: + AppendSegments(builder, heading.Segments); + break; + case MarkdownCode code: + builder.Append(code.Text); + break; + case MarkdownQuote quote: + AppendChildBlocks(builder, quote.Children); + break; + case MarkdownList list: + AppendListItems(builder, list); + break; + case MarkdownRule: + builder.Append("---"); + break; + case MarkdownTable table: + AppendTable(builder, table); + break; + } + } + + /// 子块序列追加(块间换行 — 引用/列表项子块)。 + private static void AppendChildBlocks(StringBuilder builder, IReadOnlyList children) + { + for (var i = 0; i < children.Count; i++) + { + if (i > 0) + { + builder.Append('\n'); + } + + AppendBlock(builder, children[i]); + } + } + + /// 列表项追加(无序 "- " / 有序 "N. " 前缀 + 项内子块递归)。 + private static void AppendListItems(StringBuilder builder, MarkdownList list) + { + for (var i = 0; i < list.Items.Count; i++) + { + if (i > 0) + { + builder.Append('\n'); + } + + builder.Append(list.Ordered ? $"{list.Start + i}. " : "- "); + AppendChildBlocks(builder, list.Items[i].Children); + } + } + + /// 表格追加(表头 + 数据行,单元格 | 连接 — 渲染同构文本形态)。 + private static void AppendTable(StringBuilder builder, MarkdownTable table) + { + AppendRow(builder, table.Headers); + foreach (var row in table.Rows) + { + builder.Append('\n'); + AppendRow(builder, row); + } + } + + /// 单行追加(单元格 | 连接,每单元格为段序列)。 + private static void AppendRow(StringBuilder builder, IReadOnlyList> cells) + { + for (var i = 0; i < cells.Count; i++) + { + if (i > 0) + { + builder.Append(" | "); + } + + AppendSegments(builder, cells[i]); + } + } + + /// 行内段序列追加(拼接段文本)。 + private static void AppendSegments(StringBuilder builder, IReadOnlyList segments) + { + foreach (var segment in segments) + { + builder.Append(segment.Text); + } + } +} diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index 7fbacc0d9..aaf26b664 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -138,8 +138,9 @@ public void Render(Screen screen, Rect frame, Theme theme) => /// 带补丁的工具结果项(M7-2 StructuredDiff)— summary 行沿用 MessageResponse 形态, /// 后接 hunk 块(4 空格缩进:header dim / added DiffAddedWord / removed DiffRemovedWord / context dim); /// patch 全量渲染(基准 update 渲染完整 diff 不截断 — FileWriteTool/UI.tsx "update renders the full diff"), -/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例, -/// Ctrl+O 展开交互为 M7 后续项)。 +/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例); +/// M8-4 工具折叠交互统一:IsExpanded/ToggleExpand 切换展开态 — 展开全量渲染 patch(折叠提示行消失)、 +/// 折叠回 10 行预览 + 提示行;MeasureHeight 随展开态变化(ScrollBox 每帧全量重渲自动生效 — 无需失效通知)。 /// public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem, ICursorNavigable, ITextMatchable { @@ -155,19 +156,27 @@ public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollIt /// 结构化补丁 hunks(驱动侧断言与渲染共享)。 public DiffHunk[] Patch { get; } = patch; + /// 展开态(Ctrl+O 切换 — M8-4 工具折叠交互统一):true = 全量渲染 patch,false = 10 行预览 + 折叠提示。 + public bool IsExpanded { get; private set; } + + /// 切换展开态(高度变化由下次 MeasureHeight 反映 — 对齐 ToggleThinking 先例)。 + public void ToggleExpand() => IsExpanded = !IsExpanded; + /// 补丁总行数(每 hunk = header 1 行 + diff 行数)。 private int TotalPatchRows => Patch.Sum(static hunk => 1 + hunk.Lines.Length); - /// 内容高度 = margin 1 + summary 1 + 预览行数(超预览时为预览上限 + 折叠提示 1 行)。 + /// 内容高度 = margin 1 + summary 1 + 展开全量 patch 行数 / 折叠 min(总行数, 预览上限) + 超限折叠提示 1。 public int MeasureHeight(int width) => - 2 + Math.Min(TotalPatchRows, PreviewPatchRows) + (TotalPatchRows > PreviewPatchRows ? 1 : 0); + 2 + (IsExpanded + ? TotalPatchRows + : Math.Min(TotalPatchRows, PreviewPatchRows) + (TotalPatchRows > PreviewPatchRows ? 1 : 0)); - /// 渲染:margin 留空 → summary(⎿ 形态)→ hunk 预览行 → 折叠提示(如有)。 + /// 渲染:margin 留空 → summary(⎿ 形态)→ hunk 行(展开全量 / 折叠预览上限)→ 折叠提示(折叠态超限时)。 public void Render(Screen screen, Rect frame, Theme theme) { MessageRenderer.RenderToolResult(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, Summary); - var renderedRows = Math.Min(TotalPatchRows, PreviewPatchRows); + var renderedRows = Math.Min(TotalPatchRows, IsExpanded ? TotalPatchRows : PreviewPatchRows); var row = frame.Y + 2; foreach (var hunk in Patch) { @@ -192,7 +201,7 @@ public void Render(Screen screen, Rect frame, Theme theme) } } - if (TotalPatchRows > PreviewPatchRows) + if (!IsExpanded && TotalPatchRows > PreviewPatchRows) { MessageRenderer.RenderDiffCollapseHint(screen, RowFrame(frame, row), theme, TotalPatchRows - PreviewPatchRows); } diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index 7363bc364..0da17f6da 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -119,13 +119,33 @@ public static void RenderDiffLine(Screen screen, Rect frame, Theme theme, DiffLi screen.WriteText(frame.Y, frame.X, $" {prefix}{line.Content}", style, frame.Right); } - /// 渲染折叠提示:" … +N lines (Ctrl+O)"(dim;Ctrl+O 展开交互为 M7 后续项)。 + /// 渲染折叠提示:" … +N lines (Ctrl+O)"(dim;Ctrl+O 切换 DiffResultItem.IsExpanded — M8-4 展开后此行消失)。 public static void RenderDiffCollapseHint(Screen screen, Rect frame, Theme theme, int hiddenLines) { var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); screen.WriteText(frame.Y, frame.X, $" … +{hiddenLines} lines (Ctrl+O)", dim, frame.Right); } + /// + /// 渲染聚合摘要行(M8-4,基准 CollapsedReadSearchContent "Read N files" 形态): + /// ● + 空格 + 动词段 bold + 空格 + 数量段默认样式(结构对齐 RenderToolCall 的 bold 段 + 默认段)。 + /// + public static void RenderCollapsedToolHeader(Screen screen, Rect frame, Theme theme, string verb, string countPhrase) + { + var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Text }); + var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true }); + screen.WriteText(frame.Y, frame.X, $"{Figures.MessageBullet} ", bullet, frame.Right); + var col = screen.WriteText(frame.Y, frame.X + 2, verb, bold, frame.Right); + screen.WriteText(frame.Y, col, $" {countPhrase}", 0, frame.Right); + } + + /// 渲染聚合最新操作提示行(M8-4):" ⎿ " 前缀 + 最新参数摘要,整行 dim(基准组下方 latest file_path 行)。 + public static void RenderCollapsedToolLastHint(Screen screen, Rect frame, Theme theme, string lastArgs) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $"{Figures.ToolResultIndent}{lastArgs}", dim, frame.Right); + } + /// 渲染权限对话框顶边框 — 整行 ─ 前景 permission 色(基准 borderStyle=round 无角,仅 top,全宽)。 public static void RenderPermissionBorder(Screen screen, Rect frame, Theme theme) { diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index e36f23694..6d09d8d15 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -120,7 +120,10 @@ public sealed class ReplShell private Screen _back = null!; private bool _frontValid; private long _animTimeMs; - private bool _running = true; + private volatile bool _running = true; + + /// 滚动区已请求清屏标记(M8-5 — /clear 回调置位,主循环渲染前消费;volatile 跨线程)。 + private volatile bool _screenClearPending; /// inline 模式上一帧尾光标(内容坐标)。 private CursorPos _cursor; @@ -708,7 +711,7 @@ private void ToggleTranscriptMode() } } - /// 进入 transcript:全部 CoT 展开(verbose — 基准 verbose=true 渲染)+ footer 专用提示 + 清查询态。 + /// 进入 transcript:全部 CoT 展开 + Diff 全量展开(verbose — 基准 verbose=true 渲染)+ footer 专用提示 + 清查询态。 private void EnterTranscriptMode() { _transcriptMode = true; @@ -723,13 +726,17 @@ private void EnterTranscriptMode() { md.ToggleThinking(); } + else if (item is DiffResultItem { IsExpanded: false } diff) + { + diff.ToggleExpand(); + } } _savedNotification = _view.Footer.Notification; _view.Footer = _view.Footer with { Notification = TranscriptFooterHint }; } - /// 退出 transcript:全部 CoT 收起(基准 hidePastThinking)+ footer 还原。 + /// 退出 transcript:全部 CoT 收起 + Diff 收起(基准 hidePastThinking + 折叠还原)+ footer 还原。 private void ExitTranscriptMode() { _transcriptMode = false; @@ -744,6 +751,10 @@ private void ExitTranscriptMode() { md.ToggleThinking(); } + else if (item is DiffResultItem { IsExpanded: true } diff) + { + diff.ToggleExpand(); + } } _view.Footer = _view.Footer with { Notification = _savedNotification }; @@ -977,10 +988,14 @@ private void HandleSearchKey(KeyPress key) } } + /// 底部操作条渲染项(M8-6 消息操作复制 — 导航态激活挂载于 spinner 之前 / 退出卸载)。 + private readonly MessageActionsBar _actionsBar = new(); + /// /// 消息光标导航模态按键(交互层 Step1,对齐基准 MESSAGE_ACTIONS 上下文键绑定): /// ↑/↓/k/j 相邻可导航消息逐条移动、Shift+↑/↓ 跳相邻用户消息、Ctrl+↑/↓ 首末可导航消息、 /// Enter 切换选中分组展开态(基准展开 grouped_tool_use → verbose 逐代理详细渲染)、 + /// c 复制选中消息全文 / p 复制工具主参数(M8-6 — 复制后不退出导航,基准 copy 键位)、 /// Esc 两级退出(任一分组展开先全部收起、再退出导航); /// 其余按键全部吞掉(模态 — 基准全屏模式 PromptInput 卸载语义,不进输入缓冲)。 /// @@ -1037,14 +1052,25 @@ private void HandleCursorNavKey(KeyPress key) case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "j": _cursorNav.Move(items, 1); return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "c": + CopySelectedMessage(primaryOnly: false); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "p": + CopySelectedMessage(primaryOnly: true); + return; default: // 模态:其余键吞掉(不进输入缓冲 — 基准光标态 PromptInput 卸载) return; } } - /// 进入消息光标导航(Shift+↑ — 定位最后一条用户消息;无可导航消息 no-op)。 - private void BeginCursorNavigation() => _cursorNav.Begin(_view.Messages.Items); + /// 进入消息光标导航(Shift+↑ — 定位最后一条用户消息;无可导航消息 no-op) + /// + 挂载底部操作条(激活成功才挂 — 无可导航消息空会话不出操作条)。 + private void BeginCursorNavigation() + { + _cursorNav.Begin(_view.Messages.Items); + ShowActionsBar(); + } /// Enter 切换选中分组展开态(选中非分组消息 no-op — 基准仅 grouped_tool_use 可展开)。 private void ToggleSelectedGroupExpansion() @@ -1057,7 +1083,8 @@ private void ToggleSelectedGroupExpansion() } } - /// Esc 两级退出 — 任一分组展开先全部收起(导航保持);否则退出光标导航。 + /// Esc 两级退出 — 任一分组展开先全部收起(导航保持、操作条保持); + /// 否则退出光标导航并卸载底部操作条。 private void EscapeCursorNavigation() { var expandedGroups = _view.Messages.Items @@ -1075,6 +1102,7 @@ private void EscapeCursorNavigation() } _cursorNav.Exit(); + HideActionsBar(); } /// 补全激活时的专属键拦截(基准 Autocomplete 上下文): @@ -1373,6 +1401,17 @@ private void PumpQueuedInputs() /// internal = resize/渲染测试的单帧驱动面。 internal void RenderAndFlush() { + if (_screenClearPending) + { + _screenClearPending = false; + // 终端层清屏 + 清 scrollback(对话历史滚入区)+ 光标回原点;渲染基线重置 — 下一帧按首帧语义重绘 banner+spinner+输入区 + _output.Write(Sgr.Reset + Csi.EraseScreen() + Csi.EraseSavedLines() + Csi.CursorPosition(1, 1)); + _output.Flush(); + _frontValid = false; + _prevOverride = null; + _cursor = new CursorPos(Col: 0, Row: 0); + } + SyncSuggestionState(); SyncCursorHighlight(); EnsureScreens(); @@ -1490,6 +1529,24 @@ public void WriteExit() _output.Flush(); } + /// 清滚动区(M8-5 — /clear 接线,引擎回合线程回调): + /// 移除 banner 与常驻 spinner 之间全部消息项(保留 SeedContent 不变式)+ 置清屏待决标记 — + /// 终端层 2J+3J 清屏延到主循环 RenderAndFlush 消费(回调线程不写终端 — 与渲染循环并发写交错)。 + public void ClearMessagesExceptBannerAndSpinner() + { + for (var i = _view.Messages.Count - 2; i > 0; i--) + { + _view.Messages.RemoveAt(i); + } + + _view.Messages.ScrollToBottom(); + _screenClearPending = true; + } + + /// 请求退出主循环(M8-5 — /exit 接线,引擎回合线程回调): + /// 置 _running=false 对齐 Ctrl+D 语义,Run() 当前拍收尾返回后由宿主 WriteExit 收尾。 + public void RequestExit() => _running = false; + /// 终端光标落到输入文本行(视口模式 — 0 基 Rows-2 → 1 基 Rows-1),列随编辑偏移。 private void PositionCursor() { @@ -1508,4 +1565,67 @@ private void PaceTick(long tickStart) Thread.Sleep(sleepMs); } } + + /// 复制选中消息(M8-6 消息操作复制 — 光标导航态 c 全文 / p 工具主参数): + /// 剪贴板委托 null(未注入)或选中项无可复制文本时静默无操作(对齐 CopyLastAssistantMessage + /// 降级先例);复制后不退出导航(基准 copy 键位后光标保持 — 继续导航)。 + private void CopySelectedMessage(bool primaryOnly) + { + if (_copyToClipboard is null) + { + return; + } + + if (_cursorNav.SelectedIndex is not (>= 0 and var index) || index >= _view.Messages.Count) + { + return; + } + + var text = MessageActionsCopy.TryGetText(_view.Messages.Items[index], primaryOnly); + if (text is null) + { + return; + } + + _copyToClipboard(text); + PostCopyFeedback(); + } + + /// 复制反馈 — "已复制到剪贴板" 消息插到操作条之前(消息流末尾语义,操作条/spinner + /// 尾部常驻位保持;反馈插入选中项之后不影响光标索引);无操作条(非导航态防御)退回 + /// AddMessage 语义。 + private void PostCopyFeedback() + { + var feedback = new AssistantMessageItem("已复制到剪贴板"); + var barIndex = _view.Messages.IndexOf(_actionsBar); + if (barIndex < 0) + { + AddMessage(feedback); + return; + } + + _view.Messages.Insert(barIndex, feedback); + } + + /// 挂载底部操作条 — 导航态激活时插到常驻 spinner 之前(SpinnerItem 同款尾部常驻模式; + /// 闲时 spinner 高度 0 = 操作条即滚动区末行,视觉输入框上方);未激活/已挂载幂等 no-op。 + private void ShowActionsBar() + { + if (!_cursorNav.IsCursorActive || _view.Messages.IndexOf(_actionsBar) >= 0) + { + return; + } + + _view.Messages.Insert(_view.Messages.Count - 1, _actionsBar); + } + + /// 卸载底部操作条 — 导航态退出时移除(未挂载幂等 no-op)。 + private void HideActionsBar() + { + var index = _view.Messages.IndexOf(_actionsBar); + if (index >= 0) + { + _view.Messages.RemoveAt(index); + } + } } diff --git a/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs b/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs new file mode 100644 index 000000000..664d07d63 --- /dev/null +++ b/native_tui/tests/NativeTui.Host.Tests/EngineSessionCommandCallbacksTests.cs @@ -0,0 +1,167 @@ +namespace NativeTui.Host.Tests; + +/// +/// M8-5 命令面交互回调测试 — EngineSession.DispatchSlashCommandAsync 的回调透传链: +/// TuiCommandCallbacks(Bind 延迟绑定容器,对齐 TuiPermissionBridge 形态)→ +/// SlashCommandRunner.RunAsync(clearScreen/onExitRequested 透传)→ 命令执行 +/// (ClearCommand context.ClearScreen?.Invoke / ExitCommand ShouldContinue=false → onExitRequested)。 +/// fake DI(IChatService/ICodeService/IPlanService/IToolRegistry stub)驱动真实命令注册链 +/// (ChatCommandRegistry 兜底生成注册 — /clear、/exit 命中斜杠不经 MCP),零引擎依赖纯回调管道验证; +/// 组装层全链(Program 接线 → ReplShell 能力)由主智能体收编 ReplShell 公开入口后闭环。 +/// +public sealed class EngineSessionCommandCallbacksTests { + /// /clear 回调透传 — ClearCommand 执行链触发 clearScreen 回调 + 引擎历史真实清除。 + [Fact] + public async Task Dispatch_Clear_InvokesClearScreenCallbackAndClearsHistory() { + var chat = new FakeChatService(); + using var services = BuildFakeServices(chat); + var cleared = false; + var callbacks = new TuiCommandCallbacks(); + callbacks.Bind(clearScreen: () => cleared = true, onExitRequested: null); + var consumer = new InProcessEventConsumer(); + + await EngineSession.DispatchSlashCommandAsync(services, "/clear", consumer, callbacks, CancellationToken.None); + + Assert.True(cleared, "/clear 应触发 clearScreen 回调(清 TUI 滚动区接线点)"); + Assert.Equal(1, chat.ClearHistoryCalls); + } + + /// /clear 输出上屏 — 清空反馈经 consumer.OnText(AnsiStripper 剥离后)→ token_delta。 + [Fact] + public async Task Dispatch_Clear_EmitsFeedbackText() { + var chat = new FakeChatService(); + using var services = BuildFakeServices(chat); + var callbacks = new TuiCommandCallbacks(); + var consumer = new InProcessEventConsumer(); + + await EngineSession.DispatchSlashCommandAsync(services, "/clear", consumer, callbacks, CancellationToken.None); + + var events = consumer.DrainEvents(); + Assert.Contains(events, static e => + e is BridgeEvent.TokenDelta { Text: { } text } && text.Contains("已清空", StringComparison.Ordinal)); + } + + /// /exit 回调透传 — ExitCommand 非交互直退(ShouldContinue=false)→ onExitRequested 回调触发。 + [Fact] + public async Task Dispatch_Exit_InvokesExitRequestedCallback() { + var chat = new FakeChatService(); + using var services = BuildFakeServices(chat); + var exitRequested = false; + var callbacks = new TuiCommandCallbacks(); + callbacks.Bind(clearScreen: null, onExitRequested: () => exitRequested = true); + var consumer = new InProcessEventConsumer(); + + await EngineSession.DispatchSlashCommandAsync(services, "/exit", consumer, callbacks, CancellationToken.None); + + Assert.True(exitRequested, "/exit ShouldContinue=false 应触发 onExitRequested 回调(TUI 主循环结束接线点)"); + } + + /// 未绑定降级 — Bind 前回调为 null:/clear、/exit 命令本体照常执行不抛(?.Invoke 安全跳过)。 + [Fact] + public async Task Dispatch_UnboundCallbacks_ClearAndExitExecuteSafely() { + var chat = new FakeChatService(); + using var services = BuildFakeServices(chat); + var callbacks = new TuiCommandCallbacks(); + + await EngineSession.DispatchSlashCommandAsync(services, "/clear", new InProcessEventConsumer(), callbacks, CancellationToken.None); + await EngineSession.DispatchSlashCommandAsync(services, "/exit", new InProcessEventConsumer(), callbacks, CancellationToken.None); + + Assert.Equal(1, chat.ClearHistoryCalls); + } + + /// 容器契约 — Bind 前回调 null + IsBound false。 + [Fact] + public void TuiCommandCallbacks_BeforeBind_CallbacksAreNull() { + var callbacks = new TuiCommandCallbacks(); + + Assert.Null(callbacks.ClearScreen); + Assert.Null(callbacks.OnExitRequested); + Assert.False(callbacks.IsBound); + } + + /// 容器契约 — Bind 后委托同引用暴露 + IsBound true + 调用透传生效。 + [Fact] + public void TuiCommandCallbacks_AfterBind_ExposesBoundDelegates() { + var callbacks = new TuiCommandCallbacks(); + var cleared = false; + var exited = false; + Action clear = () => cleared = true; + Action exit = () => exited = true; + + callbacks.Bind(clear, exit); + + Assert.Same(clear, callbacks.ClearScreen); + Assert.Same(exit, callbacks.OnExitRequested); + Assert.True(callbacks.IsBound); + callbacks.ClearScreen!.Invoke(); + callbacks.OnExitRequested!.Invoke(); + Assert.True(cleared); + Assert.True(exited); + } + + /// fake DI — SlashCommandRunner 必需依赖(IChatService 真实记录 ClearHistory; + /// ICodeService/IPlanService/IToolRegistry 为签名占位 stub — /clear、/exit 命中斜杠不触达)。 + private static ServiceProvider BuildFakeServices(FakeChatService chat) { + return new ServiceCollection() + .AddSingleton(chat) + .AddSingleton(new StubCodeService()) + .AddSingleton(new StubPlanService()) + .AddSingleton(new StubToolRegistry()) + .BuildServiceProvider(); + } + + /// IChatService 记录桩 — ClearHistoryAsync 计数(/clear 断言面),其余成员不支持。 + private sealed class FakeChatService : IChatService { + public int ClearHistoryCalls; + + public Task ClearHistoryAsync(CancellationToken cancellationToken = default) { + ClearHistoryCalls++; + return Task.CompletedTask; + } + + public Task SendMessageAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public IAsyncEnumerable SendMessageStreamAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public IAsyncEnumerable StreamWithEventsAsync(string message, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetMessageListAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SetSystemPromptAsync(string systemPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RewindLastTurnAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RewindToMessageIndexAsync(int messageIndex, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RewindToStartAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task LoadSessionMessagesAsync(IReadOnlyList messages, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CompactHistoryAsync(string summary, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public ValueTask DisposeAsync() => default; + } + + /// ICodeService 签名占位桩 — BuildCommandServiceProvider 必需依赖,命令执行不触达。 + private sealed class StubCodeService : ICodeService { + public Task GenerateCodeAsync(string prompt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task AnalyzeCodeAsync(string code, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExecuteCodeAsync(string code, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + /// IPlanService 签名占位桩 — BuildCommandServiceProvider 必需依赖,命令执行不触达。 + private sealed class StubPlanService : IPlanService { + public Task ExecutePlanAsync(string userPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExecutePlanWithResultAsync(string userPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + /// IToolRegistry 签名占位桩 — 使 CmdMap 构造(非 null 走斜杠优先路由);/clear、/exit 命中斜杠,MCP 面不触达。 + private sealed class StubToolRegistry : IToolRegistry { + public ValueTask DisposeAsync() => default; + public Task RegisterToolAsync(IToolHandler handler, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RegisterToolsBatchAsync(IReadOnlyList handlers, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RegisterToolAsync(string name, string description, ToolSchema inputSchema, ToolHandler handler, CancellationToken cancellationToken = default, ToolKind kind = ToolKind.System, string? groupName = null, ToolTimeoutPolicy? timeoutPolicy = null, string? category = null) => throw new NotSupportedException(); + public Task UnregisterToolAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetToolAsync(string toolName, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task> GetAllToolsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExecuteToolAsync(string toolName, Dictionary arguments, CancellationToken cancellationToken = default, ToolProgressCallback? onProgress = null) => throw new NotSupportedException(); + public Task GetToolInfoAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetAllToolInfosAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ContainsToolAsync(string toolName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCountAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ClearAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetGroupNamesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetToolsByKindAsync(ToolKind kind, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetToolsByGroupAsync(string groupName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs index 0cb11c15b..80a1955d4 100644 --- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs +++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs @@ -1,5 +1,7 @@ global using System.Text; +global using System.Text.Json; global using System.Collections.Concurrent; +global using System.Collections.Frozen; global using Core.Configuration; global using JoinCode; global using JoinCode.Abstractions.Configuration; @@ -9,9 +11,11 @@ global using JoinCode.Abstractions.Exceptions; global using JoinCode.Abstractions.Interfaces; global using JoinCode.Abstractions.LLM.Chat; +global using JoinCode.Abstractions.Models; global using JoinCode.Abstractions.Models.Diff; global using JoinCode.Abstractions.Security.Permission; global using JoinCode.Abstractions.Security.Shell; +global using JoinCode.Abstractions.Tools; global using JoinCode.Abstractions.Utils; global using JoinCode.Adapters; global using JoinCode.App.Builder; diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index 6495621b4..71b4d8c38 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -302,4 +302,20 @@ public void MessageId_IncrementsAcrossTurns_NoThinkingDefaults() Assert.All(all, m => Assert.Equal(0, m.ThinkingDurationMs)); Assert.All(all, m => Assert.Null(m.ThinkingContent)); } + + /// M8-7 上下文余量 — OnContextUsage 三参直传 ContextUsage 内存事件 + /// (percentLeft 引擎侧算好,TUI 侧零计算直路由 footer 告警)。 + [Fact] + public void OnContextUsage_EnqueuesContextUsageEvent() + { + var consumer = new InProcessEventConsumer(); + + consumer.OnContextUsage(180_000, 200_000, 8); + + var events = consumer.DrainEvents(); + var usage = Assert.IsType(Assert.Single(events)); + Assert.Equal(180_000, usage.PromptTokens); + Assert.Equal(200_000, usage.ContextWindowTokens); + Assert.Equal(8, usage.PercentLeft); + } } diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs index 1c8f00648..8abe4e9d0 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs @@ -368,4 +368,206 @@ public void BeginTurn_ClearsLeftoverStreamingItem() Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem); } + + [Fact] + public void Apply_ConsecutiveSameToolCalls_CollapseIntoSingleItem() + { + // M8-4 聚合路由:首次独立上屏 ToolCallItem,第二次同名到达替换为 CollapsedToolItem(Count=2) + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.ToolCall("Read", "src/a.cs")); + _driver.Apply(new BridgeEvent.ToolCall("Read", "src/b.cs")); + + var collapsed = Assert.IsType(_view.Messages.Items[^2]); + Assert.Equal("Read", collapsed.ToolName); + Assert.Equal(2, collapsed.Count); + Assert.Equal("src/b.cs", collapsed.LastArgs); + Assert.Same(_spinner, _view.Messages.Items[^1]); + Assert.Equal(3, _view.Messages.Count); + } + + [Fact] + public void Apply_SingleAggregatableCall_StaysPlainToolCallItem() + { + // 单次调用不聚合 — 保持 ToolCallItem 类型(现有断言面/视觉形态零变化) + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.ToolCall("Read", "src/a.cs")); + + Assert.IsType(_view.Messages.Items[^2]); + Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem); + } + + [Fact] + public void Apply_ThirdSameCall_RecordsIntoExistingCollapsedItem() + { + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs")); + _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs")); + var collapsed = Assert.IsType(_view.Messages.Items[^2]); + + _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs")); + + // 聚合激活后后续同名原地 Record — 不新增消息项 + Assert.Same(collapsed, _view.Messages.Items[^2]); + Assert.Equal(3, collapsed.Count); + Assert.Equal("c.cs", collapsed.LastArgs); + Assert.Equal(3, _view.Messages.Count); + } + + [Fact] + public void Apply_ToolResultBetweenSameCalls_DoesNotBreakAggregation() + { + // 聚合仅统计 ToolCallItem — 工具结果照旧独立上屏且不打断链 + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs")); + _driver.Apply(new BridgeEvent.ToolResult("41 lines")); + + _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs")); + + var collapsed = Assert.IsType(_view.Messages.Items[^3]); + Assert.Equal(2, collapsed.Count); + Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolResultItem)); + } + + [Fact] + public void Apply_DifferentAggregatableTool_BreaksChainForIndependentItems() + { + // 不同名工具到达即结束当前聚合 — 三次调用互不聚合,各自独立上屏 + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs")); + _driver.Apply(new BridgeEvent.ToolCall("Grep", "pattern")); + _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs")); + + Assert.Equal(3, _view.Messages.Items.Count(static item => item is ToolCallItem)); + Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem); + } + + [Fact] + public void Apply_NonAggregatableTools_NeverAggregate() + { + // Edit/Bash/Write 等非聚合类工具照旧独立上屏 + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Bash", "ls")); + _driver.Apply(new BridgeEvent.ToolCall("Bash", "pwd")); + + Assert.Equal(2, _view.Messages.Items.Count(static item => item is ToolCallItem)); + Assert.DoesNotContain(_view.Messages.Items, static item => item is CollapsedToolItem); + } + + [Fact] + public void Apply_AssistantMessage_BreaksAggregationChain() + { + // 文本消息到达断链 — 之后的同名调用重新独立上屏(不被旧聚合吸收) + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs")); + _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs")); + _driver.Apply(new BridgeEvent.AssistantMessage("正文")); + + _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs")); + + var collapsedItems = _view.Messages.Items.OfType().ToList(); + Assert.Single(collapsedItems); + Assert.Equal(2, collapsedItems[0].Count); + Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolCallItem)); + } + + [Fact] + public void EndTurn_BreaksAggregationChain() + { + // 回合边界断链 — 新回合同名调用重新独立上屏 + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "a.cs")); + _driver.Apply(new BridgeEvent.ToolCall("Read", "b.cs")); + _driver.EndTurn(); + + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.ToolCall("Read", "c.cs")); + + Assert.Equal(1, _view.Messages.Items.Count(static item => item is CollapsedToolItem)); + Assert.Equal(1, _view.Messages.Items.Count(static item => item is ToolCallItem)); + } + + [Fact] + public void Apply_GrepConsecutiveCalls_AggregatesWithSearchVerb() + { + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.ToolCall("Grep", "TODO")); + _driver.Apply(new BridgeEvent.ToolCall("Grep", "FIXME")); + + var collapsed = Assert.IsType(_view.Messages.Items[^2]); + Assert.Equal("搜索 2 次", collapsed.SummaryText); + Assert.Equal("FIXME", collapsed.LastArgs); + } + + /// M8-7 上下文余量 — percentLeft 阈值路由(TokenWarning.tsx:166-177 汉化): + /// ≤10% 告急(Critical + error 文案)、≤25% 不足(Warning + warning 文案)、>25% 清空(防噪音)。 + [Fact] + public void Apply_ContextUsage_CriticalThreshold_SetsCriticalWarning() + { + _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8)); + + Assert.Equal("上下文告急 (8%) · 运行 /compact", _view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.Critical, _view.Footer.ContextWarningLevel); + } + + [Fact] + public void Apply_ContextUsage_AtBoundary10_IsCritical() + { + _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 10)); + + Assert.Equal("上下文告急 (10%) · 运行 /compact", _view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.Critical, _view.Footer.ContextWarningLevel); + } + + [Fact] + public void Apply_ContextUsage_WarningThreshold_SetsWarning() + { + _driver.Apply(new BridgeEvent.ContextUsage(150_000, 200_000, PercentLeft: 20)); + + Assert.Equal("上下文不足 (20%) · 运行 /compact", _view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.Warning, _view.Footer.ContextWarningLevel); + } + + [Fact] + public void Apply_ContextUsage_AtBoundary25_IsWarning() + { + _driver.Apply(new BridgeEvent.ContextUsage(150_000, 200_000, PercentLeft: 25)); + + Assert.Equal("上下文不足 (25%) · 运行 /compact", _view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.Warning, _view.Footer.ContextWarningLevel); + } + + [Fact] + public void Apply_ContextUsage_AboveThreshold_ClearsWarning() + { + _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8)); + + _driver.Apply(new BridgeEvent.ContextUsage(90_000, 200_000, PercentLeft: 55)); + + Assert.Null(_view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.None, _view.Footer.ContextWarningLevel); + } + + [Fact] + public void Apply_ContextUsage_AtBoundary26_IsCleared() + { + _driver.Apply(new BridgeEvent.ContextUsage(90_000, 200_000, PercentLeft: 26)); + + Assert.Null(_view.Footer.ContextWarning); + Assert.Equal(ContextAlertLevel.None, _view.Footer.ContextWarningLevel); + } + + /// 告警独立槽位 — 不触碰 Notification(临时通知语义由 ReplShell 管理,互不覆盖)。 + [Fact] + public void Apply_ContextUsage_DoesNotTouchNotification() + { + _view.Footer = _view.Footer with { Notification = "◐ 中等 · /effort" }; + + _driver.Apply(new BridgeEvent.ContextUsage(180_000, 200_000, PercentLeft: 8)); + + Assert.Equal("◐ 中等 · /effort", _view.Footer.Notification); + Assert.Equal("上下文告急 (8%) · 运行 /compact", _view.Footer.ContextWarning); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs new file mode 100644 index 000000000..dd6351fe8 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/CollapsedToolItemTests.cs @@ -0,0 +1,108 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 工具调用聚合项测试(M8-4)— 连续同名可聚合调用(Read/Grep/Glob)的单行摘要投影: +/// 单次(Count=1)渲染与 ToolCallItem 像素一致(● text 色 + bold 工具名 + 默认 (参数) — 单次视觉不变); +/// 聚合态(Count≥2)摘要行 "● 读取 N 个文件"(● bullet + 动词 bold + 数量段默认样式)+ +/// 最新操作提示行 " ⎿ {最新参数}" 整行 dim(对齐基准 CollapsedReadSearchContent 组下方 latest 行,中文文案)。 +/// +public sealed class CollapsedToolItemTests +{ + [Fact] + public void Render_SingleCall_MatchesToolCallForm() + { + using var screen = new Screen(cols: 40, rows: 3); + var item = new CollapsedToolItem("Read", ["src/a.cs"]); + + item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark); + + // row0 margin 留空;row1 与 ToolCallItem 像素一致:● text 色 + bold 工具名 + 默认 (参数) + var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text }); + var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true }); + Assert.Equal(new Rune(' '), screen[0, 0].Value); + Assert.Equal(new Rune('●'), screen[1, 0].Value); + Assert.Equal(bullet, screen[1, 0].StyleId); + Assert.Equal(bold, screen[1, 2].StyleId); + Assert.Equal(0, screen[1, 6].StyleId); + Assert.Equal("● Read(src/a.cs)", RowText.Of(screen, 1, 0, 16)); + } + + [Fact] + public void Render_TwoCalls_AggregateSummaryAndLastHint() + { + using var screen = new Screen(cols: 40, rows: 4); + var item = new CollapsedToolItem("Read", ["src/a.cs", "src/b.cs"]); + + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + + // row1 摘要 = ● text 色 + bold "读取" + 默认 " 2 个文件"(结构对齐 RenderToolCall 的 bold 段 + 默认段; + // 汉字占 2 cell(SpacerTail 跳过)— 15 cell 覆盖全行) + var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text }); + var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true }); + Assert.Equal("● 读取 2 个文件", RowText.Of(screen, 1, 0, 15)); + Assert.Equal(bullet, screen[1, 0].StyleId); + Assert.Equal(bold, screen[1, 2].StyleId); + Assert.Equal(bold, screen[1, 3].StyleId); + Assert.Equal(bold, screen[1, 4].StyleId); + Assert.Equal(0, screen[1, 7].StyleId); + + // row2 最新提示 = " ⎿ src/b.cs" 整行 dim(前缀与正文同 dim 样式) + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(" ⎿ src/b.cs", RowText.Of(screen, 2, 0, 13)); + Assert.Equal(dim, screen[2, 2].StyleId); + Assert.Equal(dim, screen[2, 5].StyleId); + } + + [Fact] + public void MeasureHeight_SingleCallTwoRows_AggregatedThreeRows() + { + Assert.Equal(2, new CollapsedToolItem("Read", ["a.cs"]).MeasureHeight(width: 80)); + Assert.Equal(3, new CollapsedToolItem("Read", ["a.cs", "b.cs"]).MeasureHeight(width: 80)); + } + + [Fact] + public void RecordCall_IncrementsCountAndUpdatesLastArgs() + { + var item = new CollapsedToolItem("Read", ["a.cs"]); + Assert.Equal(1, item.Count); + Assert.Equal("a.cs", item.LastArgs); + + item.RecordCall("b.cs"); + item.RecordCall("c.cs"); + + Assert.Equal(3, item.Count); + Assert.Equal("c.cs", item.LastArgs); + } + + [Fact] + public void SummaryText_PerToolChineseVerbs() + { + Assert.Equal("读取 2 个文件", new CollapsedToolItem("Read", ["a.cs", "b.cs"]).SummaryText); + Assert.Equal("搜索 3 次", new CollapsedToolItem("Grep", ["a", "b", "c"]).SummaryText); + Assert.Equal("查找 2 个模式", new CollapsedToolItem("Glob", ["*.cs", "*.ts"]).SummaryText); + Assert.Equal("Bash × 2", new CollapsedToolItem("Bash", ["ls", "pwd"]).SummaryText); + } + + [Fact] + public void IsAggregatable_OnlyReadGrepGlob() + { + Assert.True(CollapsedToolItem.IsAggregatable("Read")); + Assert.True(CollapsedToolItem.IsAggregatable("Grep")); + Assert.True(CollapsedToolItem.IsAggregatable("Glob")); + Assert.False(CollapsedToolItem.IsAggregatable("Edit")); + Assert.False(CollapsedToolItem.IsAggregatable("Bash")); + Assert.False(CollapsedToolItem.IsAggregatable("Write")); + } + + [Fact] + public void ContainsText_MatchesToolNameSummaryAndAnyCallArgs() + { + var item = new CollapsedToolItem("Read", ["src/alpha.cs", "src/beta.cs"]); + + Assert.True(item.ContainsText("alpha")); + Assert.True(item.ContainsText("BETA")); + Assert.True(item.ContainsText("read")); + Assert.True(item.ContainsText("读取")); + Assert.False(item.ContainsText("gamma")); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs index 50795dddb..8ba44c37c 100644 --- a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs @@ -181,4 +181,52 @@ public void Patch_ExposedForDriverAssertions() Assert.Equal("summary", item.Summary); Assert.Same(hunks, item.Patch); } + + [Fact] + public void IsExpanded_DefaultsFalse() + { + Assert.False(new DiffResultItem("s", [Hunk(Line(DiffLineKind.Added, "x"))]).IsExpanded); + } + + [Fact] + public void ToggleExpand_RendersFullPatchWithoutCollapseHint() + { + // 13 行 patch(header+12):折叠 = 预览 10 + 提示 +3(高度 13);展开 = 全量 13 行(高度 15), + // 原提示行位置(row12)起接续渲染隐藏行 line 9..11 — 无折叠提示行 + using var screen = new Screen(cols: 40, rows: 2 + 13); + var item = new DiffResultItem("Edited a.cs", [LongHunk(12)]); + Assert.Equal(13, item.MeasureHeight(width: 80)); + + item.ToggleExpand(); + + Assert.True(item.IsExpanded); + Assert.Equal(15, item.MeasureHeight(width: 80)); + item.Render(screen, new Rect(0, 0, 40, screen.Rows), Theme.Dark); + Assert.Equal(" line 9", RowText.Of(screen, 12, 0, 11)); + Assert.Equal(" line 10", RowText.Of(screen, 13, 0, 12)); + Assert.Equal(" line 11", RowText.Of(screen, 14, 0, 12)); + } + + [Fact] + public void ToggleExpand_Twice_ReturnsToCollapsedPreview() + { + var item = new DiffResultItem("s", [LongHunk(12)]); + + item.ToggleExpand(); + item.ToggleExpand(); + + Assert.False(item.IsExpanded); + Assert.Equal(13, item.MeasureHeight(width: 80)); + } + + [Fact] + public void ToggleExpand_WithinPreviewLimit_HeightUnchanged() + { + // patch 2 行 ≤ 预览上限 10:展开态无折叠提示,高度不变(展开只对超限 patch 有视觉差异) + var item = new DiffResultItem("s", [Hunk(Line(DiffLineKind.Added, "x"))]); + + item.ToggleExpand(); + + Assert.Equal(4, item.MeasureHeight(width: 80)); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs b/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs new file mode 100644 index 000000000..f3a0cdeda --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/MessageActionsCopyTests.cs @@ -0,0 +1,404 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 消息操作复制测试(M8-6)— 对齐基准 messageActions.tsx 键位语义: +/// 光标导航态 c 复制选中消息全文 / p 复制工具主参数(按消息项类型取文本:工具调用取 args — +/// 渲染行无损提取、用户/助手/工具结果取 Text、diff 取 Summary[p]/Summary+补丁全量[c]、 +/// Markdown 递归块拼接)、复制后不退出导航(继续移动)、剪贴板未注入静默降级(对齐 +/// CopyLastAssistantMessage 先例)、复制反馈消息插到操作条之前;底部操作条 MessageActionsBar +/// 导航态挂载于 spinner 之前(闲时 spinner 高度 0 = 操作条即滚动区末行,视觉输入框上方)、 +/// Esc 退出卸载、分组展开两级 Esc 期间保持、不参与导航不被选中。 +/// +public sealed class MessageActionsCopyTests +{ + /// 构造测试 shell(StringWriter 收渲染输出,固定尺寸源,剪贴板委托捕获到 copied; + /// clipboard=false 模拟宿主未注入剪贴板能力)。 + private static (ReplShell Shell, List Copied) CreateShell(IReadOnlyList seed, bool clipboard = true) + { + var copied = new List(); + var engine = new FakeReplEngine(); + var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); + var shell = new ReplShell(engine, banner, new StringWriter(), seedMessages: seed, + sizeSource: new FakeTerminalSizeSource(80, 24), + clipboard: clipboard ? copied.Add : null); + return (shell, copied); + } + + /// 构造三代理组(不可复制类型 no-op 场景)。 + private static SubagentGroupCoordinator CreateGroup() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "reviewer", "审查"); + return coordinator; + } + + /// 构造带补丁的 diff 结果项(p=Summary / c=Summary+补丁全量场景)。 + private static DiffResultItem CreateDiff() => new("Edited 2 files", + [ + new DiffHunk + { + Header = "@@ -1,2 +1,3 @@", + Lines = + [ + new DiffLine { Kind = DiffLineKind.Context, Content = "上下文" }, + new DiffLine { Kind = DiffLineKind.Removed, Content = "旧行" }, + new DiffLine { Kind = DiffLineKind.Added, Content = "新行" }, + ], + }, + ]); + + /// 构造两块 Markdown 文档项(段落 + 代码块 — 全文递归拼接场景)。 + private static MarkdownMessageItem CreateMarkdown() => new(new MarkdownDocument( + [ + new MarkdownParagraph([new MarkdownSegment("第一段", MarkdownStyle.None)]), + new MarkdownCode("cs", "var x = 1;"), + ])); + + /// Shift+↑ 键(进入消息光标导航)。 + private static ConsoleKeyInfo ShiftUp() => new('\0', ConsoleKey.UpArrow, shift: true, alt: false, control: false); + + /// 普通 ↑ 键。 + private static ConsoleKeyInfo Up() => new('\0', ConsoleKey.UpArrow, shift: false, alt: false, control: false); + + /// 普通 ↓ 键。 + private static ConsoleKeyInfo Down() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: false); + + /// Ctrl+↓ 键(末条可导航消息)。 + private static ConsoleKeyInfo CtrlDown() => new('\0', ConsoleKey.DownArrow, shift: false, alt: false, control: true); + + /// Enter 键。 + private static ConsoleKeyInfo Enter() => new('\r', ConsoleKey.Enter, shift: false, alt: false, control: false); + + /// Esc 键。 + private static ConsoleKeyInfo Esc() => new('\0', ConsoleKey.Escape, shift: false, alt: false, control: false); + + /// 可打印字符键。 + private static ConsoleKeyInfo Char(char c) => new(c, ConsoleKey.None, shift: false, alt: false, control: false); + + /// 滚动区内 "已复制到剪贴板" 反馈消息索引(-1 = 无)。 + private static int IndexOfFeedback(IReadOnlyList items) + { + for (var i = 0; i < items.Count; i++) + { + if (items[i] is AssistantMessageItem { Text: "已复制到剪贴板" }) + { + return i; + } + } + + return -1; + } + + /// 滚动区内操作条索引(-1 = 未挂载)。 + private static int IndexOfActionBar(IReadOnlyList items) + { + for (var i = 0; i < items.Count; i++) + { + if (items[i] is MessageActionsBar) + { + return i; + } + } + + return -1; + } + + /// 全屏文本包含判断(逐行拼接 cell 文本,跳过宽字符占位尾格)。 + private static bool ScreenContains(Screen screen, string text) + { + var builder = new StringBuilder(); + for (var row = 0; row < screen.Rows; row++) + { + builder.Clear(); + for (var col = 0; col < screen.Cols; col++) + { + var cell = screen[row, col]; + if (cell.Width == CellWidth.SpacerTail) + { + continue; + } + + builder.Append(cell.Value.ToString()); + } + + if (builder.ToString().Contains(text, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + // ---- c / p 复制(按消息项类型取文本) ---- + + /// c — 复制选中用户消息全文(Text)+ 复制后不退出导航(选中保持,继续可移动)。 + [Fact] + public void Copy_c_CopiesSelectedUserText_AndStaysNavigating() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + Assert.Equal(3, shell.CursorNav.SelectedIndex); + + shell.HandleKey(Char('c')); + + Assert.Single(copied); + Assert.Equal("q2", copied[0]); + Assert.True(shell.CursorNav.IsCursorActive); + Assert.Equal(3, shell.CursorNav.SelectedIndex); + } + + /// c — 导航到助手消息后复制其全文(Text)。 + [Fact] + public void Copy_c_CopiesAssistantText() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('c')); + + Assert.Single(copied); + Assert.Equal("a1", copied[0]); + } + + /// c — 工具结果消息复制其全文(Text)。 + [Fact] + public void Copy_c_CopiesToolResultText() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolResultItem("结果正文"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('c')); + + Assert.Single(copied); + Assert.Equal("结果正文", copied[0]); + } + + /// p — 工具调用消息复制主参数(args — 渲染行无损提取,基准 PRIMARY_INPUT 本地简化)。 + [Fact] + public void Copy_p_CopiesToolCallArgs() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolCallItem("Read", "src/main.rs"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('p')); + + Assert.Single(copied); + Assert.Equal("src/main.rs", copied[0]); + } + + /// c — 工具调用消息复制全文(工具名 + 括号参数 — 渲染行剥 ● 前缀形态)。 + [Fact] + public void Copy_c_CopiesToolCallToolNameAndArgs() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new ToolCallItem("Read", "src/main.rs"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('c')); + + Assert.Single(copied); + Assert.Equal("Read(src/main.rs)", copied[0]); + } + + /// p — 用户/助手/工具结果消息主参数 = 全文(Text)。 + [Fact] + public void Copy_p_CopiesPlainMessageText() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('p')); + + Assert.Single(copied); + Assert.Equal("a1", copied[0]); + } + + /// diff — p 复制 Summary;c 复制 Summary + 补丁全量(hunk 头 + +/- 前缀行)。 + [Fact] + public void Copy_Diff_pSummary_cFullPatch() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), CreateDiff(), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('p')); + Assert.Single(copied); + Assert.Equal("Edited 2 files", copied[0]); + + shell.HandleKey(Char('c')); + Assert.Equal(2, copied.Count); + Assert.Equal("Edited 2 files\n@@ -1,2 +1,3 @@\n 上下文\n-旧行\n+新行", copied[1]); + } + + /// c — Markdown 消息复制全文(块递归拼接:段落 + 代码块,块间换行)。 + [Fact] + public void Copy_c_CopiesMarkdownBlocks() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), CreateMarkdown(), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + + shell.HandleKey(Char('c')); + + Assert.Single(copied); + Assert.Equal("第一段\nvar x = 1;", copied[0]); + } + + /// c/p — 选中不可复制类型(子代理分组)静默 no-op(不崩、不退出导航、无反馈消息)。 + [Fact] + public void Copy_OnNonCopyableGroupItem_NoOpSilent() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new SubagentGroupItem(CreateGroup())]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Down()); + + shell.HandleKey(Char('c')); + shell.HandleKey(Char('p')); + + Assert.Empty(copied); + Assert.True(shell.CursorNav.IsCursorActive); + Assert.Equal(-1, IndexOfFeedback(shell.View.Messages.Items)); + } + + /// 剪贴板委托未注入(null)— c/p 静默降级无操作(不崩、无反馈消息,对齐 CopyLastAssistantMessage 先例)。 + [Fact] + public void Copy_WithoutClipboardDelegate_SilentNoOp() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1")], clipboard: false); + shell.SeedContent(); + var countBeforeActionsBar = shell.View.Messages.Count; + + shell.HandleKey(ShiftUp()); + shell.HandleKey(Char('c')); + shell.HandleKey(Char('p')); + + Assert.Empty(copied); + Assert.True(shell.CursorNav.IsCursorActive); + Assert.Equal(countBeforeActionsBar + 1, shell.View.Messages.Count); + Assert.Equal(-1, IndexOfFeedback(shell.View.Messages.Items)); + } + + /// 复制反馈 — "已复制到剪贴板" 消息插到操作条之前(消息流末尾语义,操作条/spinner 尾部常驻保持) + /// + 反馈后继续导航与复制(选中索引不受反馈插入影响)。 + [Fact] + public void Copy_AppendsFeedbackAboveActionBar_NavigationStillMoves() + { + var (shell, copied) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Char('c')); + + var items = shell.View.Messages.Items; + var feedbackIndex = IndexOfFeedback(items); + var barIndex = IndexOfActionBar(items); + Assert.True(feedbackIndex >= 0, "反馈消息应已追加"); + Assert.True(barIndex > feedbackIndex, "反馈消息应在操作条之前"); + Assert.IsType(items[^1]); + + shell.HandleKey(Up()); + Assert.Equal(2, shell.CursorNav.SelectedIndex); + shell.HandleKey(Char('p')); + Assert.Equal(2, copied.Count); + Assert.Equal("a1", copied[1]); + } + + // ---- 底部操作条挂载 / 卸载 / 渲染 / 导航语义 ---- + + /// 操作条挂载 — 导航态激活挂到 spinner 之前(spinner 恒最后,操作条为倒数第二项); + /// Esc 退出后卸载(列表复原不含操作条项)。 + [Fact] + public void ActionBar_MountsBeforeSpinnerWhileNavigating_UnmountsOnExit() + { + var (shell, _) = CreateShell([new UserMessageItem("q1")]); + shell.SeedContent(); + + shell.HandleKey(ShiftUp()); + var items = shell.View.Messages.Items; + Assert.True(IndexOfActionBar(items) >= 0); + Assert.IsType(items[^2]); + Assert.IsType(items[^1]); + + shell.HandleKey(Esc()); + Assert.Equal(-1, IndexOfActionBar(shell.View.Messages.Items)); + } + + /// 操作条渲染 — 导航态激活期间帧内含键位提示(enter 展开 …);Esc 退出后不再渲染。 + [Fact] + public void ActionBar_RendersHintsWhileNavigating_DisappearsAfterExit() + { + var (shell, _) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1")]); + shell.SeedContent(); + + shell.HandleKey(ShiftUp()); + shell.RenderAndFlush(); + shell.RenderAndFlush(); + Assert.True(ScreenContains(shell.BackBuffer!, "enter 展开")); + Assert.True(ScreenContains(shell.BackBuffer!, "c 复制")); + Assert.True(ScreenContains(shell.BackBuffer!, "p 复制参数")); + Assert.True(ScreenContains(shell.BackBuffer!, "esc 返回")); + + shell.HandleKey(Esc()); + shell.RenderAndFlush(); + shell.RenderAndFlush(); + Assert.False(ScreenContains(shell.BackBuffer!, "enter 展开")); + } + + /// 操作条不参与导航 — Ctrl+↓ 跳末条可导航消息不落到操作条(非 ICursorNavigable)。 + [Fact] + public void ActionBar_DoesNotParticipateInNavigation() + { + var (shell, _) = CreateShell([new UserMessageItem("q1"), new AssistantMessageItem("a1"), new UserMessageItem("q2")]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Up()); + Assert.Equal(2, shell.CursorNav.SelectedIndex); + + shell.HandleKey(CtrlDown()); + Assert.Equal(3, shell.CursorNav.SelectedIndex); + Assert.IsNotType(shell.View.Messages.Items[shell.CursorNav.SelectedIndex]); + } + + /// 两级 Esc — 有展开组时第一次 Esc 仅收组(导航保持、操作条保持);第二次 Esc 退出并卸载操作条。 + [Fact] + public void ActionBar_KeptWhileCollapsingGroup_RemovedOnSecondEscape() + { + var group = CreateGroup(); + var (shell, _) = CreateShell([new UserMessageItem("q1"), new SubagentGroupItem(group)]); + shell.SeedContent(); + shell.HandleKey(ShiftUp()); + shell.HandleKey(Down()); + shell.HandleKey(Enter()); + Assert.True(group.IsExpanded); + + shell.HandleKey(Esc()); + Assert.False(group.IsExpanded); + Assert.True(shell.CursorNav.IsCursorActive); + Assert.True(IndexOfActionBar(shell.View.Messages.Items) >= 0); + + shell.HandleKey(Esc()); + Assert.False(shell.CursorNav.IsCursorActive); + Assert.Equal(-1, IndexOfActionBar(shell.View.Messages.Items)); + } + + /// 操作条键位提示文案 — 间隔符 · 与全链键位(对齐基准 messageActions 底部提示条形态)。 + [Fact] + public void ActionBar_HintsText_UsesMiddotSeparatedFullKeySet() + { + Assert.Equal("enter 展开 · c 复制 · p 复制参数 · ↑↓ 导航 · esc 返回", MessageActionsBar.HintsText); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs index 4930dc1c0..56781c248 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs @@ -297,6 +297,72 @@ public void MeasureHeight_TwoRowsOnlyWithNotification() Assert.Equal(1, footer.MeasureHeight(new FooterState { Mode = PermissionMode.Auto })); Assert.Equal(2, footer.MeasureHeight(new FooterState { Notification = "◐ medium · /effort" })); } + + /// M8-7 上下文余量告警 — 行2 右对齐三色形态(TokenWarning.tsx:166-177 汉化): + /// Warning 级 warning 色 / Critical 级 error 色;与 Notification 共存时告警优先(持续告警语义 > 临时通知)。 + [Fact] + public void ContextWarning_WarningLevel_SecondRowRightAlignedWarningColor() + { + using var screen = new Screen(cols: 60, rows: 2); + var footer = new FooterBar(); + footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState + { + Mode = PermissionMode.Auto, + ContextWarning = "上下文不足 (20%) · 运行 /compact", + ContextWarningLevel = ContextAlertLevel.Warning, + }); + var warning = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Warning }); + // 文本 32 屏幕列(中文 7 字 ×2 + "(20%) · " 9 + "/compact" 8 + 空格)→ 起始 col 28(Right=60) + Assert.Equal("上下文不足 (20%) · 运行 /compact", RowText.Of(screen, 1, 28, 32)); + Assert.Equal(warning, screen[1, 28].StyleId); + Assert.Equal(warning, screen[1, 59].StyleId); + } + + [Fact] + public void ContextWarning_CriticalLevel_SecondRowRightAlignedErrorColor() + { + using var screen = new Screen(cols: 60, rows: 2); + var footer = new FooterBar(); + footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState + { + Mode = PermissionMode.Auto, + ContextWarning = "上下文告急 (8%) · 运行 /compact", + ContextWarningLevel = ContextAlertLevel.Critical, + }); + var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error }); + // 文本 31 屏幕列(中文 7 字 ×2 + "(8%) · " 8 + "运行" 4 + "/compact" 8 + 空格 1)→ 起始 col 29 + Assert.Equal("上下文告急 (8%) · 运行 /compact", RowText.Of(screen, 1, 29, 31)); + Assert.Equal(error, screen[1, 29].StyleId); + Assert.Equal(error, screen[1, 59].StyleId); + } + + /// 告警与通知同为行2 右对齐 — 同时非空时只渲染告警(Notification 由 12s 超时自然消亡,告警持续到余量回升)。 + [Fact] + public void ContextWarning_OverridesNotificationWhenBothSet() + { + using var screen = new Screen(cols: 60, rows: 2); + var footer = new FooterBar(); + footer.Render(screen, new Rect(0, 0, 60, 2), Theme.Dark, new FooterState + { + Mode = PermissionMode.Auto, + Notification = "◐ 中等 · /effort", + ContextWarning = "上下文告急 (8%) · 运行 /compact", + ContextWarningLevel = ContextAlertLevel.Critical, + }); + var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error }); + Assert.Equal("上下文告急 (8%) · 运行 /compact", RowText.Of(screen, 1, 29, 31)); + // 通知文本不出现在行2(被告警优先占位) + Assert.DoesNotContain("◐", RowText.Of(screen, 1, 0, 29)); + Assert.Equal(error, screen[1, 29].StyleId); + } + + [Fact] + public void MeasureHeight_TwoRowsWithContextWarning() + { + var footer = new FooterBar(); + Assert.Equal(2, footer.MeasureHeight(new FooterState { ContextWarning = "上下文不足 (20%) · 运行 /compact" })); + Assert.Equal(1, footer.MeasureHeight(new FooterState { Mode = PermissionMode.Auto })); + } } /// 行文本提取辅助(golden 断言用)。 diff --git a/test/unit/host.tests/GlobalUsings.cs b/test/unit/host.tests/GlobalUsings.cs index e7437fafa..1f9195171 100644 --- a/test/unit/host.tests/GlobalUsings.cs +++ b/test/unit/host.tests/GlobalUsings.cs @@ -12,6 +12,7 @@ global using Core.Configuration; global using Core.Configuration.Providers; global using Core.Context; +global using Core.Context.Compact; global using Core.Context.Modality; global using Core.CostTracking; global using Core.DependencyInjection; diff --git a/test/unit/host.tests/adapters/SessionControllerTests.cs b/test/unit/host.tests/adapters/SessionControllerTests.cs index b6a20f348..d92a84e80 100644 --- a/test/unit/host.tests/adapters/SessionControllerTests.cs +++ b/test/unit/host.tests/adapters/SessionControllerTests.cs @@ -244,6 +244,84 @@ public void Stop_SetsIsRunningFalse() { Assert.False(controller.IsRunning); } + /// Done 携带真实 usage → 发射 OnContextUsage(M8-7 — 权威算法口径: + /// IContextWindowResolver 窗口 + ICompactService.CalculateWarningState.PercentLeft)。 + [Fact] + public async Task StreamResponseAsync_DoneWithUsage_EmitsContextUsageViaCompactAlgorithm() { + var compact = new Mock(); + compact.Setup(c => c.CalculateWarningState(150_000, 200_000)) + .Returns(new CompactWarningState { + PercentLeft = 37, + IsAboveWarningThreshold = false, + IsAboveErrorThreshold = false, + IsAboveAutoCompactThreshold = false, + IsAtBlockingLimit = false, + IsAboveSoftCompactThreshold = false, + }); + var resolver = new Mock(); + resolver.Setup(r => r.ResolveCurrentContextWindow()).Returns(200_000); + var services = new ServiceCollection(); + services.AddSingleton(compact.Object); + services.AddSingleton(resolver.Object); + var sp = services.BuildServiceProvider(); + + var events = new List { + ChatStreamEvent.Text("ok"), + ChatStreamEvent.Done(usage: new TokenUsage(150_000, 500)), + }; + var chatService = CreateMockChatService(events); + var consumer = new RecordingEventConsumer(); + var turnDiffService = new TurnDiffService(); + var controller = new SessionController(chatService, consumer, turnDiffService, "test-session", sp); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded); + var usage = Assert.Single(consumer.ContextUsages); + Assert.Equal(150_000, usage.promptTokens); + Assert.Equal(200_000, usage.contextWindowTokens); + Assert.Equal(37, usage.percentLeft); + } + + /// Done 携带 usage 但解析器/压缩服务不可达(无 sp)→ 朴素兜底(200K 窗口 + 窗口百分比)。 + [Fact] + public async Task StreamResponseAsync_DoneWithUsage_NoServices_FallsBackToNaivePercent() { + var events = new List { + ChatStreamEvent.Text("ok"), + ChatStreamEvent.Done(usage: new TokenUsage(100_000, 200)), + }; + var chatService = CreateMockChatService(events); + var consumer = new RecordingEventConsumer(); + var turnDiffService = new TurnDiffService(); + var controller = new SessionController(chatService, consumer, turnDiffService, "test-session"); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded); + var usage = Assert.Single(consumer.ContextUsages); + Assert.Equal(100_000, usage.promptTokens); + Assert.Equal(200_000, usage.contextWindowTokens); + Assert.Equal(50, usage.percentLeft); + } + + /// Done 无 usage(mock/无计量路径)→ 不发射 OnContextUsage(防噪音)。 + [Fact] + public async Task StreamResponseAsync_DoneWithoutUsage_DoesNotEmitContextUsage() { + var events = new List { + ChatStreamEvent.Text("ok"), + ChatStreamEvent.Done(), + }; + var chatService = CreateMockChatService(events); + var consumer = new RecordingEventConsumer(); + var turnDiffService = new TurnDiffService(); + var controller = new SessionController(chatService, consumer, turnDiffService, "test-session"); + + var result = await controller.StreamResponseAsync("test", CancellationToken.None).ConfigureAwait(true); + + Assert.True(result.Succeeded); + Assert.Empty(consumer.ContextUsages); + } + [Fact] public async Task StreamResponseAsync_MainAgentPath_CallsSaveContext() { var queryEngine = CreateMockQueryEngine(); @@ -376,6 +454,7 @@ private sealed class RecordingEventConsumer : IEventConsumer { public List<(int triggerCount, int loopStartIndex, string? repeatedPattern)> LoopDetectedEvents { get; } = []; public List TimingSummaryEvents { get; } = []; public List<(TokenUsage? usage, string? modelId)> DoneEvents { get; } = []; + public List<(int promptTokens, int contextWindowTokens, int percentLeft)> ContextUsages { get; } = []; public void OnText(string content) => TextEvents.Add(content); public void OnThinking(string thinking) => ThinkingEvents.Add(thinking); @@ -385,5 +464,6 @@ private sealed class RecordingEventConsumer : IEventConsumer { public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeatedPattern) => LoopDetectedEvents.Add((triggerCount, loopStartIndex, repeatedPattern)); public void OnTimingSummary(string summary) => TimingSummaryEvents.Add(summary); public void OnDone(TokenUsage? usage, string? modelId) => DoneEvents.Add((usage, modelId)); + public void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) => ContextUsages.Add((promptTokens, contextWindowTokens, percentLeft)); } } \ No newline at end of file diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs index 9039db7cf..92c34bd94 100644 --- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs +++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs @@ -30,6 +30,18 @@ public void SubagentCallbacks_EmitSubagentEventLines() { @out.ToString()); } + /// 上下文余量回调映射(M8-7)— OnContextUsage → context_usage 事件行(与 native_tui 侧字段逐一对齐)。 + [Fact] + public void OnContextUsage_EmitsContextUsageEventLine() { + var (consumer, @out) = Create(); + + consumer.OnContextUsage(150_000, 200_000, 13); + + Assert.Equal( + "{\"type\":\"context_usage\",\"promptTokens\":150000,\"contextWindowTokens\":200000,\"percentLeft\":13}\n", + @out.ToString()); + } + [Fact] public void OnText_EmitsTokenDelta_AndOnDoneEmitsFullAssistantMessage() { var (consumer, @out) = Create(); diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs index feab945b6..93b803180 100644 --- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs +++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs @@ -103,6 +103,17 @@ public void Deserialize_TuiEmittedRequestLine_ParsesBack() { Assert.Equal("你好 world", submit.Text); } + /// 上下文余量事件 JSON 形状(M8-7 — 与 native_tui 侧 BridgeEvent.ContextUsage 逐字段镜像: + /// context_usage + promptTokens/contextWindowTokens/percentLeft camelCase 字段)。 + [Fact] + public void Serialize_ContextUsage_CarriesCamelCaseFields() { + var json = JsonSerializer.Serialize( + new CliBridge.BridgeEvent.ContextUsage(150_000, 200_000, 13), CliBridge.BridgeJsonContext.Safe.BridgeEvent); + Assert.Equal( + "{\"type\":\"context_usage\",\"promptTokens\":150000,\"contextWindowTokens\":200000,\"percentLeft\":13}", + json); + } + /// 子代理三事件 JSON 形状(DSG041 批3 — 与 native_tui 侧 BridgeEvent 逐字段镜像: /// subagent_started/subagent_tool_use/subagent_finished,camelCase 字段)。 [Fact]