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" new file mode 100644 index 000000000..e61cb47a7 --- /dev/null +++ "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" @@ -0,0 +1,142 @@ +# TASK064 — TUI 对齐 Claude Code 下一批差距(M8 规划) + +> 状态:**M8-1/2/3(P0 三连)已 implemented**(2026-10-22,提交链 689f830b3 → 423187e9d → aef776136);M8-4..M8-8 + P3 池待做 +> 状态:proposed(2026-10-22;PR #446 合并后双调研挖掘——native_tui 现状侧 + 基准源码侧) +> 来源:用户指令"挖掘还有什么没对齐 Claude Code TUI 功能的,构建新规划" +> 双调研证据:①native_tui 逐文件实证(InProcessEventConsumer/ReplShell/ReplBridgeDriver 等)②基准源码逐项核实(claude-code-rev-main/src,文件:行号 见各项) + +## 一、差距总表(双调研合并) + +| # | 差距项 | 本地现状 | 基准行为(证据) | 级别 | +|---|--------|---------|----------------|------| +| 1 | **逐字流式渲染** | ❌ busy 期仅 spinner;`TokenDelta(int Length)` 协议无文本(BridgeEvent.cs:65),`OnDone` 才整条上屏(InProcessEventConsumer.cs:96-110) | `StreamingMarkdown` 块级增量——按顶层块边界切稳定前缀 memoize,增长块逐 delta 重 parse(Markdown.tsx:176-235);`⎿` 前缀流式行(Messages.tsx:703-709) | **P0** | +| 2 | **Esc 打断闭环** | ⚠️ 双击 500ms 打断已有(ReplShell.cs:941-965);打断后已流文本**丢失**、prompt 不恢复 | 打断时 streamingText 定格为 partial 消息(REPL.tsx:2153-2161);无有效响应时自动 rewind + 恢复输入文本 + 撤销历史条目(REPL.tsx:3028-3054) | **P0** | +| 3 | **ctrl+o 全局 transcript 模式** | ❌ ctrl+o 仅单条 thinking 展开(ReplShell.cs:500-501);全局模态只有 Shift+↑ MessageCursor | ctrl+o 切 prompt↔transcript 全屏 verbose(useGlobalKeybindings.tsx:95-132);less 键位 j/k/g/G/ctrl+u/d + `/` 增量搜索 + n/N 跳转 + cur/count 徽章(REPL.tsx:4470-4523);footer 专用提示行 | **P0** | +| 4 | **工具结果折叠交互** | ⚠️ Bash/Write 折叠提示文案已有;Ctrl+O 展开断裂(只路由 thinking,MessageItems.cs:118-119 注释自认"后续项");**Read N files 聚合摘要完全缺失**(ToolCallItem 逐条独立) | 三套独立折叠策略:Bash 3 行(terminal.ts:7 + 差1行特例直显4行)/Write 10 行(FileWriteTool/UI.tsx:26)/Edit 全量;`CollapsedReadSearchContent` 聚合摘要行 `Read N files, Searched N patterns` + 最新操作提示行 + 700ms 防闪烁(:296-461) | P1 | +| 5 | **命令面交互断裂** | ⚠️ 执行链通但回调全 null:`/clear` 不清滚动区(clearScreen 空引用)、`/exit` 不退出(ShouldContinue 丢弃)、confirm/prompt 降级(EngineSession.cs:140-155) | CLI 侧完整交互闭环 | P1 | +| 6 | **消息操作复制** | ❌ MessageCursor 导航已有,无 c/p 复制 | `c` 复制全文(含 grouped 拼接/system-reminder 剥离 messageActions.tsx:409-441)、`p` 复制工具主参数(path/command/pattern/url 映射 :70-119)、底部操作条 `enter expand · c copy · p copy path`(:296-398) | P1 | +| 7 | **上下文告警** | ❌ 无 token 余量提示 | `N% until auto-compact` / `Context low (N%) · Run /compact`(TokenWarning.tsx:166-177);阈值 13k/20k 缓冲(autoCompact.ts:62-65) | P1 | +| 8 | **后台任务面板** | ❌ 无 CoordinatorTaskPanel/BackgroundTasksDialog/↓ manage(footer `← agents` 仅文字无入口) | 输入框 footer 下方常驻面板(1s tick + evictAfter 驱逐 + x 停止 + Enter 查看,CoordinatorAgentStatus.tsx:34-76)+ Shift+↓ 开任务对话框(分组排序 + x 停止/f 前台化,BackgroundTasksDialog.tsx:217-289) | P1 | +| 9 | 小终端塌缩 | ❌ 仅 spinner/banner 局部门控 | `rows < inProgress*9+7` 时子代理进度塌缩单行 `In progress… · N tool uses (ctrl+o to expand)`(AgentTool/UI.tsx:469-503) | P2 | +| 10 | ctrl+s stash / ctrl+g 外部编辑器 / vim 模式 | ❌ | chat:stash 暂存自动恢复(defaultBindings.ts:85)、$EDITOR 编辑(:83-84)、VimTextInput + `-- INSERT --` | P2 | +| 11 | 大段粘贴折叠 / `!` bash 直通 / `&` 后台语法 / 图片粘贴 | ❌ | `[Pasted text #N]` 折叠引用(PromptInput.tsx:1222-1239)、`!cmd` 直通(:1206-1213)、`&` 前缀后台 | P2 | +| 12 | 未读分隔线 / 通知队列 / footer Notification 联动 | ❌ Notification 静态占位(ReplShell.cs:197) | `── N new messages ──`(Messages.tsx:631-634)、优先级+timeout 通知队列(notifications.tsx)、effort 变更刷新(PromptInput.tsx:1967-1979) | P2 | + +**已确认不缺**(调研实证,勿重做):输入历史 ↑↓+Ctrl+R ✅、排队消息面板 ✅、footer 三段(汉化)✅、斜杠补全面板/参数补全/ghost ✅、thinking 行+展开 ✅、spinner 动词/闪烁/shimmer ✅、子代理分组+活动日志 ✅、消息导航 Shift+↑ ✅、Markdown/高亮/diff 渲染 ✅、resize ✅。 + +## 二、M8 分批规划 + +> 排序原则:感知差距大的先行(流式=最大体感)→ 打断闭环依赖流式 buffer → transcript 模式独立可并行 → 折叠/命令面/复制为交互闭环 → 引擎依赖项殿后。 + +### M8-1 逐字流式渲染(P0,依赖:无;引擎侧 TASK043 已修好透传,事件流通) + +**协议扩展**:`TokenDelta(int Length)` → 增量字段 `Text`(string?,WhenWritingNull 兼容旧 golden);双投影: +- 进程内路径:`InProcessEventConsumer.OnText` 直发带文本事件(保留 Length 语义供 spinner 计数) +- 进程协议路径:`BridgeEventConsumer` 投影 + 引擎侧 Bridge DTO 同步 + +**渲染层两方案**(实施时二选一): +- 方案 A(先简):流式期纯文本逐字追加(`StreamingTextItem` 原地 append,⎿ 前缀),`OnDone` 替换为完整 Markdown 渲染 +- 方案 B(对齐基准):块级增量 Markdown——顶层块边界切稳定前缀(测量结果缓存)+ 增长块逐 delta 重投影(复用 Markdig 卫星) +- 判据:A 先行保首字延迟体感(开心路径),B 为后续升级项——A 的 StreamingTextItem 接口按 B 的增量契约设计,替换不破消费方 + +**验收**:busy 期滚动区正文逐字增长(E2E/selftest-turn 实证首 token 到达后正文行长度递增);打断时已流文本不丢(衔接 M8-2);像素回归 0 差异;JSON 契约旧用例零破坏。 + +**✅ M8-1 实施收口(2026-10-22,689f830b3)**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| TokenDelta 协议 +Text 字段(WhenWritingNull 旧形态零破坏)双镜像 | InProcessEventConsumer/BridgeEventConsumer OnText | ✅ | ✅ 单元:协议 JSON 新旧双形态 + Consumer 文本透传 | +| StreamingTextItem 可变缓冲逐帧增长 | Driver 惰性建项/原地追加/AssistantMessage 原位替换 | ✅ | ✅ 渲染 6 测试(前缀/续行 6 显示列对齐/多行)+ Driver 8 测试(替换/EndTurn 兜底定格 partial/BeginTurn 防残留) | +| SimulatedEngine 预分片演示 | Demo 流式可观测 | ✅ | ✅ 虚拟时钟确定性(进入 Responding 即带文本增量) | +| 全链回归 | — | ✅ | ✅ Core 841 + Host.Tests 64 + host.tests 1382 + 子代理 E2E 3/3 + selftest 真跑 EXIT=0 | + +### M8-2 Esc 打断闭环(P0,依赖:M8-1——partial 定格需流式 buffer) + +- 打断时流式 buffer 定格为 partial AssistantMessage(基准 `[user, partial, interrupted]` 顺序) +- 无有效响应时(打断守卫条件对齐 REPL.tsx:3028-3054)自动恢复用户输入到输入框 + 撤销该条历史记录 +- Esc 重载链补全:排队命令弹回输入框(基准 popAllCommandsFromQueue 语义——本地已有队列,补弹回) + +**验收**:打断后 partial 文本可见且标记 interrupted;恢复路径输入框文本回填;单测锁守卫条件(有响应/无响应两分支)。 + +**✅ M8-2 实施收口(2026-10-22,423187e9d)**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| partial 定格(EndTurn 兜底) | Driver EndTurn 非空流式项 → AssistantMessage | ✅ | ✅ M8-1 测试集(EndTurn_WithLeftoverStreamingText) | +| 无响应自动恢复 | DrainEngine 截获 TurnInterrupted → RestoreInterruptedInput | ✅ | ✅ 三分支测试:无响应恢复(输入回填+用户消息移除+历史 RemoveLast)/partial 不恢复/完整不恢复 | +| 队列弹回 | HandleEscape 忙时优先于双击打断 | ✅ | ✅ 单击弹回按行拼接+清队列+不 Interrupt;无队列双击打断回归守卫 | + +### M8-3 ctrl+o 全局 transcript 模式(P0,依赖:无——与 M8-1 可并行) + +- ReplShell 第六交互态(分派链:permission > modelPicker > search > cursor > **transcript** > suggestion) +- verbose 渲染开关:全部消息走展开形态(thinking 全文/工具全量/diff 不折叠)——消费既有 ToggleThinking/展开基建 +- less 键位:j/k/g/G/ctrl+u/ctrl+d/home/end + `/` 增量搜索栏(复用 HistorySearch 的搜索基建模式)+ n/N 跳转 + cur/count +- 专用 footer:`详细转录 · ctrl+o 切换 · ↑↓ 滚动 · ctrl+e 全部展开/收起` + +**验收**:ctrl+o 双向切换;搜索高亮+跳转;verbose 下 thinking/diff/工具全展开(与折叠提示行联动消隐)。 + +**✅ M8-3 实施收口(2026-10-22,aef776136)**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| 第六交互态(分派链 search 后 cursor 前) | HandleTranscriptKey 全键模态 | ✅ | ✅ 吞键守卫(字符不进输入框) | +| verbose 全展开/收起 | Enter/Exit + footer Notification 存还原 | ✅ | ✅ 进=全部 CoT 展开+提示;出=收起+还原;无 CoT 仍可切 | +| less 键位 | j/k/↑↓/g/G/Home/End/ctrl+u/d/PageUp/Down | ✅ | ✅ 视口滚动路径复用(inline 原生回看语义保持) | +| / 搜索 | ITextMatchable 七消息项 + Enter 跳匹配(环形)+ Esc 清 | ✅ | ✅ 查询累积/footer 显示/跳转 ScrollTop 断言/Esc 清除/退模态 | +| 语义演进回归 | ctrl+o 单条→全局(旧测试同步全局断言) | ✅ | ✅ Core 853 全绿(含 StructuredMessageTests 更新) | + +### M8-4 工具折叠交互统一 + Read N files 聚合(P1,依赖:M8-3——展开消费 transcript/局部展开双通道) + +- Ctrl+O 展开路由统一:thinking/diff/Bash/Write 折叠共享统一展开入口(消息级 ToggleExpand,不再只路由最后一条 thinking) +- Bash 3 行差 1 行特例(基准 terminal.ts:44-54);Write 10 行上限语义已有 +- `Read N files` 聚合摘要行:连续同类工具调用(Read/Grep/Glob)合并单行摘要 + 最新操作提示行(⎿ path/pattern)+ 700ms 防闪烁——落点 ReplBridgeDriver 的 ToolCall 路由(连续同类合并)+ 新 CollapsedToolItem + +**验收**:折叠提示行点击/按键展开可用;连续 3 次 Read 显示 `Read 3 files` + 最新文件提示行;verbose 全展开。 + +### M8-5 命令面交互闭环(P1,依赖:无) + +- `clearScreen` 回调接 TUI 清滚动区(ReplShell 暴露清屏入口) +- `onExitRequested` 接线 → `/exit` 后 TUI 退出(走 ShouldContinue 主循环出口) +- confirm/prompt 交互回调接 TUI 模态(复用 PermissionCoordinator TCS 桥模式);readPassword 输入遮蔽 + +**验收**:/clear 滚动区清空+banner 重现;/exit 进程退出码 0;交互命令不降级(ExitCommand 走确认分支)。 + +### M8-6 消息操作复制(P1,依赖:无——MessageCursor 基建已有) + +- 消息导航态补 `c`(复制全文——grouped 拼接+system-reminder 剥离)与 `p`(复制工具主参数:path/command/pattern/url 按 ToolName 映射) +- 底部操作条 `enter 展开 · c 复制 · p 复制参数 · ↑↓ 导航 · esc 返回` +- Windows 剪贴板路径(现有引擎剪贴板服务复用) + +**验收**:导航态 c/p 写入剪贴板可粘贴验证;操作条渲染像素对齐(上边框+间隔符)。 + +### M8-7 上下文告警(P1,依赖:引擎侧 token 余量事件——需先调研引擎 contextWindow 消费链) + +- footer Notification 槽位真数据化:`N% 直到自动压缩`(dim) / `上下文不足 (N%) · 运行 /compact`(warning/error 色) +- 引擎侧需求:每回合 Done 时上报 contextWindow 用量百分比(IEventConsumer 扩展或 session_info 增量)——落 DSG 调研后定 + +**验收**:长会话实测百分比递增显示;/compact 后百分比回落。 + +### M8-8 后台任务面板(P1,依赖:引擎侧后台任务事件源——teammate/fork 后台代理状态查询) + +- CoordinatorTaskPanel 式输入框 footer 下方常驻面板(Enter 查看 / x 停止 / evictAfter 驱逐 / 1s tick) +- Shift+↓ 开 BackgroundTasksDialog(分组列表 + x 停止/f 前台化/Enter 详情) +- footer `← agents` 提示接真实入口(当前 LeftArrow 是光标移动——基准也非 ← 键,实为文字提示,改为 ↓ manage 对齐) + +**验收**:agents 后台 spawn 后面板出现该任务;x 停止后代理真实终止;Enter 详情切换。 + +### P3 池(不排期,随机会动) + +小终端塌缩(#9)/ ctrl+s stash / ctrl+g 外部编辑器 / vim / 大段粘贴折叠 / `!` `&` 直通 / 未读分隔线 / 通知队列系统 / effort 变更 Notification 联动 / OSC 9;4 终端进度上报 / 键位可配置系统。 + +## 三、执行说明 + +- 每批走 TDD 红绿 + 像素回归门禁 + selftest 冒烟 + TASK 文档验收表四列收口 +- M8-1/M8-3 可双线并行(协议扩展 vs 纯 TUI 交互态,无文件冲突面——ReplShell 分派链追加注意并行会话共享规则) +- M8-7/M8-8 各含引擎侧前置调研,先出 mini-DSG 再实施 +- 全批次汉化对齐现状(功能性文案中文,基准动词/符号资产保留) + + + + + + diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs index 8b8e4d0f5..4ea8fce8b 100644 --- a/modules/apps/engine/adapters/BridgeEventConsumer.cs +++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs @@ -30,13 +30,13 @@ public void Reset() { _thinkingSignalled = false; } - /// 收到文本内容 → token_delta(length);思考结束时结算 thinking_ended。 + /// 收到文本内容 → token_delta(length, text)(M8-1 逐字流式 — 携带增量文本,TUI StreamingTextItem 消费);思考结束时结算 thinking_ended。 public void OnText(string content) { EndThinkingIfActive(); if (content.Length == 0) return; _fullResponse.Append(content); - WriteEvent(new Cli.Bridge.BridgeEvent.TokenDelta(content.Length)); + WriteEvent(new Cli.Bridge.BridgeEvent.TokenDelta(content.Length, content)); } /// 收到思考内容 — 回合内首个思考发 thinking_started 并记录墙钟(回合内单次信号,Reset 后新回合再发)。 diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs index 7d0b1394e..5fb52c949 100644 --- a/modules/apps/engine/bridge/BridgeProtocol.cs +++ b/modules/apps/engine/bridge/BridgeProtocol.cs @@ -113,8 +113,13 @@ public sealed record BridgeDiffHunk public sealed record ToolResult(string Summary, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] BridgeDiffHunk[]? Patch = null) : BridgeEvent; - /// 流式 token 增量(spinner ResponseLength 累加)。 - public sealed record TokenDelta(int Length) : BridgeEvent; + /// + /// 流式 token 增量(spinner ResponseLength 累加)。M8-1 逐字流式 — 与 native_tui 侧镜像: + /// Text 携带本次增量文本(TUI StreamingTextItem 原地追加);null 不输出字段(WhenWritingNull — + /// 旧 TUI 纯计数形态逐字节兼容)。 + /// + public sealed record TokenDelta(int Length, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Text = null) : BridgeEvent; /// 子代理启动(DSG041 批3 — 与 native_tui 侧 BridgeEvent.SubagentStarted 逐字段镜像; /// 同回合内全部子代理归一组,TUI 侧 SubagentGroupCoordinator 建组条目)。 diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index cc246e9dc..a896b9f7c 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -43,13 +43,13 @@ public void Reset() { _thinkingTotalMs = 0; } - /// 收到文本内容 → token_delta(length);思考结束时结算 thinking_ended。 + /// 收到文本内容 → token_delta(length, text)(M8-1 逐字流式 — 携带增量文本供 StreamingTextItem 原地追加);思考结束时结算 thinking_ended。 public void OnText(string content) { EndThinkingIfActive(); if (content.Length == 0) return; _fullResponse.Append(content); - _events.Enqueue(new BridgeEvent.TokenDelta(content.Length)); + _events.Enqueue(new BridgeEvent.TokenDelta(content.Length, content)); } /// 收到思考内容 — 回合内首个思考发 thinking_started(回合内单次信号,Reset 后新回合再发); diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 1d54608ff..20beeb097 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -60,7 +60,7 @@ private static async Task RunSelftestTurnAsync() { Console.WriteLine($"[probe] t={firstThinking} 首思考"); } break; - case BridgeEvent.TokenDelta(var delta): + case BridgeEvent.TokenDelta(var delta, _): if (firstToken is null) { firstToken = $"{sw.ElapsedMilliseconds}ms"; Console.WriteLine($"[probe] t={firstToken} 首 token"); diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index 04c221ed7..85cde6f9a 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -61,8 +61,13 @@ public sealed record ToolResult(string Summary, /// public sealed record PermissionRequest(string ToolName, string Description) : BridgeEvent; - /// 流式 token 增量(spinner ResponseLength 累加)。 - public sealed record TokenDelta(int Length) : BridgeEvent; + /// + /// 流式 token 增量(spinner ResponseLength 累加)。M8-1 逐字流式:Text 携带本次增量文本 + /// (渲染层 StreamingTextItem 原地追加 — 对齐基准流式行);null 不输出字段(WhenWritingNull — + /// 旧引擎纯计数形态逐字节兼容,Length 语义保留供 spinner 计数)。 + /// + public sealed record TokenDelta(int Length, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Text = null) : BridgeEvent; /// /// 完整助手消息(追加滚动区)。Markdown 非空时渲染为块模型(M7-4); diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 8d95f7ea3..e6e30707b 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -24,6 +24,10 @@ public sealed class ReplBridgeDriver /// 活动子代理组(M7-7)— 回合内惰性建组,TurnCompleted/Interrupted 定格脱离;历史组留滚动区。 private SubagentGroupCoordinator? _activeSubagentGroup; + /// 活动流式正文项(M8-1 逐字流式)— 首个带 Text 的 token_delta 惰性创建落位 spinner 前, + /// 后续增量原地追加;AssistantMessage 原位替换为完整消息项;EndTurn 兜底定格/清理。 + private StreamingTextItem? _streamingItem; + /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项; /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景); /// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用)。 @@ -94,13 +98,21 @@ public void Apply(BridgeEvent bridgeEvent) } break; - case BridgeEvent.TokenDelta(var length): + case BridgeEvent.TokenDelta(var length, var text): _spinner.Mode = SpinnerMode.Responding; _spinner.HasActiveTools = false; _spinner.ResponseLength += length; + if (text is { Length: > 0 }) { + // M8-1 逐字流式 — 惰性建流式项(spinner 前)+ 原地追加(每帧全量重渲自然增长) + if (_streamingItem is null) { + _streamingItem = new StreamingTextItem(); + AddMessage(_streamingItem); + } + _streamingItem.Append(text); + } break; case BridgeEvent.AssistantMessage(var text, var markdown, var messageId, var thinkingDurationMs, var thinkingContent): - AddMessage(markdown is not null + ReplaceStreamingWithFinal(markdown is not null ? new MarkdownMessageItem(markdown, _codeHighlighter, thinkingDurationMs, thinkingContent, messageId) : new AssistantMessageItem(text, thinkingDurationMs, thinkingContent, messageId)); break; @@ -161,12 +173,13 @@ private void ApplySessionInfo(string version, string? model, string permissionMo } } - /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置);防御性脱离子代理组(引擎漏发回合收尾时不串组)。 - /// spinner 显示(对齐基准 REPL.tsx showSpinner:isLoading 期 = 等待/思考/工具运行的唯一反馈 — TUI 无流式正文时尤其如此)。 + /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置);防御性脱离子代理组与流式项(引擎漏发回合收尾时不串组/不残留)。 + /// spinner 显示(对齐基准 REPL.tsx showSpinner:isLoading 期等待/思考/工具运行的反馈 — 流式正文到达前 M8-1 前为唯一反馈)。 public void BeginTurn() { IsBusy = true; _activeSubagentGroup = null; + DiscardStreamingItem(); _view.Footer = _view.Footer with { Busy = true }; _spinner.Message = string.Empty; _spinner.ResetVerb(); @@ -178,6 +191,8 @@ public void BeginTurn() } /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加);子代理组定格脱离(未完成条目定格为非错误完成)。 + /// 流式项兜底(M8-1):引擎被打断/异常未发 assistant_message 时 — 非空定格为 partial AssistantMessage + /// (基准 [user, partial, interrupted] 顺序 — 已流文本不丢),空则移除(无正文零残留)。 /// spinner Mode 复位 Thinking→Responding(修复:回合结束后思考动画续转 — Thinking 态渲染器持续 shimmer); /// Hidden 置位(对齐基准:空闲非 busy 完全不渲染 spinner 行 — 不再常驻闪烁)。 public void EndTurn() @@ -185,6 +200,7 @@ public void EndTurn() IsBusy = false; _activeSubagentGroup?.FinalizePending(); _activeSubagentGroup = null; + FinalizeStreamingItem(); _view.Footer = _view.Footer with { Busy = false }; _spinner.Message = string.Empty; _spinner.ResponseLength = 0; @@ -199,4 +215,53 @@ public void EndTurn() /// 消息插入 spinner 之前(spinner 恒为滚动区最后一项)。 public void AddMessage(IScrollItem item) => _view.Messages.Insert(_view.Messages.Count - 1, item); + + /// 流式项原位替换为完整消息项(OnDone 路径)— 流式项位置不变(视觉无跳变), + /// 无流式项时退化为普通 AddMessage。 + private void ReplaceStreamingWithFinal(IScrollItem final) + { + if (_streamingItem is null) { + AddMessage(final); + return; + } + + var index = _view.Messages.IndexOf(_streamingItem); + _streamingItem = null; + if (index < 0) { + AddMessage(final); + return; + } + + _view.Messages.RemoveAt(index); + _view.Messages.Insert(index, final); + } + + /// EndTurn 兜底 — 残留流式项定格(非空 → partial AssistantMessage)或移除(空 — 无正文零残留)。 + private void FinalizeStreamingItem() + { + if (_streamingItem is null) { + return; + } + + if (_streamingItem.Text.Length == 0) { + DiscardStreamingItem(); + return; + } + + ReplaceStreamingWithFinal(new AssistantMessageItem(_streamingItem.Text)); + } + + /// BeginTurn 防御 — 清除上一回合残留流式项(引擎漏发收尾事件时不跨回合泄漏)。 + private void DiscardStreamingItem() + { + if (_streamingItem is null) { + return; + } + + var index = _view.Messages.IndexOf(_streamingItem); + _streamingItem = null; + if (index >= 0) { + _view.Messages.RemoveAt(index); + } + } } diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index 05186b5eb..911db5de8 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -25,6 +25,9 @@ public sealed class SimulatedEngine : IReplEngine /// Responding 阶段每拍 token 增量。 private const int TokenDeltaPerTick = 9; + /// 流式分片队列(M8-1 逐字流式演示 — stage 2 进入时按完成文本预切分,Responding 每拍弹一段)。 + private readonly Queue _streamSlices = new(); + /// 子代理批量启动时刻(ms)— Submit 后一拍内全部 SubagentStarted(_pending 队列承载 300 规模)。 private const long SubagentStartedAtMs = 200; @@ -401,6 +404,11 @@ private void AdvanceToolUse(long elapsed) } _stage = 2; + // M8-1 逐字流式演示 — 完成文本按空格预分片,Responding 期每拍携带一段增量(对齐 MockServer word 流形态) + _streamSlices.Clear(); + foreach (var slice in SliceBySpaces(CompletionText(_script))) { + _streamSlices.Enqueue(slice); + } if (_script is TurnScript.Full or TurnScript.Diff) { _pending.Enqueue(new BridgeEvent.ToolResult("Updated demo/simulated.cs (simulated)", SimulatedPatch)); @@ -422,8 +430,34 @@ private void AdvanceResponding(long elapsed) if (_stage == 2 && _script is not TurnScript.Permission) { - _pending.Enqueue(new BridgeEvent.TokenDelta(TokenDeltaPerTick)); + // M8-1:分片队列非空发带文本增量(StreamingTextItem 逐字上屏),耗尽后回落纯计数形态 + if (_streamSlices.TryDequeue(out var slice)) { + _pending.Enqueue(new BridgeEvent.TokenDelta(slice.Length, slice)); + } else { + _pending.Enqueue(new BridgeEvent.TokenDelta(TokenDeltaPerTick)); + } + } + } + + /// 按空格切分文本为流式分片(保留空格 — 逐段 Append 拼回原文;空文本零分片)。 + private static List SliceBySpaces(string text) { + var slices = new List(); + if (string.IsNullOrEmpty(text)) { + return slices; + } + + var start = 0; + while (start < text.Length) { + var next = text.IndexOf(' ', start); + if (next < 0) { + slices.Add(text[start..]); + break; + } + + slices.Add(text[start..(next + 1)]); + start = next + 1; } + return slices; } /// 完成时刻(ms)— 权限脚本 8s(交互窗口),其余 5.4s。 diff --git a/native_tui/src/NativeTui.Core/components/ScrollBox.cs b/native_tui/src/NativeTui.Core/components/ScrollBox.cs index f3f570009..5826164fc 100644 --- a/native_tui/src/NativeTui.Core/components/ScrollBox.cs +++ b/native_tui/src/NativeTui.Core/components/ScrollBox.cs @@ -60,6 +60,9 @@ public ScrollBox(bool stickyScroll = false) /// 移除指定位置内容项(权限对话框完成/收尾移除场景 — 项类型由调用方判定)。 public void RemoveAt(int index) => _items.RemoveAt(index); + /// 查找内容项索引(流式项替换/清理场景 — Driver 定位原位置;未找到 -1)。 + public int IndexOf(IScrollItem item) => _items.IndexOf(item); + /// 相对滚动(打破 sticky;边界钳制延迟到渲染,对齐基准 pendingScrollDelta 累加语义)。 public void ScrollBy(int dy) { diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs index a97402902..e9613f854 100644 --- a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs +++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs @@ -8,7 +8,7 @@ namespace NativeTui.Core.Repl; /// 代码行高亮在段写完后按色段覆写前景(M7-5 — 高亮器由宿主注入,null = 降级纯文本)。 /// TASK062 结构化:思考时长 > 0 时正文上方 dim 思考摘要行 + Ctrl+O 展开 CoT(同 AssistantMessageItem 语义)。 /// -public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable +public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable, ITextMatchable { /// 思考内容行数上限守卫(超限折叠截断提示 — CoT 刷屏防护)。 private const int MaxThinkingLines = 200; @@ -38,6 +38,32 @@ public void ToggleThinking() { } } + /// 段落/标题文本 + CoT 匹配(transcript 搜索 — 块段拼接为可搜索纯文本)。 + public bool ContainsText(string query) { + if (thinkingContent?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) { + return true; + } + + foreach (var block in Document.Blocks) { + var segments = block switch { + MarkdownParagraph paragraph => paragraph.Segments, + MarkdownHeading heading => heading.Segments, + _ => null, + }; + if (segments is null) { + continue; + } + + foreach (var segment in segments) { + if (segment.Text.Contains(query, StringComparison.OrdinalIgnoreCase)) { + return true; + } + } + } + + return false; + } + /// 内容高度 = margin 1 + 布局行数(空文档至少 1 行圆点行)+ 思考摘要行(有思考)+ CoT 行数(展开态)。 public int MeasureHeight(int width) => 1 + Math.Max(1, LayoutFor(width).Lines.Count) diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index 875f2e639..7fbacc0d9 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -5,8 +5,15 @@ namespace NativeTui.Core.Repl; /// 每项 = marginTop 1(基准 addMargin)+ 正文 1 行;裁剪由 Screen 越界忽略实现。 /// +/// transcript `/` 搜索文本匹配(M8-3)— 消息项暴露可搜索文本(大小写不敏感)。 +public interface ITextMatchable +{ + /// 是否包含查询文本。 + bool ContainsText(string query); +} + /// 用户消息项(整行灰底 + ❯ subtle + 正文)。 -public sealed class UserMessageItem(string text) : IScrollItem, ICursorNavigable +public sealed class UserMessageItem(string text) : IScrollItem, ICursorNavigable, ITextMatchable { /// 用户正文(测试断言面 — 排队消息等场景)。 public string Text => text; @@ -17,12 +24,15 @@ public sealed class UserMessageItem(string text) : IScrollItem, ICursorNavigable /// margin 行留空,正文画在 frame.Y+1。 public void Render(Screen screen, Rect frame, Theme theme) => MessageRenderer.RenderUser(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, text); + + /// 正文匹配。 + public bool ContainsText(string query) => text.Contains(query, StringComparison.OrdinalIgnoreCase); } /// 助手文本消息项(● text 色 + 正文)。TASK062 结构化:思考时长 > 0 时正文上方渲染 /// dim 思考摘要行(⎿ 思考 {s} · ctrl+o 展开推理);ThinkingContent 非空支持 Ctrl+O 展开 CoT 全文 ///(展开态 ▾ + dim 缩进逐行);MessageId 会话序号(定位/对账,UI 不显示)。 -public sealed class AssistantMessageItem(string text, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable +public sealed class AssistantMessageItem(string text, int thinkingDurationMs = 0, string? thinkingContent = null, int messageId = 0) : IScrollItem, ICursorNavigable, ITextMatchable { /// 思考内容行数上限守卫(超限折叠为截断提示 — CoT 刷屏防护)。 private const int MaxThinkingLines = 200; @@ -84,10 +94,15 @@ private string TruncateThinking() { ? string.Join("\n", lines[..MaxThinkingLines]) + $"\n… 思考内容超 {MaxThinkingLines} 行已截断" : thinkingContent; } + + /// 正文 + CoT 匹配(transcript 搜索)。 + public bool ContainsText(string query) => + text.Contains(query, StringComparison.OrdinalIgnoreCase) + || (thinkingContent?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); } /// 工具调用消息项(● + bold 工具名 + (参数摘要))。 -public sealed class ToolCallItem(string toolName, string args) : IScrollItem, ICursorNavigable +public sealed class ToolCallItem(string toolName, string args) : IScrollItem, ICursorNavigable, ITextMatchable { /// 内容高度 = margin 1 + 正文 1。 public int MeasureHeight(int width) => 2; @@ -95,10 +110,15 @@ public sealed class ToolCallItem(string toolName, string args) : IScrollItem, IC /// margin 行留空,正文画在 frame.Y+1。 public void Render(Screen screen, Rect frame, Theme theme) => MessageRenderer.RenderToolCall(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, toolName, args); + + /// 工具名 + 参数匹配。 + public bool ContainsText(string query) => + toolName.Contains(query, StringComparison.OrdinalIgnoreCase) + || args.Contains(query, StringComparison.OrdinalIgnoreCase); } /// 工具结果消息项(" ⎿ " dim 前缀 + 正文)。 -public sealed class ToolResultItem(string text) : IScrollItem, ICursorNavigable +public sealed class ToolResultItem(string text) : IScrollItem, ICursorNavigable, ITextMatchable { /// 结果正文(测试断言面 — 排队反馈等复用场景)。 public string Text => text; @@ -109,6 +129,9 @@ public sealed class ToolResultItem(string text) : IScrollItem, ICursorNavigable /// margin 行留空,正文画在 frame.Y+1。 public void Render(Screen screen, Rect frame, Theme theme) => MessageRenderer.RenderToolResult(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, text); + + /// 结果正文匹配。 + public bool ContainsText(string query) => text.Contains(query, StringComparison.OrdinalIgnoreCase); } /// @@ -118,7 +141,7 @@ public void Render(Screen screen, Rect frame, Theme theme) => /// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例, /// Ctrl+O 展开交互为 M7 后续项)。 /// -public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem, ICursorNavigable +public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem, ICursorNavigable, ITextMatchable { /// patch 预览行数上限(summary 之外;超限折叠 — 对齐基准 Write MAX_LINES_TO_RENDER=10 先例)。 private const int PreviewPatchRows = 10; @@ -177,10 +200,15 @@ public void Render(Screen screen, Rect frame, Theme theme) /// 单行 frame(Y 为该渲染行,高度 1)。 private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 }; + + /// 摘要 + diff 行内容匹配(transcript 搜索)。 + public bool ContainsText(string query) => + Summary.Contains(query, StringComparison.OrdinalIgnoreCase) + || Patch.Any(hunk => hunk.Lines.Any(line => line.Content.Contains(query, StringComparison.OrdinalIgnoreCase))); } /// 错误消息项(● theme.Error 红色 + 正文,bridge error 事件载体)。 -public sealed class ErrorMessageItem(string text) : IScrollItem, ICursorNavigable +public sealed class ErrorMessageItem(string text) : IScrollItem, ICursorNavigable, ITextMatchable { /// 错误正文。 public string Text { get; } = text; @@ -191,4 +219,7 @@ public sealed class ErrorMessageItem(string text) : IScrollItem, ICursorNavigabl /// margin 行留空,正文画在 frame.Y+1。 public void Render(Screen screen, Rect frame, Theme theme) => MessageRenderer.RenderError(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, Text); + + /// 错误正文匹配。 + public bool ContainsText(string query) => Text.Contains(query, StringComparison.OrdinalIgnoreCase); } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 6492bfffd..e36f23694 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -79,6 +79,24 @@ public sealed class ReplShell /// Esc 时钟源(internal — 测试注入可控时间戳;默认墙钟)。 internal Func EscClock { get; set; } = static () => Environment.TickCount64; + /// 最近一次直发提交的文本(M8-2 打断恢复 — 无有效响应时弹回输入框)。 + private string _lastSubmittedText = string.Empty; + + /// 最近一次直发提交的用户消息项位置(M8-2 打断恢复 — 无有效响应时移除该条上屏)。 + private int _lastSubmittedIndex = -1; + + /// ctrl+o 全局 transcript 模式(M8-3 — 基准 app:toggleTranscript):全屏 verbose 模态。 + private bool _transcriptMode; + + /// transcript 内 `/` 搜索查询(null = 非搜索态;非空 = 搜索输入态,字符累积)。 + private string? _transcriptQuery; + + /// 进入 transcript 前的 footer Notification 槽原值(退出还原)。 + private string? _savedNotification; + + /// transcript 模式态(internal — 测试断言面)。 + internal bool IsTranscriptMode => _transcriptMode; + /// 剪贴板写入委托(TASK059 — Ctrl+C 复制最后 assistant 消息;宿主注入引擎 /// IClipboardService 适配,Core 零依赖不持接口;null = 复制能力降级无操作)。 private readonly Action? _copyToClipboard; @@ -208,8 +226,13 @@ internal void SeedContent() /// 排空引擎事件:全部经 ReplBridgeDriver 应用到视图 + 诊断旁路写 stderr + 引擎死亡检测(非忙时追加退出消息并结束)。 internal void DrainEngine() { + var interrupted = false; foreach (var bridgeEvent in _engine.DrainEvents()) { + if (bridgeEvent is BridgeEvent.TurnInterrupted) + { + interrupted = true; + } _driver.Apply(bridgeEvent); } @@ -218,6 +241,14 @@ internal void DrainEngine() Console.Error.WriteLine(diagnostic); } + // M8-2 打断恢复(基准 REPL.tsx auto-restore):打断且无任何有效响应到达 → + // 恢复用户输入到输入框 + 移除该条用户消息上屏 + 撤销历史条目(等同 rewind 到上一条)。 + // 守卫:输入框为空(不打扰用户已开始的新输入)+ 队列空(队列有独立泵语义)。 + if (interrupted && _input.Text.Length == 0 && _queuedInputs.Count == 0) + { + RestoreInterruptedInput(); + } + if (!_engine.IsAlive && !_driver.IsBusy) { AddMessage(new ErrorMessageItem("引擎进程已退出,会话结束。")); @@ -234,6 +265,32 @@ internal void DrainEngine() PumpQueuedInputs(); } + /// 打断恢复(M8-2)— 该回合用户消息之后无实质响应项(assistant/工具/流式/错误/子代理) + /// 时移除该条用户消息 + 恢复文本到输入框 + 历史撤销;有实质响应(含 partial 定格)不恢复。 + private void RestoreInterruptedInput() + { + if (_lastSubmittedIndex < 0 || _lastSubmittedText.Length == 0) + { + return; + } + + for (var i = _lastSubmittedIndex + 1; i < _view.Messages.Count - 1; i++) + { + if (_view.Messages.Items[i] is AssistantMessageItem or MarkdownMessageItem or ToolCallItem + or ToolResultItem or DiffResultItem or ErrorMessageItem or StreamingTextItem + or SubagentGroupItem or PermissionRequestItem) + { + return; + } + } + + _view.Messages.RemoveAt(_lastSubmittedIndex); + FillInput(_lastSubmittedText, cursorToStart: false); + _history.RemoveLast(_lastSubmittedText); + _lastSubmittedIndex = -1; + _lastSubmittedText = string.Empty; + } + /// 挂起权限请求(宿主 TuiPermissionBridge.Confirm → 此处 → 用户按键 Complete)。 public void BeginPermissionRequest(string toolName, string description) { @@ -349,6 +406,12 @@ private void HandleKeyPress(KeyPress key) return; } + if (_transcriptMode) + { + HandleTranscriptKey(key); + return; + } + if (_cursorNav.IsCursorActive) { HandleCursorNavKey(key); @@ -498,7 +561,7 @@ private void HandleCtrlKey(string name) BeginOrCycleSearch(); break; case "o": - ToggleLastThinking(); + ToggleTranscriptMode(); break; case "n": ScrollIfViewport(3); @@ -632,6 +695,217 @@ private void ScrollIfViewport(int rows) } } + /// ctrl+o 切换 transcript 模式(M8-3 — 基准 app:toggleTranscript 双向)。 + private void ToggleTranscriptMode() + { + if (_transcriptMode) + { + ExitTranscriptMode(); + } + else + { + EnterTranscriptMode(); + } + } + + /// 进入 transcript:全部 CoT 展开(verbose — 基准 verbose=true 渲染)+ footer 专用提示 + 清查询态。 + private void EnterTranscriptMode() + { + _transcriptMode = true; + _transcriptQuery = null; + foreach (var item in _view.Messages.Items) + { + if (item is AssistantMessageItem { ThinkingContent: { Length: > 0 } } plain && !plain.IsThinkingExpanded) + { + plain.ToggleThinking(); + } + else if (item is MarkdownMessageItem { ThinkingContent: { Length: > 0 } } md && !md.IsThinkingExpanded) + { + md.ToggleThinking(); + } + } + + _savedNotification = _view.Footer.Notification; + _view.Footer = _view.Footer with { Notification = TranscriptFooterHint }; + } + + /// 退出 transcript:全部 CoT 收起(基准 hidePastThinking)+ footer 还原。 + private void ExitTranscriptMode() + { + _transcriptMode = false; + _transcriptQuery = null; + foreach (var item in _view.Messages.Items) + { + if (item is AssistantMessageItem { IsThinkingExpanded: true } plain) + { + plain.ToggleThinking(); + } + else if (item is MarkdownMessageItem { IsThinkingExpanded: true } md) + { + md.ToggleThinking(); + } + } + + _view.Footer = _view.Footer with { Notification = _savedNotification }; + } + + /// transcript footer 提示文案(基准 TranscriptModeFooter 汉化)。 + private const string TranscriptFooterHint = "详细转录 · ctrl+o 切换 · ↑↓ 滚动 · / 搜索"; + + /// transcript 模态按键(M8-3 — 基准 transcript 屏 less 键位): + /// 搜索态:字符累积查询/Enter 跳匹配/Backspace 删/Esc 清查询; + /// 普通态:ctrl+o 或 Esc 退出、j/k/↑/↓ 滚动、g/G 首末、ctrl+u/d 半页、/ 进入搜索; + /// 其余键吞掉(模态 — 字符不进输入框)。 + private void HandleTranscriptKey(KeyPress key) + { + if (_transcriptQuery is not null) + { + HandleTranscriptSearchKey(key); + return; + } + + if (key.Ctrl && key is { Kind: KeyKind.Char, Input: "o" }) + { + ExitTranscriptMode(); + return; + } + + switch (key.Kind) + { + case KeyKind.Escape: + ExitTranscriptMode(); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "j": + ScrollIfViewport(3); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "k": + ScrollIfViewport(-3); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "g": + ScrollIfViewport(int.MinValue); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "G": + _view.Messages.ScrollToBottom(); + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "/": + _transcriptQuery = string.Empty; + return; + case KeyKind.UpArrow: + ScrollIfViewport(-3); + return; + case KeyKind.DownArrow: + ScrollIfViewport(3); + return; + case KeyKind.PageUp: + ScrollIfViewport(-10); + return; + case KeyKind.PageDown: + ScrollIfViewport(10); + return; + case KeyKind.Home: + ScrollIfViewport(int.MinValue); + return; + case KeyKind.End: + _view.Messages.ScrollToBottom(); + return; + default: + // 模态:其余键吞掉(含 ctrl+u/ctrl+d 编辑键 — transcript 态非编辑上下文) + if (key.Ctrl && key is { Kind: KeyKind.Char, Input: "u" }) + { + ScrollIfViewport(-10); + } + else if (key.Ctrl && key is { Kind: KeyKind.Char, Input: "d" }) + { + ScrollIfViewport(10); + } + + return; + } + } + + /// transcript 搜索态按键:字符累积/Backspace 删/Enter 跳下一个匹配项/非搜索态 Esc 退出。 + private void HandleTranscriptSearchKey(KeyPress key) + { + switch (key.Kind) + { + case KeyKind.Escape: + _transcriptQuery = null; + _view.Footer = _view.Footer with { Notification = TranscriptFooterHint }; + return; + case KeyKind.Return: + JumpToNextTranscriptMatch(); + return; + case KeyKind.Backspace: + if (_transcriptQuery is { Length: > 0 }) + { + _transcriptQuery = _transcriptQuery[..^1]; + UpdateTranscriptSearchFooter(); + } + + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input.Length == 1: + _transcriptQuery += key.Input[0]; + UpdateTranscriptSearchFooter(); + return; + default: + return; + } + } + + /// 搜索态 footer 显示查询(`/查询` 形态)。 + private void UpdateTranscriptSearchFooter() => + _view.Footer = _view.Footer with { Notification = $"/{_transcriptQuery}" }; + + /// Enter 跳转到下一个含查询的滚动项(从当前滚动位置向下环形找 — 视口模式滚动定位)。 + private void JumpToNextTranscriptMatch() + { + if (string.IsNullOrEmpty(_transcriptQuery)) + { + return; + } + + var items = _view.Messages.Items; + var query = _transcriptQuery; + for (var i = 0; i < items.Count; i++) + { + if (items[i] is not ITextMatchable matchable || !matchable.ContainsText(query)) + { + continue; + } + + var targetTop = ItemContentTop(i); + if (targetTop > _view.Messages.ScrollTop) + { + _view.Messages.ScrollBy(targetTop - _view.Messages.ScrollTop); + return; + } + } + + // 无更靠下的匹配 — 环形回卷首个匹配(基准 n/N 循环语义) + for (var i = 0; i < items.Count; i++) + { + if (items[i] is ITextMatchable matchable && matchable.ContainsText(query)) + { + var targetTop = ItemContentTop(i); + _view.Messages.ScrollBy(targetTop - _view.Messages.ScrollTop); + return; + } + } + } + + /// 第 index 项的内容起始 Y(前缀高度和 — 跳转定位用)。 + private int ItemContentTop(int index) + { + var width = _sizeSource.Current.Cols; + var top = 0; + for (var i = 0; i < index; i++) + { + top += _view.Messages.Items[i].MeasureHeight(width); + } + + return top; + } + /// 进入或循环搜索(Ctrl+R)— 未激活:以当前输入为查询进入(暂存草稿); /// 已激活由 HandleSearchKey 拦截(本分支不会命中,防御保留)。 private void BeginOrCycleSearch() @@ -938,9 +1212,10 @@ private void FillInput(string text, bool cursorToStart) /// 刷新建议(用户编辑路径;建议状态机内部判定触发条件)。 private void UpdateSuggestions() => _suggester?.Update(_input.Text); - /// Esc:忙时打断当前回合;闲时有输入清空(对齐基准双击清空的单击近似),无输入退出。 - /// Esc 处置(TASK059 退出安全 — Esc 永不退出):忙时 500ms 双击 → 打断(单击等窗口无操作); - /// 闲时有文本 → 清空输入(既有行为);闲时空输入 → 无操作。退出路径只剩 Ctrl+D 与 /exit 命令。 + /// Esc 处置(TASK059 退出安全 — Esc 永不退出;M8-2 打断闭环补全基准重载链): + /// 忙时:有排队输入 → 单击弹回全部到输入框(基准 popAllCommandsFromQueue — 优先于打断判定); + /// 无排队 → 500ms 双击打断(单击等窗口无操作)。 + /// 闲时:有文本 → 清空输入;空输入 → 无操作。退出路径只剩 Ctrl+D 与 /exit 命令。 internal void HandleEscape() { var now = EscClock(); @@ -949,6 +1224,17 @@ internal void HandleEscape() if (IsBusy) { + if (_queuedInputs.Count > 0) + { + // M8-2 基准重载链⑤ — 可编辑排队命令全部弹回输入框(按行拼接)+ 清空队列 + 重置双击窗口 + var merged = string.Join("\n", _queuedInputs); + _queuedInputs.Clear(); + _view.QueuedInputs = _queuedInputs; + FillInput(merged, cursorToStart: false); + _lastEscapeMs = 0; + return; + } + if (isDoubleTap) { _lastEscapeMs = 0; @@ -1061,6 +1347,8 @@ internal void Submit() } AddMessage(new UserMessageItem(text)); + _lastSubmittedText = text; + _lastSubmittedIndex = _view.Messages.Count - 2; _engine.Submit(text); } diff --git a/native_tui/src/NativeTui.Core/repl/StreamingTextItem.cs b/native_tui/src/NativeTui.Core/repl/StreamingTextItem.cs new file mode 100644 index 000000000..85aa6d640 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/StreamingTextItem.cs @@ -0,0 +1,54 @@ +namespace NativeTui.Core.Repl; + +/// +/// 流式正文项(M8-1 逐字流式 — TASK064 方案 A)— 回合进行中 assistant 正文逐字上屏: +/// 对齐基准 Messages.tsx 尾部 ⎿ 前缀行渲染 StreamingMarkdown(方案 A 先纯文本逐字追加, +/// OnDone 由 Driver 原位替换为完整 Markdown/Assistant 项 — ⏺ 形态)。 +/// ⎿ 前缀整体 dim(对齐 ToolResultItem " ⎿ " 先例);续行缩进对齐内容列; +/// ScrollBox 每帧全量重渲(MeasureHeight/Render 每帧调用)— 追加后自然逐帧增长,无需失效通知。 +/// 接口按方案 B(块级增量 Markdown)的增量契约设计(Append 语义不变),后续升级替换实现不破消费方。 +/// +public sealed class StreamingTextItem : IScrollItem, ITextMatchable +{ + private readonly StringBuilder _buffer = new(); + + /// 当前累积文本(测试断言面 + Driver 替换/定格语义消费)。 + public string Text => _buffer.ToString(); + + /// 流式缓冲文本匹配(transcript 搜索)。 + public bool ContainsText(string query) => _buffer.ToString().Contains(query, StringComparison.OrdinalIgnoreCase); + + /// 追加一段增量文本(引擎 OnText 粒度 — 空串无操作)。 + public void Append(string text) { + if (text.Length > 0) { + _buffer.Append(text); + } + } + + /// 内容高度 = margin 1 + 文本行数(\n 分行;超宽由 Screen 裁剪 — 对齐现有消息项先例)。 + public int MeasureHeight(int width) => 1 + LineCount(); + + /// 渲染:margin 行留空 → 首行 " ⎿ " dim 前缀 + 默认正文 → 续行空格缩进对齐内容列。 + public void Render(Screen screen, Rect frame, Theme theme) { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + // 续行缩进 = 首行内容列的显示对齐(" ⎿ " = 5 rune 但 ⎿ 宽字符占 2 列 = 6 显示列,正文视觉对齐取 6) + const string continuation = " "; + var row = frame.Y + 1; + var first = true; + foreach (var line in Lines()) { + var prefix = first ? Figures.ToolResultIndent : continuation; + screen.WriteText(row, frame.X, prefix, dim, frame.Right); + if (line.Length > 0) { + screen.WriteText(row, frame.X + prefix.Length, line, 0, frame.Right); + } + first = false; + row++; + } + } + + private int LineCount() => Lines().Length; + + private string[] Lines() => _buffer.Length == 0 + ? [string.Empty] + : _buffer.ToString().Split('\n'); +} diff --git a/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs index 11ef2fe90..60cce1da5 100644 --- a/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs +++ b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs @@ -54,6 +54,20 @@ public void Seed(IEnumerable entries) } } + /// 撤销末条历史(M8-2 打断恢复 — 基准 removeLastFromHistory): + /// 仅当末条与给定文本一致才移除(防误撤他人条目 — 弹回输入与历史撤销原子配套)。 + public void RemoveLast(string text) + { + if (_entries.Count > 0 && _entries[^1] == text) + { + _entries.RemoveAt(_entries.Count - 1); + if (Index > _entries.Count) + { + Index = _entries.Count; + } + } + } + /// 翻上一条(旧方向):index 递增,返回目标历史文本; /// 首次离开输入框以 draft 暂存当前输入(缺省空串);越过最旧一条返回 false(index 保持,不回绕)。 public bool TryUp(out string text, string? draft = null) diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index aef831a44..6495621b4 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -27,6 +27,20 @@ public void OnText_EmitsTokenDeltaPerCall() Assert.Empty(consumer.DrainEvents()); } + /// M8-1 逐字流式 — OnText 发携带文本的 TokenDelta(渲染层 StreamingTextItem 消费)。 + [Fact] + public void OnText_EmitsTokenDeltaWithTextContent() + { + var consumer = new InProcessEventConsumer(); + + consumer.OnText("你好"); + + var events = consumer.DrainEvents(); + var delta = Assert.IsType(events[0]); + Assert.Equal("你好", delta.Text); + Assert.Equal(2, delta.Length); + } + /// 子代理三回调映射(DSG041 批4)— OnSubagentStarted/OnSubagentToolUse/OnSubagentFinished /// → SubagentStarted/SubagentToolUse/SubagentFinished 内存事件(ReplBridgeDriver 路由既有,零改动验收面)。 [Fact] diff --git a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs index bcf7fad7c..a48dd0d20 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs @@ -130,6 +130,23 @@ public void ReadEvent_ToolResultCarriesSummary_TokenDeltaCarriesLength() Assert.Equal(9, delta.Length); } + /// M8-1 逐字流式 — token_delta 携带 text 增量字段(StreamingTextItem 消费); + /// 旧无 text 形态反序列化兼容(Text=null,Length 计数语义保留)。 + [Fact] + public void ReadEvent_TokenDeltaCarriesText_LegacyWithoutTextStaysNull() + { + var reader = new StringReader("{\"type\":\"token_delta\",\"length\":5,\"text\":\"你好\"}\n{\"type\":\"token_delta\",\"length\":9}\n"); + BridgeProtocol.TryReadEvent(reader, out var first, out _); + var withText = Assert.IsType(first); + Assert.Equal("你好", withText.Text); + Assert.Equal(5, withText.Length); + + BridgeProtocol.TryReadEvent(reader, out var second, out _); + var legacy = Assert.IsType(second); + Assert.Null(legacy.Text); + Assert.Equal(9, legacy.Length); + } + [Fact] public void ReadEvent_AssistantMessageCarriesText_ErrorCarriesMessage() { diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs index d8307de64..1c8f00648 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs @@ -265,4 +265,107 @@ public void EndTurn_ResetsSpinnerState() Assert.False(_spinner.HasActiveTools); Assert.Equal(string.Empty, _spinner.Message); } + + [Fact] + public void Apply_TokenDeltaWithText_CreatesStreamingItemBeforeSpinner() + { + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.TokenDelta(5, "hello")); + + var streaming = Assert.IsType(_view.Messages.Items[^2]); + Assert.Equal("hello", streaming.Text); + Assert.Same(_spinner, _view.Messages.Items[^1]); + Assert.Equal(SpinnerMode.Responding, _spinner.Mode); + Assert.Equal(5, _spinner.ResponseLength); + } + + [Fact] + public void Apply_SubsequentTokenDelta_AppendsToSameStreamingItem() + { + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.TokenDelta(5, "hello")); + var streaming = Assert.IsType(_view.Messages.Items[^2]); + + _driver.Apply(new BridgeEvent.TokenDelta(6, " world")); + + Assert.Same(streaming, _view.Messages.Items[^2]); + Assert.Equal("hello world", streaming.Text); + Assert.Equal(11, _spinner.ResponseLength); + } + + [Fact] + public void Apply_TokenDeltaWithoutText_KeepsSpinnerCounterOnly() + { + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.TokenDelta(9)); + + Assert.Equal(2, _view.Messages.Count); + Assert.Equal(9, _spinner.ResponseLength); + } + + [Fact] + public void Apply_AssistantMessage_ReplacesStreamingItemInPlace() + { + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.TokenDelta(5, "hello")); + var streamingIndex = _view.Messages.Count - 2; + + _driver.Apply(new BridgeEvent.AssistantMessage("hello world", MessageId: 1)); + + // 流式项被完整消息替换(原位置),不残留流式项 + var final = Assert.IsType(_view.Messages.Items[streamingIndex]); + Assert.Equal("hello world", final.Text); + Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem); + Assert.Same(_spinner, _view.Messages.Items[^1]); + } + + [Fact] + public void Apply_AssistantMessageWithoutStreaming_AppendsDirectly() + { + _driver.BeginTurn(); + + _driver.Apply(new BridgeEvent.AssistantMessage("direct", MessageId: 1)); + + var final = Assert.IsType(_view.Messages.Items[^2]); + Assert.Equal("direct", final.Text); + } + + [Fact] + public void EndTurn_WithLeftoverStreamingText_FinalizesAsPartialAssistantMessage() + { + // 打断场景(M8-1 兜底):引擎被打断时 OnDone 不发,流式 buffer 残留 → + // EndTurn 定格为 partial AssistantMessage(基准 [user, partial, interrupted] 顺序) + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.TokenDelta(7, "partial ")); + + _driver.EndTurn(); + + var final = Assert.IsType(_view.Messages.Items[^2]); + Assert.Equal("partial ", final.Text); + Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem); + } + + [Fact] + public void EndTurn_WithEmptyStreamingLeftover_RemovesItem() + { + _driver.BeginTurn(); + _driver.Apply(new BridgeEvent.TokenDelta(3, "abc")); + _driver.Apply(new BridgeEvent.AssistantMessage("abc", MessageId: 1)); + + _driver.EndTurn(); + + Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem); + } + + [Fact] + public void BeginTurn_ClearsLeftoverStreamingItem() + { + _driver.Apply(new BridgeEvent.TokenDelta(5, "residual")); + + _driver.BeginTurn(); + + Assert.DoesNotContain(_view.Messages.Items, static item => item is StreamingTextItem); + } } diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs index 5d3ac7657..31b50c244 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs @@ -57,7 +57,8 @@ public void Advance_ToResponding_Emits_ToolResultWithPatchAndTokenDelta() var result = Assert.IsType(events.First(e => e is BridgeEvent.ToolResult)); Assert.NotNull(result.Patch); Assert.All(result.Patch, hunk => Assert.False(string.IsNullOrEmpty(hunk.Header))); - Assert.Contains(events, e => e is BridgeEvent.TokenDelta { Length: 9 }); + // M8-1:进入 Responding 即开始逐字流式 — 首拍发带文本增量(预分片首段) + Assert.Contains(events, e => e is BridgeEvent.TokenDelta { Length: > 0, Text: { Length: > 0 } }); } /// 虚拟时钟 5.5s:回合完成,产出 AssistantMessage + TurnCompleted,后续拍空。 diff --git a/native_tui/tests/NativeTui.Tests/repl/EscInterruptClosureTests.cs b/native_tui/tests/NativeTui.Tests/repl/EscInterruptClosureTests.cs new file mode 100644 index 000000000..374bc57c4 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/EscInterruptClosureTests.cs @@ -0,0 +1,128 @@ +namespace NativeTui.Tests.Repl; + +/// +/// Esc 打断闭环测试(M8-2 — TASK064,基准 REPL.tsx onCancel + auto-restore): +/// ①打断后无有效响应到达 → 自动恢复用户输入到输入框 + 移除该条用户消息 + 撤销历史条目(基准 rewind 近似); +/// ②有 partial 响应 → 消息定格保留、不恢复输入(M8-1 兜底衔接); +/// ③忙时有排队输入 → 单击 Esc 全部弹回输入框(基准 popAllCommandsFromQueue — 优先于双击打断判定)。 +/// +public sealed class EscInterruptClosureTests +{ + /// 可控时钟 — 逐击推进(每击 +100ms,双击窗口 500ms 内)。 + private sealed class ManualClock { + private long _ms; + public long Tick() => _ms += 100; + public void Advance(int ms) => _ms += ms; + } + + private static (ReplShell Shell, FakeReplEngine Engine, ManualClock Clock) CreateShell() { + var engine = new FakeReplEngine(); + var banner = new BannerItem("0.0.0", "m", "b", "C:\\"); + var shell = new ReplShell(engine, banner, new StringWriter()); + shell.SeedContent(); + var clock = new ManualClock(); + shell.EscClock = clock.Tick; + return (shell, engine, clock); + } + + private static ConsoleKeyInfo Esc() => new('\0', ConsoleKey.Escape, false, false, false); + + private static void TypeAndSubmit(ReplShell shell, string text) { + foreach (var ch in text) { + shell.HandleKey(new ConsoleKeyInfo(ch, ConsoleKey.A, false, false, false)); + } + shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + } + + /// 首条直发后置 busy(TurnStarted drain — 后续提交走排队路径)。 + private static void MakeBusy(ReplShell shell, FakeReplEngine engine) { + engine.Events.Enqueue(new BridgeEvent.TurnStarted()); + shell.DrainEngine(); + } + + private static void RunTurn(FakeReplEngine engine, ReplShell shell, params BridgeEvent[] events) { + engine.Events.Enqueue(new BridgeEvent.TurnStarted()); + foreach (var bridgeEvent in events) { + engine.Events.Enqueue(bridgeEvent); + } + engine.Events.Enqueue(new BridgeEvent.TurnInterrupted()); + shell.DrainEngine(); + } + + /// 打断后无任何有效响应(无正文/无工具/无流式)→ 输入恢复 + 用户消息移除 + 历史撤销。 + [Fact] + public void Interrupt_NoResponseArrived_RestoresInputAndRemovesUserMessage() + { + var (shell, engine, _) = CreateShell(); + TypeAndSubmit(shell, "帮我查一下"); + + RunTurn(engine, shell); + + Assert.Equal("帮我查一下", shell.Input.Text); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is UserMessageItem); + Assert.False(shell.History.Entries.Contains("帮我查一下"), "历史条目应被撤销"); + } + + /// 打断后有 partial 响应(流式增量已到达)→ 消息定格保留 + 输入不恢复 + 历史保留(基准 partial 语义)。 + [Fact] + public void Interrupt_PartialResponseArrived_KeepsMessages_NoRestore() + { + var (shell, engine, _) = CreateShell(); + TypeAndSubmit(shell, "帮我查一下"); + + RunTurn(engine, shell, new BridgeEvent.TokenDelta(3, "部分")); + + var partial = Assert.Single(shell.View.Messages.Items.OfType()); + Assert.Equal("部分", partial.Text); + Assert.Empty(shell.Input.Text); + Assert.Contains("帮我查一下", shell.History.Entries); + } + + /// 打断前有完整响应到达 → 不恢复(正常完成形态的打断近似)。 + [Fact] + public void Interrupt_FullResponseArrived_NoRestore() + { + var (shell, engine, _) = CreateShell(); + TypeAndSubmit(shell, "帮我查一下"); + + RunTurn(engine, shell, + new BridgeEvent.TokenDelta(4, "完整回复"), + new BridgeEvent.AssistantMessage("完整回复", MessageId: 1)); + + Assert.Single(shell.View.Messages.Items.OfType()); + Assert.Empty(shell.Input.Text); + Assert.Contains("帮我查一下", shell.History.Entries); + } + + /// 忙时有排队输入 → 单击 Esc 全部弹回输入框(按行拼接)+ 清空队列 + 不打断(基准重载链⑤优先)。 + [Fact] + public void Busy_Escape_WithQueuedInputs_PopsAllBackToInput_NoInterrupt() + { + var (shell, engine, _) = CreateShell(); + TypeAndSubmit(shell, "第一条"); + MakeBusy(shell, engine); + TypeAndSubmit(shell, "第二条"); + TypeAndSubmit(shell, "第三条"); + Assert.Equal(["第二条", "第三条"], shell.View.QueuedInputs); + + shell.HandleKey(Esc()); + + Assert.Equal("第二条\n第三条", shell.Input.Text); + Assert.Empty(shell.View.QueuedInputs); + Assert.Equal(0, engine.Interrupts); + } + + /// 忙时无排队 → 双击 Esc 打断(既有行为回归守卫 — 队列弹回不得破坏打断主路径)。 + [Fact] + public void Busy_DoubleEscape_NoQueue_StillInterrupts() + { + var (shell, engine, _) = CreateShell(); + TypeAndSubmit(shell, "唯一一条"); + MakeBusy(shell, engine); + + shell.HandleKey(Esc()); + shell.HandleKey(Esc()); + + Assert.Equal(1, engine.Interrupts); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/StreamingTextItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/StreamingTextItemTests.cs new file mode 100644 index 000000000..0d505f695 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/StreamingTextItemTests.cs @@ -0,0 +1,83 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 流式文本项渲染测试(M8-1 逐字流式 — TASK064)— 对齐基准流式行形态: +/// Messages.tsx 尾部追加 ⎿ 前缀行渲染 StreamingMarkdown(方案 A 先纯文本逐字追加); +/// ⎿ 前缀整体 dim(对齐 ToolResultItem " ⎿ " 先例),续行缩进对齐内容列; +/// OnDone 后由 Driver 替换为完整 Markdown/Assistant 项(⏺ 形态 — 替换语义见 ReplBridgeDriverTests)。 +/// +public sealed class StreamingTextItemTests +{ + [Fact] + public void MeasureHeight_MarginPlusLineCount() + { + var item = new StreamingTextItem(); + item.Append("hello world"); + + Assert.Equal(2, item.MeasureHeight(40)); + } + + [Fact] + public void MeasureHeight_GrowsAsTextAppends() + { + var item = new StreamingTextItem(); + Assert.Equal(2, item.MeasureHeight(40)); + + item.Append("first"); + Assert.Equal(2, item.MeasureHeight(40)); + + item.Append("\nsecond line"); + Assert.Equal(3, item.MeasureHeight(40)); + } + + [Fact] + public void Render_ToolResultIndentPrefixWithDimStyle() + { + using var screen = new Screen(cols: 40, rows: 3); + var item = new StreamingTextItem(); + item.Append("streaming text"); + + item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark); + + // row0 margin 留空;row1 = " ⎿ " dim 前缀 + 默认样式正文(对齐 RenderToolResult 形态) + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(" ⎿ ", RowText.Of(screen, 1, 0, 5)); + Assert.Equal(dim, screen[1, 0].StyleId); + Assert.Equal("streaming text", RowText.Of(screen, 1, 5, 14)); + Assert.Equal(0, screen[1, 5].StyleId); + } + + [Fact] + public void Render_MultiLineContinuationAlignsAtContentColumn() + { + using var screen = new Screen(cols: 40, rows: 4); + var item = new StreamingTextItem(); + item.Append("line one\nline two"); + + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + + Assert.Equal(" ⎿ line one", RowText.Of(screen, 1, 0, 13)); + Assert.Equal(" line two", RowText.Of(screen, 2, 0, 14)); + } + + [Fact] + public void Text_ExposesAccumulatedBuffer() + { + var item = new StreamingTextItem(); + item.Append("hello "); + item.Append("world"); + + Assert.Equal("hello world", item.Text); + } + + [Fact] + public void Append_EmptyTextKeepsState() + { + var item = new StreamingTextItem(); + item.Append("abc"); + item.Append(string.Empty); + + Assert.Equal("abc", item.Text); + Assert.Equal(2, item.MeasureHeight(40)); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs index 6a0754bfb..4e516cf1d 100644 --- a/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/StructuredMessageTests.cs @@ -114,7 +114,7 @@ public void Toggle_NoContent_NoOp() // ---- 交互(ReplShell Ctrl+O) ---- - /// Ctrl+O — 切换最近一条带 CoT 消息(含多条时取最后)。 + /// Ctrl+O — 进入 transcript 全局模式(M8-3 语义演进:原单条切换 → 全部 CoT 展开 verbose)。 [Fact] public void CtrlO_TogglesLastMessageWithCoT() { @@ -128,7 +128,7 @@ public void CtrlO_TogglesLastMessageWithCoT() shell.HandleKey(new ConsoleKeyInfo('\x0f', ConsoleKey.O, false, false, false)); var items = shell.View.Messages.Items.OfType().ToList(); - Assert.False(items[0].IsThinkingExpanded); + Assert.True(items[0].IsThinkingExpanded, "transcript verbose 全展开(M8-3 — 基准 verbose=true 渲染)"); Assert.True(items[1].IsThinkingExpanded); } diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs index da9064449..f6bcb0d30 100644 --- a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs @@ -472,7 +472,7 @@ public void Driver_SubagentEvents_UpdateGroupInPlace() driver.Apply(new BridgeEvent.SubagentStarted("a1", "explore", "搜索")); driver.Apply(new BridgeEvent.SubagentToolUse("a1", "Read", "file.cs", 100)); - driver.Apply(new BridgeEvent.SubagentFinished("a1", IsError: false)); + driver.Apply(new BridgeEvent.SubagentFinished("a1", IsError: false, TokenTotal: 0)); var group = view.Messages.Items.OfType().Single(); Assert.Equal(1, group.Coordinator.Count); @@ -507,7 +507,7 @@ public void Driver_SubagentEventsWithoutGroup_Ignored() var (driver, view, _) = CreateDriver(); driver.Apply(new BridgeEvent.SubagentToolUse("ghost", "Read", "f", 1)); - driver.Apply(new BridgeEvent.SubagentFinished("ghost", IsError: false)); + driver.Apply(new BridgeEvent.SubagentFinished("ghost", IsError: false, TokenTotal: 0)); Assert.DoesNotContain(view.Messages.Items, m => m is SubagentGroupItem); } diff --git a/native_tui/tests/NativeTui.Tests/repl/TranscriptModeTests.cs b/native_tui/tests/NativeTui.Tests/repl/TranscriptModeTests.cs new file mode 100644 index 000000000..3a0feaf05 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/TranscriptModeTests.cs @@ -0,0 +1,142 @@ +namespace NativeTui.Tests.Repl; + +/// +/// ctrl+o 全局 transcript 模式测试(M8-3 — TASK064,基准 app:toggleTranscript): +/// ctrl+o 双向切换 prompt↔transcript 全屏模态(第六交互态); +/// 进入 = 全部 CoT 消息展开(verbose)+ footer 专用提示;退出 = 全部收起 + footer 还原; +/// transcript 态吞全部按键(j/k/g/G/ctrl+u/ctrl+d 滚动、/ 搜索、ctrl+o 退出 — 字符不进输入框); +/// / 搜索:字符累积查询 + Enter 跳转到下一个匹配项(滚动定位)+ Esc 清除。 +/// +public sealed class TranscriptModeTests +{ + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell() { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("0.0.0", "m", "b", "C:\\"), new StringWriter(), sizeSource: new FakeTerminalSizeSource(80, 24)); + shell.SeedContent(); + return (shell, engine); + } + + private static void CtrlO(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('\x0f', ConsoleKey.O, false, false, false)); + + private static void Type(ReplShell shell, string text) { + foreach (var ch in text) { + shell.HandleKey(new ConsoleKeyInfo(ch, ConsoleKey.A, false, false, false)); + } + } + + /// 两条带 CoT 消息 → ctrl+o 进入 transcript → 全部展开 + footer 提示。 + [Fact] + public void CtrlO_EntersTranscriptMode_AllCoTExpanded_FooterHint() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("第一条", MessageId: 1, ThinkingDurationMs: 100, ThinkingContent: "CoT1")); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("第二条", MessageId: 2, ThinkingDurationMs: 200, ThinkingContent: "CoT2")); + shell.DrainEngine(); + + CtrlO(shell); + + Assert.True(shell.IsTranscriptMode); + var items = shell.View.Messages.Items.OfType().ToList(); + Assert.True(items[0].IsThinkingExpanded); + Assert.True(items[1].IsThinkingExpanded); + Assert.Contains("详细转录", shell.View.Footer.Notification); + } + + /// 再次 ctrl+o 退出 → 全部收起 + footer 还原(Notification 恢复进入前值)。 + [Fact] + public void CtrlO_Again_Exits_AllCollapsed_FooterRestored() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("带思考", MessageId: 1, ThinkingDurationMs: 100, ThinkingContent: "CoT")); + shell.DrainEngine(); + + CtrlO(shell); + CtrlO(shell); + + Assert.False(shell.IsTranscriptMode); + Assert.False(shell.View.Messages.Items.OfType().Single().IsThinkingExpanded); + Assert.DoesNotContain("详细转录", shell.View.Footer.Notification ?? string.Empty); + } + + /// 无 CoT 消息 ctrl+o — 模式仍切换(verbose 全屏可看全文),无展开项不报错。 + [Fact] + public void CtrlO_NoCoT_StillTogglesMode() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("无思考回复")); + shell.DrainEngine(); + + CtrlO(shell); + + Assert.True(shell.IsTranscriptMode); + Assert.False(shell.View.Messages.Items.OfType().Single().IsThinkingExpanded); + } + + /// transcript 态吞按键 — 字符 j/k 不进输入框(模态语义守卫)。 + [Fact] + public void TranscriptMode_SwallowsCharacterKeys_NotIntoInput() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("回复", MessageId: 1)); + shell.DrainEngine(); + CtrlO(shell); + + Type(shell, "jkx"); + + Assert.Equal(string.Empty, shell.Input.Text); + Assert.True(shell.IsTranscriptMode); + } + + /// / 搜索 — 字符累积查询 + footer 显示查询 + Esc 清除回 transcript。 + [Fact] + public void TranscriptSearch_TypeAccumulates_EscClears() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("错误修复完成")); + shell.DrainEngine(); + CtrlO(shell); + + shell.HandleKey(new ConsoleKeyInfo('/', ConsoleKey.D1, false, false, false)); + Type(shell, "错误"); + Assert.Contains("错误", shell.View.Footer.Notification); + + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false)); + Assert.DoesNotContain("错误", shell.View.Footer.Notification ?? string.Empty); + Assert.True(shell.IsTranscriptMode); + } + + /// / 搜索 Enter — 跳转到下一个含查询的滚动项(ScrollTop 移向匹配项)。 + [Fact] + public void TranscriptSearch_Enter_ScrollsToNextMatch() + { + var (shell, engine) = CreateShell(); + for (var i = 1; i <= 30; i++) { + engine.Events.Enqueue(new BridgeEvent.AssistantMessage($"普通回复 {i}", MessageId: i)); + } + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("目标关键词在这里", MessageId: 31)); + shell.DrainEngine(); + CtrlO(shell); + var before = shell.View.Messages.ScrollTop; + + shell.HandleKey(new ConsoleKeyInfo('/', ConsoleKey.D1, false, false, false)); + Type(shell, "目标关键词"); + shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + Assert.True(shell.View.Messages.ScrollTop > before, $"Enter 应跳向匹配项({before} → {shell.View.Messages.ScrollTop})"); + } + + /// transcript 态 Esc — 退出 transcript(非搜索态时)。 + [Fact] + public void TranscriptMode_Esc_ExitsMode() + { + var (shell, engine) = CreateShell(); + engine.Events.Enqueue(new BridgeEvent.AssistantMessage("回复", MessageId: 1)); + shell.DrainEngine(); + CtrlO(shell); + + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false)); + + Assert.False(shell.IsTranscriptMode); + } +} diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs index 9fc728ada..9039db7cf 100644 --- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs +++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs @@ -39,8 +39,8 @@ public void OnText_EmitsTokenDelta_AndOnDoneEmitsFullAssistantMessage() { consumer.OnDone(null, null); Assert.Equal( - "{\"type\":\"token_delta\",\"length\":6}\n" + - "{\"type\":\"token_delta\",\"length\":5}\n" + + "{\"type\":\"token_delta\",\"length\":6,\"text\":\"Hello \"}\n" + + "{\"type\":\"token_delta\",\"length\":5,\"text\":\"World\"}\n" + "{\"type\":\"assistant_message\",\"text\":\"Hello World\"}\n", @out.ToString()); } @@ -59,7 +59,7 @@ public void OnThinking_FirstEmitsThinkingStarted_ThenTextEmitsThinkingEndedWithD Assert.Equal( "{\"type\":\"thinking_started\"}\n" + "{\"type\":\"thinking_ended\",\"durationMs\":2500}\n" + - "{\"type\":\"token_delta\",\"length\":2}\n" + + "{\"type\":\"token_delta\",\"length\":2,\"text\":\"产出\"}\n" + "{\"type\":\"assistant_message\",\"text\":\"产出\"}\n", @out.ToString()); } @@ -173,9 +173,9 @@ public void Reset_ClearsState_NoLeakAcrossTurns() { Assert.Equal( "{\"type\":\"thinking_started\"}\n" + "{\"type\":\"thinking_ended\",\"durationMs\":1000}\n" + - "{\"type\":\"token_delta\",\"length\":3}\n" + + "{\"type\":\"token_delta\",\"length\":3,\"text\":\"第一轮\"}\n" + "{\"type\":\"assistant_message\",\"text\":\"第一轮\"}\n" + - "{\"type\":\"token_delta\",\"length\":3}\n" + + "{\"type\":\"token_delta\",\"length\":3,\"text\":\"第二轮\"}\n" + "{\"type\":\"assistant_message\",\"text\":\"第二轮\"}\n", @out.ToString()); } @@ -196,7 +196,7 @@ public void OnThinking_AgainAfterEnded_DoesNotRestartSignal() { Assert.Equal( "{\"type\":\"thinking_started\"}\n" + "{\"type\":\"thinking_ended\",\"durationMs\":1000}\n" + - "{\"type\":\"token_delta\",\"length\":1}\n" + + "{\"type\":\"token_delta\",\"length\":1,\"text\":\"t\"}\n" + "{\"type\":\"assistant_message\",\"text\":\"t\"}\n", @out.ToString()); } diff --git a/test/unit/host.tests/bridge/BridgeServeLoopTests.cs b/test/unit/host.tests/bridge/BridgeServeLoopTests.cs index d3762042d..dadc21cbc 100644 --- a/test/unit/host.tests/bridge/BridgeServeLoopTests.cs +++ b/test/unit/host.tests/bridge/BridgeServeLoopTests.cs @@ -31,7 +31,7 @@ public async Task Submit_RunsTurn_WithLifecycleEventsInOrder() { Assert.Equal("{\"type\":\"thinking_started\"}", lines[1]); Assert.StartsWith("{\"type\":\"thinking_ended\",\"durationMs\":", lines[2]); Assert.EndsWith("}", lines[2]); - Assert.Equal("{\"type\":\"token_delta\",\"length\":4}", lines[3]); + Assert.Equal("{\"type\":\"token_delta\",\"length\":4,\"text\":\"回复正文\"}", lines[3]); Assert.Equal("{\"type\":\"assistant_message\",\"text\":\"回复正文\"}", lines[4]); Assert.Equal("{\"type\":\"turn_completed\"}", lines[5]); }