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]);
}