From a1465bd4c7b32328ff6105d8862a716596a4e8eb Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:20:27 +0800 Subject: [PATCH 01/43] =?UTF-8?q?fix:=20=E5=BF=AB=E7=85=A7=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=E8=A1=8C=E5=B0=BE=E6=97=A0=E5=85=B3=E5=8C=96(CRLF?= =?UTF-8?q?=E6=8A=98=E5=8F=A0LF)=E4=BF=AE=E5=A4=8D=20autocrlf=20=E7=89=A9?= =?UTF-8?q?=E5=8C=96=E5=87=BB=E7=A9=BF=20|=20=E5=86=B3=E7=AD=96:=20Verifie?= =?UTF-8?q?r=E4=B8=A4=E4=BE=A7=E5=BD=92=E4=B8=80=E5=8C=96+gitattributes?= =?UTF-8?q?=E9=92=89=E6=AD=BBapproved=E5=BF=AB=E7=85=A7eol=3Dlf=E5=8F=8C?= =?UTF-8?q?=E4=BF=9D=E9=99=A9,=E6=A0=B9=E5=9B=A0=3Dautocrlf=3Dtrue?= =?UTF-8?q?=E7=89=A9=E5=8C=96=E6=94=B9=E5=86=99approved.txt=E4=B8=BACRLF?= =?UTF-8?q?=E8=80=8C=E8=A3=B8Ordinal=E6=AF=94=E8=BE=83=E8=A2=AB=E8=A1=8C?= =?UTF-8?q?=E5=B0=BE=E5=B7=AE=E5=BC=82=E5=87=BB=E7=A9=BF(blob=20LF?= =?UTF-8?q?=E6=98=AF=E7=9C=9F=E5=80=BC),=E5=85=88=E7=BA=A2=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A4=8D=E7=8E=B0=E5=86=8D=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 6 +++++ .../snapshot/SnapshotVerifier.cs | 15 ++++++++----- .../snapshot/SnapshotVerifierTests.cs | 22 ++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.gitattributes b/.gitattributes index cce8d5c877..04336c6aaa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -94,3 +94,9 @@ #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain + +############################################################################### +# 快照基线字节稳定 — approved 快照含 ANSI 控制序列且比对期望 LF 行尾, +# 钉死 eol=lf 防 autocrlf 物化改写字节(stash/checkout 周期免疫) +############################################################################### +*.approved.txt text eol=lf diff --git a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs index 3773f4baac..7a8d7bda52 100644 --- a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs +++ b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs @@ -3,11 +3,13 @@ namespace NativeTui.Tests.Snapshot; /// /// 轻量快照比对器 — 与 .approved.txt 比对,首次生成 .received.txt 供人工审核。 /// 零外部依赖,AOT 友好。对齐 ApprovalTests/Verify 的 received/approved 模式。 +/// 行尾无关比对(CRLF 折叠为 LF)— approved 文件经 git autocrlf 物化或 Windows 编辑器 +/// 保存会带 CRLF,实际输出统一 LF,裸 Ordinal 比较会被行尾差异击穿(行尾非快照语义)。 /// public static class SnapshotVerifier { /// - /// 验证实际输出与批准的快照一致。 + /// 验证实际输出与批准的快照一致(两侧行尾先归一化 CRLF → LF)。 /// 首次运行(无 .approved.txt)时生成 .received.txt 并断言失败,提示人工审核。 /// 后续运行比对内容,不匹配时写 .received.txt 供 diff 调试。 /// @@ -22,15 +24,18 @@ public static void Verify(string actual, string snapshotName, [System.Runtime.Co if (!File.Exists(approvedPath)) { - File.WriteAllText(receivedPath, actual); + File.WriteAllText(receivedPath, NormalizeLineEndings(actual)); Assert.Fail($"快照未批准: 已生成 {receivedPath}。审核内容后将其重命名为 {snapshotName}.approved.txt。"); } - var approved = File.ReadAllText(approvedPath); - if (!string.Equals(approved, actual, StringComparison.Ordinal)) + var approved = NormalizeLineEndings(File.ReadAllText(approvedPath)); + if (!string.Equals(approved, NormalizeLineEndings(actual), StringComparison.Ordinal)) { - File.WriteAllText(receivedPath, actual); + File.WriteAllText(receivedPath, NormalizeLineEndings(actual)); Assert.Fail($"快照不匹配: 实际输出已写入 {receivedPath},请与 {approvedPath} 对比。"); } } + + /// 行尾归一化 — CRLF 折叠为 LF(快照语义与行尾无关;孤立 CR 保留为帧数据)。 + private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n"); } diff --git a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs index 72a53fab90..9f61341e38 100644 --- a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs +++ b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs @@ -4,7 +4,8 @@ namespace NativeTui.Tests.Snapshot; /// SnapshotVerifier 基建行为测试 — approved/received 快照模式: /// ① 无 approved 时生成 .received.txt 并失败(提示人工审核) /// ② approved 与实际一致时通过 -/// ③ 不一致时写 .received.txt 供 diff 调试并失败。 +/// ③ 不一致时写 .received.txt 供 diff 调试并失败 +/// ④ approved 行尾 CRLF 与实际 LF 等价通过(git autocrlf 物化差异免疫 — 快照比对行尾无关)。 /// public sealed class SnapshotVerifierTests { @@ -66,4 +67,23 @@ public void Verify_MismatchedApproved_WritesReceivedAndFails() Directory.Delete(dir, recursive: true); } } + + /// approved 为 CRLF(git autocrlf=true 物化 / Windows 编辑器保存)而实际输出为 LF 时等价通过 + /// — 复现 reference_scene 快照在 stash/checkout 周期后 CRLF 化导致的基建性失败(行尾无关比对)。 + [Fact] + public void Verify_ApprovedWithCrlfLineEndings_PassesWithLfActual() + { + var dir = TempDir(); + try + { + var sourceFile = Path.Combine(dir, "caller.cs"); + File.WriteAllText(Path.Combine(dir, "scene.approved.txt"), "row 1\r\nrow 2\r\n"); + SnapshotVerifier.Verify("row 1\nrow 2\n", "scene", sourceFile); + Assert.False(File.Exists(Path.Combine(dir, "scene.received.txt")), "行尾等价不应写 .received.txt"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } From 7a6dbe64268f5643b53a8886c5558c1923a03928 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:20:42 +0800 Subject: [PATCH 02/43] =?UTF-8?q?feat:=20Demo=E6=BC=94=E7=A4=BA=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=B7=AF=E7=94=B1md/diff/perm/all+=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=BA=8B=E4=BB=B6=20|=20=E5=86=B3=E7=AD=96:?= =?UTF-8?q?=20TurnScript=E5=9B=9B=E5=80=BC=E8=84=9A=E6=9C=AC=E6=8C=89?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=88=86=E6=B4=BE(=E6=9C=AA=E7=9F=A5?= =?UTF-8?q?=E5=91=BD=E4=BB=A4Error=E8=AF=B1=E5=AF=BC=E5=90=AB=E5=9B=9B?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=94=A8=E9=80=94);perm=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF1.2s=E5=8F=91PermissionRequest=E6=8C=82=E8=B5=B7?= =?UTF-8?q?=E6=9C=9F=E6=97=A0token=E6=B5=81=E5=BC=8F8s=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=AE=8C=E6=88=90;PermissionRequest=E5=88=86=E6=B4=BE=E8=B5=B0?= =?UTF-8?q?Driver=E5=8F=AF=E9=80=89=E6=9D=83=E9=99=90=E9=92=A9=E5=AD=90?= =?UTF-8?q?=E6=B3=A8=E5=85=A5ReplShell.BeginPermissionRequest(=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A8=A1=E6=80=81=E6=98=AFshell=E5=B1=82=E7=8A=B6?= =?UTF-8?q?=E6=80=81Driver=E5=8F=AA=E8=B7=AF=E7=94=B1),=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E8=B7=AF=E5=BE=84=E4=BB=8D=E8=B5=B0TuiPermis?= =?UTF-8?q?sionBridge=E5=90=8C=E6=AD=A5=E5=9B=9E=E8=B0=83=E4=B8=8D?= =?UTF-8?q?=E7=BB=8F=E6=AD=A4=E4=BA=8B=E4=BB=B6;=E9=AA=8C=E8=AF=81558/558+?= =?UTF-8?q?18/18+selftest=E5=8F=8C=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 33 ++++- native_tui/apps/NativeTui.Demo/Program.cs | 7 +- .../src/NativeTui.Core/bridge/BridgeEvent.cs | 9 +- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 19 ++- .../NativeTui.Core/bridge/SimulatedEngine.cs | 127 +++++++++++++++-- .../src/NativeTui.Core/repl/ReplShell.cs | 2 +- .../bridge/SimulatedEngineTests.cs | 128 ++++++++++++++++-- .../NativeTui.Tests/repl/ReplShellTests.cs | 15 ++ 8 files changed, 303 insertions(+), 37 deletions(-) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 7c4b549547..7e60187547 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -720,8 +720,39 @@ native_tui/ # 顶层独立工程,零 modules/libs 依 | 步骤 | 命令 | 预期 | |------|------|------| | 1 种子渲染 | dotnet run --project native_tui/apps/NativeTui.Demo(真实终端) | 启动即见 ● 圆点 + 行内样式:粗体"Screen cell 网格"、permission 色行内代码 | -| 2 全形态回合 | 同上提交任意文本 → 等 5.4s 回合完成 | assistant 消息渲染 8 类块:h2 粗体标题/段落行内样式/无序+有序列表/▎ 引用斜体/csharp 代码块/---/表格(表头粗体+对齐分隔行) | +| 2 全形态回合 | 同上提交 `md` → 等 5.4s 回合完成 | assistant 消息渲染 8 类块:h2 粗体标题/段落行内样式/无序+有序列表/▎ 引用斜体/csharp 代码块/---/表格(表头粗体+对齐分隔行) | | 3 真实引擎 | NativeTui.Host.exe(需配置模型) | 引擎回复含 Markdown 语法 → ProjectOptional 投影 → 块模型渲染(无语法回复仍纯文本快路径) | | 4 窄终端换行 | 终端调窄后重跑步骤 2 | 段落软换行(词边界贪心+CJK 逐字)、列表续行对齐内容列、代码块不换行 | +## M7-4b Demo 演示命令路由(2026-10-21,用户验收模式驱动) + + + + + + + + + + + + + + + + + + + +### M7-4b 手动验收(ADR 0080,待用户执行) + +| 步骤 | 命令 | 预期 | +|------|------|------| +| 1 命令提示 | 直接运行 NativeTui.Demo.exe(真实终端) | 种子消息末条显示四命令说明(md/diff/perm/all) | +| 2 md | 输入 `md` 回车 → 等 5.4s | 纯 Markdown 回合:8 类块全形态渲染,全程无工具事件(无 ⎿ 工具行) | +| 3 diff | 输入 `diff` 回车 → 等 5.4s | 纯补丁回合:⎿ 工具结果 + 结构化 diff 块(+绿/-红/折叠提示),完成消息纯文本 | +| 4 perm | 输入 `perm` 回车 → 1.2s 后弹权限对话框 | 数字 1-3 直选/Esc=Deny/上下环绕/Enter=焦点档;等待期 spinner 无 token 计数;8s 回合自动收束(未选择时 Deny 收尾) | +| 5 all | 输入 `all` 回车 → 等 5.4s | 完整回合:工具调用+补丁+Markdown 完成消息(既有默认演示) | +| 6 未知命令 | 输入任意其他文本回车 | Error 诱导消息:列出四命令名+用途+正确写法,无 TurnStarted | + diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index 7d876a9e69..d565af5c31 100644 --- a/native_tui/apps/NativeTui.Demo/Program.cs +++ b/native_tui/apps/NativeTui.Demo/Program.cs @@ -4,7 +4,7 @@ namespace NativeTui.Demo; /// M6a 手动验收 demo(ADR 0080)— REPL 全链路真实运行(非 mock): /// raw mode + 50ms 动画 tick + 帧双缓冲(共享 StylePool)+ CellDiffer 差分 + DEC 2026 原子上屏 /// + InputBuffer 编辑 + 模拟回合(Thinking → ToolUse → Responding)。 -/// 交互:Enter 提交(触发模拟回合);Esc 忙时打断/闲时退出;Ctrl+C 退出;↑↓ 滚动消息区。 +/// 交互:Enter 提交演示命令(md / diff / perm / all — 未知命令回 Error 诱导);Esc 忙时打断/闲时退出;Ctrl+C 退出;↑↓ 滚动消息区。 /// --selftest:非交互冒烟(spinner 三帧链路 + 静态零输出 + 输入编辑),退出码 0/1。 /// public static class Program @@ -53,7 +53,7 @@ private static int RunCapture(string capturePath) return 0; } - /// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 事件驱动模拟回合;inline 模式退出只清尾部动态区,历史留终端 scrollback)。 + /// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 演示命令路由 md/diff/perm/all;inline 模式退出只清尾部动态区,历史留终端 scrollback)。 private static int RunInteractive(string? model = null, bool viewport = false) { if (Console.IsInputRedirected || Console.IsOutputRedirected) @@ -74,7 +74,7 @@ private static int RunInteractive(string? model = null, bool viewport = false) return 0; } - /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染)。 + /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染;末条为演示命令提示)。 private static IReadOnlyList SeedMessages() => [ new UserMessageItem("这个 TUI 是像素级对齐 Claude Code 的自研实现吗?"), @@ -93,6 +93,7 @@ private static IReadOnlyList SeedMessages() => ])), new ToolCallItem("Read", "native_tui/src/NativeTui.Core/repl/ReplView.cs"), new ToolResultItem("41 行 — REPL 整屏视图(footer + 输入框 + 消息滚动区)"), + new AssistantMessageItem("演示命令:md(Markdown 全形态)/ diff(结构化补丁)/ perm(权限对话框)/ all(完整回合)— 输入命令回车触发;其他输入回 Error 诱导。"), ]; /// diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index e3b8f8f271..bcd64659f3 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -4,7 +4,7 @@ namespace NativeTui.Core.Bridge; /// bridge 事件(Engine → TUI,读子进程 stdout)— ADR 0141 事件契约: /// 会话握手(session_info)、权限模式(permission_mode_changed)、 /// 回合生命周期(turn_started/completed/interrupted)、思考(thinking_started/ended)、 -/// 工具(tool_call/tool_result)、流式(token_delta)、消息(assistant_message/error)。 +/// 工具(tool_call/tool_result/permission_request)、流式(token_delta)、消息(assistant_message/error)。 /// [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] [JsonDerivedType(typeof(SessionInfo), "session_info")] @@ -14,6 +14,7 @@ namespace NativeTui.Core.Bridge; [JsonDerivedType(typeof(ThinkingEnded), "thinking_ended")] [JsonDerivedType(typeof(ToolCall), "tool_call")] [JsonDerivedType(typeof(ToolResult), "tool_result")] +[JsonDerivedType(typeof(PermissionRequest), "permission_request")] [JsonDerivedType(typeof(TokenDelta), "token_delta")] [JsonDerivedType(typeof(AssistantMessage), "assistant_message")] [JsonDerivedType(typeof(TurnCompleted), "turn_completed")] @@ -49,6 +50,12 @@ public sealed record ToolCall(string Name, string Args) : BridgeEvent; public sealed record ToolResult(string Summary, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DiffHunk[]? Patch = null) : BridgeEvent; + /// + /// 权限请求(工具执行前需用户确认 — TUI 模态对话框)。Demo 进程内路径由 SimulatedEngine 事件驱动; + /// 真实宿主路径不经此事件(TuiPermissionBridge.Confirm 同步回调直挂 ReplShell.BeginPermissionRequest)。 + /// + public sealed record PermissionRequest(string ToolName, string Description) : BridgeEvent; + /// 流式 token 增量(spinner ResponseLength 累加)。 public sealed record TokenDelta(int Length) : BridgeEvent; diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index 6ae533630f..a4929c6b4d 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -5,7 +5,8 @@ namespace NativeTui.Core.Bridge; /// 会话握手(session_info 更新 banner 真实数据 + footer 初始模式 + bypass 可用性)、 /// 权限模式回执(permission_mode_changed 更新 footer)、 /// 回合生命周期驱动 Busy + SpinnerItem(BeginTurn/EndTurn),消息事件插入滚动区 -/// (spinner 恒为末项),token_delta 累加 ResponseLength,错误追加 ErrorMessageItem。 +/// (spinner 恒为末项),token_delta 累加 ResponseLength,错误追加 ErrorMessageItem; +/// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态)。 /// 模拟路径与真实 bridge 路径共用同一套回合视图状态。 /// public sealed class ReplBridgeDriver @@ -14,12 +15,17 @@ public sealed class ReplBridgeDriver private readonly SpinnerItem _spinner; private readonly BannerItem _banner; - /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项)。 - public ReplBridgeDriver(ReplView view, SpinnerItem spinner, BannerItem banner) + /// 权限请求挂起钩子(shell 注入 BeginPermissionRequest — 权限模态是 shell 层状态,Driver 只路由不持有)。 + private readonly Action? _onPermissionRequest; + + /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项; + /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景))。 + public ReplBridgeDriver(ReplView view, SpinnerItem spinner, BannerItem banner, Action? onPermissionRequest = null) { _view = view; _spinner = spinner; _banner = banner; + _onPermissionRequest = onPermissionRequest; } /// 回合进行中(turn_started 后未 completed/interrupted)。 @@ -64,6 +70,13 @@ public void Apply(BridgeEvent bridgeEvent) ? new DiffResultItem(summary, patch) : new ToolResultItem(summary)); _spinner.HasActiveTools = false; + break; + case BridgeEvent.PermissionRequest(var toolName, var description): + if (_onPermissionRequest is { } beginPermission) + { + beginPermission(toolName, description); + } + break; case BridgeEvent.TokenDelta(var length): _spinner.Mode = SpinnerMode.Responding; diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index c4361bf8ca..280b1dc581 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -1,27 +1,36 @@ namespace NativeTui.Core.Bridge; /// -/// 模拟引擎 — Demo 模拟回合的事件化改造(ReplShell 提取后替代 DemoSession 内嵌状态机): -/// 时间驱动三阶段(Thinking 1.2s → ToolUse 2.4s → Responding 5.4s), -/// 阶段迁移产出 BridgeEvent(ToolCall/ToolResult/TokenDelta/TurnCompleted), +/// 模拟引擎 — Demo 演示命令路由(md/diff/perm/all — 未知命令 Error 诱导): +/// Submit 按命令选定回合脚本(TurnScript),时间驱动阶段状态机按脚本产出 BridgeEvent +/// (ToolCall/ToolResult/TokenDelta/PermissionRequest/TurnCompleted), /// 与真实 bridge 路径共用同一事件协议(ReplBridgeDriver 唯一翻译层)。 /// 时钟注入(默认墙钟)— 单元测试可注入虚拟时钟做确定性验证。 /// spinner Message 不经事件协议(真实路径空消息+随机动词采样,模拟直改属可弃糖)。 /// public sealed class SimulatedEngine : IReplEngine { - /// Thinking → ToolUse 迁移时刻(ms)。 + /// Thinking → 下一阶段迁移时刻(ms)— 工具脚本产 ToolCall、权限脚本产 PermissionRequest。 private const long ToolUseAtMs = 1200; - /// ToolUse → Responding 迁移时刻(ms)。 + /// ToolUse → Responding 迁移时刻(ms)— 工具脚本产 ToolResult + 补丁。 private const long RespondingAtMs = 2400; - /// 回合完成时刻(ms)。 + /// 回合完成时刻(ms)— md/diff/all 脚本。 private const long CompletedAtMs = 5400; + /// 权限脚本回合完成时刻(ms)— 交互窗口加长(用户从容选择档位)。 + private const long PermissionCompletedAtMs = 8000; + /// Responding 阶段每拍 token 增量。 private const int TokenDeltaPerTick = 9; + /// 权限演示工具名(对话框标题)。 + private const string PermissionToolName = "Bash"; + + /// 权限演示描述(对话框正文 — 说明将执行什么)。 + private const string PermissionDescription = "运行演示命令 demo/simulated.sh(模拟需确认的工具调用)"; + /// 回合计时起点(clock 值)。 private long _turnStart; @@ -31,12 +40,31 @@ public sealed class SimulatedEngine : IReplEngine /// 回合进行中。 private bool _inTurn; + /// 当前回合演示脚本(Submit 命令路由产物 — 决定阶段迁移事件序列)。 + private TurnScript _script = TurnScript.Full; + /// 待排空事件队列(Submit/Interrupt 立即入队,DrainEvents 阶段迁移追加)。 private readonly Queue _pending = new(); /// 时钟(默认墙钟 — 测试注入虚拟时钟)。 private readonly Func _clock; + /// 回合演示脚本 — Submit 命令路由产物,决定阶段迁移产出的事件序列。 + private enum TurnScript + { + /// md — 纯 Markdown 演示:无工具事件,完成消息携带全形态块模型。 + Markdown, + + /// diff — 纯 StructuredDiff 演示:ToolResult 带补丁,完成消息纯文本。 + Diff, + + /// perm — 权限对话框演示:1.2s PermissionRequest,挂起期无 token 流式,8s 延迟完成。 + Permission, + + /// all — 完整回合:补丁 + Markdown(既有默认演示)。 + Full, + } + /// /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):4 行 hunk 超 2 行预览,同时演示折叠提示。 /// @@ -113,7 +141,8 @@ public sealed class SimulatedEngine : IReplEngine /// 进程内模拟 — 恒存活。 public bool IsAlive => true; - /// 提交回合:忙时拒绝回 Error 诱导(对齐 EngineTurnClient 语义),否则入队 TurnStarted 并开始计时。 + /// 提交回合:忙时拒绝回 Error 诱导(对齐 EngineTurnClient 语义);未知命令拒绝回 Error 诱导 + /// (列出全部演示命令与用途 — 含为什么拒绝/怎么做);有效命令选定脚本、入队 TurnStarted 并开始计时。 public void Submit(string text) { if (_inTurn) @@ -122,12 +151,33 @@ public void Submit(string text) return; } + if (ParseCommand(text) is not { } script) + { + _pending.Enqueue(new BridgeEvent.Error(UnknownCommandMessage(text))); + return; + } + + _script = script; _inTurn = true; _stage = 0; _turnStart = _clock(); _pending.Enqueue(new BridgeEvent.TurnStarted()); } + /// 演示命令路由(trim 后精确匹配)— null = 未知命令。 + private static TurnScript? ParseCommand(string text) => text.Trim() switch + { + "md" => TurnScript.Markdown, + "diff" => TurnScript.Diff, + "perm" => TurnScript.Permission, + "all" => TurnScript.Full, + _ => null, + }; + + /// 未知命令 Error 诱导 — 列出全部演示命令与各自用途(含怎么做)。 + private static string UnknownCommandMessage(string input) => + $"未知演示命令「{input}」。可用命令:md(Markdown 全形态渲染)、diff(结构化补丁)、perm(权限对话框)、all(完整回合 = 补丁 + Markdown)。请输入上述命令之一后回车。"; + /// 打断当前回合(闲时 no-op)。 public void Interrupt() { @@ -153,7 +203,7 @@ public IReadOnlyList DrainEvents() return drained; } - /// 按已流逝时间推进阶段状态机(到期迁移入队事件;Responding 阶段每拍 token 增量)。 + /// 按已流逝时间推进阶段状态机(阶段迁移按脚本分派事件;Responding 阶段非权限脚本每拍 token 增量)。 private void AdvanceStages() { if (!_inTurn) @@ -162,30 +212,77 @@ private void AdvanceStages() } var elapsed = _clock() - _turnStart; - if (_stage == 0 && elapsed >= ToolUseAtMs) + AdvanceThinking(elapsed); + AdvanceToolUse(elapsed); + AdvanceResponding(elapsed); + } + + /// Thinking → 下一阶段(1.2s 到点):工具脚本产 ToolCall,权限脚本产 PermissionRequest,md 无事件直入。 + private void AdvanceThinking(long elapsed) + { + if (_stage != 0 || elapsed < ToolUseAtMs) + { + return; + } + + _stage = 1; + if (_script is TurnScript.Full or TurnScript.Diff) { - _stage = 1; _pending.Enqueue(new BridgeEvent.ToolCall("Edit", "demo/simulated.cs")); } + else if (_script is TurnScript.Permission) + { + _pending.Enqueue(new BridgeEvent.PermissionRequest(PermissionToolName, PermissionDescription)); + } + } - if (_stage == 1 && elapsed >= RespondingAtMs) + /// ToolUse → Responding(2.4s 到点):工具脚本产 ToolResult + 结构化补丁,其余脚本无事件。 + private void AdvanceToolUse(long elapsed) + { + if (_stage != 1 || elapsed < RespondingAtMs) + { + return; + } + + _stage = 2; + if (_script is TurnScript.Full or TurnScript.Diff) { - _stage = 2; _pending.Enqueue(new BridgeEvent.ToolResult("Updated demo/simulated.cs (simulated)", SimulatedPatch)); } + } - if (_stage == 2 && elapsed >= CompletedAtMs) + /// Responding → 完成(脚本完成时刻到点):完成消息(md/all 携带 Markdown 块模型)+ TurnCompleted; + /// 权限脚本挂起期无 token 流式(权限等待语义),其余脚本每拍 token 增量。 + private void AdvanceResponding(long elapsed) + { + if (_stage == 2 && elapsed >= CompletionAtMs(_script)) { _stage = 3; _inTurn = false; - _pending.Enqueue(new BridgeEvent.AssistantMessage("演示回复 — markdown 全形态渲染管线运行正常。", DemoMarkdown)); + _pending.Enqueue(new BridgeEvent.AssistantMessage(CompletionText(_script), CompletionMarkdown(_script))); _pending.Enqueue(new BridgeEvent.TurnCompleted()); return; } - if (_stage == 2) + if (_stage == 2 && _script is not TurnScript.Permission) { _pending.Enqueue(new BridgeEvent.TokenDelta(TokenDeltaPerTick)); } } + + /// 完成时刻(ms)— 权限脚本 8s(交互窗口),其余 5.4s。 + private static long CompletionAtMs(TurnScript script) => script is TurnScript.Permission ? PermissionCompletedAtMs : CompletedAtMs; + + /// 完成消息文本(按脚本语义区分)。 + private static string CompletionText(TurnScript script) => script switch + { + TurnScript.Diff => "演示回复 — StructuredDiff 补丁渲染链路运行正常。", + TurnScript.Permission => "演示回复 — 权限对话框交互完成(挂起期无 token 流式,回合正常收束)。", + TurnScript.Markdown => "演示回复 — Markdown 全形态渲染管线运行正常。", + _ => "演示回复 — 完整回合(补丁 + Markdown)运行正常。", + }; + + /// 完成消息 Markdown 块模型 — md/all 携带全形态演示文档,diff/perm 纯文本。 + private static MarkdownDocument? CompletionMarkdown(TurnScript script) => + script is TurnScript.Markdown or TurnScript.Full ? DemoMarkdown : null; } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index f4226f5245..45926cbbac 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -64,7 +64,7 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool _output = output; _inline = inline; _seedMessages = seedMessages ?? []; - _driver = new ReplBridgeDriver(_view, _spinner, _banner); + _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest); } /// 视图(测试断言面)。 diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs index e11f4842d5..05b97f8a06 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs @@ -1,8 +1,8 @@ namespace NativeTui.Tests.Bridge; /// -/// SimulatedEngine 单元测试 — Demo 模拟回合的事件化改造(时钟注入确定性验证): -/// Submit 立即产 TurnStarted、时间推进产阶段事件(ToolCall/ToolResult/TokenDelta/TurnCompleted)、 +/// SimulatedEngine 单元测试 — Demo 演示命令路由(md/diff/perm/all — 未知命令 Error 诱导)+ 时钟注入确定性验证: +/// 有效命令立即产 TurnStarted、时间推进按脚本产阶段事件(ToolCall/ToolResult/TokenDelta/PermissionRequest/TurnCompleted)、 /// Interrupt 忙时打断、忙时提交拒绝(Error 诱导)、权限模式本地回执统一走事件。 /// public sealed class SimulatedEngineTests @@ -24,7 +24,7 @@ private sealed class VirtualClock public void Submit_Drains_TurnStartedImmediately() { var engine = new SimulatedEngine(static () => 0L); - engine.Submit("hello"); + engine.Submit("all"); var events = engine.DrainEvents(); Assert.Equal([new BridgeEvent.TurnStarted()], events); } @@ -35,7 +35,7 @@ public void Advance_ToToolUse_Emits_ToolCall() { var clock = new VirtualClock(); var engine = new SimulatedEngine(() => clock.Now); - engine.Submit("hello"); + engine.Submit("all"); engine.DrainEvents(); clock.Advance(1300); @@ -48,7 +48,7 @@ public void Advance_ToResponding_Emits_ToolResultWithPatchAndTokenDelta() { var clock = new VirtualClock(); var engine = new SimulatedEngine(() => clock.Now); - engine.Submit("hello"); + engine.Submit("all"); engine.DrainEvents(); clock.Advance(2500); @@ -66,7 +66,7 @@ public void Advance_ToCompletion_Emits_AssistantMessageAndTurnCompleted() { var clock = new VirtualClock(); var engine = new SimulatedEngine(() => clock.Now); - engine.Submit("hello"); + engine.Submit("all"); engine.DrainEvents(); clock.Advance(5500); @@ -77,13 +77,13 @@ public void Advance_ToCompletion_Emits_AssistantMessageAndTurnCompleted() Assert.Empty(engine.DrainEvents()); } - /// 虚拟时钟 5.5s:完成事件携带 Markdown 块模型(M7-4 演示全形态 — 8 类块全覆盖)。 + /// 虚拟时钟 5.5s:完成事件携带 Markdown 块模型(md 演示命令 — 全形态 8 类块)。 [Fact] public void Advance_ToCompletion_Emits_AssistantMessageWithMarkdown() { var clock = new VirtualClock(); var engine = new SimulatedEngine(() => clock.Now); - engine.Submit("hello"); + engine.Submit("md"); engine.DrainEvents(); clock.Advance(5500); @@ -100,12 +100,114 @@ public void Advance_ToCompletion_Emits_AssistantMessageWithMarkdown() Assert.Contains(message.Markdown.Blocks, b => b is MarkdownTable); } + /// md 命令:纯 Markdown 演示 — 全程无工具事件(ToolCall/ToolResult)。 + [Fact] + public void Submit_MdCommand_NoToolEvents() + { + var clock = new VirtualClock(); + var engine = new SimulatedEngine(() => clock.Now); + engine.Submit("md"); + engine.DrainEvents(); + + clock.Advance(5500); + var events = engine.DrainEvents(); + Assert.DoesNotContain(events, e => e is BridgeEvent.ToolCall); + Assert.DoesNotContain(events, e => e is BridgeEvent.ToolResult); + } + + /// diff 命令:纯 StructuredDiff 演示 — ToolResult 带补丁,完成消息为纯文本(Markdown null)。 + [Fact] + public void Submit_DiffCommand_PatchWithoutMarkdown() + { + var clock = new VirtualClock(); + var engine = new SimulatedEngine(() => clock.Now); + engine.Submit("diff"); + engine.DrainEvents(); + + clock.Advance(2500); + var midEvents = engine.DrainEvents(); + var result = Assert.IsType(midEvents.First(e => e is BridgeEvent.ToolResult)); + Assert.NotNull(result.Patch); + + clock.Advance(3000); + var message = Assert.IsType(engine.DrainEvents().First(e => e is BridgeEvent.AssistantMessage)); + Assert.Null(message.Markdown); + } + + /// perm 命令:1.2s 产出 PermissionRequest 事件(工具名/描述非空 — 权限对话框演示)。 + [Fact] + public void Submit_PermCommand_EmitsPermissionRequest() + { + var clock = new VirtualClock(); + var engine = new SimulatedEngine(() => clock.Now); + engine.Submit("perm"); + engine.DrainEvents(); + + clock.Advance(1300); + var request = Assert.IsType(engine.DrainEvents().First(e => e is BridgeEvent.PermissionRequest)); + Assert.False(string.IsNullOrEmpty(request.ToolName)); + Assert.False(string.IsNullOrEmpty(request.Description)); + } + + /// perm 命令:挂起期无 token 流式(权限等待语义),8s 延迟完成(交互窗口加长)。 + [Fact] + public void Submit_PermCommand_NoTokenDeltaUntilLateCompletion() + { + var clock = new VirtualClock(); + var engine = new SimulatedEngine(() => clock.Now); + engine.Submit("perm"); + engine.DrainEvents(); + + clock.Advance(3000); + var events = engine.DrainEvents(); + Assert.DoesNotContain(events, e => e is BridgeEvent.TokenDelta); + Assert.DoesNotContain(events, e => e is BridgeEvent.TurnCompleted); + + clock.Advance(5100); + Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.TurnCompleted); + } + + /// all 命令:完整回合 — 补丁与 Markdown 齐全(既有默认演示)。 + [Fact] + public void Submit_AllCommand_PatchAndMarkdownTogether() + { + var clock = new VirtualClock(); + var engine = new SimulatedEngine(() => clock.Now); + engine.Submit("all"); + engine.DrainEvents(); + + clock.Advance(2500); + Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.ToolResult { Patch: not null }); + + clock.Advance(3000); + var message = Assert.IsType(engine.DrainEvents().First(e => e is BridgeEvent.AssistantMessage)); + Assert.NotNull(message.Markdown); + } + + /// 未知命令:拒绝并回 Error 诱导(列出全部演示命令 — 含为什么/怎么做)。 + [Theory] + [InlineData("随便聊聊")] + [InlineData("hello")] + public void Submit_UnknownCommand_RejectsWithGuidance(string input) + { + var engine = new SimulatedEngine(static () => 0L); + engine.Submit(input); + var events = engine.DrainEvents(); + + var error = Assert.IsType(events.Single()); + Assert.Contains("md", error.Message); + Assert.Contains("diff", error.Message); + Assert.Contains("perm", error.Message); + Assert.Contains("all", error.Message); + Assert.DoesNotContain(events, e => e is BridgeEvent.TurnStarted); + } + /// 忙时 Interrupt:产出 TurnInterrupted,回合终止。 [Fact] public void Interrupt_WhileBusy_Emits_TurnInterrupted() { var engine = new SimulatedEngine(static () => 0L); - engine.Submit("hello"); + engine.Submit("all"); engine.DrainEvents(); engine.Interrupt(); @@ -118,10 +220,10 @@ public void Interrupt_WhileBusy_Emits_TurnInterrupted() public void Submit_WhileBusy_RejectsWithError() { var engine = new SimulatedEngine(static () => 0L); - engine.Submit("first"); + engine.Submit("all"); engine.DrainEvents(); - engine.Submit("second"); + engine.Submit("all"); Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.Error { Message.Length: > 0 }); } @@ -130,12 +232,12 @@ public void Submit_WhileBusy_RejectsWithError() public void Submit_AfterInterrupt_StartsNewTurn() { var engine = new SimulatedEngine(static () => 0L); - engine.Submit("first"); + engine.Submit("all"); engine.DrainEvents(); engine.Interrupt(); engine.DrainEvents(); - engine.Submit("second"); + engine.Submit("all"); Assert.Equal([new BridgeEvent.TurnStarted()], engine.DrainEvents()); } diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs index 3eb9f8a901..17ce6ac432 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs @@ -126,6 +126,21 @@ public void DrainEngine_PermissionModeChanged_UpdatesFooter() Assert.Equal(PermissionMode.Plan, shell.View.Footer.Mode); } + /// DrainEngine:PermissionRequest 事件经 Driver 钩子分派到权限对话框(模态挂起 + 对话框项插入滚动区)。 + [Fact] + public void DrainEngine_PermissionRequestEvent_BeginsPermissionDialog() + { + var (shell, engine) = CreateShell(); + shell.SeedContent(); + + engine.Events.Enqueue(new BridgeEvent.TurnStarted()); + engine.Events.Enqueue(new BridgeEvent.PermissionRequest("Bash", "运行演示命令")); + shell.DrainEngine(); + + Assert.True(shell.Permission.IsPending); + Assert.Contains(shell.View.Messages.Items, m => m is PermissionRequestItem); + } + /// DrainEngine:引擎死亡(非忙)— 追加错误消息并请求停止。 [Fact] public void DrainEngine_EngineDead_AppendsErrorAndStops() From 5c1c6a277389c6a3935f1c68ee241865fa89b6b2 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:39:05 +0800 Subject: [PATCH 03/43] =?UTF-8?q?fix:=20diff=E7=BA=A2=E7=BB=BF=E8=A1=8C?= =?UTF-8?q?=E8=A2=AB2=E8=A1=8C=E9=A2=84=E8=A7=88=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E5=90=9E=E6=8E=89=E8=87=B4=E7=9C=9F=E5=AE=9E=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=97=A0=E7=BA=A2=E7=BB=BF=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8A=E9=99=902=E2=86=9210=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=9F=BA=E5=87=86update=E5=85=A8=E9=87=8F=E6=B8=B2?= =?UTF-8?q?=E6=9F=93diff=E5=85=88=E4=BE=8B(FileWriteTool/UI.tsx=20L138-141?= =?UTF-8?q?,=E6=8A=98=E5=8F=A0=E4=B8=8A=E9=99=90=E9=87=87Write=20MAX=5FLIN?= =?UTF-8?q?ES=5FTO=5FRENDER=3D10=E9=98=B2=E5=88=B7=E5=B1=8F);SimulatedPatc?= =?UTF-8?q?h=E8=A1=8C=E5=BA=8Fremoved/added=E5=89=8D=E7=BD=AE=E5=B9=B6?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E4=BE=9B=E6=B8=B2=E6=9F=93=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95(=E5=AF=B9?= =?UTF-8?q?=E9=BD=90DemoMarkdown=E5=85=88=E4=BE=8B);=E6=A0=B9=E5=9B=A0=3D?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E9=93=BE=E8=B7=AF=E6=97=A0=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E8=80=8C=E6=98=AF=E9=A2=84=E8=A7=88=E6=8A=98=E5=8F=A0=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E5=81=8F=E7=A6=BB=E5=9F=BA=E5=87=86,4=E8=A1=8Chunk?= =?UTF-8?q?=E8=A2=ABheader+context=E5=90=83=E5=85=89=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E7=BA=A2=E7=BB=BF=E5=85=A8=E8=A2=AB=E6=8A=98?= =?UTF-8?q?=E5=8F=A0;=E9=AA=8C=E8=AF=81559/559+selftest=E7=BB=BF+Demo.exe?= =?UTF-8?q?=E9=87=8D=E7=BC=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NativeTui.Core/bridge/SimulatedEngine.cs | 8 +- .../src/NativeTui.Core/repl/MessageItems.cs | 7 +- .../repl/DiffResultItemTests.cs | 76 ++++++++++++------- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index 280b1dc581..75f5b5ce25 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -66,19 +66,21 @@ private enum TurnScript } /// - /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):4 行 hunk 超 2 行预览,同时演示折叠提示。 + /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):Removed/Added 行在前(红绿可见), + /// Context 行殿后;4 行 hunk 全量渲染(≤ DiffResultItem 预览上限 10 行)。 + /// 公开静态供渲染可见性回归测试与 Demo 构造复用(对齐 DemoMarkdown 先例)。 /// - private static readonly DiffHunk[] SimulatedPatch = + public static readonly DiffHunk[] SimulatedPatch = [ new() { Header = "@@ -1,4 +1,5 @@", Lines = [ - new DiffLine { Kind = DiffLineKind.Context, Content = "existing line" }, new DiffLine { Kind = DiffLineKind.Removed, Content = "old behavior" }, new DiffLine { Kind = DiffLineKind.Added, Content = "new behavior + diff render" }, new DiffLine { Kind = DiffLineKind.Added, Content = "extra line to show collapse" }, + new DiffLine { Kind = DiffLineKind.Context, Content = "existing line" }, ], }, ]; diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index f9b76e443d..4f51183b5f 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -52,13 +52,14 @@ public void Render(Screen screen, Rect frame, Theme theme) => /// /// 带补丁的工具结果项(M7-2 StructuredDiff)— summary 行沿用 MessageResponse 形态, /// 后接 hunk 块(4 空格缩进:header dim / added DiffAddedWord / removed DiffRemovedWord / context dim); -/// patch 行数超预览折叠为 "… +N lines (Ctrl+O)" dim(预览行数对齐青蛙TUI Take(3) = summary + 2 行先例, +/// patch 全量渲染(基准 update 渲染完整 diff 不截断 — FileWriteTool/UI.tsx "update renders the full diff"), +/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)" dim(上限对齐基准 Write MAX_LINES_TO_RENDER=10 防刷屏先例, /// Ctrl+O 展开交互为 M7 后续项)。 /// public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem { - /// patch 预览行数(summary 之外;对齐青蛙TUI ToolResultPreview 3 行中的 2 行 patch)。 - private const int PreviewPatchRows = 2; + /// patch 预览行数上限(summary 之外;超限折叠 — 对齐基准 Write MAX_LINES_TO_RENDER=10 先例)。 + private const int PreviewPatchRows = 10; /// diff 块缩进列数(对齐青蛙TUI " " 缩进先例)。 private const int IndentCols = 4; diff --git a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs index 3c36f3f4e2..50795dddb6 100644 --- a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs @@ -5,7 +5,8 @@ namespace NativeTui.Tests.Repl; /// summary 行沿用 MessageResponse 形态(" ⎿ " dim 前缀 + 默认正文); /// hunk 块 4 空格缩进 — header dim / context dim / added 行 DiffAddedWord 前景 / removed 行 DiffRemovedWord 前景 ///(对齐青蛙TUI TranscriptWriter ±行前景色先例,色键换用基准 theme.ts diff 前景键); -/// patch 行数超预览折叠为 "… +N lines (Ctrl+O)" dim(青蛙TUI Take(3) = summary + 2 行预览先例)。 +/// patch 全量渲染(基准 update 渲染完整 diff — FileWriteTool/UI.tsx L138-141), +/// 仅超 10 行预览上限折叠为 "… +N lines (Ctrl+O)"(上限对齐基准 Write MAX_LINES_TO_RENDER=10 先例)。 /// public sealed class DiffResultItemTests { @@ -15,6 +16,13 @@ public sealed class DiffResultItemTests /// 构造 diff 行。 private static DiffLine Line(DiffLineKind kind, string content) => new() { Kind = kind, Content = content }; + /// 构造超预览上限的 patch(header + N 行,总行数 = 1 + N)。 + private static DiffHunk LongHunk(int lineCount) => new() + { + Header = "@@ -1,3 +1,4 @@", + Lines = Enumerable.Range(0, lineCount).Select(i => Line(DiffLineKind.Context, $"line {i}")).ToArray(), + }; + [Fact] public void Render_SummaryRowKeepsToolResultStyle() { @@ -89,65 +97,79 @@ public void Render_ContextLineDimmed() Assert.Equal(" unchanged", RowText.Of(screen, 3, 0, 14)); } + [Fact] + public void Render_DemoPatchShowsRedAndGreenLines() + { + // 回归(用户真实终端验收反馈):SimulatedEngine 演示补丁渲染后预览窗口内必须可见红绿行 — + // 4 行 hunk 全量渲染(≤10 行预览上限),Removed 行 DiffRemovedWord + Added 行 DiffAddedWord 都在屏上 + var patch = SimulatedEngine.SimulatedPatch; + using var screen = new Screen(cols: 80, rows: 2 + 1 + 1 + patch.Sum(static h => 1 + h.Lines.Length)); + + var item = new DiffResultItem("Updated demo/simulated.cs (simulated)", patch); + item.Render(screen, new Rect(0, 0, screen.Cols, screen.Rows), Theme.Dark); + + var removed = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.DiffRemovedWord }); + var added = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.DiffAddedWord }); + Assert.Equal("-old behavior", RowText.Of(screen, 3, 4, 13)); + Assert.Equal(removed, screen[3, 4].StyleId); + Assert.Equal("+new behavior + diff render", RowText.Of(screen, 4, 4, 27)); + Assert.Equal(added, screen[4, 4].StyleId); + } + [Fact] public void Render_CollapsesBeyondPreviewWithHint() { - // 1 hunk = header + 4 行 → patch 5 行 > 预览 2 行 → 渲染 header + 首行 + 折叠提示 - using var screen = new Screen(cols: 40, rows: 5); - var item = new DiffResultItem("Edited a.cs", [Hunk( - Line(DiffLineKind.Context, "keep"), - Line(DiffLineKind.Removed, "drop"), - Line(DiffLineKind.Added, "add1"), - Line(DiffLineKind.Added, "add2"))]); + // header + 12 行 = 13 行 > 预览上限 10 → 渲染 header + 前 9 行 + 折叠提示 +3 + using var screen = new Screen(cols: 40, rows: 2 + 10 + 1); + var item = new DiffResultItem("Edited a.cs", [LongHunk(12)]); - item.Render(screen, new Rect(0, 0, 40, 5), Theme.Dark); + item.Render(screen, new Rect(0, 0, 40, screen.Rows), Theme.Dark); - // row2 header(预览第 1 行)→ row3 首个 diff 行(预览第 2 行)→ row4 折叠提示 dim + // row2 header → row3..row11 预览 9 行 → row12 折叠提示 dim var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); Assert.Equal(" @@ -1,3 +1,4 @@", RowText.Of(screen, 2, 0, 19)); - Assert.Equal(" keep", RowText.Of(screen, 3, 0, 9)); - Assert.Equal(" … +3 lines (Ctrl+O)", RowText.Of(screen, 4, 0, 23)); - Assert.Equal(dim, screen[4, 4].StyleId); + Assert.Equal(" line 0", RowText.Of(screen, 3, 0, 11)); + Assert.Equal(" line 8", RowText.Of(screen, 11, 0, 11)); + Assert.Equal(" … +3 lines (Ctrl+O)", RowText.Of(screen, 12, 0, 23)); + Assert.Equal(dim, screen[12, 4].StyleId); } [Fact] - public void Render_MultipleHunksPreviewSpansHunks() + public void Render_MultipleHunksAllRendered() { - // 2 hunks(各 header + 1 行)→ patch 4 行 > 预览 2 → 预览行 = h1 header + h1 line,折叠 +2 - using var screen = new Screen(cols: 40, rows: 5); + // 2 hunks(各 header + 1 行)共 4 行 ≤ 预览上限 → 全量渲染无折叠 + using var screen = new Screen(cols: 40, rows: 6); var item = new DiffResultItem("2 files", [ Hunk(Line(DiffLineKind.Added, "a")), new DiffHunk { Header = "@@ -9 +9 @@", Lines = [Line(DiffLineKind.Removed, "b")] }, ]); - item.Render(screen, new Rect(0, 0, 40, 5), Theme.Dark); + item.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark); Assert.Equal(" @@ -1,3 +1,4 @@", RowText.Of(screen, 2, 0, 19)); Assert.Equal(" +a", RowText.Of(screen, 3, 0, 6)); - Assert.Equal(" … +2 lines (Ctrl+O)", RowText.Of(screen, 4, 0, 23)); + Assert.Equal(" @@ -9 +9 @@", RowText.Of(screen, 4, 0, 15)); + Assert.Equal(" -b", RowText.Of(screen, 5, 0, 6)); } [Fact] public void MeasureHeight_MarginPlusSummaryPlusRenderedPatchRows() { - // patch 2 行(header+1 行)恰在预览内 → 1 margin + 1 summary + 2 = 4 + // patch 2 行(header+1 行)→ 1 margin + 1 summary + 2 = 4(预览内全渲染) var fits = new DiffResultItem("s", [Hunk(Line(DiffLineKind.Added, "x"))]); Assert.Equal(4, fits.MeasureHeight(width: 80)); - // patch 5 行超预览 → 1 + 1 + 2 预览 + 1 折叠提示 = 5 + // patch 5 行 ≤ 预览上限 10 → 1 + 1 + 5 = 7(全渲染无折叠) var collapsed = new DiffResultItem("s", [Hunk( Line(DiffLineKind.Context, "a"), Line(DiffLineKind.Context, "b"), Line(DiffLineKind.Context, "c"), Line(DiffLineKind.Context, "d"))]); - Assert.Equal(5, collapsed.MeasureHeight(width: 80)); + Assert.Equal(7, collapsed.MeasureHeight(width: 80)); - // 2 hunks 共 4 行 → 折叠 → 5 - var multiHunk = new DiffResultItem("s", [ - Hunk(Line(DiffLineKind.Added, "a")), - Hunk(Line(DiffLineKind.Removed, "b")), - ]); - Assert.Equal(5, multiHunk.MeasureHeight(width: 80)); + // patch 13 行(header+12)> 预览 10 → 1 + 1 + 10 预览 + 1 折叠提示 = 13 + var overflow = new DiffResultItem("s", [LongHunk(12)]); + Assert.Equal(13, overflow.MeasureHeight(width: 80)); } [Fact] From ade1e34133f8e9ea5a28f0c0ddc23423fb0c3321 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:41:07 +0800 Subject: [PATCH 04/43] =?UTF-8?q?docs:=20TASK055=20=E8=BF=BD=E5=8A=A0=20M7?= =?UTF-8?q?-4b=20=E9=AA=8C=E6=94=B6=E5=8F=8D=E9=A6=88=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E5=9D=97(diff=E7=BA=A2=E7=BB=BF=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=3D=E9=A2=84=E8=A7=88=E6=8A=98=E5=8F=A0=E5=90=9E?= =?UTF-8?q?=E8=A1=8C)=20|=20=E5=86=B3=E7=AD=96:=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=A1=A8=E6=AD=A5=E9=AA=A43=E5=90=8C=E6=AD=A5=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9D=80=E8=89=B2=E5=8F=AF=E8=A7=81=E9=A2=84=E6=9C=9F?= =?UTF-8?q?;=E5=8F=8D=E9=A6=88=E2=91=A0=E8=BE=93=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=B8=8E=E2=91=A1/=E5=91=BD=E4=BB=A4=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E6=8C=89=E7=94=A8=E6=88=B7=E6=8C=87=E7=A4=BA=E7=A7=BB?= =?UTF-8?q?=E4=BA=A4=E4=BB=96=E4=BA=BA=E6=9C=AC=E7=BA=BF=E4=B8=8D=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...272\247\345\257\271\351\275\220ClaudeCode.md" | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 7e60187547..46b5e8b061 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -750,9 +750,23 @@ native_tui/ # 顶层独立工程,零 modules/libs 依 |------|------|------| | 1 命令提示 | 直接运行 NativeTui.Demo.exe(真实终端) | 种子消息末条显示四命令说明(md/diff/perm/all) | | 2 md | 输入 `md` 回车 → 等 5.4s | 纯 Markdown 回合:8 类块全形态渲染,全程无工具事件(无 ⎿ 工具行) | -| 3 diff | 输入 `diff` 回车 → 等 5.4s | 纯补丁回合:⎿ 工具结果 + 结构化 diff 块(+绿/-红/折叠提示),完成消息纯文本 | +| 3 diff | 输入 `diff` 回车 → 等 5.4s | 纯补丁回合:⎿ 工具结果 + 结构化 diff 块(+绿/-红 着色可见),完成消息纯文本 | | 4 perm | 输入 `perm` 回车 → 1.2s 后弹权限对话框 | 数字 1-3 直选/Esc=Deny/上下环绕/Enter=焦点档;等待期 spinner 无 token 计数;8s 回合自动收束(未选择时 Deny 收尾) | | 5 all | 输入 `all` 回车 → 等 5.4s | 完整回合:工具调用+补丁+Markdown 完成消息(既有默认演示) | | 6 未知命令 | 输入任意其他文本回车 | Error 诱导消息:列出四命令名+用途+正确写法,无 TurnStarted | +## M7-4b 用户验收反馈修复(2026-10-21,真实终端验收驱动) + + + + + + + + + + + + + From 96cc0360914d9def023bc6397662e82755ec1d21 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:50:28 +0800 Subject: [PATCH 05/43] =?UTF-8?q?docs:=20M7-5=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E8=B0=83=E7=A0=94=E8=90=BD=E5=9C=B0(?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E5=8F=8C=E5=BC=95=E6=93=8E=E7=9C=9F=E7=9B=B8?= =?UTF-8?q?+C#=E4=B8=89=E6=96=B9=E6=A1=88=E5=AF=B9=E6=AF=94)=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E6=8E=A8=E8=8D=90TextMateSharp=E8=B7=AF?= =?UTF-8?q?=E7=BA=BF=3D=E5=9F=BA=E5=87=86Rust=E5=8E=9F=E7=89=88syntect?= =?UTF-8?q?=E5=90=8C=E6=BA=90(TextMate=E5=BC=95=E6=93=8E+Monokai=20Extende?= =?UTF-8?q?d=E4=B8=BB=E9=A2=98,TS=E7=A7=BB=E6=A4=8D=E7=89=88highlight.js?= =?UTF-8?q?=E5=8F=AA=E6=98=AFvendor=E7=BC=BA=E5=A4=B1=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E5=93=81),=E5=8D=AB=E6=98=9F=E9=A1=B9=E7=9B=AE=E9=9A=94?= =?UTF-8?q?=E7=A6=BBOnigwrap=E5=8E=9F=E7=94=9F=E4=BE=9D=E8=B5=96=E5=BE=85A?= =?UTF-8?q?OT=E5=AE=9E=E6=B5=8B;=E5=85=B3=E9=94=AE=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=3Ddiff=E5=88=A0=E9=99=A4=E8=A1=8C=E6=9C=89?= =?UTF-8?q?=E6=84=8F=E8=B7=B3=E8=BF=87=E8=AF=AD=E6=B3=95=E9=AB=98=E4=BA=AE?= =?UTF-8?q?+24=E9=94=AEscope=E8=A1=A8=E4=B8=89=E7=BA=A7=E5=9B=9E=E9=80=80+?= =?UTF-8?q?=E4=BA=94=E5=B1=82=E9=99=8D=E7=BA=A7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 46b5e8b061..46ae82ab6d 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -770,3 +770,36 @@ native_tui/ # 顶层独立工程,零 modules/libs 依 + +## M7-5 代码高亮调研(2026-10-21,基准源码全读 + nuget 生态调研) + +### 基准双引擎体系真相(claude-code-rev-main 全读结论) + +| 引擎 | 底层 | 场景 | 颜色策略 | +|------|------|------|---------| +| A: cli-highlight | highlight.js + 库内置主题 | Markdown ```代码块 | 库直出 ANSI(还原树无源码,从调用方推断) | +| B: color-diff-napi | Rust 原版=syntect+bat;TS 移植版=highlight.js+手写 scope 表 | StructuredDiff / HighlightedCode | scope→RGB 手写表 + 自拼 ANSI | + +**关键事实**: +1. Rust 原版(syntect+Monokai Extended)才是基准真实架构;TS 移植版用 highlight.js 是 vendor 缺失的无奈替代(`src/native-ts/color-diff/index.ts` L1-17 头注释自证) +2. token 树 → scope 映射表只需 ~24 键(MONOKAI_SCOPES dark / GITHUB_SCOPES light,L188-243),三级回退查表:精确 scope → `scope.split('.')[0]` 前缀 → 默认前景 +3. **diff 删除行有意跳过语法高亮**(`-` 行默认样式+红背景;`+`/context 行全高亮,L913-918) +4. 语言检测:特殊文件名表(Dockerfile/Makefile/Rakefile→ruby...)→ 扩展名 getLanguage(ext) → shebang(#!/bin/bash→bash 等,L422-451) +5. highlight.js 全量注册 190+ 语法代价 ~50MB/100-200ms → 惰性 require + LRU 500 缓存(token/render 两级) +6. 降级链五层全落"默认前景纯文本"无崩溃:env 开关 → 设置开关 → 加载失败 → 语言不支持(plaintext/markdown 回退) → emitter 形状校验 +7. STORAGE_KEYWORDS 重拆:const/let/var/class/fn 等从 keyword 色重拆为 _storage 色(对齐 syntect storage.type 着色,L248-265) + +### C# NativeAOT 候选方案对比 + +| 方案 | 语言覆盖 | AOT 风险 | 依赖 | 基准接近度 | +|------|---------|---------|------|-----------| +| **A: TextMateSharp + Grammars(推荐)** | VS Code 全量语法 | 中:Onigwrap 原生 DLL(P/Invoke,AOT 支持需卫星实测) | 2 nuget,活跃维护(2.0.4 2026-06) | **=基准 Rust 原版 syntect 路线**(TextMate 引擎同源) | +| B: ColorCode.NET | ~23 语言 | 低(纯托管) | 1 nuget | 低(颜色体系不同需重做映射) | +| C: 手写词法引擎 | 自选子集 | 零 | 零 | 中(scope 表可照抄基准 24 键) | + +**A 方案背书**:Terminal.Gui v2(2.3M 下载)高亮即建在 TextMateSharp 上且官方支持 NativeAOT;语法 JSON 数据驱动无反射 emit;Monokai Extended 主题文件可直接复用(对齐基准 Rust 原版行为而非 TS 替代品)。 + +**A 方案架构(ADR 0140 先例)**:卫星项目 `NativeTui.Highlighting` 隔离 Onigwrap/TextMateSharp(对齐 NativeTui.Markdown 隔离 Markdig),Core 零依赖只消费 DTO(`HighlightedSpan(text, Rgb)` 列表);Markdown 代码块 + diff 行高亮共用单管线(基准两引擎合一启示);语言检测照抄基准 detectLanguage 三级;diff 删除行跳过高亮语义直接复用。 + + + From 5ddfb7179447aa8c335153a2a99f09fa099418fb Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:06:13 +0800 Subject: [PATCH 06/43] =?UTF-8?q?feat:=20M7-5=20=E9=AB=98=E4=BA=AEAOT?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E9=80=9A=E8=BF=87(TextMateSharp+Onigwrap?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E9=93=BENativeAOT=E5=AE=9E=E6=B5=8B)=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E7=94=A8=E6=88=B7=E9=80=89=E5=AE=9AA?= =?UTF-8?q?=E6=96=B9=E6=A1=88TextMateSharp=3D=E5=9F=BA=E5=87=86Rust?= =?UTF-8?q?=E5=8E=9F=E7=89=88syntect=E5=90=8C=E6=BA=90;=E5=8D=AB=E6=98=9F?= =?UTF-8?q?=E9=A1=B9=E7=9B=AENativeTui.Highlighting=E9=9A=94=E7=A6=BB(ADR?= =?UTF-8?q?=200140=E5=85=88=E4=BE=8B)=E6=8C=82slnx;AotProbe=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E4=B8=BAAOT=E5=9B=9E=E5=BD=92=E5=B7=A5=E5=85=B7;?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=B8=89=E5=85=B3=E5=85=A8=E8=BF=87=3DPublis?= =?UTF-8?q?hAot=E7=BC=96=E8=AF=91=E9=9B=B6=E9=94=99+libonigwrap.dll?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E6=90=BA=E5=B8=A6+AOT=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E4=B8=8EDebug=E4=B8=80=E8=87=B4(Monokai?= =?UTF-8?q?=E6=A0=87=E5=87=86=E8=89=B2keyword249,38,114=20string230,219,11?= =?UTF-8?q?6);API=E5=9D=91=3D2.0=E7=89=88README=E8=BF=87=E6=97=B6,GetScope?= =?UTF-8?q?ByLanguageId=E9=9D=9EGetScopeNameFromLanguageId,foreground?= =?UTF-8?q?=E6=98=AFint=E5=9E=8BcolorId(-1=3D=E6=97=A0=E8=89=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NativeTui.Highlighting.AotProbe.csproj | 17 +++ .../Program.cs | 112 ++++++++++++++++++ native_tui/native_tui.slnx | 2 + .../NativeTui.Highlighting.csproj | 17 +++ 4 files changed, 148 insertions(+) create mode 100644 native_tui/apps/NativeTui.Highlighting.AotProbe/NativeTui.Highlighting.AotProbe.csproj create mode 100644 native_tui/apps/NativeTui.Highlighting.AotProbe/Program.cs create mode 100644 native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj diff --git a/native_tui/apps/NativeTui.Highlighting.AotProbe/NativeTui.Highlighting.AotProbe.csproj b/native_tui/apps/NativeTui.Highlighting.AotProbe/NativeTui.Highlighting.AotProbe.csproj new file mode 100644 index 0000000000..18b68e7b7b --- /dev/null +++ b/native_tui/apps/NativeTui.Highlighting.AotProbe/NativeTui.Highlighting.AotProbe.csproj @@ -0,0 +1,17 @@ + + + + Exe + NativeTui.Highlighting.AotProbe + NativeTui.Highlighting.AotProbe + NativeTui 高亮 AOT 探针 — PublishAot 发布实测 TextMateSharp/Onigwrap 原生依赖链(验证后保留为 AOT 回归工具) + false + false + false + + + + + + + diff --git a/native_tui/apps/NativeTui.Highlighting.AotProbe/Program.cs b/native_tui/apps/NativeTui.Highlighting.AotProbe/Program.cs new file mode 100644 index 0000000000..06d90ef757 --- /dev/null +++ b/native_tui/apps/NativeTui.Highlighting.AotProbe/Program.cs @@ -0,0 +1,112 @@ +namespace NativeTui.Highlighting.AotProbe; + +/// +/// AOT 探针主程序 — PublishAot 发布后运行,验证 TextMateSharp/Onigwrap 原生依赖链在 NativeAOT 下可用: +/// ① Onigwrap 原生 DLL 正确携带并 P/Invoke 成功(TextMate 语法用 oniguruma 正则) +/// ② TextMateSharp.Grammars 语法 JSON 嵌入资源在裁剪后可加载 +/// ③ 逐行 tokenize + 主题色匹配出彩色 ANSI +/// 任一失败进程非零退出(CI 可用 exit code 判定)。 +/// +public static class Program +{ + /// 探针入口:高亮固定 C#/bash 样本,输出 token 色彩统计与 ANSI 样例。 + public static int Main() + { + try + { + var ansiDark = ProbeLanguage( + "csharp", + [ + "// comment", + "using static System.Console;", + "var keyword = \"string literal\";", + ]); + var ansiBash = ProbeLanguage( + "shellscript", + [ + "#!/bin/bash", + "echo \"hello\" | grep -q hello", + ]); + + Console.WriteLine(ansiDark); + Console.WriteLine(ansiBash); + Console.WriteLine("AOTPROBE OK — TextMateSharp 语法/主题/Onigwrap 原生链路全部通过"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"AOTPROBE FAIL: {ex.GetType().Name}: {ex.Message}"); + return 1; + } + } + + /// 单语言探针:加载语法 → 逐行 tokenize → 主题取色 → ANSI 输出(校验 token 数与着色非空)。 + private static string ProbeLanguage(string languageId, IReadOnlyList lines) + { + var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.Monokai); + var registry = new TextMateSharp.Registry.Registry(registryOptions); + var scopeName = registryOptions.GetScopeByLanguageId(languageId) + ?? throw new InvalidOperationException($"language id not found: {languageId}"); + var grammar = registry.LoadGrammar(scopeName) + ?? throw new InvalidOperationException($"grammar not loaded: {scopeName}"); + var theme = registry.GetTheme(); + + var builder = new System.Text.StringBuilder(); + TextMateSharp.Grammars.IStateStack? ruleStack = null; + var coloredTokens = 0; + foreach (var line in lines) + { + builder.Append(" "); + var result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue); + ruleStack = result.RuleStack; + foreach (var token in result.Tokens) + { + var start = Math.Min(token.StartIndex, line.Length); + var end = Math.Min(token.EndIndex, line.Length); + var text = line[start..end]; + var foreground = string.Empty; + foreach (var rule in theme.Match(token.Scopes)) + { + if (rule.foreground >= 0) + { + foreground = theme.GetColor(rule.foreground); + break; + } + } + + if (foreground.Length != 0) + { + coloredTokens++; + builder.Append($"\x1b[38;2;{RgbFromHtml(foreground)}m{text}\x1b[0m"); + } + else + { + builder.Append(text); + } + } + + builder.AppendLine(); + } + + if (coloredTokens == 0) + { + throw new InvalidOperationException($"no colored tokens for {languageId} — theme matching broken"); + } + + return builder.ToString(); + } + + /// #RRGGBB → "r;g;b"(SGR truecolor 参数)。 + private static string RgbFromHtml(string htmlColor) + { + if (htmlColor.Length is 7 && htmlColor[0] == '#') + { + var r = Convert.ToInt32(htmlColor.Substring(1, 2), 16); + var g = Convert.ToInt32(htmlColor.Substring(3, 2), 16); + var b = Convert.ToInt32(htmlColor.Substring(5, 2), 16); + return $"{r};{g};{b}"; + } + + return "255;255;255"; + } +} diff --git a/native_tui/native_tui.slnx b/native_tui/native_tui.slnx index 50c001a195..8fe34f8917 100644 --- a/native_tui/native_tui.slnx +++ b/native_tui/native_tui.slnx @@ -1,8 +1,10 @@ + + diff --git a/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj b/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj new file mode 100644 index 0000000000..10f53f7c60 --- /dev/null +++ b/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj @@ -0,0 +1,17 @@ + + + + NativeTui.Highlighting + NativeTui.Highlighting + NativeTui 语法高亮卫星项目 — TextMateSharp 隔离在本层(ADR 0140:Onigwrap 原生依赖不进 Core,Core 只消费 HighlightedSpan DTO) + false + false + false + + + + + + + + From c006ca3ce715f317b1fd06fda5ad44c2e53a4fff Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:13:25 +0800 Subject: [PATCH 07/43] =?UTF-8?q?feat:=20M7-5=20=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E8=90=BD=E5=9C=B0(TextMateCodeHighlighter?= =?UTF-8?q?=E5=8D=AB=E6=98=9F=E5=AE=9E=E7=8E=B0+Core=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3)=20|=20=E5=86=B3=E7=AD=96:=20Core=E5=8F=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89ICodeHighlighter=E6=8E=A5=E5=8F=A3+HighlightS?= =?UTF-8?q?pan=20DTO(=E4=B8=87=E7=89=A9=E7=9A=86=E6=8F=92=E4=BB=B6,?= =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E5=8F=AF=E6=8D=A2=E5=AE=9E=E7=8E=B0);TextMat?= =?UTF-8?q?eSharp=E9=9A=94=E7=A6=BB=E5=9C=A8=E5=8D=AB=E6=98=9F=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE;=E6=95=B4=E5=9D=97=E9=AB=98=E4=BA=AEAPI(=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E8=B0=83=E7=94=A8=E7=8B=AC=E7=AB=8B=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?ruleStack=E8=B7=A8=E8=A1=8C=E7=8A=B6=E6=80=81,=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E7=BC=93=E5=AD=98=E9=87=8D=E7=AE=97=E5=B9=82=E7=AD=89?= =?UTF-8?q?);=E8=AF=AD=E8=A8=80=E8=A7=A3=E6=9E=90=E4=B8=89=E7=BA=A7=3D?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=88=AB=E5=90=8D=E8=A1=A8(bash/sh=E2=86=92s?= =?UTF-8?q?hellscript=E7=AD=8914=E9=A1=B9)=E2=86=92id=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E2=86=92Grammar=E5=8C=85Aliases=E9=81=8D=E5=8E=86;dark=3DMonok?= =?UTF-8?q?ai=20light=3DLightPlus;=E8=B4=9F=E7=BB=93=E6=9E=9C=E7=BC=93?= =?UTF-8?q?=E5=AD=98(=E8=AF=AD=E8=A8=80null/=E8=AF=AD=E6=B3=95null);?= =?UTF-8?q?=E9=AA=8C=E8=AF=8116/16=E7=BB=BF=E5=90=ABMonokai=E8=89=B2?= =?UTF-8?q?=E5=80=BC=E6=96=AD=E8=A8=80(string230,219,116=20comment136,132,?= =?UTF-8?q?111)+=E8=B7=A8=E8=A1=8C=E5=9D=97=E6=B3=A8=E9=87=8A=E7=8A=B6?= =?UTF-8?q?=E6=80=81+=E5=B9=82=E7=AD=89=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/native_tui.slnx | 1 + .../markdown/CodeHighlighting.cs | 16 ++ .../NativeTui.Highlighting/GlobalUsings.cs | 1 + .../NativeTui.Highlighting.csproj | 4 + .../TextMateCodeHighlighter.cs | 209 ++++++++++++++++++ .../GlobalUsings.cs | 3 + .../NativeTui.Highlighting.Tests.csproj | 25 +++ .../TextMateCodeHighlighterTests.cs | 128 +++++++++++ 8 files changed, 387 insertions(+) create mode 100644 native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs create mode 100644 native_tui/src/NativeTui.Highlighting/GlobalUsings.cs create mode 100644 native_tui/src/NativeTui.Highlighting/TextMateCodeHighlighter.cs create mode 100644 native_tui/tests/NativeTui.Highlighting.Tests/GlobalUsings.cs create mode 100644 native_tui/tests/NativeTui.Highlighting.Tests/NativeTui.Highlighting.Tests.csproj create mode 100644 native_tui/tests/NativeTui.Highlighting.Tests/TextMateCodeHighlighterTests.cs diff --git a/native_tui/native_tui.slnx b/native_tui/native_tui.slnx index 8fe34f8917..cd46982090 100644 --- a/native_tui/native_tui.slnx +++ b/native_tui/native_tui.slnx @@ -7,5 +7,6 @@ + diff --git a/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs b/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs new file mode 100644 index 0000000000..7799ee026c --- /dev/null +++ b/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs @@ -0,0 +1,16 @@ +namespace NativeTui.Core.Markdown; + +/// 代码高亮 token 色段 — Start/Length 为行内字符区间,R/G/B 为真彩前景。 +public readonly record struct HighlightSpan(int Start, int Length, int R, int G, int B); + +/// +/// 代码高亮器插件接口(M7-5)— 实现方隔离在卫星项目(ADR 0140:TextMateSharp/Onigwrap 原生依赖不进 Core), +/// 宿主组装注入(null = 高亮禁用降级,对齐基准 settings.syntaxHighlightingDisabled 语义)。 +/// 整块一次性高亮(内部会话承载跨行语法状态 — 块注释/模板字符串等); +/// null 返回 = 语言不支持,调用方降级纯文本(对齐基准五层降级链的"语言不支持"层)。 +/// +public interface ICodeHighlighter +{ + /// 按语言整块高亮 — 返回每行的 token 色段列表(索引对齐代码行;空行 = 空列表);null = 语言不支持。 + IReadOnlyList>? Highlight(string language, IReadOnlyList lines); +} diff --git a/native_tui/src/NativeTui.Highlighting/GlobalUsings.cs b/native_tui/src/NativeTui.Highlighting/GlobalUsings.cs new file mode 100644 index 0000000000..fda814ceda --- /dev/null +++ b/native_tui/src/NativeTui.Highlighting/GlobalUsings.cs @@ -0,0 +1 @@ +global using NativeTui.Core.Markdown; diff --git a/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj b/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj index 10f53f7c60..130a38d0c5 100644 --- a/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj +++ b/native_tui/src/NativeTui.Highlighting/NativeTui.Highlighting.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/native_tui/src/NativeTui.Highlighting/TextMateCodeHighlighter.cs b/native_tui/src/NativeTui.Highlighting/TextMateCodeHighlighter.cs new file mode 100644 index 0000000000..4ff98a010e --- /dev/null +++ b/native_tui/src/NativeTui.Highlighting/TextMateCodeHighlighter.cs @@ -0,0 +1,209 @@ +namespace NativeTui.Highlighting; + +/// +/// TextMateSharp 高亮器(M7-5 A 方案)— ICodeHighlighter 卫星实现: +/// 引擎 = TextMateSharp(VS Code 同源 TextMate 语法 + oniguruma 正则,基准 Rust 原版 syntect 同路线); +/// 主题 dark=Monokai / light=LightPlus(对齐基准暗色 Monokai Extended / 亮色 GitHub-light 的近似项); +/// 语法按需加载并缓存(scope → IGrammar,null 缓存负结果);语言名解析走 Id/别名表(OrdinalIgnoreCase)。 +/// Core 零依赖 — 本类型只经宿主组装注入(ReplShell 构造参数),不进入 Core 编译单元。 +/// +public sealed class TextMateCodeHighlighter(bool dark) : ICodeHighlighter +{ + /// Registry(语法注册表 + 主题持有者 — 主构造参数 dark 选定 Monokai/LightPlus)。 + private readonly TextMateSharp.Registry.Registry _registry = new(new TextMateSharp.Grammars.RegistryOptions( + dark ? TextMateSharp.Grammars.ThemeName.Monokai + : TextMateSharp.Grammars.ThemeName.LightPlus)); + + /// 主题(scope → 颜色匹配器;Registry 内部缓存实例,取用零成本)。 + private TextMateSharp.Themes.Theme Theme => _registry.GetTheme(); + + /// 语法选项(语言解析入口 — Registry 持有的同一实例,取用零成本)。 + private TextMateSharp.Grammars.RegistryOptions Options => + (TextMateSharp.Grammars.RegistryOptions)_registry.GetLocator(); + + /// 语言名 → scope 缓存(含 null 负结果 — 未命中别名也缓存避免重复遍历)。 + private readonly Dictionary _scopeByAlias = new(StringComparer.OrdinalIgnoreCase); + + /// scope → 语法缓存(含 null 负结果)。 + private readonly Dictionary _grammars = new(StringComparer.Ordinal); + + /// 语言名手动别名(Grammar 包 Aliases 未覆盖的常见写法 — bash/sh/zsh 族与 C 系缩写)。 + private static readonly IReadOnlyDictionary ManualAliases = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["bash"] = "shellscript", + ["sh"] = "shellscript", + ["zsh"] = "shellscript", + ["shell"] = "shellscript", + ["cs"] = "csharp", + ["c++"] = "cpp", + ["js"] = "javascript", + ["ts"] = "typescript", + ["py"] = "python", + ["rb"] = "ruby", + ["rs"] = "rust", + ["golang"] = "go", + ["yml"] = "yaml", + ["docker"] = "dockerfile", + ["md"] = "markdown", + }; + + /// 按语言整块高亮 — 每调用一个会话(ruleStack 逐行推进,跨行语法正确);null = 语言不支持。 + public IReadOnlyList>? Highlight(string language, IReadOnlyList lines) + { + var scopeName = ResolveScope(language); + if (scopeName is null) + { + return null; + } + + var grammar = LoadGrammar(scopeName); + if (grammar is null) + { + return null; + } + + var result = new IReadOnlyList[lines.Count]; + TextMateSharp.Grammars.IStateStack? ruleStack = null; + for (var index = 0; index < lines.Count; index++) + { + result[index] = HighlightSingleLine(grammar, lines[index], ruleStack, out ruleStack); + } + + return result; + } + + /// 语言名 → scope(手动别名 → Id 精确 → Grammar 包 Name/Aliases 遍历;未命中缓存负结果)。 + private string? ResolveScope(string language) + { + var name = language.Trim(); + if (name.Length == 0) + { + return null; + } + + if (_scopeByAlias.TryGetValue(name, out var cached)) + { + return cached; + } + + var scope = ResolveScopeUncached(name); + _scopeByAlias[name] = scope; + return scope; + } + + /// 未缓存解析路径 — 手动别名改写 → languageId 精确 → 可用语言 Id/Name/Aliases 遍历。 + private string? ResolveScopeUncached(string name) + { + if (ManualAliases.TryGetValue(name, out var aliased)) + { + name = aliased; + } + + var options = Options; + var scope = options.GetScopeByLanguageId(name); + if (scope is not null) + { + return scope; + } + + return options.GetAvailableLanguages() + .Where(lang => IdOrAliasMatches(lang, name)) + .Select(lang => options.GetScopeByLanguageId(lang.Id)) + .FirstOrDefault(resolved => resolved is not null); + } + + /// 语言 Id/Aliases 任一命中(OrdinalIgnoreCase)。 + private static bool IdOrAliasMatches(TextMateSharp.Grammars.Language language, string name) => + string.Equals(language.Id, name, StringComparison.OrdinalIgnoreCase) + || (language.Aliases?.Contains(name, StringComparer.OrdinalIgnoreCase) ?? false); + + /// scope → 语法(缓存含负结果;加载失败返回 null)。 + private TextMateSharp.Grammars.IGrammar? LoadGrammar(string scopeName) + { + if (_grammars.TryGetValue(scopeName, out var cached)) + { + return cached; + } + + TextMateSharp.Grammars.IGrammar? grammar = null; + try + { + grammar = _registry.LoadGrammar(scopeName); + } + catch (Exception) + { + grammar = null; + } + + _grammars[scopeName] = grammar; + return grammar; + } + + /// 单行 tokenize + 主题取色 → 色段列表(token 前景未设置 = 无色段,调用方用默认色)。 + private IReadOnlyList HighlightSingleLine( + TextMateSharp.Grammars.IGrammar grammar, + string line, + TextMateSharp.Grammars.IStateStack? prevState, + out TextMateSharp.Grammars.IStateStack? nextState) + { + var tokens = grammar.TokenizeLine(line, prevState, TimeSpan.MaxValue); + nextState = tokens.RuleStack; + + var spans = new List(); + foreach (var token in tokens.Tokens) + { + var start = Math.Min(token.StartIndex, line.Length); + var end = Math.Min(token.EndIndex, line.Length); + if (end <= start) + { + continue; + } + + var (r, g, b) = ForegroundColor(token.Scopes); + if (r >= 0) + { + spans.Add(new HighlightSpan(start, end - start, r, g, b)); + } + } + + return spans; + } + + /// scopes → 前景 RGB(首个带前景的主题规则;-1 = 未设置回退默认色)。 + private (int R, int G, int B) ForegroundColor(IList scopes) + { + foreach (var rule in Theme.Match(scopes)) + { + if (rule.foreground >= 0 && TryParseHtmlColor(Theme.GetColor(rule.foreground), out var rgb)) + { + return rgb; + } + } + + return (-1, -1, -1); + } + + /// #RRGGBB → RGB 解析(非 7 位 # 形态回退 false)。 + private static bool TryParseHtmlColor(string color, out (int R, int G, int B) rgb) + { + rgb = default; + if (color.Length is not 7 || color[0] != '#') + { + return false; + } + + try + { + rgb = ( + Convert.ToInt32(color.Substring(1, 2), 16), + Convert.ToInt32(color.Substring(3, 2), 16), + Convert.ToInt32(color.Substring(5, 2), 16)); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/native_tui/tests/NativeTui.Highlighting.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Highlighting.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..736a58a2e7 --- /dev/null +++ b/native_tui/tests/NativeTui.Highlighting.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using NativeTui.Core.Markdown; +global using NativeTui.Highlighting; +global using Xunit; diff --git a/native_tui/tests/NativeTui.Highlighting.Tests/NativeTui.Highlighting.Tests.csproj b/native_tui/tests/NativeTui.Highlighting.Tests/NativeTui.Highlighting.Tests.csproj new file mode 100644 index 0000000000..d41e518ffa --- /dev/null +++ b/native_tui/tests/NativeTui.Highlighting.Tests/NativeTui.Highlighting.Tests.csproj @@ -0,0 +1,25 @@ + + + + NativeTui.Highlighting.Tests + NativeTui.Highlighting.Tests + NativeTui 高亮器测试(TextMateSharp 卫星实现 — 语言解析/跨行状态/色段映射/降级) + false + true + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/native_tui/tests/NativeTui.Highlighting.Tests/TextMateCodeHighlighterTests.cs b/native_tui/tests/NativeTui.Highlighting.Tests/TextMateCodeHighlighterTests.cs new file mode 100644 index 0000000000..a99e6b5789 --- /dev/null +++ b/native_tui/tests/NativeTui.Highlighting.Tests/TextMateCodeHighlighterTests.cs @@ -0,0 +1,128 @@ +namespace NativeTui.Highlighting.Tests; + +/// +/// TextMateCodeHighlighter 行为测试(M7-5)— 语言解析(id/别名/未支持降级 null)、 +/// 跨行语法状态(块注释跨行着色)、色段区间映射(token → HighlightSpan)、 +/// 幂等性(同块重复高亮结果一致 — 布局缓存重算场景)。 +/// +public sealed class TextMateCodeHighlighterTests +{ + /// 构造高亮器(dark 主题)。 + private static TextMateCodeHighlighter Create() => new(dark: true); + + [Theory] + [InlineData("csharp")] + [InlineData("cs")] + [InlineData("bash")] + [InlineData("shellscript")] + [InlineData("json")] + [InlineData("python")] + [InlineData("ts")] + public void Highlight_CommonLanguage_ProducesSpans(string language) + { + var highlighter = Create(); + + var result = highlighter.Highlight(language, ["var x = 1; // note"]); + + Assert.NotNull(result); + Assert.Single(result); + Assert.NotEmpty(result[0]); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("no-such-language")] + public void Highlight_UnknownLanguage_ReturnsNull(string language) + { + var highlighter = Create(); + + Assert.Null(highlighter.Highlight(language, ["anything"])); + } + + [Fact] + public void Highlight_StringLiteralGetsMonokaiYellow() + { + // Monokai 主题 string 色 = rgb(230,219,116)(探针实测输出同值) + var highlighter = Create(); + + var result = highlighter.Highlight("csharp", ["var s = \"abc\";"]); + + Assert.NotNull(result); + var stringSpan = result![0].FirstOrDefault(span => span.R == 230 && span.G == 219 && span.B == 116); + Assert.True(stringSpan.Length > 0, $"string span not found in [{string.Join(",", result[0].Select(s => $"{s.Start}+{s.Length}:{s.R},{s.G},{s.B}"))}]"); + } + + [Fact] + public void Highlight_CommentTokenGetsMonokaiCommentColor() + { + // Monokai 主题 comment 色 = rgb(136,132,111)(探针实测输出同值) + var highlighter = Create(); + + var result = highlighter.Highlight("csharp", ["// whole line comment"]); + + Assert.NotNull(result); + var commentSpans = result![0].Where(span => span.R == 136 && span.G == 132 && span.B == 111).ToList(); + Assert.NotEmpty(commentSpans); + Assert.Equal(0, commentSpans[0].Start); + Assert.Equal("// whole line comment".Length, commentSpans.Sum(span => span.Length)); + } + + [Fact] + public void Highlight_BlockCommentSpansLines_StateCarriesOver() + { + // 跨行状态:/* 开注释 → 第二行仍在注释内(ruleStack 逐行推进) + var highlighter = Create(); + + var result = highlighter.Highlight("csharp", ["var a = 1; /* start", "still comment", "end */ var b = 2;"]); + + Assert.NotNull(result); + var line2Comment = result![1].Where(span => span.R == 136 && span.G == 132 && span.B == 111).ToList(); + Assert.NotEmpty(line2Comment); + Assert.Equal("still comment".Length, line2Comment.Sum(span => span.Length)); + } + + [Fact] + public void Highlight_SameBlockTwice_IsIdempotent() + { + // 布局缓存失效重算场景 — 每次调用独立会话,同输入同输出 + var highlighter = Create(); + var lines = new[] { "var x = 1;", "/* c", "still */ var y = 2;" }; + + var first = highlighter.Highlight("csharp", lines); + var second = highlighter.Highlight("csharp", lines); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal( + first!.SelectMany(line => line).ToList(), + second!.SelectMany(line => line).ToList()); + } + + [Fact] + public void Highlight_EmptyLine_ProducesEmptySpanList() + { + var highlighter = Create(); + + var result = highlighter.Highlight("csharp", ["var x = 1;", "", "var y = 2;"]); + + Assert.NotNull(result); + Assert.Empty(result![1]); + } + + [Fact] + public void Highlight_SpanBoundsWithinLineLength() + { + var highlighter = Create(); + const string line = "var keyword = \"string value\"; // comment"; + + var result = highlighter.Highlight("csharp", [line]); + + Assert.NotNull(result); + Assert.All(result![0], span => + { + Assert.InRange(span.Start, 0, line.Length); + Assert.InRange(span.Start + span.Length, span.Start, line.Length); + }); + } +} From 24de0554d830f3f8a44c9b39d10c100b9ec58a5e Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:49:39 +0800 Subject: [PATCH 08/43] =?UTF-8?q?feat:=20=E8=BE=93=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E7=8E=AF=E7=BA=AF=E9=80=BB=E8=BE=91=EF=BC=88=E5=AE=B9?= =?UTF-8?q?=E9=87=8F100+=E8=8D=89=E7=A8=BF=E6=9A=82=E5=AD=98/=E6=81=A2?= =?UTF-8?q?=E5=A4=8D+=E4=B8=8D=E5=9B=9E=E7=BB=95=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E5=AF=B9=E9=BD=90useArro?= =?UTF-8?q?wKeyHistory=E7=8A=B6=E6=80=81=E6=9C=BA,index=200=3D=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A1=86=E6=80=81,draft=E7=94=B1=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E4=BC=A0=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...16\345\216\206\345\217\262\347\216\257.md" | 45 ++++ .../repl/history/HistoryRing.cs | 102 ++++++++ .../repl/history/HistoryRingTests.cs | 221 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 "docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" create mode 100644 native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/history/HistoryRingTests.cs diff --git "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" new file mode 100644 index 0000000000..ae49e05ddc --- /dev/null +++ "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" @@ -0,0 +1,45 @@ +# TASK056 — TUI 输入栏:斜杠命令补全 + 历史环 + +> 状态:in-progress +> 参考基准:`D:\project\claude-code-rust\claude-code-rev-main\src\`(useTypeahead.tsx / commandSuggestions.ts / useArrowKeyHistory.tsx / PromptInputFooterSuggestions.tsx) +> 历史数据源:复用现有 `~/.jcc/sessions/{id}/transcript.json`(`ITranscriptService`,`TranscriptPersistMiddleware` 已自动落盘,无需新存储) + +## 任务分解(渐进式:每步 编译 → 测试 → commit) + +| # | 任务 | 产物 | 状态 | +|---|------|------|------| +| 1 | 历史环纯逻辑 | `repl/history/HistoryRing.cs` + 测试 | ⬜ | +| 2 | 补全过滤排序纯逻辑 | `repl/suggestions/SlashSuggester.cs` + `CommandSuggestion.cs` + 测试 | ⬜ | +| 3 | 建议列表渲染 | `repl/suggestions/SuggestionBox.cs` + 测试 | ⬜ | +| 4 | ReplView 渲染集成 | 建议区插入输入区与 footer 之间 | ⬜ | +| 5 | ReplShell 键位集成 | Tab/Enter/Esc/Up/Down 分派 + 键位仲裁 + 历史导航 + 提交入史 | ⬜ | +| 6 | Host 组装 | 命令清单注入(SlashCommandCatalog)+ 历史种子(ITranscriptService) | ⬜ | +| 7 | 手动 exe 验收 | ADR 0080 方式 | ⬜ | + +## 关键规格(TS 对齐) + +### 斜杠补全 +- 触发:text 以 `/` 开头、光标>0、无空格、非 Esc 抑制态 +- 排序链:精确名 > 精确切名 > 前缀名(短优先) > 前缀切名 > 子序列名 > 子序列切名;空查询全量字母序;隐藏命令输入全名时前插 +- 渲染:≤6 条两列(命令列宽=最长名+6 预计算;描述列截断),选中高亮未选中 dim,滚动窗口以选中项为中心 +- 键位:Tab 接受不执行(补全 `/cmd ` 光标末尾);Enter 接受并提交;Esc 只关建议(优先于打断/清空);Up/Down 循环导航 + +### 历史环 +- 容量 100(MAX_HISTORY_ITEMS);提交时追加(末条相同去重);导航 index 0=当前输入 +- Up:暂存草稿(首次离开 0)→ 翻旧;到底保持不动 +- Down:翻新;回到 0 恢复草稿(无草稿清空) +- 不回绕(对齐 GUI NavigateHistory) +- 翻历史时抑制建议刷新(程序化填充不触发补全) +- 种子:启动时从最近会话 transcript 的 user 条目加载(宿主注入) + +## 架构决策 + +- NativeTui.Core 保持零依赖(ADR 0140):命令清单与历史种子均以纯数据 `IReadOnlyList<>` 注入,宿主(NativeTui.Host)经 `engine.Services` 拉取转换 +- Up/Down 仲裁:建议激活 → 补全导航;否则 inline 模式 → 历史环;viewport 旧模式保持滚动 +- Esc 仲裁:建议激活 → 关建议(优先于忙打断/清输入/退出) + + + + + + diff --git a/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs new file mode 100644 index 0000000000..10ac7bec41 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs @@ -0,0 +1,102 @@ +namespace NativeTui.Core.Repl; + +/// +/// 输入历史环 — 对齐基准 useArrowKeyHistory 导航状态机 + history.ts 容量语义: +/// index 0 = 输入框态(非历史);Up 翻旧(首次离开 0 暂存草稿、到底保持不动不回绕); +/// Down 翻新(回到 0 恢复草稿,无草稿返回空串);提交追加(末条去重); +/// 容量 MAX_HISTORY_ITEMS=100 淘汰最旧。内部按提交顺序(旧→新)存储,导航从末尾倒数。 +/// +public sealed class HistoryRing +{ + /// 历史容量上限(基准 history.ts MAX_HISTORY_ITEMS)。 + public const int Capacity = 100; + + private readonly List _entries = []; + + /// 当前导航 index(0 = 输入框态;1..Count = 历史深度,越大越旧)。 + public int Index { get; private set; } + + /// 翻历史前暂存的输入草稿(首次离开 index 0 时捕获;null = 未捕获)。 + public string? Draft { get; private set; } + + /// 历史条数。 + public int Count => _entries.Count; + + /// 提交入史:空文本忽略;末条相同去重;超容量淘汰最旧。 + public void Add(string text) + { + if (text.Length == 0) + { + return; + } + + if (_entries.Count > 0 && _entries[^1] == text) + { + return; + } + + _entries.Add(text); + if (_entries.Count > Capacity) + { + _entries.RemoveAt(0); + } + } + + /// 宿主注入历史种子(旧→新顺序)— 启动时从 transcript 加载。 + public void Seed(IEnumerable entries) + { + foreach (var entry in entries) + { + Add(entry); + } + } + + /// 翻上一条(旧方向):index 递增,返回目标历史文本; + /// 首次离开输入框以 draft 暂存当前输入(缺省空串);越过最旧一条返回 false(index 保持,不回绕)。 + public bool TryUp(out string text, string? draft = null) + { + var target = Index + 1; + if (target > _entries.Count) + { + text = string.Empty; + return false; + } + + if (Index == 0) + { + Draft = draft ?? string.Empty; + } + + Index = target; + text = _entries[^target]; + return true; + } + + /// 翻下一条(新方向):index 递减,返回目标历史文本; + /// 回到输入框(index 0)恢复草稿(无草稿为空串);已在输入框返回 false。 + public bool TryDown(out string text) + { + if (Index == 0) + { + text = string.Empty; + return false; + } + + Index--; + if (Index == 0) + { + text = Draft ?? string.Empty; + return true; + } + + text = _entries[^Index]; + return true; + } + + /// 重置导航态:回输入框 + 清草稿(提交/清空输入后调用)。 + public void Reset() + { + Index = 0; + Draft = null; + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/history/HistoryRingTests.cs b/native_tui/tests/NativeTui.Tests/repl/history/HistoryRingTests.cs new file mode 100644 index 0000000000..65fa440ea3 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/history/HistoryRingTests.cs @@ -0,0 +1,221 @@ +namespace NativeTui.Tests.Repl.History; + +/// +/// HistoryRing 单元测试 — 对齐基准 useArrowKeyHistory 导航状态机 + history.ts 容量语义: +/// index 0 = 输入框态(非历史);Up 翻旧(首次离开 0 暂存草稿、到底保持不动); +/// Down 翻新(回到 0 恢复草稿);提交追加(末条去重);容量 100 淘汰最旧;不回绕。 +/// +public sealed class HistoryRingTests +{ + /// 提交追加:条目按提交顺序可翻回。 + [Fact] + public void Add_StoresEntriesInSubmitOrder() + { + var ring = new HistoryRing(); + ring.Add("first"); + ring.Add("second"); + Assert.Equal(2, ring.Count); + } + + /// 末条相同去重:连续提交相同文本不重复入史。 + [Fact] + public void Add_SameAsLast_DoesNotDuplicate() + { + var ring = new HistoryRing(); + ring.Add("same"); + ring.Add("same"); + Assert.Equal(1, ring.Count); + } + + /// 非末条相同仍追加(历史保留重复,仅末条去重)。 + [Fact] + public void Add_SameAsNonLast_Appends() + { + var ring = new HistoryRing(); + ring.Add("a"); + ring.Add("b"); + ring.Add("a"); + Assert.Equal(3, ring.Count); + } + + /// 空文本不入史。 + [Fact] + public void Add_Empty_Ignored() + { + var ring = new HistoryRing(); + ring.Add(string.Empty); + Assert.Equal(0, ring.Count); + } + + /// 容量 100:超限淘汰最旧(MAX_HISTORY_ITEMS 对齐基准)。 + [Fact] + public void Add_OverCapacity_DropsOldest() + { + var ring = new HistoryRing(); + for (var i = 0; i < HistoryRing.Capacity + 5; i++) + { + ring.Add($"cmd-{i}"); + } + + Assert.Equal(HistoryRing.Capacity, ring.Count); + Assert.True(ring.TryUp(out var newest)); + Assert.Equal($"cmd-{HistoryRing.Capacity + 4}", newest); + for (var i = 0; i < HistoryRing.Capacity - 1; i++) + { + Assert.True(ring.TryUp(out _)); + } + + Assert.False(ring.TryUp(out _)); + } + + /// Up:从输入框翻入历史 — 返回最新一条,index 递增。 + [Fact] + public void TryUp_FromInput_ReturnsNewest() + { + var ring = new HistoryRing(); + ring.Add("old"); + ring.Add("new"); + Assert.True(ring.TryUp(out var text)); + Assert.Equal("new", text); + Assert.Equal(1, ring.Index); + } + + /// Up 连按:逐条翻旧。 + [Fact] + public void TryUp_Repeated_WalksOlder() + { + var ring = new HistoryRing(); + ring.Add("a"); + ring.Add("b"); + ring.Add("c"); + Assert.True(ring.TryUp(out var first)); + Assert.True(ring.TryUp(out var second)); + Assert.True(ring.TryUp(out var third)); + Assert.Equal("c", first); + Assert.Equal("b", second); + Assert.Equal("a", third); + } + + /// Up 首次离开输入框:以传入 draft 暂存。 + [Fact] + public void TryUp_FirstTime_StoresDraft() + { + var ring = new HistoryRing(); + ring.Add("hist"); + Assert.True(ring.TryUp(out _, "draft")); + Assert.Equal("draft", ring.Draft); + } + + /// Up 到底(越过最旧一条):返回 false、index 不变、可再次尝试仍 false。 + [Fact] + public void TryUp_BeyondOldest_ReturnsFalseAndKeepsIndex() + { + var ring = new HistoryRing(); + ring.Add("only"); + Assert.True(ring.TryUp(out _)); + Assert.False(ring.TryUp(out _)); + Assert.Equal(1, ring.Index); + } + + /// Down:翻回更新一条,再翻回输入框恢复草稿。 + [Fact] + public void TryDown_WalksNewer() + { + var ring = new HistoryRing(); + ring.Add("a"); + ring.Add("b"); + ring.TryUp(out _, "draft"); + ring.TryUp(out _); + Assert.True(ring.TryDown(out var first)); + Assert.True(ring.TryDown(out var second)); + Assert.Equal("b", first); + Assert.Equal("draft", second); + Assert.Equal(0, ring.Index); + } + + /// Down 回到输入框:恢复草稿(index 归 0)。 + [Fact] + public void TryDown_ToInput_RestoresDraft() + { + var ring = new HistoryRing(); + ring.Add("hist"); + ring.TryUp(out _, "draft"); + Assert.True(ring.TryDown(out var draft)); + Assert.Equal("draft", draft); + Assert.Equal(0, ring.Index); + } + + /// Down 无草稿回到输入框:返回空串(调用方清空输入)。 + [Fact] + public void TryDown_ToInput_NoDraft_ReturnsEmpty() + { + var ring = new HistoryRing(); + ring.Add("hist"); + ring.TryUp(out _); + Assert.True(ring.TryDown(out var draft)); + Assert.Equal(string.Empty, draft); + } + + /// Down 已在输入框(index 0):返回 false。 + [Fact] + public void TryDown_AtInput_ReturnsFalse() + { + var ring = new HistoryRing(); + ring.Add("hist"); + Assert.False(ring.TryDown(out _)); + } + + /// Reset:回输入框态 + 清草稿(提交/清空后调用)。 + [Fact] + public void Reset_ClearsIndexAndDraft() + { + var ring = new HistoryRing(); + ring.Add("hist"); + ring.TryUp(out _); + ring.Reset(); + Assert.Equal(0, ring.Index); + Assert.Null(ring.Draft); + Assert.False(ring.TryDown(out _)); + } + + /// Seed:宿主注入种子(旧→新顺序),与运行时追加共存。 + [Fact] + public void Seed_AppendsInOrder() + { + var ring = new HistoryRing(); + ring.Seed(["old-seed", "new-seed"]); + ring.Add("live"); + Assert.Equal(3, ring.Count); + Assert.True(ring.TryUp(out var newest)); + Assert.Equal("live", newest); + Assert.True(ring.TryUp(out var second)); + Assert.Equal("new-seed", second); + Assert.True(ring.TryUp(out var third)); + Assert.Equal("old-seed", third); + } + + /// Seed 超容量:同 Add 淘汰语义。 + [Fact] + public void Seed_OverCapacity_DropsOldest() + { + var ring = new HistoryRing(); + var entries = Enumerable.Range(0, HistoryRing.Capacity + 10).Select(i => $"s-{i}").ToArray(); + ring.Seed(entries); + Assert.Equal(HistoryRing.Capacity, ring.Count); + Assert.True(ring.TryUp(out var newest)); + Assert.Equal($"s-{HistoryRing.Capacity + 9}", newest); + } + + /// 草稿仅在首次离开输入框捕获:翻历史中途草稿不变(后续 draft 参数忽略)。 + [Fact] + public void Draft_CapturedOnlyOnFirstLeave() + { + var ring = new HistoryRing(); + ring.Add("a"); + ring.Add("b"); + Assert.True(ring.TryUp(out _, "draft")); + Assert.Equal("draft", ring.Draft); + Assert.True(ring.TryUp(out _, "changed-later")); + Assert.Equal("draft", ring.Draft); + } +} From 3d6e6edadadf9078a5b13698db06c13a54971527 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:52:23 +0800 Subject: [PATCH 09/43] =?UTF-8?q?feat:=20=E6=96=9C=E6=9D=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=A5=E5=85=A8=E8=BF=87=E6=BB=A4=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9C=BA=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E6=8E=92=E5=BA=8F=E7=B2=BE=E7=A1=AE=E5=90=8D?= =?UTF-8?q?>=E7=B2=BE=E7=A1=AE=E5=88=87=E5=90=8D>=E5=89=8D=E7=BC=80>?= =?UTF-8?q?=E5=AD=90=E5=BA=8F=E5=88=97(=E7=AE=80=E5=8C=96Fuse),Esc?= =?UTF-8?q?=E6=8A=91=E5=88=B6=E5=90=8C=E8=BE=93=E5=85=A5,=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repl/suggestions/CommandSuggestion.cs | 8 + .../repl/suggestions/SlashSuggester.cs | 156 +++++++++++++ .../repl/suggestions/SlashSuggesterTests.cs | 213 ++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs create mode 100644 native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs b/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs new file mode 100644 index 0000000000..b18fabbc1a --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs @@ -0,0 +1,8 @@ +namespace NativeTui.Core.Repl; + +/// 斜杠命令建议项 — 宿主注入的纯数据(name 不含 / 前缀),NativeTui.Core 零依赖桥接形态。 +/// 命令名(不含 /)。 +/// 单行描述(建议列表右列)。 +/// 别名(精确/前缀/子序列切名匹配用)。 +/// 隐藏命令(默认不参与建议,输入全名时前插)。 +public sealed record CommandSuggestion(string Name, string Description, string[] Aliases, bool IsHidden); diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs new file mode 100644 index 0000000000..c786af134f --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs @@ -0,0 +1,156 @@ +namespace NativeTui.Core.Repl; + +/// +/// 斜杠命令补全状态机 — 对齐基准 commandSuggestions.ts + useTypeahead.tsx 的命令建议分支: +/// 触发(/ 开头、无空格、非 Esc 抑制态);空查询全量可见命令字母序; +/// 二级排序(精确名 > 精确切名 > 前缀名短优先 > 前缀切名 > 子序列名 > 子序列切名); +/// 隐藏命令输入全名时前插;Dismiss 抑制同一输入,输入变化解除; +/// 选中索引循环导航(对齐基准 autocomplete previous/next 回绕)。 +/// +public sealed class SlashSuggester +{ + private readonly IReadOnlyList _commands; + private string _currentInput = string.Empty; + private string _suppressedInput = string.Empty; + + /// 创建补全状态机 — commands 为宿主注入的全量命令清单。 + public SlashSuggester(IReadOnlyList commands) + { + _commands = commands; + } + + /// 当前建议列表(已过滤排序)。 + public IReadOnlyList Items { get; private set; } = []; + + /// 选中索引(默认 0 = 首项预选,基准 commandItems 首项预选语义)。 + public int SelectedIndex { get; private set; } + + /// 激活态(有建议项)。 + public bool IsActive => Items.Count > 0; + + /// 选中项(无建议时 null)。 + public CommandSuggestion? Selected => Items.Count > 0 ? Items[SelectedIndex] : null; + + /// 输入变化时刷新建议(用户按键编辑路径调用;程序化填充如历史导航不调用)。 + public void Update(string input) + { + _currentInput = input; + if (input == _suppressedInput) + { + Items = []; + SelectedIndex = 0; + return; + } + + _suppressedInput = string.Empty; + (Items, SelectedIndex) = (Match(input), 0); + } + + /// Esc 关闭建议并抑制同一输入(基准 autocomplete:dismiss — dismissedForInputRef)。 + public void Dismiss() + { + _suppressedInput = _currentInput; + Items = []; + SelectedIndex = 0; + } + + /// 选中索引循环移动(正 = 向下,负 = 向上;越界回绕,基准循环语义)。 + public void Move(int delta) + { + if (Items.Count == 0) + { + return; + } + + SelectedIndex = ((SelectedIndex + delta) % Items.Count + Items.Count) % Items.Count; + } + + /// 匹配与排序 — 返回新建议列表;不触发(非 / 开头/含空格/空输入)时为空。 + private List Match(string input) + { + if (input.Length == 0 || input[0] != '/' || input.Contains(' ')) + { + return []; + } + + var query = input[1..]; + if (query.Length == 0) + { + return _commands.Where(c => !c.IsHidden) + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + } + + var ranked = _commands.Where(c => !c.IsHidden) + .Select(c => (Command: c, Tier: MatchTier(c, query))) + .Where(pair => pair.Tier >= 0) + .OrderBy(pair => pair.Tier) + .ThenBy(pair => pair.Command.Name.Length) + .ThenBy(pair => pair.Command.Name, StringComparer.Ordinal) + .Select(pair => pair.Command) + .ToList(); + + var hiddenExact = _commands.Where(c => c.IsHidden && MatchesExactly(c, query)).ToList(); + foreach (var hidden in hiddenExact) + { + ranked.Insert(0, hidden); + } + + return ranked; + } + + /// 精确匹配(名或切名等于查询)。 + private static bool MatchesExactly(CommandSuggestion command, string query) => + command.Name == query || command.Aliases.Contains(query, StringComparer.OrdinalIgnoreCase); + + /// 匹配层级 — -1 = 不匹配;0 = 精确名;1 = 精确切名;2 = 前缀名;3 = 前缀切名;4 = 子序列名;5 = 子序列切名。 + private static int MatchTier(CommandSuggestion command, string query) + { + if (command.Name == query) + { + return 0; + } + + if (command.Aliases.Contains(query, StringComparer.OrdinalIgnoreCase)) + { + return 1; + } + + if (command.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + if (command.Aliases.Any(a => a.StartsWith(query, StringComparison.OrdinalIgnoreCase))) + { + return 3; + } + + if (IsSubsequence(query, command.Name)) + { + return 4; + } + + if (command.Aliases.Any(a => IsSubsequence(query, a))) + { + return 5; + } + + return -1; + } + + /// 子序列匹配(Fuse 简化):query 各字符按顺序出现于 target(大小写不敏感)。 + private static bool IsSubsequence(string query, string target) + { + var matched = 0; + foreach (var ch in target) + { + if (matched < query.Length && char.ToLowerInvariant(ch) == char.ToLowerInvariant(query[matched])) + { + matched++; + } + } + + return matched == query.Length; + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs new file mode 100644 index 0000000000..3e0ab1a8f4 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs @@ -0,0 +1,213 @@ +namespace NativeTui.Tests.Repl.Suggestions; + +/// +/// SlashSuggester 单元测试 — 对齐基准 commandSuggestions.ts 过滤排序: +/// 触发(/ 开头、无空格、非抑制);空查询全量可见字母序; +/// 二级排序(精确名 > 精确切名 > 前缀名短优先 > 前缀切名 > 子序列); +/// 隐藏命令全名前插;Esc 抑制同输入;选中循环导航。 +/// +public sealed class SlashSuggesterTests +{ + /// 标准命令集(含别名/隐藏命令,覆盖各排序层级)。 + private static List Catalog() => + [ + new("help", "显示帮助", [], IsHidden: false), + new("hello", "问候", [], IsHidden: false), + new("help-me", "求助", ["hm"], IsHidden: false), + new("compact", "压缩上下文", [], IsHidden: false), + new("secret", "隐藏命令", [], IsHidden: true), + ]; + + /// 构造 suggester 并指定输入。 + private static SlashSuggester Suggester(string input) + { + var suggester = new SlashSuggester(Catalog()); + suggester.Update(input); + return suggester; + } + + /// 空查询(仅 /):全量可见命令,字母序(hello < help)。 + [Fact] + public void Update_SlashOnly_AllVisibleAlphabetical() + { + var suggester = Suggester("/"); + Assert.Equal(["compact", "hello", "help", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 隐藏命令默认不参与建议。 + [Fact] + public void Update_Hidden_Excluded() + { + var suggester = Suggester("/"); + Assert.DoesNotContain(suggester.Items, i => i.Name == "secret"); + } + + /// 前缀匹配:/he 命中 help/hello/help-me(按层级排序)。 + [Fact] + public void Update_PrefixQuery_MatchesPrefixCommands() + { + var suggester = Suggester("/he"); + Assert.Equal(["help", "hello", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 精确名优先:/help 排在 help-me 与 hello 之前。 + [Fact] + public void Update_ExactName_RanksFirst() + { + var suggester = Suggester("/help"); + Assert.Equal("help", suggester.Items[0].Name); + } + + /// 精确切名优先于前缀名:/hm 是 help-me 的别名 → 排第一。 + [Fact] + public void Update_ExactAlias_RanksBeforePrefixNames() + { + var suggester = Suggester("/hm"); + Assert.Equal("help-me", suggester.Items[0].Name); + } + + /// 同为前缀名:短命令优先(/he → help(4) 在 hello(5) 前)。 + [Fact] + public void Update_PrefixTie_ShorterFirst() + { + var suggester = Suggester("/he"); + Assert.Equal("help", suggester.Items[0].Name); + Assert.Equal("hello", suggester.Items[1].Name); + } + + /// 子序列匹配(Fuse 简化):/cmp 命中 compact。 + [Fact] + public void Update_Subsequence_Matches() + { + var suggester = Suggester("/cmp"); + Assert.Equal(["compact"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 子序列名(tier4)优先于子序列切名(tier5)。 + [Fact] + public void Update_SubsequenceName_BeforeSubsequenceAlias() + { + var commands = new List + { + new("gamma", "描述", ["xalp"], IsHidden: false), + new("alphabet", "描述", [], IsHidden: false), + }; + var suggester = new SlashSuggester(commands); + suggester.Update("/alp"); + Assert.Equal(["alphabet", "gamma"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 无匹配:Items 空且不激活。 + [Fact] + public void Update_NoMatch_Inactive() + { + var suggester = Suggester("/zzz"); + Assert.Empty(suggester.Items); + Assert.False(suggester.IsActive); + } + + /// 非 / 开头:不触发。 + [Fact] + public void Update_NotSlash_Prefix_Inactive() + { + var suggester = Suggester("plain text"); + Assert.False(suggester.IsActive); + } + + /// 命令后带空格(参数态):不触发。 + [Fact] + public void Update_SlashWithSpace_Inactive() + { + var suggester = Suggester("/help me"); + Assert.False(suggester.IsActive); + } + + /// 空输入:不触发。 + [Fact] + public void Update_Empty_Inactive() + { + var suggester = Suggester(string.Empty); + Assert.False(suggester.IsActive); + } + + /// 激活态:有匹配项即激活,选中默认首项(基准 commandItems 首项预选)。 + [Fact] + public void Update_Match_ActiveWithDefaultSelection() + { + var suggester = Suggester("/he"); + Assert.True(suggester.IsActive); + Assert.Equal(0, suggester.SelectedIndex); + Assert.Equal("help", suggester.Selected?.Name); + } + + /// 隐藏命令全名:前插首位(精确名永远赢过隐藏过滤)。 + [Fact] + public void Update_HiddenFullName_Prepended() + { + var suggester = Suggester("/secret"); + Assert.Equal("secret", suggester.Items[0].Name); + } + + /// Esc 抑制:Dismiss 后同一输入不再触发。 + [Fact] + public void Dismiss_SuppressesSameInput() + { + var suggester = Suggester("/he"); + Assert.True(suggester.IsActive); + suggester.Dismiss(); + Assert.False(suggester.IsActive); + + suggester.Update("/he"); + Assert.False(suggester.IsActive); + } + + /// Esc 抑制解除:输入变化后重新触发。 + [Fact] + public void Dismiss_InputChanges_Retriggers() + { + var suggester = Suggester("/he"); + suggester.Dismiss(); + suggester.Update("/hel"); + Assert.True(suggester.IsActive); + } + + /// 导航循环:末项 +1 回绕到 0(基准 autocomplete 循环语义)。 + [Fact] + public void Move_WrapsAround() + { + var suggester = Suggester("/he"); + suggester.Move(1); + Assert.Equal(1, suggester.SelectedIndex); + suggester.Move(1); + Assert.Equal(2, suggester.SelectedIndex); + suggester.Move(1); + Assert.Equal(0, suggester.SelectedIndex); + } + + /// 导航循环:首项 -1 回绕到末项。 + [Fact] + public void Move_Backward_WrapsToLast() + { + var suggester = Suggester("/he"); + suggester.Move(-1); + Assert.Equal(2, suggester.SelectedIndex); + } + + /// 列表更新后选中回零(简单回退,基准按 id 找回的简化)。 + [Fact] + public void Update_NewQuery_ResetsSelection() + { + var suggester = Suggester("/he"); + suggester.Move(1); + suggester.Update("/hel"); + Assert.Equal(0, suggester.SelectedIndex); + } + + /// 大小写不敏感:/HE 命中 help。 + [Fact] + public void Update_CaseInsensitive() + { + var suggester = Suggester("/HE"); + Assert.Equal(["help", "hello", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + } +} From 3d1ddf16745a406277bcf84f29e6ab18f3db3e95 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 00:54:38 +0800 Subject: [PATCH 10/43] =?UTF-8?q?feat:=20=E5=BB=BA=E8=AE=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=B8=B2=E6=9F=93=E7=BB=84=E4=BB=B6(6=E6=9D=A1?= =?UTF-8?q?=E5=8F=AF=E8=A7=81+=E4=B8=A4=E5=88=97=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=88=97=E5=AE=BD+=E9=80=89=E4=B8=AD=E5=B1=85=E4=B8=AD?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E7=AA=97=E5=8F=A3)=20|=20=E5=86=B3=E7=AD=96:?= =?UTF-8?q?=20=E9=80=89=E4=B8=ADSuggestion=E8=89=B2=E6=9C=AA=E9=80=89?= =?UTF-8?q?=E4=B8=ADdim,=E5=88=97=E5=AE=BD=E7=94=B1=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E9=A2=84=E8=AE=A1=E7=AE=97=E9=98=B2=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repl/suggestions/SuggestionBox.cs | 42 ++++++ .../repl/suggestions/SuggestionBoxTests.cs | 129 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/suggestions/SuggestionBoxTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs new file mode 100644 index 0000000000..933683b89b --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs @@ -0,0 +1,42 @@ +namespace NativeTui.Core.Repl; + +/// +/// 建议列表渲染 — 对齐基准 PromptInputFooterSuggestions.tsx: +/// 最多 6 条可见(MAX_VISIBLE_ITEMS);两列布局("/命令名" + 描述,命令列宽固定防布局跳动); +/// 选中项 Suggestion 色 / 未选中 dim;滚动窗口以选中项为中心(钳制首尾)。 +/// +public sealed class SuggestionBox +{ + /// 可见条数上限(基准非全屏 maxVisibleItems = min(6, rows-3),TUI 固定 6)。 + public const int MaxVisibleItems = 6; + + /// 可见高度 = min(条数, 上限)。 + public int MeasureHeight(int itemCount) => Math.Min(itemCount, MaxVisibleItems); + + /// 渲染建议列表 — frame 内自顶向下每行一条;选中项 Suggestion 色,未选中 dim; + /// 命令名自 frame.X 起,描述自 frame.X + nameColumnWidth 起(超宽截断)。 + public void Render(Screen screen, Rect frame, Theme theme, IReadOnlyList items, int selectedIndex, int nameColumnWidth) + { + if (items.Count == 0) + { + return; + } + + var visible = Math.Min(MeasureHeight(items.Count), frame.Height); + var startIndex = Math.Clamp(selectedIndex - visible / 2, 0, items.Count - visible); + var selectedStyle = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Suggestion }); + var dimStyle = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + + for (var row = 0; row < visible; row++) + { + var index = startIndex + row; + var item = items[index]; + var style = index == selectedIndex ? selectedStyle : dimStyle; + screen.WriteText(frame.Y + row, frame.X, $"/{item.Name}", style, frame.X + nameColumnWidth); + if (item.Description.Length > 0) + { + screen.WriteText(frame.Y + row, frame.X + nameColumnWidth, item.Description, style, frame.Right); + } + } + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/suggestions/SuggestionBoxTests.cs b/native_tui/tests/NativeTui.Tests/repl/suggestions/SuggestionBoxTests.cs new file mode 100644 index 0000000000..1f17c9bee9 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/suggestions/SuggestionBoxTests.cs @@ -0,0 +1,129 @@ +namespace NativeTui.Tests.Repl.Suggestions; + +/// +/// SuggestionBox 渲染测试 — 对齐基准 PromptInputFooterSuggestions.tsx: +/// 最多 6 条可见;两列布局(/命令名 + 描述,命令列宽固定防布局跳动); +/// 选中项 Suggestion 色 / 未选中 dim;滚动窗口以选中项为中心;描述超宽截断。 +/// +public sealed class SuggestionBoxTests +{ + /// 建议项构造辅助。 + private static List Items(int count, string description = "desc") => + Enumerable.Range(0, count).Select(i => new CommandSuggestion($"cmd{i}", description, [], IsHidden: false)).ToList(); + + /// 可见高度 = min(条数, 6)。 + [Theory] + [InlineData(0, 0)] + [InlineData(3, 3)] + [InlineData(6, 6)] + [InlineData(10, 6)] + public void MeasureHeight_MinOfCountAndSix(int count, int expected) + { + var box = new SuggestionBox(); + Assert.Equal(expected, box.MeasureHeight(count)); + } + + /// 基础渲染:每行 "/命令名" + 描述(固定列宽),选中首项。 + [Fact] + public void Render_ShowsSlashCommandAndDescription() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(3), selectedIndex: 0, nameColumnWidth: 12); + + Assert.Equal("/cmd0", RowText.Of(screen, 0, 0, 5)); + Assert.Equal("desc", RowText.Of(screen, 0, 12, 4)); + Assert.Equal("/cmd1", RowText.Of(screen, 1, 0, 5)); + Assert.Equal("desc", RowText.Of(screen, 1, 12, 4)); + Assert.Equal("/cmd2", RowText.Of(screen, 2, 0, 5)); + Assert.Equal(Cell.Blank, screen[3, 0]); + } + + /// 选中项 Suggestion 色,未选中 dim。 + [Fact] + public void Render_SelectedUsesSuggestionColor_OthersDim() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(3), selectedIndex: 1, nameColumnWidth: 12); + + var selected = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion }); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[0, 0].StyleId); + Assert.Equal(selected, screen[1, 0].StyleId); + Assert.Equal(dim, screen[2, 0].StyleId); + } + + /// 命令列固定宽:描述从 nameColumnWidth 起渲染(不随过滤收缩)。 + [Fact] + public void Render_DescriptionStartsAtFixedColumn() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(1), selectedIndex: 0, nameColumnWidth: 12); + + Assert.Equal("desc", RowText.Of(screen, 0, 12, 4)); + Assert.Equal(string.Empty, RowText.Of(screen, 0, 5, 7).TrimEnd()); + } + + /// 滚动窗口:选中项居中(selected=7 → startIndex=4,窗口 [4,10))。 + [Fact] + public void Render_ScrollsSelectedCentered() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(10), selectedIndex: 7, nameColumnWidth: 12); + + Assert.Equal("/cmd4", RowText.Of(screen, 0, 0, 5).TrimEnd()); + Assert.Equal("/cmd7", RowText.Of(screen, 3, 0, 5).TrimEnd()); + Assert.Equal("/cmd9", RowText.Of(screen, 5, 0, 5).TrimEnd()); + } + + /// 滚动窗口钳制:选中靠前时 startIndex=0。 + [Fact] + public void Render_ScrollClampsToStart() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(10), selectedIndex: 1, nameColumnWidth: 12); + + Assert.Equal("/cmd0", RowText.Of(screen, 0, 0, 5).TrimEnd()); + Assert.Equal("/cmd1", RowText.Of(screen, 1, 0, 5).TrimEnd()); + } + + /// 滚动窗口钳制:选中末项时窗口贴底。 + [Fact] + public void Render_ScrollClampsToEnd() + { + using var screen = new Screen(cols: 40, rows: 6); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark, Items(10), selectedIndex: 9, nameColumnWidth: 12); + + Assert.Equal("/cmd4", RowText.Of(screen, 0, 0, 5).TrimEnd()); + Assert.Equal("/cmd9", RowText.Of(screen, 5, 0, 5).TrimEnd()); + } + + /// 描述超宽截断:不越过 frame 右界。 + [Fact] + public void Render_DescriptionTruncatedToFrame() + { + using var screen = new Screen(cols: 20, rows: 6); + var box = new SuggestionBox(); + var items = new List { new("cmd0", "0123456789abcdefghij", [], IsHidden: false) }; + box.Render(screen, new Rect(0, 0, 20, 6), Theme.Dark, items, selectedIndex: 0, nameColumnWidth: 12); + + Assert.Equal("01234567", RowText.Of(screen, 0, 12, 8)); + } + + /// frame 高度不足:按 frame 高度截断渲染行数。 + [Fact] + public void Render_TruncatesToFrameHeight() + { + using var screen = new Screen(cols: 40, rows: 2); + var box = new SuggestionBox(); + box.Render(screen, new Rect(0, 0, 40, 2), Theme.Dark, Items(5), selectedIndex: 0, nameColumnWidth: 12); + + Assert.Equal("/cmd0", RowText.Of(screen, 0, 0, 5).TrimEnd()); + Assert.Equal("/cmd1", RowText.Of(screen, 1, 0, 5).TrimEnd()); + } +} From 123cab86f9a2e748700e7d20c4c7c95f22ede3cf Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:10:13 +0800 Subject: [PATCH 11/43] =?UTF-8?q?feat:=20ReplShell/ReplView=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E4=B8=8E=E5=8E=86=E5=8F=B2=E9=9B=86=E6=88=90(?= =?UTF-8?q?=E9=94=AE=E4=BD=8D=E4=BB=B2=E8=A3=81+=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=E5=8C=BA=E6=B8=B2=E6=9F=93)=20|=20=E5=86=B3=E7=AD=96:=20Handle?= =?UTF-8?q?Key=E6=96=87=E6=9C=AC=E5=8F=98=E5=8C=96=E6=89=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=98=B2=E5=AF=BC=E8=88=AA=E7=B4=A2=E5=BC=95=E9=87=8D?= =?UTF-8?q?=E7=BD=AE,Enter=E6=8F=90=E4=BA=A4=E4=B8=8D=E5=B8=A6=E5=B0=BE?= =?UTF-8?q?=E9=9A=8F=E7=A9=BA=E6=A0=BC,=E7=A8=8B=E5=BA=8F=E5=8C=96?= =?UTF-8?q?=E5=A1=AB=E5=85=85=E8=B7=B3=E8=BF=87=E5=BB=BA=E8=AE=AE=E8=A7=A6?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/InputBuffer.cs | 9 + .../src/NativeTui.Core/repl/ReplShell.cs | 144 ++++++++++++++- .../src/NativeTui.Core/repl/ReplView.cs | 44 +++-- .../repl/suggestions/SlashSuggester.cs | 16 +- .../NativeTui.Tests/repl/FakeReplEngine.cs | 40 ++++ .../NativeTui.Tests/repl/InputBufferTests.cs | 18 ++ .../repl/ReplShellHistoryTests.cs | 171 ++++++++++++++++++ .../repl/ReplShellSuggestionsTests.cs | 165 +++++++++++++++++ .../NativeTui.Tests/repl/ReplShellTests.cs | 43 +---- .../NativeTui.Tests/repl/ReplViewTests.cs | 57 ++++++ 10 files changed, 641 insertions(+), 66 deletions(-) create mode 100644 native_tui/tests/NativeTui.Tests/repl/FakeReplEngine.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/InputBuffer.cs b/native_tui/src/NativeTui.Core/repl/InputBuffer.cs index 1376636f91..7a9134fc35 100644 --- a/native_tui/src/NativeTui.Core/repl/InputBuffer.cs +++ b/native_tui/src/NativeTui.Core/repl/InputBuffer.cs @@ -97,6 +97,15 @@ public void Clear() CursorCol = 0; } + /// 全量替换文本(历史导航/补全接受用)— cursorToStart=true 光标置行首(基准历史导航语义),否则置末尾。 + public void Replace(string text, bool cursorToStart = false) + { + BeginEditText(); + _text.Clear(); + _text.Append(text); + CursorCol = cursorToStart ? 0 : text.Length; + } + /// 词左移(Alt+B / Ctrl+Left)— 跳到前一个词首:词内回到词首,词首跳过非词字符落前词首;CJK 单字成词。 public void WordLeft() { diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 45926cbbac..982642bc22 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -34,6 +34,18 @@ public sealed class ReplShell /// 权限对话框协调器(M7-3 — 挂起时键盘模态改道)。 private readonly PermissionCoordinator _permission = new(); + /// 斜杠命令补全状态机(TASK056 — 宿主注入命令清单后启用;null = 无补全能力)。 + private readonly SlashSuggester? _suggester; + + /// 建议列表渲染组件。 + private readonly SuggestionBox _suggestionBox = new(); + + /// 输入历史环(TASK056 — 提交入史 + 上下键翻阅)。 + private readonly HistoryRing _history = new(); + + /// 程序化填充标记(历史导航/补全接受 — HandleKey 尾部跳过建议刷新)。 + private bool _programmaticFill; + /// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。 private readonly BannerItem _banner; @@ -56,14 +68,16 @@ public sealed class ReplShell private Screen? _prevOverride; /// 创建外壳 — engine=引擎交互面;banner=启动横幅(首项);output=渲染 writer; - /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前)。 - public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool inline = true, IReadOnlyList? seedMessages = null) + /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前); + /// commands=斜杠命令清单(宿主注入;null = 无补全能力)。 + public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool inline = true, IReadOnlyList? seedMessages = null, IReadOnlyList? commands = null) { _engine = engine; _banner = banner; _output = output; _inline = inline; _seedMessages = seedMessages ?? []; + _suggester = commands is { Count: > 0 } ? new SlashSuggester(commands) : null; _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest); } @@ -82,6 +96,12 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool /// 权限对话框协调器(宿主权限桥 Begin/Wait 消费面 + 测试断言面)。 public PermissionCoordinator Permission => _permission; + /// 斜杠补全状态机(测试断言面;null = 无命令清单)。 + internal SlashSuggester? Suggester => _suggester; + + /// 历史种子注入(宿主启动时从 transcript 加载最近输入)。 + public void SeedHistory(IEnumerable entries) => _history.Seed(entries); + /// 主循环 — 每拍:排空键盘 → 排空引擎事件 → 差分上屏 → 对齐 50ms。 public void Run() { @@ -176,7 +196,8 @@ private void DrainInput() } } - /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序 — 测试经 ConsoleKeyInfo 全链注入)。 + /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序 — 测试经 ConsoleKeyInfo 全链注入); + /// 文本变化才刷新建议(导航/移动键不重置选中索引;程序化填充跳过 — 历史导航不触发补全)。 internal void HandleKey(ConsoleKeyInfo keyInfo) { var key = ConsoleKeyAdapter.ToKeyPress(keyInfo); @@ -185,10 +206,23 @@ internal void HandleKey(ConsoleKeyInfo keyInfo) return; } + var textBefore = _input.Text; HandleKeyPress(key); + if (_programmaticFill) + { + _programmaticFill = false; + return; + } + + if (_input.Text != textBefore) + { + UpdateSuggestions(); + } } - /// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框),其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。 + /// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框)→ 补全激活专属键 + /// (Up/Down 导航/Tab 接受/Enter 执行/Esc 关闭,基准 Autocomplete 上下文优先于 Chat), + /// 其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。 private void HandleKeyPress(KeyPress key) { if (_permission.IsPending) @@ -197,6 +231,11 @@ private void HandleKeyPress(KeyPress key) return; } + if (HandleSuggestionKey(key)) + { + return; + } + if (key.Kind == KeyKind.Escape) { HandleEscape(); @@ -264,10 +303,10 @@ private void HandleKeyPress(KeyPress key) _input.Right(); break; case KeyKind.UpArrow: - ScrollIfViewport(-3); + NavigateHistory(-1); break; case KeyKind.DownArrow: - ScrollIfViewport(3); + NavigateHistory(1); break; case KeyKind.Home: _input.Home(); @@ -426,6 +465,91 @@ private void ScrollIfViewport(int rows) } } + /// 补全激活时的专属键拦截(基准 Autocomplete 上下文): + /// Up/Down 循环导航、Tab 接受不执行、Enter 接受并提交、Esc 仅关建议(优先于打断/清输入/退出); + /// 其余键返回 false 走正常编辑路径(编辑后刷新建议)。 + private bool HandleSuggestionKey(KeyPress key) + { + if (_suggester is not { IsActive: true } suggester) + { + return false; + } + + switch (key.Kind) + { + case KeyKind.UpArrow: + suggester.Move(-1); + return true; + case KeyKind.DownArrow: + suggester.Move(1); + return true; + case KeyKind.Tab: + AcceptSuggestion(execute: false); + return true; + case KeyKind.Return: + AcceptSuggestion(execute: true); + return true; + case KeyKind.Escape: + suggester.Dismiss(); + return true; + default: + return false; + } + } + + /// 接受选中建议(基准 applyCommandSuggestion)— Tab 补全为 "/命令 "(带尾随空格进入参数态,不执行); + /// Enter 直接提交 "/命令"(不带尾随空格,历史与解析干净)。 + private void AcceptSuggestion(bool execute) + { + if (_suggester?.Selected is not { } selected) + { + return; + } + + FillInput(execute ? $"/{selected.Name}" : $"/{selected.Name} ", cursorToStart: false); + UpdateSuggestions(); + if (execute) + { + Submit(); + } + } + + /// 历史导航(inline 主模式)— 上=翻旧(光标置行首,基准 cursorToStart)、下=翻新(恢复草稿光标置末尾); + /// 到底/到顶保持不动(不回绕);viewport 旧模式保留滚动语义。 + private void NavigateHistory(int direction) + { + if (!_inline) + { + ScrollIfViewport(direction * 3); + return; + } + + string text; + if (direction < 0) + { + if (!_history.TryUp(out text, _input.Text)) + { + return; + } + } + else if (!_history.TryDown(out text)) + { + return; + } + + FillInput(text, cursorToStart: direction < 0); + } + + /// 程序化填充输入(历史导航/补全接受)— 设置跳过标记,HandleKey 尾部不刷新建议。 + private void FillInput(string text, bool cursorToStart) + { + _programmaticFill = true; + _input.Replace(text, cursorToStart); + } + + /// 刷新建议(用户编辑路径;建议状态机内部判定触发条件)。 + private void UpdateSuggestions() => _suggester?.Update(_input.Text); + /// Esc:忙时打断当前回合;闲时有输入清空(对齐基准双击清空的单击近似),无输入退出。 internal void HandleEscape() { @@ -451,7 +575,8 @@ internal void CyclePermissionMode() _engine.SetPermissionMode(PermissionModeInfo.ToValue(next)); } - /// 提交输入:空文本忽略;否则追加用户消息并提交引擎(TurnStarted 事件驱动 busy,忙时由实现拒绝并 Error 诱导)。 + /// 提交输入:空文本忽略;否则追加用户消息并提交引擎(TurnStarted 事件驱动 busy,忙时由实现拒绝并 Error 诱导); + /// 提交文本入历史环(末条去重)+ 导航态复位。 internal void Submit() { var text = _input.Text; @@ -461,6 +586,8 @@ internal void Submit() } _input.Clear(); + _history.Add(text); + _history.Reset(); AddMessage(new UserMessageItem(text)); _engine.Submit(text); } @@ -475,6 +602,9 @@ private void RenderAndFlush() _spinner.WallNowMs = Environment.TickCount64; _view.Input = _input.Text; _view.CursorCol = _input.CursorCol; + _view.Suggestions = _suggester is { IsActive: true } ? _suggester.Items : null; + _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; + _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; _view.Render(_back, _theme); if (_inline) { diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index 288d75d80d..9f70c3f4f0 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -2,15 +2,16 @@ namespace NativeTui.Core.Repl; /// /// REPL 整屏视图 — 对齐基准 REPL.tsx bottom slot 布局(自底向上): -/// footer 1-2 行(paddingX=2,通知非空时 2 行)+ 输入底边线 1 行 + 输入文本 1 行 -/// + 输入顶边线 1 行(基准 PromptInput 上下全宽 ─ 分隔线)+ 间隔 1 行(输入框 marginTop=1), -/// 其余全部给消息滚动区(sticky 钉底)。 +/// footer 1-2 行(paddingX=2,通知非空时 2 行)+ 建议区 0-6 行(斜杠补全激活时,基准 PromptInputFooterSuggestions) +/// + 输入底边线 1 行 + 输入文本 1 行 + 输入顶边线 1 行(基准 PromptInput 上下全宽 ─ 分隔线) +/// + 间隔 1 行(输入框 marginTop=1),其余全部给消息滚动区(sticky 钉底)。 /// spinner 行(输入框上方,基准 scrollable 区尾部)由滚动区尾部 SpinnerItem 承载。 /// public sealed class ReplView { private readonly FooterBar _footer = new(); private readonly PromptInputBox _inputBox = new(); + private readonly SuggestionBox _suggestionBox = new(); /// 消息滚动区(sticky 钉底)。 public ScrollBox Messages { get; } = new(stickyScroll: true); @@ -21,40 +22,59 @@ public sealed class ReplView /// 光标文本内偏移(0 = 首字符之前)。 public int CursorCol { get; set; } + /// 建议列表(null/空 = 补全未激活不渲染)。 + public IReadOnlyList? Suggestions { get; set; } + + /// 建议选中索引。 + public int SuggestionIndex { get; set; } + + /// 建议命令列宽(全量命令最长名 + 6,防布局跳动)。 + public int SuggestionNameColumnWidth { get; set; } + /// footer 状态(模式/busy/agents 提示/登录错误/通知)。 public FooterState Footer { get; set; } = new(); + /// 建议区高度(0 = 未激活)。 + private int SuggestionHeight => _suggestionBox.MeasureHeight(Suggestions?.Count ?? 0); + /// - /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + footer 高度; + /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + 建议区 + footer 高度; /// 屏高取此值时消息区恰好全量展示(无滚动窗口),历史自然滚入终端 scrollback。 /// public int MeasureContentHeight(int cols) => - Messages.MeasureContentHeight(cols) + 4 + _footer.MeasureHeight(Footer); + Messages.MeasureContentHeight(cols) + 4 + SuggestionHeight + _footer.MeasureHeight(Footer); - /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + footer 高度)— 退出清理用。 - public int TailHeight => 4 + _footer.MeasureHeight(Footer); + /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + 建议区 + footer 高度)— 退出清理用。 + public int TailHeight => 4 + SuggestionHeight + _footer.MeasureHeight(Footer); /// - /// 输入光标内容坐标(inline 模式帧尾光标位)— 行 = 输入文本行(内容底上方 footer+2), + /// 输入光标内容坐标(inline 模式帧尾光标位)— 行 = 输入文本行(内容底上方 footer+建议区+2), /// 列 = PromptWidth + cursorCol(钳制行宽,右缘即 pending wrap 状态)。 /// public CursorPos InputCursorPos(int contentRows, int cols, int cursorCol) { var footerHeight = _footer.MeasureHeight(Footer); - return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + cursorCol), contentRows - footerHeight - 2); + return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + cursorCol), contentRows - footerHeight - SuggestionHeight - 2); } - /// 渲染整屏(消息区 → 输入框 → footer)。 + /// 渲染整屏(消息区 → 输入框 → 建议区 → footer)。 public void Render(Screen screen, Theme theme) { var footerHeight = _footer.MeasureHeight(Footer); + var suggestionHeight = SuggestionHeight; var footerFrame = new Rect(2, screen.Rows - footerHeight, Math.Max(0, screen.Cols - 4), footerHeight); _footer.Render(screen, footerFrame, theme, Footer); - var inputFrame = new Rect(0, screen.Rows - footerHeight - 3, screen.Cols, 3); + if (suggestionHeight > 0 && Suggestions is { Count: > 0 }) + { + var suggestionFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight, screen.Cols, suggestionHeight); + _suggestionBox.Render(screen, suggestionFrame, theme, Suggestions, SuggestionIndex, SuggestionNameColumnWidth); + } + + var inputFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight - 3, screen.Cols, 3); _inputBox.Render(screen, inputFrame, theme, Input, CursorCol, Footer.Busy); - var messagesHeight = Math.Max(0, screen.Rows - footerHeight - 4); + var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - 4); var messagesFrame = new Rect(0, 0, screen.Cols, messagesHeight); Messages.Render(screen, messagesFrame, theme); } diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs index c786af134f..2a2c951b92 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs @@ -13,12 +13,6 @@ public sealed class SlashSuggester private string _currentInput = string.Empty; private string _suppressedInput = string.Empty; - /// 创建补全状态机 — commands 为宿主注入的全量命令清单。 - public SlashSuggester(IReadOnlyList commands) - { - _commands = commands; - } - /// 当前建议列表(已过滤排序)。 public IReadOnlyList Items { get; private set; } = []; @@ -31,6 +25,16 @@ public SlashSuggester(IReadOnlyList commands) /// 选中项(无建议时 null)。 public CommandSuggestion? Selected => Items.Count > 0 ? Items[SelectedIndex] : null; + /// 命令列宽 — 全量命令最长名 + 6(含 / 与列间距,基准预计算防过滤时布局跳动)。 + public int NameColumnWidth { get; } + + /// 创建补全状态机 — commands 为宿主注入的全量命令清单。 + public SlashSuggester(IReadOnlyList commands) + { + _commands = commands; + NameColumnWidth = commands.Count > 0 ? commands.Max(c => c.Name.Length) + 6 : 0; + } + /// 输入变化时刷新建议(用户按键编辑路径调用;程序化填充如历史导航不调用)。 public void Update(string input) { diff --git a/native_tui/tests/NativeTui.Tests/repl/FakeReplEngine.cs b/native_tui/tests/NativeTui.Tests/repl/FakeReplEngine.cs new file mode 100644 index 0000000000..951e091688 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/FakeReplEngine.cs @@ -0,0 +1,40 @@ +namespace NativeTui.Tests.Repl; + +/// 记录型假引擎 — 捕获 Submit/Interrupt/SetPermissionMode 调用 + 可注入事件队列(ReplShell 测试共享)。 +internal sealed class FakeReplEngine : IReplEngine +{ + /// 收到的提交文本。 + public List Submits { get; } = []; + + /// 收到的打断次数。 + public int Interrupts; + + /// 收到的权限模式。 + public List PermissionModes { get; } = []; + + /// 待排空事件队列(DrainEvents 一次清空)。 + public Queue Events { get; } = new(); + + /// 引擎存活标记(测试可置 false 模拟死亡)。 + public bool Alive = true; + + /// 记录提交。 + public void Submit(string text) => Submits.Add(text); + + /// 记录打断。 + public void Interrupt() => Interrupts++; + + /// 记录权限模式。 + public void SetPermissionMode(string mode) => PermissionModes.Add(mode); + + /// 排空事件队列。 + public IReadOnlyList DrainEvents() + { + var drained = Events.ToList(); + Events.Clear(); + return drained; + } + + /// 存活标记。 + public bool IsAlive => Alive; +} diff --git a/native_tui/tests/NativeTui.Tests/repl/InputBufferTests.cs b/native_tui/tests/NativeTui.Tests/repl/InputBufferTests.cs index 6350ea160a..2dfe6cd467 100644 --- a/native_tui/tests/NativeTui.Tests/repl/InputBufferTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/InputBufferTests.cs @@ -100,4 +100,22 @@ public void EmptyBuffer_AllOperationsSafe() Assert.Equal(string.Empty, buffer.Text); Assert.Equal(0, buffer.CursorCol); } + + [Fact] + public void Replace_SetsTextAndCursorToEnd() + { + var buffer = InputBuffer.Of("old", cursorCol: 2); + buffer.Replace("new text"); + Assert.Equal("new text", buffer.Text); + Assert.Equal("new text".Length, buffer.CursorCol); + } + + [Fact] + public void Replace_CursorToStart_PutsCursorAtZero() + { + var buffer = InputBuffer.Of("old", cursorCol: 2); + buffer.Replace("new text", cursorToStart: true); + Assert.Equal("new text", buffer.Text); + Assert.Equal(0, buffer.CursorCol); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs new file mode 100644 index 0000000000..d6b19e658c --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs @@ -0,0 +1,171 @@ +namespace NativeTui.Tests.Repl; + +/// +/// ReplShell 历史环集成测试 — 对齐基准 useArrowKeyHistory(Chat 上下文 up/down = history:previous/next): +/// 提交入史;Up 翻旧(首次暂存草稿、光标置行首、到底保持);Down 翻新(回底恢复草稿); +/// 种子注入;不回绕;建议激活时 Up/Down 归补全不翻历史。 +/// +public sealed class ReplShellHistoryTests +{ + /// 构造测试 shell(SeedContent 就绪消息区)。 + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell() + { + var engine = new FakeReplEngine(); + var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); + var shell = new ReplShell(engine, banner, new StringWriter()); + shell.SeedContent(); + return (shell, engine); + } + + /// 按键注入(功能键)。 + private static void Press(ReplShell shell, ConsoleKey key) => + shell.HandleKey(new ConsoleKeyInfo('\0', key, false, false, false)); + + /// 键入文本(经 HandleKey 全链路)。 + private static void Type(ReplShell shell, string text) + { + foreach (var c in text) + { + shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.None, false, false, false)); + } + } + + /// 提交两条并回到空输入态。 + private static void SubmitTwo(ReplShell shell) + { + Type(shell, "first"); + Press(shell, ConsoleKey.Enter); + Type(shell, "second"); + Press(shell, ConsoleKey.Enter); + } + + /// 提交入史:Up 翻回最新一条,光标置行首。 + [Fact] + public void Submit_RecordsHistory() + { + var (shell, _) = CreateShell(); + Type(shell, "hello"); + Press(shell, ConsoleKey.Enter); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("hello", shell.Input.Text); + Assert.Equal(0, shell.Input.CursorCol); + } + + /// Up 逐条翻旧;Down 翻新。 + [Fact] + public void UpDown_WalksHistory() + { + var (shell, _) = CreateShell(); + SubmitTwo(shell); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("second", shell.Input.Text); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("first", shell.Input.Text); + Press(shell, ConsoleKey.DownArrow); + Assert.Equal("second", shell.Input.Text); + } + + /// 草稿暂存与恢复:翻历史前输入 draft → Up → Down 底部恢复 draft。 + [Fact] + public void UpThenDown_RestoresDraft() + { + var (shell, _) = CreateShell(); + SubmitTwo(shell); + Type(shell, "draft"); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("second", shell.Input.Text); + Press(shell, ConsoleKey.DownArrow); + Assert.Equal("draft", shell.Input.Text); + Assert.Equal("draft".Length, shell.Input.CursorCol); + } + + /// Up 到底:越过最旧一条后保持当前输入不动。 + [Fact] + public void Up_BeyondOldest_KeepsInput() + { + var (shell, _) = CreateShell(); + SubmitTwo(shell); + Press(shell, ConsoleKey.UpArrow); + Press(shell, ConsoleKey.UpArrow); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("first", shell.Input.Text); + } + + /// Down 在输入框态:no-op。 + [Fact] + public void Down_AtInput_NoOp() + { + var (shell, _) = CreateShell(); + SubmitTwo(shell); + Press(shell, ConsoleKey.DownArrow); + Assert.Equal(string.Empty, shell.Input.Text); + } + + /// 空历史 Up:no-op。 + [Fact] + public void Up_EmptyHistory_NoOp() + { + var (shell, _) = CreateShell(); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal(string.Empty, shell.Input.Text); + } + + /// 历史种子:宿主注入(transcript 加载)后可翻阅。 + [Fact] + public void SeedHistory_ProvidesEntries() + { + var (shell, _) = CreateShell(); + shell.SeedHistory(["old-seed", "new-seed"]); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("new-seed", shell.Input.Text); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("old-seed", shell.Input.Text); + } + + /// 连续提交相同文本只入史一次(末条去重)— 翻阅仅一条可回。 + [Fact] + public void Submit_SameTextTwice_SingleHistoryEntry() + { + var (shell, _) = CreateShell(); + Type(shell, "same"); + Press(shell, ConsoleKey.Enter); + Type(shell, "same"); + Press(shell, ConsoleKey.Enter); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("same", shell.Input.Text); + Press(shell, ConsoleKey.DownArrow); + Assert.Equal(string.Empty, shell.Input.Text); + Press(shell, ConsoleKey.UpArrow); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("same", shell.Input.Text); + } + + /// 建议激活时 Up 归补全导航,不翻历史。 + [Fact] + public void SuggestionsActive_UpGoesToCompletion() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("1.0.0", "m", "API 按量计费", "/tmp"), new StringWriter(), + commands: [new("help", "h", [], IsHidden: false), new("hello", "h", [], IsHidden: false)]); + shell.SeedContent(); + shell.SeedHistory(["hist-entry"]); + Type(shell, "/he"); + Press(shell, ConsoleKey.UpArrow); + Assert.True(shell.Suggester?.IsActive); + Assert.Equal("/he", shell.Input.Text); + Assert.Equal(1, shell.Suggester?.SelectedIndex); + } + + /// 翻历史后提交:新输入入史 + 导航态复位。 + [Fact] + public void SubmitAfterNavigating_RecordsNewText() + { + var (shell, _) = CreateShell(); + SubmitTwo(shell); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("second", shell.Input.Text); + Press(shell, ConsoleKey.Enter); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("second", shell.Input.Text); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs new file mode 100644 index 0000000000..ec6fd2d8d3 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs @@ -0,0 +1,165 @@ +namespace NativeTui.Tests.Repl; + +/// +/// ReplShell 斜杠补全集成测试 — 对齐基准 useTypeahead.tsx 键位仲裁(Autocomplete 上下文优先于 Chat): +/// 输入 / 触发建议;建议激活时 Up/Down 循环导航、Tab 接受不执行(补 "/cmd ")、Enter 接受并提交、 +/// Esc 仅关建议(优先于打断/清输入/退出);编辑键后刷新;历史填充不触发建议;无命令清单安全降级。 +/// +public sealed class ReplShellSuggestionsTests +{ + /// 测试命令集。 + private static readonly IReadOnlyList Commands = + [ + new("help", "显示帮助", [], IsHidden: false), + new("hello", "问候", [], IsHidden: false), + new("compact", "压缩上下文", [], IsHidden: false), + ]; + + /// 构造带命令清单的测试 shell(SeedContent 就绪消息区)。 + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell(IReadOnlyList? commands = null) + { + var engine = new FakeReplEngine(); + var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); + var shell = new ReplShell(engine, banner, new StringWriter(), commands: commands ?? Commands); + shell.SeedContent(); + return (shell, engine); + } + + /// 按键注入(功能键)。 + private static void Press(ReplShell shell, ConsoleKey key) => + shell.HandleKey(new ConsoleKeyInfo('\0', key, false, false, false)); + + /// 键入文本(经 HandleKey 全链路 — 触发建议刷新)。 + private static void Type(ReplShell shell, string text) + { + foreach (var c in text) + { + shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.None, false, false, false)); + } + } + + /// 输入 / 后建议激活且首项预选。 + [Fact] + public void SlashInput_ActivatesSuggestions() + { + var (shell, _) = CreateShell(); + Type(shell, "/"); + Assert.True(shell.Suggester?.IsActive); + Assert.Equal(3, shell.Suggester?.Items.Count); + Assert.Equal("compact", shell.Suggester?.Selected?.Name); + } + + /// 前缀输入过滤:/he 命中 help/hello。 + [Fact] + public void PrefixInput_FiltersSuggestions() + { + var (shell, _) = CreateShell(); + Type(shell, "/he"); + Assert.Equal(["help", "hello"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + } + + /// 普通文本输入不触发建议。 + [Fact] + public void PlainInput_NoSuggestions() + { + var (shell, _) = CreateShell(); + Type(shell, "hello world"); + Assert.False(shell.Suggester?.IsActive); + } + + /// 退格后建议刷新:/c 仅 compact;退格回 / 后全量 3 条。 + [Fact] + public void Backspace_RefreshesSuggestions() + { + var (shell, _) = CreateShell(); + Type(shell, "/c"); + Assert.Equal(["compact"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + Press(shell, ConsoleKey.Backspace); + Assert.Equal(3, shell.Suggester!.Items.Count); + } + + /// 建议激活时 Up/Down 循环导航(不消费历史)。 + [Fact] + public void SuggestionsActive_ArrowKeys_NavigateSuggestions() + { + var (shell, _) = CreateShell(); + Type(shell, "/he"); + Press(shell, ConsoleKey.DownArrow); + Assert.Equal(1, shell.Suggester?.SelectedIndex); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal(0, shell.Suggester?.SelectedIndex); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal(1, shell.Suggester?.SelectedIndex); + } + + /// Tab 接受:补全为 "/help "(带尾随空格),不提交引擎,建议关闭。 + [Fact] + public void Tab_AcceptsSuggestionWithoutSubmit() + { + var (shell, engine) = CreateShell(); + Type(shell, "/he"); + Press(shell, ConsoleKey.Tab); + Assert.Equal("/help ", shell.Input.Text); + Assert.Equal("/help ".Length, shell.Input.CursorCol); + Assert.False(shell.Suggester?.IsActive); + Assert.Empty(engine.Submits); + } + + /// Enter 接受并执行:提交 "/help" 给引擎。 + [Fact] + public void Enter_AcceptsSuggestionAndSubmits() + { + var (shell, engine) = CreateShell(); + Type(shell, "/he"); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["/help"], engine.Submits); + Assert.Equal(string.Empty, shell.Input.Text); + } + + /// Esc 仅关建议:输入保留,不退出 REPL。 + [Fact] + public void Escape_DismissesSuggestionsKeepsInput() + { + var (shell, _) = CreateShell(); + Type(shell, "/he"); + Press(shell, ConsoleKey.Escape); + Assert.Equal("/he", shell.Input.Text); + Assert.False(shell.Suggester?.IsActive); + Assert.True(shell.Running); + } + + /// Esc 抑制同一输入:移动键不重触发(文本不变不刷新);文本变化后重触发。 + [Fact] + public void Escape_SuppressesSameInput() + { + var (shell, _) = CreateShell(); + Type(shell, "/hel"); + Press(shell, ConsoleKey.Escape); + Assert.False(shell.Suggester?.IsActive); + Press(shell, ConsoleKey.RightArrow); + Assert.False(shell.Suggester?.IsActive); + Type(shell, "p"); + Assert.True(shell.Suggester?.IsActive); + } + + /// 空命令清单(安全降级):无补全能力,输入 / 不崩溃。 + [Fact] + public void NoCommandCatalog_SafeDegradation() + { + var (shell, _) = CreateShell(commands: []); + Type(shell, "/he"); + Assert.Null(shell.Suggester); + Assert.True(shell.Running); + } + + /// 历史导航填充不触发建议(翻到 / 开头历史条目时建议保持关闭)。 + [Fact] + public void HistoryNavigation_DoesNotTriggerSuggestions() + { + var (shell, _) = CreateShell(); + shell.SeedHistory(["/help"]); + Press(shell, ConsoleKey.UpArrow); + Assert.Equal("/help", shell.Input.Text); + Assert.False(shell.Suggester?.IsActive); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs index 17ce6ac432..bef0e9b5ac 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs @@ -7,49 +7,10 @@ namespace NativeTui.Tests.Repl; /// public sealed class ReplShellTests { - /// 记录型假引擎 — 捕获 Submit/Interrupt/SetPermissionMode 调用 + 可注入事件队列。 - private sealed class FakeEngine : IReplEngine - { - /// 收到的提交文本。 - public List Submits { get; } = []; - - /// 收到的打断次数。 - public int Interrupts; - - /// 收到的权限模式。 - public List PermissionModes { get; } = []; - - /// 待排空事件队列(DrainEvents 一次清空)。 - public Queue Events { get; } = new(); - - /// 引擎存活标记(测试可置 false 模拟死亡)。 - public bool Alive = true; - - /// 记录提交。 - public void Submit(string text) => Submits.Add(text); - - /// 记录打断。 - public void Interrupt() => Interrupts++; - - /// 记录权限模式。 - public void SetPermissionMode(string mode) => PermissionModes.Add(mode); - - /// 排空事件队列。 - public IReadOnlyList DrainEvents() - { - var drained = Events.ToList(); - Events.Clear(); - return drained; - } - - /// 存活标记。 - public bool IsAlive => Alive; - } - /// 构造测试 shell(StringWriter 收渲染输出,seed 消息可缺省)。 - private static (ReplShell Shell, FakeEngine Engine) CreateShell(IReadOnlyList? seed = null) + private static (ReplShell Shell, FakeReplEngine Engine) CreateShell(IReadOnlyList? seed = null) { - var engine = new FakeEngine(); + var engine = new FakeReplEngine(); var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); var shell = new ReplShell(engine, banner, new StringWriter(), seedMessages: seed); return (shell, engine); diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplViewTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplViewTests.cs index f3622e90ce..14c36312b4 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplViewTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplViewTests.cs @@ -121,4 +121,61 @@ public void MeasureContentHeight_TwoRowFooter_AddsOneRow() // 消息 2 + 间隔 1 + 输入区 3 + footer 2 = 8 Assert.Equal(8, view.MeasureContentHeight(cols: 40)); } + + [Fact] + public void SuggestionsActive_RenderedBetweenInputAndFooter() + { + using var screen = new Screen(cols: 40, rows: 12); + var view = new ReplView + { + Input = "/he", + CursorCol = 3, + Footer = new FooterState { Mode = PermissionMode.Auto }, + Suggestions = + [ + new CommandSuggestion("help", "显示帮助", [], IsHidden: false), + new CommandSuggestion("hello", "问候", [], IsHidden: false), + ], + SuggestionIndex = 0, + SuggestionNameColumnWidth = 11, + }; + view.Render(screen, Theme.Dark); + + // 自底向上:footer 行 11 / 建议区行 9-10 / 输入底边线 8 / 文本 7 / 顶边线 6 / 间隔 5 + Assert.StartsWith("? 查看快捷键", RowText.Of(screen, 11, 2, 20).TrimEnd()); + Assert.Equal("/help 显示帮助", RowText.Of(screen, 9, 0, 19).TrimEnd()); + Assert.Equal("/hello 问候", RowText.Of(screen, 10, 0, 15).TrimEnd()); + Assert.Equal(new string('─', 40), RowText.Of(screen, 8, 0, 40)); + Assert.Equal("❯ /he", RowText.Of(screen, 7, 0, 5).TrimEnd()); + Assert.Equal(new string('─', 40), RowText.Of(screen, 6, 0, 40)); + // 选中首项 Suggestion 色 + var selected = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion }); + Assert.Equal(selected, screen[9, 0].StyleId); + } + + [Fact] + public void SuggestionsInactive_LayoutUnchanged() + { + using var screen = new Screen(cols: 40, rows: 10); + var view = new ReplView { Input = "hi", CursorCol = 2, Footer = new FooterState { Mode = PermissionMode.Auto } }; + view.Render(screen, Theme.Dark); + + // 建议未激活:布局与原版一致(footer 9 / 底边线 8 / 文本 7 / 顶边线 6) + Assert.Equal(new string('─', 40), RowText.Of(screen, 6, 0, 40)); + Assert.Equal(new string('─', 40), RowText.Of(screen, 8, 0, 40)); + Assert.StartsWith("? 查看快捷键", RowText.Of(screen, 9, 2, 20).TrimEnd()); + } + + [Fact] + public void MeasureContentHeight_WithSuggestions_AddsSuggestionRows() + { + var view = new ReplView + { + Footer = new FooterState { Mode = PermissionMode.Auto }, + Suggestions = [new("help", "h", [], IsHidden: false)], + }; + view.Messages.Add(new UserMessageItem("hello")); + // 消息 2 + 间隔 1 + 输入区 3 + 建议 1 + footer 1 = 8 + Assert.Equal(8, view.MeasureContentHeight(cols: 40)); + } } From fa7d2ee9f98811f10f5499c96320ed5cf48a1a46 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:24:18 +0800 Subject: [PATCH 12/43] =?UTF-8?q?feat:=20Host=E7=BB=84=E8=A3=85=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=B8=85=E5=8D=95=E4=B8=8E=E5=8E=86=E5=8F=B2=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E6=B3=A8=E5=85=A5=20|=20=E5=86=B3=E7=AD=96:=20DI?= =?UTF-8?q?=E8=A7=A3=E6=9E=90Catalog=E5=85=9C=E5=BA=95=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=8C=96=E7=94=9F=E6=88=90=E7=9B=AE=E5=BD=95?= =?UTF-8?q?,=E5=8E=86=E5=8F=B2=E4=BB=8Esessions=20transcript=E5=90=8C?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9C=80=E8=BF=91=E4=BC=9A=E8=AF=9D=E8=81=9A?= =?UTF-8?q?=E5=90=88(=E6=8E=92=E9=99=A4=E5=BD=93=E5=89=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=98=B2=E5=8F=8C=E4=BB=BD),=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/NativeTui.Host/EngineSession.cs | 6 + .../apps/NativeTui.Host/GlobalUsings.cs | 1 + native_tui/apps/NativeTui.Host/Program.cs | 4 +- .../tui_assembly/HistorySeeder.cs | 68 ++++++++ .../tui_assembly/SlashCommandSource.cs | 24 +++ .../src/NativeTui.Core/repl/ReplShell.cs | 3 + .../repl/history/HistoryRing.cs | 3 + .../NativeTui.Host.Tests/GlobalUsings.cs | 2 + .../HistorySeederTests.cs | 160 ++++++++++++++++++ .../SlashCommandSourceTests.cs | 53 ++++++ 10 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 native_tui/apps/NativeTui.Host/tui_assembly/HistorySeeder.cs create mode 100644 native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs create mode 100644 native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs create mode 100644 native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs index b5a7ad341e..34e43c4185 100644 --- a/native_tui/apps/NativeTui.Host/EngineSession.cs +++ b/native_tui/apps/NativeTui.Host/EngineSession.cs @@ -24,6 +24,12 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien /// 权限确认桥 — Program 在 shell 构造后 Bind(引擎 PendingConfirmation → TUI 对话框闭环,M7-3)。 public TuiPermissionBridge PermissionBridge => _permissionBridge; + /// 引擎服务容器 — 宿主组装层消费(命令清单/历史种子等只读解析)。 + public IServiceProvider Services => _engine.Services; + + /// 当前会话标识 — 历史种子排除当前会话防双份。 + public string SessionId => _engine.SessionId; + /// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。 public static async Task CreateAsync(CancellationToken cancellationToken = default) { var permissionBridge = new TuiPermissionBridge(); diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs index 497c9befc9..9dc05a6ae5 100644 --- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs @@ -12,6 +12,7 @@ global using JoinCode.Abstractions.Utils; global using JoinCode.Adapters; global using JoinCode.App.Builder; +global using JoinCode.ChatCommands; global using JoinCode.Cli; global using Microsoft.Extensions.DependencyInjection; global using NativeTui.Core; diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index bd4ae02c37..cdba58bb4d 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -49,7 +49,9 @@ private static async Task RunInteractiveAsync() { await using (session) { // banner 占位值 — 首拍 session_info 种子事件覆盖为引擎真实数据(EngineSession.Enqueue) var banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory); - var shell = new ReplShell(session.Client, banner, tuiOutput); + var commands = SlashCommandSource.Load(session.Services); + var shell = new ReplShell(session.Client, banner, tuiOutput, commands: commands); + await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); shell.Run(); shell.WriteExit(); diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/HistorySeeder.cs b/native_tui/apps/NativeTui.Host/tui_assembly/HistorySeeder.cs new file mode 100644 index 0000000000..c5d3f075fc --- /dev/null +++ b/native_tui/apps/NativeTui.Host/tui_assembly/HistorySeeder.cs @@ -0,0 +1,68 @@ +namespace NativeTui.Host; + +/// +/// 输入历史种子加载器 — 复用 ~/.jcc/sessions transcript 存储(TranscriptPersistMiddleware 已自动落盘 TUI 输入, +/// 无需第二套 history 存储):最近同项目会话的 user 输入(排除当前会话防双份),聚合为旧→新顺序, +/// 取末尾 HistoryRing.Capacity 条注入 shell。加载失败静默降级(历史种子不阻塞 TUI 启动)。 +/// +public static class HistorySeeder +{ + /// 扫描会话数(项目过滤前取样的窗口)。 + private const int ScanSessions = 15; + + /// 参与种子的同项目会话数上限。 + private const int MaxProjectSessions = 5; + + /// 加载历史种子并注入 shell — transcript 服务未注册/加载失败返回(无历史降级)。 + public static async Task SeedAsync(ReplShell shell, IServiceProvider services, string excludeSessionId, CancellationToken cancellationToken = default) + { + try + { + var transcript = services.GetService(); + if (transcript is null) + { + return; + } + + var projectRoot = Environment.CurrentDirectory; + var sessionsNewFirst = new List>(); + var summaries = await transcript.ListTranscriptsAsync(ScanSessions, cancellationToken).ConfigureAwait(false); + foreach (var summary in summaries) + { + if (sessionsNewFirst.Count >= MaxProjectSessions) + { + break; + } + + if (summary.SessionId == excludeSessionId) + { + continue; + } + + var info = await transcript.GetSessionInfoAsync(summary.SessionId, cancellationToken).ConfigureAwait(false); + if (info?.ProjectPath != projectRoot) + { + continue; + } + + var entries = await transcript.LoadTranscriptAsync(summary.SessionId, cancellationToken).ConfigureAwait(false); + var userInputs = entries + .Where(entry => entry.Role == "user" && entry.Type is null && !entry.IsSidechain && entry.Content.Length > 0) + .Select(entry => entry.Content) + .ToList(); + if (userInputs.Count > 0) + { + sessionsNewFirst.Add(userInputs); + } + } + + sessionsNewFirst.Reverse(); + var chronological = sessionsNewFirst.SelectMany(inputs => inputs).ToList(); + shell.SeedHistory(chronological.Skip(Math.Max(0, chronological.Count - HistoryRing.Capacity))); + } + catch (Exception ex) + { + Console.Error.WriteLine($"输入历史加载失败(已降级为无历史): {ex.Message}"); + } + } +} diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs new file mode 100644 index 0000000000..687efbc086 --- /dev/null +++ b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs @@ -0,0 +1,24 @@ +namespace NativeTui.Host; + +/// +/// 斜杠命令清单源 — 从引擎 DI 解析 ISlashCommandCatalog(源码生成器在 Composition 中生成), +/// 未注册时兜底直接实例化 GeneratedSlashCommandCatalog(CLI SlashCommandExecutors 先例); +/// 过滤 IsEnabled=false,转为 NativeTui.Core 零依赖的 CommandSuggestion 纯数据。 +/// +public static class SlashCommandSource +{ + /// 加载命令清单(name 去 / 前缀)— 无目录时返回空列表(TUI 无补全能力降级)。 + public static IReadOnlyList Load(IServiceProvider services) + { + var catalog = services.GetService() ?? new GeneratedSlashCommandCatalog(); + return catalog.ByCategory.Values + .SelectMany(category => category) + .Where(command => command.IsEnabled) + .Select(command => new CommandSuggestion( + command.Name.StartsWith('/') ? command.Name[1..] : command.Name, + command.Description, + command.Aliases, + command.IsHidden)) + .ToList(); + } +} diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 982642bc22..b4bcd04c2a 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -99,6 +99,9 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool /// 斜杠补全状态机(测试断言面;null = 无命令清单)。 internal SlashSuggester? Suggester => _suggester; + /// 输入历史环(测试断言面)。 + internal HistoryRing History => _history; + /// 历史种子注入(宿主启动时从 transcript 加载最近输入)。 public void SeedHistory(IEnumerable entries) => _history.Seed(entries); diff --git a/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs index 10ac7bec41..11ef2fe902 100644 --- a/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs +++ b/native_tui/src/NativeTui.Core/repl/history/HistoryRing.cs @@ -22,6 +22,9 @@ public sealed class HistoryRing /// 历史条数。 public int Count => _entries.Count; + /// 历史条目快照(旧→新;测试断言面)。 + internal IReadOnlyList Entries => _entries; + /// 提交入史:空文本忽略;末条相同去重;超容量淘汰最旧。 public void Add(string text) { diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs index 0bb0392c82..e5770fb436 100644 --- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs +++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs @@ -2,6 +2,8 @@ global using System.Collections.Concurrent; global using JoinCode; global using JoinCode.Abstractions.Configuration; +global using JoinCode.Abstractions.Interfaces; +global using JoinCode.Abstractions.LLM.Chat; global using JoinCode.Abstractions.Models.Diff; global using JoinCode.Abstractions.Security.Permission; global using JoinCode.Abstractions.Security.Shell; diff --git a/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs b/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs new file mode 100644 index 0000000000..8d36810591 --- /dev/null +++ b/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs @@ -0,0 +1,160 @@ +namespace NativeTui.Host.Tests; + +/// +/// 输入历史种子加载器测试 — 复用 sessions transcript 存储: +/// 同项目会话过滤(异项目跳过)、当前会话排除防双份、时序聚合(会话旧→新 + 会话内提交序)、 +/// 末尾容量截断、transcript 服务未注册静默降级。 +/// +public sealed class HistorySeederTests +{ + /// 假 transcript 服务 — 内存会话存储。 + private sealed class FakeTranscriptService : ITranscriptService + { + public List<(string SessionId, string ProjectPath, List Entries)> Sessions { get; } = []; + + public Task AppendEntryAsync(string sessionId, TranscriptEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task AppendEntriesAsync(string sessionId, IReadOnlyList entries, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> LoadTranscriptAsync(string sessionId, CancellationToken cancellationToken = default) + { + var session = Sessions.Single(s => s.SessionId == sessionId); + return Task.FromResult>(session.Entries); + } + + public Task> ListTranscriptsAsync(int limit = 20, CancellationToken cancellationToken = default) + { + var summaries = Sessions + .OrderByDescending(s => s.Entries.Count > 0 ? s.Entries[^1].Timestamp : DateTime.MinValue) + .Take(limit) + .Select(s => new TranscriptSummary { SessionId = s.SessionId }) + .ToList(); + return Task.FromResult>(summaries); + } + + public Task GetSessionInfoAsync(string sessionId, CancellationToken cancellationToken = default) + { + var index = Sessions.FindIndex(s => s.SessionId == sessionId); + return Task.FromResult(index < 0 ? null : new SessionInfo { Id = sessionId, ProjectPath = Sessions[index].ProjectPath }); + } + public Task DeleteTranscriptAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(true); + + public Task TranscriptExistsAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(true); + + public Task SaveCustomTitleAsync(string sessionId, string customTitle, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetCustomTitleAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(null); + + public Task InsertContentReplacementAsync(string sessionId, IReadOnlyList records, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> LoadContentReplacementsAsync(string sessionId, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task SaveSessionInfoAsync(string sessionId, SessionInfo info, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task MigrateLegacyAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + /// 用户输入条目。 + private static TranscriptEntry User(string content, int minute) => new() { Role = "user", Content = content, Timestamp = DateTime.UtcNow.AddMinutes(minute) }; + + /// 最小假引擎(历史种子测试不需要回合行为)。 + private sealed class StubEngine : IReplEngine + { + public void Submit(string text) { } + public void Interrupt() { } + public void SetPermissionMode(string mode) { } + public IReadOnlyList DrainEvents() => []; + public bool IsAlive => true; + } + + /// 构造 shell + 注册假 transcript 的服务容器。 + private static (ReplShell Shell, FakeTranscriptService Transcript, IServiceProvider Services) CreateShell() + { + var transcript = new FakeTranscriptService(); + var services = new ServiceCollection().AddSingleton(transcript).BuildServiceProvider(); + var shell = new ReplShell(new StubEngine(), new BannerItem("1.0.0", "m", "API 按量计费", "/tmp"), new StringWriter()); + return (shell, transcript, services); + } + + /// 同项目两个会话(新→旧列出):时序聚合为 旧会话输入 → 新会话输入。 + [Fact] + public async Task SeedAsync_AggregatesProjectSessionsChronologically() + { + var (shell, transcript, services) = CreateShell(); + var project = Environment.CurrentDirectory; + transcript.Sessions.Add(("session-new", project, [User("new-1", 10), User("new-2", 11)])); + transcript.Sessions.Add(("session-old", project, [User("old-1", 1), User("old-2", 2)])); + + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal(["old-1", "old-2", "new-1", "new-2"], ReadHistory(shell)); + } + + /// 异项目会话跳过(基准按 project 过滤)。 + [Fact] + public async Task SeedAsync_SkipsOtherProjects() + { + var (shell, transcript, services) = CreateShell(); + transcript.Sessions.Add(("other-project", @"D:\other", [User("foreign", 5)])); + transcript.Sessions.Add(("same-project", Environment.CurrentDirectory, [User("local", 6)])); + + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal(["local"], ReadHistory(shell)); + } + + /// 当前会话排除(防双份 — 本会话输入由运行时 Submit 入史)。 + [Fact] + public async Task SeedAsync_ExcludesCurrentSession() + { + var (shell, transcript, services) = CreateShell(); + var project = Environment.CurrentDirectory; + transcript.Sessions.Add(("current", project, [User("already-live", 5)])); + transcript.Sessions.Add(("past", project, [User("past-input", 1)])); + + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal(["past-input"], ReadHistory(shell)); + } + + /// assistant 回复/元数据条目/侧链/空输入不入史(仅 Role=user 且 Type=null)。 + [Fact] + public async Task SeedAsync_FiltersUserEntriesOnly() + { + var (shell, transcript, services) = CreateShell(); + transcript.Sessions.Add(("s", Environment.CurrentDirectory, + [ + User("real-input", 1), + new TranscriptEntry { Role = "assistant", Content = "reply", Timestamp = DateTime.UtcNow }, + new TranscriptEntry { Role = "user", Type = "custom-title", Content = "title", Timestamp = DateTime.UtcNow }, + new TranscriptEntry { Role = "user", IsSidechain = true, Content = "side", Timestamp = DateTime.UtcNow }, + new TranscriptEntry { Role = "user", Content = string.Empty, Timestamp = DateTime.UtcNow }, + ])); + + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal(["real-input"], ReadHistory(shell)); + } + + /// 超过容量 100:取末尾(最旧淘汰)。 + [Fact] + public async Task SeedAsync_OverCapacity_KeepsLatestHundred() + { + var (shell, transcript, services) = CreateShell(); + transcript.Sessions.Add(("s", Environment.CurrentDirectory, + Enumerable.Range(0, 120).Select(i => User($"input-{i}", i)).ToList())); + + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal(Enumerable.Range(20, 100).Select(i => $"input-{i}"), ReadHistory(shell)); + } + + /// transcript 服务未注册:静默降级(无历史不报错)。 + [Fact] + public async Task SeedAsync_NoTranscriptService_SilentDegradation() + { + var shell = new ReplShell(new StubEngine(), new BannerItem("1.0.0", "m", "API 按量计费", "/tmp"), new StringWriter()); + var services = new ServiceCollection().BuildServiceProvider(); + await HistorySeeder.SeedAsync(shell, services, excludeSessionId: "current"); + Assert.Equal([], ReadHistory(shell)); + } + + /// 读取 shell 历史条目(旧→新)。 + private static List ReadHistory(ReplShell shell) => shell.History.Entries.ToList(); +} diff --git a/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs new file mode 100644 index 0000000000..cf2d625103 --- /dev/null +++ b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs @@ -0,0 +1,53 @@ +namespace NativeTui.Host.Tests; + +/// +/// 斜杠命令清单源测试 — DI 解析目录 → CommandSuggestion 纯数据转换: +/// name 去 / 前缀、IsEnabled=false 过滤、IsHidden 透传(补全层处理可见性)、DI 未注册兜底直接实例化生成目录。 +/// +public sealed class SlashCommandSourceTests +{ + /// 假命令目录(手动构造元数据)。 + private sealed class FakeCatalog : ISlashCommandCatalog + { + public IReadOnlyDictionary> ByCategory { get; } = + new Dictionary> + { + ["General"] = + [ + new SlashCommandMetadata { Name = "/help", Description = "显示帮助", Aliases = ["h"], IsHidden = false }, + new SlashCommandMetadata { Name = "/secret", Description = "隐藏", IsHidden = true }, + new SlashCommandMetadata { Name = "/disabled", Description = "禁用", IsEnabled = false }, + ], + ["Session"] = + [ + new SlashCommandMetadata { Name = "clear", Description = "清屏" }, + ], + }; + } + + /// 构造注册假目录的服务容器。 + private static IServiceProvider Services() => + new ServiceCollection().AddSingleton(new FakeCatalog()).BuildServiceProvider(); + + /// 转换:name 去 / 前缀、描述/别名/隐藏透传、禁用命令过滤、跨分类合并。 + [Fact] + public void Load_ConvertsMetadataToSuggestions() + { + var commands = SlashCommandSource.Load(Services()); + Assert.Equal(["clear", "help", "secret"], commands.Select(c => c.Name).OrderBy(n => n).ToArray()); + var help = commands.Single(c => c.Name == "help"); + Assert.Equal("显示帮助", help.Description); + Assert.Equal(["h"], help.Aliases); + Assert.False(help.IsHidden); + Assert.True(commands.Single(c => c.Name == "secret").IsHidden); + } + + /// DI 未注册目录:兜底直接实例化生成目录(真实生成器输出,非空)。 + [Fact] + public void Load_NoCatalog_FallsBackToGenerated() + { + var commands = SlashCommandSource.Load(new ServiceCollection().BuildServiceProvider()); + Assert.NotEmpty(commands); + Assert.All(commands, c => Assert.False(c.Name.StartsWith('/'))); + } +} From 5210c42f51b65a2e6f5f86d27ac05f951c7866b4 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:26:01 +0800 Subject: [PATCH 13/43] =?UTF-8?q?fix:=20=E5=BB=BA=E8=AE=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=90=8C=E6=AD=A5=E5=85=88=E4=BA=8EEnsureScreens(?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BB=BA=E8=AE=AE=E6=BF=80=E6=B4=BB=E9=A6=96?= =?UTF-8?q?=E5=B8=A7=E5=B1=8F=E9=AB=98=E6=BB=9E=E5=90=8E=E4=B8=80=E5=B8=A7?= =?UTF-8?q?=E8=A3=81=E5=89=AA)=20|=20=E5=86=B3=E7=AD=96:=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96SyncSuggestionState=E6=9C=89=E5=90=8D=E5=87=BD?= =?UTF-8?q?=E6=95=B0,=E6=B8=B2=E6=9F=93=E7=AE=A1=E7=BA=BF=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=3D=E7=8A=B6=E6=80=81=E6=8A=95=E5=BD=B1=E2=86=92?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E8=AE=A1=E7=AE=97=E2=86=92=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/src/NativeTui.Core/repl/ReplShell.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index b4bcd04c2a..2d7336a372 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -598,6 +598,7 @@ internal void Submit() /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。 private void RenderAndFlush() { + SyncSuggestionState(); EnsureScreens(); _back.Clear(); _animTimeMs += TickMs; @@ -605,9 +606,6 @@ private void RenderAndFlush() _spinner.WallNowMs = Environment.TickCount64; _view.Input = _input.Text; _view.CursorCol = _input.CursorCol; - _view.Suggestions = _suggester is { IsActive: true } ? _suggester.Items : null; - _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; - _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; _view.Render(_back, _theme); if (_inline) { @@ -637,6 +635,14 @@ private void RenderAndFlush() _frontValid = true; } + /// 同步补全建议到视图(渲染前状态投影 — EnsureScreens 屏高计算依赖建议区高度,必须先行)。 + private void SyncSuggestionState() + { + _view.Suggestions = _suggester is { IsActive: true } ? _suggester.Items : null; + _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; + _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; + } + /// /// 尺寸计算(含首帧)成对重建双缓冲 — inline 屏高 = 内容总高(消息全量展示,可超视口), /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset);视口模式屏高 = 终端行数。 From dfadac82dc98842b4e155c98b60e5fa57dc29193 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:25:16 +0800 Subject: [PATCH 14/43] =?UTF-8?q?feat:=20M7-5=20Core=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E7=BA=BF=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20|=20=E5=86=B3=E7=AD=96:=20LayoutWithHighli?= =?UTF-8?q?ghts=E5=8F=8C=E5=85=A5=E5=8F=A3=E4=BF=9D=E7=95=99=E6=97=A7Layou?= =?UTF-8?q?t=E5=85=BC=E5=AE=B9,=E5=B8=83=E5=B1=80=E8=A1=8C=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E5=99=A8=E5=90=8C=E6=AD=A5=E6=94=B6=E9=9B=86=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E4=B8=8A=E4=B8=8B=E6=96=87,=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E6=9C=9F=E6=8C=89HighlightSpan=E8=A6=86=E5=86=99cell=E5=89=8D?= =?UTF-8?q?=E6=99=AFStyleId(=E5=90=AB=E5=89=8D=E7=BC=80=E5=81=8F=E7=A7=BB?= =?UTF-8?q?=E5=B9=B3=E7=A7=BB),highlighter=E5=85=A8=E7=A8=8B=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E6=B3=A8=E5=85=A5null=3D=E7=BA=AF=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E9=9B=B6=E8=A1=8C=E4=B8=BA=E5=8F=98=E5=8C=96,MarkdownMessageIt?= =?UTF-8?q?em=E5=B8=83=E5=B1=80=E7=BC=93=E5=AD=98=E9=94=AE=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E9=87=8D=E7=AE=97=E5=B9=82=E7=AD=89;=E6=B5=8B?= =?UTF-8?q?=E8=AF=958=E4=B8=AAstub=E9=AB=98=E4=BA=AE=E5=99=A8=E7=BA=AFCore?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E4=B8=8D=E5=90=AB=E5=8D=AB=E6=98=9F=E4=BE=9D?= =?UTF-8?q?=E8=B5=96,=E5=85=A8=E9=87=8F642/642=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 16 +- .../markdown/CodeHighlighting.cs | 8 + .../NativeTui.Core/markdown/LayoutLines.cs | 34 ++++ .../NativeTui.Core/markdown/MarkdownLayout.cs | 60 +++++-- .../repl/MarkdownMessageItem.cs | 34 ++-- .../NativeTui.Core/repl/MessageRenderer.cs | 18 ++ .../src/NativeTui.Core/repl/ReplShell.cs | 14 +- .../markdown/MarkdownHighlightTests.cs | 155 ++++++++++++++++++ 8 files changed, 302 insertions(+), 37 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/markdown/LayoutLines.cs create mode 100644 native_tui/tests/NativeTui.Tests/markdown/MarkdownHighlightTests.cs diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index a4929c6b4d..f9830295d5 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -18,14 +18,24 @@ public sealed class ReplBridgeDriver /// 权限请求挂起钩子(shell 注入 BeginPermissionRequest — 权限模态是 shell 层状态,Driver 只路由不持有)。 private readonly Action? _onPermissionRequest; + /// 代码高亮器(宿主注入的卫星插件 — null = 高亮禁用降级纯文本,MarkdownMessageItem 消费)。 + private readonly ICodeHighlighter? _codeHighlighter; + /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项; - /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景))。 - public ReplBridgeDriver(ReplView view, SpinnerItem spinner, BannerItem banner, Action? onPermissionRequest = null) + /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景); + /// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用)。 + public ReplBridgeDriver( + ReplView view, + SpinnerItem spinner, + BannerItem banner, + Action? onPermissionRequest = null, + ICodeHighlighter? codeHighlighter = null) { _view = view; _spinner = spinner; _banner = banner; _onPermissionRequest = onPermissionRequest; + _codeHighlighter = codeHighlighter; } /// 回合进行中(turn_started 后未 completed/interrupted)。 @@ -85,7 +95,7 @@ public void Apply(BridgeEvent bridgeEvent) break; case BridgeEvent.AssistantMessage(var text, var markdown): AddMessage(markdown is not null - ? new MarkdownMessageItem(markdown) + ? new MarkdownMessageItem(markdown, _codeHighlighter) : new AssistantMessageItem(text)); break; case BridgeEvent.TurnCompleted: diff --git a/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs b/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs index 7799ee026c..9f735034bb 100644 --- a/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs +++ b/native_tui/src/NativeTui.Core/markdown/CodeHighlighting.cs @@ -14,3 +14,11 @@ public interface ICodeHighlighter /// 按语言整块高亮 — 返回每行的 token 色段列表(索引对齐代码行;空行 = 空列表);null = 语言不支持。 IReadOnlyList>? Highlight(string language, IReadOnlyList lines); } + +/// 单行代码高亮结果 — token 色段(Start 已含行首前缀宽度偏移,坐标系 = 布局行完整段文本)。 +public sealed record CodeLineHighlight(IReadOnlyList Spans); + +/// 布局产物 — 物理行模型 + 逐行高亮上下文(与 Lines 等长;null = 非代码行或无色段)。 +public sealed record MarkdownLayoutResult( + IReadOnlyList> Lines, + IReadOnlyList Highlights); diff --git a/native_tui/src/NativeTui.Core/markdown/LayoutLines.cs b/native_tui/src/NativeTui.Core/markdown/LayoutLines.cs new file mode 100644 index 0000000000..2bdc371328 --- /dev/null +++ b/native_tui/src/NativeTui.Core/markdown/LayoutLines.cs @@ -0,0 +1,34 @@ +namespace NativeTui.Core.Markdown; + +/// +/// 布局行收集器 — 段行与高亮上下文双列表严格同步(普通行 Add 补 null,代码行走 AddCode); +/// 持有高亮器(AppendCode 整块调用 — 布局期一次性算好缓存,渲染期零成本消费)。 +/// +internal sealed class LayoutLines(ICodeHighlighter? highlighter) +{ + /// 物理行(段序列)。 + private readonly List> _lines = []; + + /// 逐行高亮上下文(与 _lines 等长 — null = 非代码行)。 + private readonly List _highlights = []; + + /// 高亮器(null = 高亮禁用 — 代码块降级纯文本)。 + public ICodeHighlighter? Highlighter => highlighter; + + /// 追加普通行(高亮上下文 = null)。 + public void Add(IReadOnlyList line) + { + _lines.Add(line); + _highlights.Add(null); + } + + /// 追加代码行(携带该行 token 色段;null = 该行无色段,如空行)。 + public void AddCode(IReadOnlyList line, CodeLineHighlight? highlight) + { + _lines.Add(line); + _highlights.Add(highlight); + } + + /// 产出布局结果(行 + 高亮等长对齐)。 + public MarkdownLayoutResult ToResult() => new(_lines, _highlights); +} diff --git a/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs b/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs index 8471447854..d7e5e5d493 100644 --- a/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs +++ b/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs @@ -17,12 +17,16 @@ public static class MarkdownLayout /// 列表缩进宽度(列/层,基准 ' '.repeat(listDepth))。 private const int ListIndentCols = 2; - /// 块模型 → 物理行列表(空行 = 空段列表;顶层块间空 1 行)。 - public static IReadOnlyList> Layout(MarkdownDocument document, int width) + /// 块模型 → 物理行列表(无高亮 — 旧入口兼容既有调用方;高亮场景走 LayoutWithHighlights)。 + public static IReadOnlyList> Layout(MarkdownDocument document, int width) => + LayoutWithHighlights(document, width, null).Lines; + + /// 块模型 → 布局结果(行 + 逐行代码高亮;highlighter null = 高亮禁用降级纯文本)。 + public static MarkdownLayoutResult LayoutWithHighlights(MarkdownDocument document, int width, ICodeHighlighter? highlighter) { - var lines = new List>(); + var lines = new LayoutLines(highlighter); AppendBlocks(document.Blocks, Math.Max(0, width), [], [], MarkdownStyle.None, lines, gapBetweenBlocks: true); - return lines; + return lines.ToResult(); } /// 追加块序列 — 顶层块间空 1 行,引用/列表内子块连续(gapBetweenBlocks 由调用方决定)。 @@ -32,7 +36,7 @@ private static void AppendBlocks( IReadOnlyList prefix, IReadOnlyList continuationPrefix, MarkdownStyle extraStyle, - List> lines, + LayoutLines lines, bool gapBetweenBlocks) { var first = true; @@ -55,7 +59,7 @@ private static void AppendBlock( IReadOnlyList prefix, IReadOnlyList continuationPrefix, MarkdownStyle extraStyle, - List> lines) + LayoutLines lines) { switch (block) { @@ -103,7 +107,7 @@ private static void AppendParagraph( IReadOnlyList prefix, IReadOnlyList continuationPrefix, MarkdownStyle extraStyle, - List> lines) + LayoutLines lines) { var prefixWidth = SegmentsWidth(prefix); var segments = ApplyStyle(paragraph.Segments, extraStyle); @@ -123,7 +127,7 @@ private static void AppendQuote( IReadOnlyList prefix, IReadOnlyList continuationPrefix, MarkdownStyle extraStyle, - List> lines) + LayoutLines lines) { var quotePrefix = AppendSegment(prefix, new MarkdownSegment(QuotePrefix, MarkdownStyle.Dim)); var innerWidth = Math.Max(0, width - SegmentsWidth(quotePrefix)); @@ -136,7 +140,7 @@ private static void AppendList( int width, IReadOnlyList prefix, MarkdownStyle extraStyle, - List> lines) + LayoutLines lines) { var indentWidth = SegmentsWidth(prefix); for (var index = 0; index < list.Items.Count; index++) @@ -155,7 +159,7 @@ private static void AppendListItem( int indentWidth, string marker, MarkdownStyle extraStyle, - List> lines) + LayoutLines lines) { var markerPrefix = new List { new(new string(' ', indentWidth) + marker, MarkdownStyle.None) }; var continuationPrefix = new List { new(new string(' ', indentWidth + marker.Length), MarkdownStyle.None) }; @@ -172,8 +176,8 @@ private static void AppendListItem( } } - /// 代码块 — 原文逐行(不换行,超宽由 Screen 裁剪),前缀段打头。 - private static void AppendCode(MarkdownCode code, IReadOnlyList prefix, List> lines) + /// 代码块 — 原文逐行(不换行,超宽由 Screen 裁剪),前缀段打头;高亮器在位时整块高亮(跨行语法状态)。 + private static void AppendCode(MarkdownCode code, IReadOnlyList prefix, LayoutLines lines) { var text = code.Text.TrimEnd('\n'); if (text.Length == 0) @@ -181,22 +185,44 @@ private static void AppendCode(MarkdownCode code, IReadOnlyList return; } - foreach (var lineText in text.Split('\n')) + var codeLines = text.Split('\n'); + var highlights = HighlightCodeBlock(lines.Highlighter, code.Language, codeLines, SegmentsWidth(prefix)); + for (var index = 0; index < codeLines.Length; index++) { - var line = new List(prefix) { new(lineText, MarkdownStyle.None) }; - lines.Add(line); + var line = new List(prefix) { new(codeLines[index], MarkdownStyle.None) }; + lines.AddCode(line, highlights?[index]); + } + } + + /// 整块高亮 → 逐行色段(Start 平移前缀宽度,坐标系 = 布局行完整段文本;null = 高亮器缺席/语言不支持)。 + private static IReadOnlyList? HighlightCodeBlock( + ICodeHighlighter? highlighter, + string language, + string[] codeLines, + int prefixWidth) + { + var spans = highlighter?.Highlight(language, codeLines); + if (spans is null) + { + return null; } + + return spans.Select(lineSpans => new CodeLineHighlight( + lineSpans + .Where(span => span.Length > 0) + .Select(span => span with { Start = span.Start + prefixWidth }) + .ToArray())).ToArray(); } /// 分隔线 — "---" 三字符(基准 hr)。 - private static void AppendRule(IReadOnlyList prefix, List> lines) + private static void AppendRule(IReadOnlyList prefix, LayoutLines lines) { var line = new List(prefix) { new("---", MarkdownStyle.None) }; lines.Add(line); } /// 表格 — 列宽 = max(显示宽, 3);表头/数据行 "| x | y |"(末格 trimEnd 语义),分隔行 "|---|---|"。 - private static void AppendTable(MarkdownTable table, IReadOnlyList prefix, List> lines) + private static void AppendTable(MarkdownTable table, IReadOnlyList prefix, LayoutLines lines) { var columnCount = table.Headers.Count; if (columnCount == 0) diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs index 8fa76e32cd..9dab7a3531 100644 --- a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs +++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs @@ -1,12 +1,13 @@ namespace NativeTui.Core.Repl; /// -/// Markdown 消息项(M7-4)— 对齐基准 AssistantTextMessage: +/// Markdown 消息项(M7-4/M7-5)— 对齐基准 AssistantTextMessage: /// ● 圆点占 col0(NoSelect minWidth 2,text 色),Markdown 各行从 col2 起(flex row 续行对齐); -/// 物理行由 MarkdownLayout 按 width-2 缓存布局(宽度变化才重算); -/// 段样式经 MessageRenderer.MapMarkdownStyle 映射(Code → permission 色,对齐基准 codespan)。 +/// 物理行由 MarkdownLayout 按 width-2 缓存布局(宽度变化才重算;高亮随布局一次性算好缓存); +/// 段样式经 MessageRenderer.MapMarkdownStyle 映射(Code → permission 色,对齐基准 codespan); +/// 代码行高亮在段写完后按色段覆写前景(M7-5 — 高亮器由宿主注入,null = 降级纯文本)。 /// -public sealed class MarkdownMessageItem(MarkdownDocument document) : IScrollItem +public sealed class MarkdownMessageItem(MarkdownDocument document, ICodeHighlighter? highlighter = null) : IScrollItem { /// Markdown 正文缩进列(● + 空格,对齐基准 NoSelect minWidth 2)。 private const int BodyIndentCols = 2; @@ -17,30 +18,35 @@ public sealed class MarkdownMessageItem(MarkdownDocument document) : IScrollItem /// 缓存的布局宽度(-1 = 未布局)。 private int _layoutWidth = -1; - /// 缓存的物理行模型。 - private IReadOnlyList> _layout = []; + /// 缓存的布局结果(行 + 逐行高亮)。 + private MarkdownLayoutResult? _layout; /// 内容高度 = margin 1 + 布局行数(空文档至少 1 行圆点行)。 - public int MeasureHeight(int width) => 1 + Math.Max(1, LayoutFor(width).Count); + public int MeasureHeight(int width) => 1 + Math.Max(1, LayoutFor(width).Lines.Count); - /// 渲染:margin 行留空 → ● text 色(col0)→ 各物理行从 col2 起逐段写。 + /// 渲染:margin 行留空 → ● text 色(col0)→ 各物理行从 col2 起逐段写 → 代码行覆写高亮前景。 public void Render(Screen screen, Rect frame, Theme theme) { var text = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Text }); screen.WriteText(frame.Y + 1, frame.X, Figures.MessageBullet, text, frame.Right); var layout = LayoutFor(frame.Width); - for (var index = 0; index < layout.Count; index++) + for (var index = 0; index < layout.Lines.Count; index++) { - MessageRenderer.RenderMarkdownLine(screen, frame with { Y = frame.Y + 1 + index, Height = 1 }, theme, BodyIndentCols, layout[index]); + var lineFrame = frame with { Y = frame.Y + 1 + index, Height = 1 }; + MessageRenderer.RenderMarkdownLine(screen, lineFrame, theme, BodyIndentCols, layout.Lines[index]); + if (layout.Highlights[index] is { Spans.Count: > 0 } highlight) + { + MessageRenderer.ApplyCodeHighlight(screen, lineFrame.Y, frame.X + BodyIndentCols, highlight.Spans); + } } } - /// 按宽度取缓存布局(内容宽 = width - 2 缩进)。 - private IReadOnlyList> LayoutFor(int width) + /// 按宽度取缓存布局(内容宽 = width - 2 缩进;高亮随布局缓存 — 宽度不变不重算)。 + private MarkdownLayoutResult LayoutFor(int width) { - if (_layoutWidth != width) + if (_layout is null || _layoutWidth != width) { - _layout = MarkdownLayout.Layout(Document, Math.Max(0, width - BodyIndentCols)); + _layout = MarkdownLayout.LayoutWithHighlights(Document, Math.Max(0, width - BodyIndentCols), highlighter); _layoutWidth = width; } diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index a98b775e89..4a34dbcee0 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -190,4 +190,22 @@ public static void RenderMarkdownLine(Screen screen, Rect frame, Theme theme, in Strikethrough = style.HasFlag(MarkdownStyle.Strikethrough), Foreground = style.HasFlag(MarkdownStyle.Code) ? theme.Permission : default, }; + + /// + /// 代码行高亮覆写(M7-5)— 段文本已写完后按 token 色段覆写前景(保留 rune 与宽度标记, + /// 只换 StyleId;色段坐标系 = 行段文本,调用方补 startCol 偏移;同色相邻段由 StylePool intern 合并)。 + /// + public static void ApplyCodeHighlight(Screen screen, int row, int startCol, IReadOnlyList spans) + { + foreach (var span in spans) + { + var styleId = screen.Styles.GetOrAdd(new TextStyle { Foreground = Color.FromRgb(span.R, span.G, span.B) }); + for (var offset = 0; offset < span.Length; offset++) + { + var col = startCol + span.Start + offset; + var cell = screen[row, col]; + screen[row, col] = cell with { StyleId = styleId }; + } + } + } } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 2d7336a372..fcc1c45128 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -69,8 +69,16 @@ public sealed class ReplShell /// 创建外壳 — engine=引擎交互面;banner=启动横幅(首项);output=渲染 writer; /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前); - /// commands=斜杠命令清单(宿主注入;null = 无补全能力)。 - public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool inline = true, IReadOnlyList? seedMessages = null, IReadOnlyList? commands = null) + /// commands=斜杠命令清单(宿主注入;null = 无补全能力); + /// codeHighlighter=代码高亮插件(M7-5 卫星注入;null = 高亮禁用降级纯文本)。 + public ReplShell( + IReplEngine engine, + BannerItem banner, + TextWriter output, + bool inline = true, + IReadOnlyList? seedMessages = null, + IReadOnlyList? commands = null, + ICodeHighlighter? codeHighlighter = null) { _engine = engine; _banner = banner; @@ -78,7 +86,7 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool _inline = inline; _seedMessages = seedMessages ?? []; _suggester = commands is { Count: > 0 } ? new SlashSuggester(commands) : null; - _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest); + _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest, codeHighlighter); } /// 视图(测试断言面)。 diff --git a/native_tui/tests/NativeTui.Tests/markdown/MarkdownHighlightTests.cs b/native_tui/tests/NativeTui.Tests/markdown/MarkdownHighlightTests.cs new file mode 100644 index 0000000000..362a33bc9c --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/markdown/MarkdownHighlightTests.cs @@ -0,0 +1,155 @@ +namespace NativeTui.Tests.Markdown; + +/// +/// 代码高亮 Core 接线测试(M7-5)— 布局产物高亮上下文(普通行 null / 代码行色段 / 前缀偏移 / 降级 null) +/// + MarkdownMessageItem 渲染覆写(token 色段 → cell 前景)+ Driver 注入链(AssistantMessage → 消息项)。 +/// stub 高亮器固定色值(不依赖 TextMateSharp 卫星 — Core 纯视图断言)。 +/// +public sealed class MarkdownHighlightTests +{ + /// stub 高亮器 — 固定三段色(红/绿/蓝各 3 字符)支持 csharp,其余语言返回 null。 + private sealed class StubHighlighter : ICodeHighlighter + { + public IReadOnlyList>? Highlight(string language, IReadOnlyList lines) => + language == "csharp" + ? lines.Select(line => (IReadOnlyList)new[] + { + new HighlightSpan(0, 3, 255, 0, 0), + new HighlightSpan(3, 3, 0, 255, 0), + new HighlightSpan(6, Math.Max(0, line.Length - 6), 0, 0, 255), + }).ToArray() + : null; + } + + /// 构造含代码块的文档。 + private static MarkdownDocument DocWithCode(string language, string code) => + new([new MarkdownCode(language, code)]); + + [Fact] + public void Layout_CodeLinesCarrySpans_NormalLinesNull() + { + var result = MarkdownLayout.LayoutWithHighlights( + DocWithCode("csharp", "var x = 1;\nvar y = 2;"), + width: 80, + new StubHighlighter()); + + Assert.Equal(2, result.Lines.Count); + Assert.All(result.Highlights, highlight => Assert.NotNull(highlight)); + Assert.Equal(3, result.Highlights[0]!.Spans.Count); + } + + [Fact] + public void Layout_SpanStartOffsetByQuotePrefix() + { + // 引用内代码块:前缀 "▎ " 宽 2 → 色段 Start 平移 +2 + var doc = new MarkdownDocument( + [ + new MarkdownQuote([new MarkdownCode("csharp", "abcdef")]), + ]); + + var result = MarkdownLayout.LayoutWithHighlights(doc, width: 80, new StubHighlighter()); + + var spans = result.Highlights[0]!.Spans; + Assert.Equal(2, spans[0].Start); + Assert.Equal(5, spans[1].Start); + } + + [Fact] + public void Layout_UnsupportedLanguage_DegradesToNoHighlight() + { + var result = MarkdownLayout.LayoutWithHighlights( + DocWithCode("no-such-lang", "code"), + width: 80, + new StubHighlighter()); + + Assert.All(result.Highlights, Assert.Null); + } + + [Fact] + public void Layout_NoHighlighter_CodeLinesPureText() + { + var result = MarkdownLayout.LayoutWithHighlights(DocWithCode("csharp", "var x = 1;"), width: 80, null); + + Assert.All(result.Highlights, Assert.Null); + } + + [Fact] + public void Render_CodeLineCellsColoredBySpan() + { + using var screen = new Screen(cols: 40, rows: 4); + var item = new MarkdownMessageItem(DocWithCode("csharp", "var x = 1;"), new StubHighlighter()); + + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + + // row1 = margin 后首行:● 占 col0-1,代码从 col2 起 + // stub:col2-4 红(255,0,0)、col5-7 绿(0,255,0)、col8+ 蓝(0,0,255) + var red = screen.Styles.GetOrAdd(new TextStyle { Foreground = Color.FromRgb(255, 0, 0) }); + var green = screen.Styles.GetOrAdd(new TextStyle { Foreground = Color.FromRgb(0, 255, 0) }); + var blue = screen.Styles.GetOrAdd(new TextStyle { Foreground = Color.FromRgb(0, 0, 255) }); + Assert.Equal(red, screen[1, 2].StyleId); + Assert.Equal(red, screen[1, 4].StyleId); + Assert.Equal(green, screen[1, 5].StyleId); + Assert.Equal(green, screen[1, 7].StyleId); + Assert.Equal(blue, screen[1, 8].StyleId); + Assert.Equal(new Rune('v'), screen[1, 2].Value); + Assert.Equal(new Rune('='), screen[1, 8].Value); + } + + [Fact] + public void Render_WithoutHighlighter_CodeCellsDefaultStyle() + { + using var screen = new Screen(cols: 40, rows: 4); + var item = new MarkdownMessageItem(DocWithCode("csharp", "var x = 1;")); + + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + + Assert.Equal(0, screen[1, 2].StyleId); + Assert.Equal(0, screen[1, 8].StyleId); + } + + [Fact] + public void Render_LayoutCachedAcrossFrames() + { + // 布局缓存 — 同宽度二次渲染不重算高亮(stub 计数验证) + var calls = 0; + var counting = new CallbackHighlighter(() => calls++); + using var screen = new Screen(cols: 40, rows: 4); + var item = new MarkdownMessageItem(DocWithCode("csharp", "var x = 1;"), counting); + + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + + Assert.Equal(1, calls); + } + + /// 计数 stub(每次 Highlight 调用回调)。 + private sealed class CallbackHighlighter(Action onHighlight) : ICodeHighlighter + { + public IReadOnlyList>? Highlight(string language, IReadOnlyList lines) + { + onHighlight(); + return new[] { (IReadOnlyList)Array.Empty() }.Concat( + lines.Skip(1).Select(_ => (IReadOnlyList)Array.Empty())).ToArray(); + } + } + + [Fact] + public void Driver_PassesHighlighterToMarkdownMessageItem() + { + // 注入链:Driver 构造注入 → AssistantMessage(markdown) → 视图渲染出现色段 + var view = new ReplView(); + var spinner = new SpinnerItem(); + var banner = new BannerItem("0.0.0", "占位模型", "API 按量计费", @"C:\placeholder"); + var driver = new ReplBridgeDriver(view, spinner, banner, codeHighlighter: new StubHighlighter()); + view.Messages.Add(banner); + view.Messages.Add(spinner); + + driver.Apply(new BridgeEvent.AssistantMessage("# 演示", DocWithCode("csharp", "var x = 1;"))); + + var item = Assert.IsType(view.Messages.Items[^2]); + using var screen = new Screen(cols: 40, rows: 4); + item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark); + var red = screen.Styles.GetOrAdd(new TextStyle { Foreground = Color.FromRgb(255, 0, 0) }); + Assert.Equal(red, screen[1, 2].StyleId); + } +} From 1bf09a3f5a9cb6bd2294af97a0f2cfbfbb5ff8ad Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:27:50 +0800 Subject: [PATCH 15/43] =?UTF-8?q?docs:=20TASK056=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95(634+43?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=9F=BA=E7=BA=BF+=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=B8=85=E5=8D=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...16\345\216\206\345\217\262\347\216\257.md" | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" index ae49e05ddc..3a15bb02bd 100644 --- "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" +++ "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" @@ -1,6 +1,6 @@ # TASK056 — TUI 输入栏:斜杠命令补全 + 历史环 -> 状态:in-progress +> 状态:implemented(待手动 exe 验收) > 参考基准:`D:\project\claude-code-rust\claude-code-rev-main\src\`(useTypeahead.tsx / commandSuggestions.ts / useArrowKeyHistory.tsx / PromptInputFooterSuggestions.tsx) > 历史数据源:复用现有 `~/.jcc/sessions/{id}/transcript.json`(`ITranscriptService`,`TranscriptPersistMiddleware` 已自动落盘,无需新存储) @@ -8,38 +8,64 @@ | # | 任务 | 产物 | 状态 | |---|------|------|------| -| 1 | 历史环纯逻辑 | `repl/history/HistoryRing.cs` + 测试 | ⬜ | -| 2 | 补全过滤排序纯逻辑 | `repl/suggestions/SlashSuggester.cs` + `CommandSuggestion.cs` + 测试 | ⬜ | -| 3 | 建议列表渲染 | `repl/suggestions/SuggestionBox.cs` + 测试 | ⬜ | -| 4 | ReplView 渲染集成 | 建议区插入输入区与 footer 之间 | ⬜ | -| 5 | ReplShell 键位集成 | Tab/Enter/Esc/Up/Down 分派 + 键位仲裁 + 历史导航 + 提交入史 | ⬜ | -| 6 | Host 组装 | 命令清单注入(SlashCommandCatalog)+ 历史种子(ITranscriptService) | ⬜ | -| 7 | 手动 exe 验收 | ADR 0080 方式 | ⬜ | +| 1 | 历史环纯逻辑 | `repl/history/HistoryRing.cs` + 17 测试 | ✅ e69297a | +| 2 | 补全过滤排序纯逻辑 | `repl/suggestions/SlashSuggester.cs` + `CommandSuggestion.cs` + 20 测试 | ✅ 3f45ff8 | +| 3 | 建议列表渲染 | `repl/suggestions/SuggestionBox.cs` + 12 测试 | ✅ 4721996 | +| 4+5 | ReplView/ReplShell 集成 | 键位仲裁 + 建议区渲染 + 历史导航 + 提交入史 + 47 集成测试 | ✅ 6264f58 | +| 6 | Host 组装 | `tui_assembly/SlashCommandSource.cs` + `HistorySeeder.cs` + 9 测试 | ✅ ea5bdf2 | +| 6b | 渲染时序修复 | SyncSuggestionState 先于 EnsureScreens | ✅ e86a001 | +| 7 | 手动 exe 验收 | ADR 0080 方式(需真实终端交互) | ⬜ 待用户执行 | -## 关键规格(TS 对齐) +## 四列验收表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| `HistoryRing`(容量100+草稿+不回绕)`repl/history/HistoryRing.cs` | `ReplShell.NavigateHistory/Submit/SeedHistory` | ✅ | ✅ 单元 17 + 集成 10(HandleKey 全链路注入真实 ConsoleKeyInfo) | +| `SlashSuggester`(二级排序+Esc抑制+循环导航)`repl/suggestions/SlashSuggester.cs` | `ReplShell.HandleSuggestionKey/AcceptSuggestion/UpdateSuggestions` | ✅ | ✅ 单元 20 + 集成 10 | +| `SuggestionBox`(6条两列+选中居中滚动)`repl/suggestions/SuggestionBox.cs` | `ReplView.Render` 建议区(输入区与 footer 之间) | ✅ | ✅ 单元 12 + ReplView 渲染 4 | +| 键位仲裁(Autocomplete 上下文优先于 Chat)`ReplShell.HandleKeyPress/HandleSuggestionKey` | inline 模式 Up/Down=历史、Tab/Enter/Esc=补全 | ✅ | ✅ 集成测试(建议激活时 Up 归补全不翻历史等) | +| 命令清单注入 `Host/tui_assembly/SlashCommandSource.cs`(DI 解析 Catalog 兜底直接实例化) | `Program.RunInteractiveAsync` 构造 ReplShell | ✅ | ✅ 单元 2 + selftest 引擎组装真实链路 | +| 历史种子 `Host/tui_assembly/HistorySeeder.cs`(同项目最近5会话聚合+排除当前会话) | `Program.RunInteractiveAsync` SeedAsync | ✅ | ✅ 单元 6(项目过滤/时序/容量/降级) | +| 视觉/手感验收(建议浮层像素 + 历史翻阅手感) | 真实终端 | ✅ 代码 | ⬜ 用户手动:`dotnet run --project native_tui/apps/NativeTui.Host` | + +## 回归基线 + +- NativeTui.Tests:558 → **634**(+76,全绿,含像素回归 snapshot) +- NativeTui.Host.Tests:35 → **43**(+8,全绿) +- selftest(真实引擎组装+握手+权限回执):✅(`OPENAI_API_KEY=dummy-key` 过本机配置校验后通过——本机 settings.json 的 openai vendor 缺 key,与本次改动无关,stash 对照已验证) + +## 关键规格(TS 对齐落地情况) ### 斜杠补全 -- 触发:text 以 `/` 开头、光标>0、无空格、非 Esc 抑制态 -- 排序链:精确名 > 精确切名 > 前缀名(短优先) > 前缀切名 > 子序列名 > 子序列切名;空查询全量字母序;隐藏命令输入全名时前插 -- 渲染:≤6 条两列(命令列宽=最长名+6 预计算;描述列截断),选中高亮未选中 dim,滚动窗口以选中项为中心 -- 键位:Tab 接受不执行(补全 `/cmd ` 光标末尾);Enter 接受并提交;Esc 只关建议(优先于打断/清空);Up/Down 循环导航 +- 触发:text 以 `/` 开头、无空格、非 Esc 抑制态 ✅ +- 排序链:精确名 > 精确切名 > 前缀名(短优先) > 前缀切名 > 子序列名 > 子序列切名 ✅(Fuse 简化为子序列) +- 渲染:≤6 条两列(命令列宽=全量最长名+6 预计算防跳动),选中 Suggestion 色/未选中 dim,滚动窗口以选中项为中心 ✅ +- 键位:Tab 接受不执行(`/cmd ` 光标末尾);Enter 接受并提交(不带尾随空格);Esc 仅关建议(优先于打断/清输入/退出);Up/Down 循环导航 ✅ ### 历史环 -- 容量 100(MAX_HISTORY_ITEMS);提交时追加(末条相同去重);导航 index 0=当前输入 -- Up:暂存草稿(首次离开 0)→ 翻旧;到底保持不动 -- Down:翻新;回到 0 恢复草稿(无草稿清空) -- 不回绕(对齐 GUI NavigateHistory) -- 翻历史时抑制建议刷新(程序化填充不触发补全) -- 种子:启动时从最近会话 transcript 的 user 条目加载(宿主注入) +- 容量 100、末条去重、index 0=输入框态、Up 暂存草稿(光标行首)、Down 恢复草稿(光标末尾)、不回绕 ✅ +- 翻历史时抑制建议刷新(程序化填充跳过)✅ +- 种子:最近同项目 5 会话 user 输入聚合(排除当前会话防双份,旧→新时序,末尾 100 截断)✅ -## 架构决策 +### 已知简化(后续边缘场景) +- 参数补全(/model xxx)未做(TS Argument 模式)— GUI 侧有 `SlashCommandParser` ArgumentCompletableCommands 先例 +- 幽灵文本(ghost text / mid-input slash)未做 +- 历史导航时光标行首(TS cursorToStart)已实现;ctrl+r 历史搜索未做 -- NativeTui.Core 保持零依赖(ADR 0140):命令清单与历史种子均以纯数据 `IReadOnlyList<>` 注入,宿主(NativeTui.Host)经 `engine.Services` 拉取转换 -- Up/Down 仲裁:建议激活 → 补全导航;否则 inline 模式 → 历史环;viewport 旧模式保持滚动 -- Esc 仲裁:建议激活 → 关建议(优先于忙打断/清输入/退出) + + + + + + + + + + + - - - - + + + + From 9049483b97cdc3379b4dfe1a4ab51b178c4c9f74 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:27:49 +0800 Subject: [PATCH 16/43] =?UTF-8?q?feat:=20Demo=E6=8E=A5=E5=85=A5TextMate?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE=E5=99=A8(M7-5=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=9D=A2)=20|=20=E5=86=B3=E7=AD=96:=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8Ebridge=E4=B8=A4=E5=A4=84ReplShell=E6=9E=84?= =?UTF-8?q?=E9=80=A0=E5=9D=87=E6=B3=A8=E5=85=A5TextMateCodeHighlighter(dar?= =?UTF-8?q?k=E5=9B=BA=E5=AE=9Atrue=E5=AF=B9=E9=BD=90Demo=E6=9A=97=E8=89=B2?= =?UTF-8?q?=E4=B8=BB=E9=A2=98),md=E5=91=BD=E4=BB=A4csharp=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E5=90=AF=E5=8A=A8=E5=8D=B3=E8=A7=81Monokai?= =?UTF-8?q?=E9=85=8D=E8=89=B2;=E9=AA=8C=E8=AF=81selftest=E7=BB=BF+Onigwrap?= =?UTF-8?q?=E5=8E=9F=E7=94=9FDLL=E9=BD=90=E5=A4=87+capture=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E5=B8=A7911=E5=AD=97=E8=8A=82=E4=B8=8D=E5=8F=98(?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E5=9B=9E=E5=BD=92=E5=8F=8C=E4=BE=9D=E6=8D=AE?= =?UTF-8?q?=E8=B7=B3=E8=BF=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Demo/GlobalUsings.cs | 1 + native_tui/apps/NativeTui.Demo/NativeTui.Demo.csproj | 1 + native_tui/apps/NativeTui.Demo/Program.cs | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/native_tui/apps/NativeTui.Demo/GlobalUsings.cs b/native_tui/apps/NativeTui.Demo/GlobalUsings.cs index 5ad2d9dd43..c57b09cc33 100644 --- a/native_tui/apps/NativeTui.Demo/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Demo/GlobalUsings.cs @@ -11,3 +11,4 @@ global using NativeTui.Core.Markdown; global using NativeTui.Core.Repl; global using NativeTui.Core.Terminal; +global using NativeTui.Highlighting; diff --git a/native_tui/apps/NativeTui.Demo/NativeTui.Demo.csproj b/native_tui/apps/NativeTui.Demo/NativeTui.Demo.csproj index 2650fd7cbb..3b7425209a 100644 --- a/native_tui/apps/NativeTui.Demo/NativeTui.Demo.csproj +++ b/native_tui/apps/NativeTui.Demo/NativeTui.Demo.csproj @@ -11,6 +11,7 @@ + diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index d565af5c31..2600496753 100644 --- a/native_tui/apps/NativeTui.Demo/Program.cs +++ b/native_tui/apps/NativeTui.Demo/Program.cs @@ -67,7 +67,7 @@ private static int RunInteractive(string? model = null, bool viewport = false) var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0"; var info = SessionInfo.Create(version, model); var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory); - var shell = new ReplShell(new SimulatedEngine(), banner, Console.Out, inline: !viewport, seedMessages: SeedMessages()); + var shell = new ReplShell(new SimulatedEngine(), banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), codeHighlighter: new TextMateCodeHighlighter(dark: true)); shell.Run(); shell.WriteExit(); Console.WriteLine("NativeTui.Demo 已退出。"); @@ -127,7 +127,7 @@ private static int RunBridgeInteractive(string exePath, string? model = null, bo var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0"; var info = SessionInfo.Create(version, model); var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory); - var shell = new ReplShell(engine, banner, Console.Out, inline: !viewport); + var shell = new ReplShell(engine, banner, Console.Out, inline: !viewport, codeHighlighter: new TextMateCodeHighlighter(dark: true)); shell.Run(); shell.WriteExit(); } From 88ed60d079be00afcfb0310feb799eaa06747e0c Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:28:33 +0800 Subject: [PATCH 17/43] =?UTF-8?q?docs:=20TASK055=20M7-5=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E5=86=B3=E7=AD=96=E5=9D=97(4commit=E9=93=BE+=E5=9B=9B?= =?UTF-8?q?=E5=88=97=E9=AA=8C=E6=94=B6=E8=A1=A8+TextMateSharp=20API?= =?UTF-8?q?=E5=AE=9E=E8=AF=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 46ae82ab6d..9b4ca976ae 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -803,3 +803,34 @@ native_tui/ # 顶层独立工程,零 modules/libs 依 + +## M7-5 代码高亮落地(2026-10-21,A 方案 TextMateSharp 全链贯通) + +### 实施记录(5 commit 链) + +| 阶段 | commit | 内容 | +|------|--------|------| +| AOT 探针 | 7e706d905 | 卫星项目 NativeTui.Highlighting + AotProbe:PublishAot 编译零错 + libonigwrap.dll 正确携带 + AOT 产物输出与 Debug 一致(三关全过) | +| 卫星实现 | 22d8fa618 | TextMateCodeHighlighter:整块高亮 API(独立会话 ruleStack 跨行状态)+ 语言三级检测(手动别名表 14 项→id 精确→Grammar Aliases 遍历)+ Monokai(dark)/LightPlus(light) + 负结果缓存;16/16 测试绿 | +| Core 接线 | c6df3d26a | ICodeHighlighter 接口 + HighlightSpan DTO(Core 零依赖)→ LayoutWithHighlights 双入口 → MessageRenderer.ApplyCodeHighlight 覆写 cell 前景 → ReplBridgeDriver/ReplShell 可选注入(null=纯文本零行为变化);全量 642/642 绿 | +| Demo 接入 | 18eee0b13 | 交互/bridge 两处 ReplShell 构造注入 TextMateCodeHighlighter(dark),md 命令 csharp 代码块启动即见 Monokai 配色 | + +### TextMateSharp 2.0.4 API 与 README 差异(实现期实证) + +- `GetScopeByLanguageId`(非 README 的 GetScopeNameFromLanguageId);`TextMateSharp.Registry.Registry`(非 Internal.Registry) +- `ThemeTrieElementRule.foreground` 是 **int colorId**(-1=无色),经 `theme.GetColor(id)` 得 "#RRGGBB";`Language` 无 Name 字段(仅 Id/Aliases/Extensions) + +### 四列验收表(AGENTS.md 强制) + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| NativeTui.Highlighting 卫星项目(TextMateCodeHighlighter + Monokai/LightPlus 主题) | Demo Program.cs 两处 ReplShell 注入(交互 :70 / bridge :130) | ✅ | ✅ ADR 0080:--selftest 绿 + Onigwrap 原生 DLL 齐备(libonigwrap.dll/Onigwrap.dll/TextMateSharp*.dll)+ capture 基准帧 911 字节不变;真实终端 Monokai 视觉验收待用户跑 md 命令确认 | +| Core ICodeHighlighter + HighlightSpan DTO(CodeHighlighting.cs) | ReplBridgeDriver → MarkdownMessageItem(:98) → MarkdownLayout.LayoutWithHighlights → MessageRenderer.ApplyCodeHighlight | ✅ | ✅ MarkdownHighlightTests 8/8(stub 高亮器纯 Core 断言)+ 全量 642/642 + Highlighting.Tests 16/16 | +| AOT 三关(PublishAot 编译/原生 DLL 携带/AOT 产物等价) | AotProbe 卫星项目(保留为回归工具) | ✅ | ✅ AotProbe 实测三关全过(7e706d905) | + +### 像素回归跳过依据(双依据成立) + +ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,grep 零命中)且本次改动未触碰 → capture 911 字节与历史基准一致 → 高亮属新渲染路径不影响既有基准 region。 + + + From 29419656c202ab7da97fc56e6d8e371f773bd41e Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:38:28 +0800 Subject: [PATCH 18/43] =?UTF-8?q?feat:=20Demo=E6=8E=A5=E7=BA=BF=E6=96=9C?= =?UTF-8?q?=E6=9D=A0=E8=A1=A5=E5=85=A8(=E6=BC=94=E7=A4=BA=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=B8=85=E5=8D=95=E6=B3=A8=E5=85=A5)+SimulatedEngine?= =?UTF-8?q?=E5=85=BC=E5=AE=B9/=E5=89=8D=E7=BC=80=E8=B7=AF=E7=94=B1=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20ParseCommand=20trim=E5=90=8E=E5=89=A5/?= =?UTF-8?q?=E5=89=8D=E7=BC=80,/md=E7=AD=89=E4=BB=B7md,=E6=9C=AA=E7=9F=A5?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=AF=B1=E5=AF=BC=E6=8F=90=E7=A4=BA=E5=8F=AF?= =?UTF-8?q?=E8=BE=93=E5=85=A5/=E5=BC=B9=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Demo/Program.cs | 14 +++++++-- .../NativeTui.Core/bridge/SimulatedEngine.cs | 29 ++++++++++++------- .../bridge/SimulatedEngineTests.cs | 20 +++++++++++++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index 2600496753..48f8062477 100644 --- a/native_tui/apps/NativeTui.Demo/Program.cs +++ b/native_tui/apps/NativeTui.Demo/Program.cs @@ -53,7 +53,8 @@ private static int RunCapture(string capturePath) return 0; } - /// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 演示命令路由 md/diff/perm/all;inline 模式退出只清尾部动态区,历史留终端 scrollback)。 + /// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 演示命令路由 md/diff/perm/all;inline 模式退出只清尾部动态区,历史留终端 scrollback)。 + /// TASK056:注入演示命令清单启用斜杠补全(输入 / 弹建议列表)+ 历史环默认启用(↑↓ 翻阅提交记录)。 private static int RunInteractive(string? model = null, bool viewport = false) { if (Console.IsInputRedirected || Console.IsOutputRedirected) @@ -67,13 +68,22 @@ private static int RunInteractive(string? model = null, bool viewport = false) var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0"; var info = SessionInfo.Create(version, model); var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory); - var shell = new ReplShell(new SimulatedEngine(), banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), codeHighlighter: new TextMateCodeHighlighter(dark: true)); + var shell = new ReplShell(new SimulatedEngine(), banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), commands: DemoCommands(), codeHighlighter: new TextMateCodeHighlighter(dark: true)); shell.Run(); shell.WriteExit(); Console.WriteLine("NativeTui.Demo 已退出。"); return 0; } + /// Demo 演示命令清单(斜杠补全数据源)— 与 SimulatedEngine.ParseCommand 路由同源。 + private static IReadOnlyList DemoCommands() => + [ + new CommandSuggestion("md", "Markdown 全形态渲染", [], IsHidden: false), + new CommandSuggestion("diff", "结构化补丁", [], IsHidden: false), + new CommandSuggestion("perm", "权限对话框", [], IsHidden: false), + new CommandSuggestion("all", "完整回合(补丁 + Markdown)", [], IsHidden: false), + ]; + /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染;末条为演示命令提示)。 private static IReadOnlyList SeedMessages() => [ diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index 75f5b5ce25..a1432c4110 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -166,19 +166,28 @@ public void Submit(string text) _pending.Enqueue(new BridgeEvent.TurnStarted()); } - /// 演示命令路由(trim 后精确匹配)— null = 未知命令。 - private static TurnScript? ParseCommand(string text) => text.Trim() switch + /// 演示命令路由(trim 后匹配,兼容斜杠前缀 /md 形态 — TUI 补全接受后提交带 /)— null = 未知命令。 + private static TurnScript? ParseCommand(string text) { - "md" => TurnScript.Markdown, - "diff" => TurnScript.Diff, - "perm" => TurnScript.Permission, - "all" => TurnScript.Full, - _ => null, - }; + var command = text.Trim(); + if (command.StartsWith('/')) + { + command = command[1..]; + } + + return command switch + { + "md" => TurnScript.Markdown, + "diff" => TurnScript.Diff, + "perm" => TurnScript.Permission, + "all" => TurnScript.Full, + _ => null, + }; + } - /// 未知命令 Error 诱导 — 列出全部演示命令与各自用途(含怎么做)。 + /// 未知命令 Error 诱导 — 列出全部演示命令与各自用途(含怎么做,提示可输入 / 弹补全列表)。 private static string UnknownCommandMessage(string input) => - $"未知演示命令「{input}」。可用命令:md(Markdown 全形态渲染)、diff(结构化补丁)、perm(权限对话框)、all(完整回合 = 补丁 + Markdown)。请输入上述命令之一后回车。"; + $"未知演示命令「{input}」。可用命令:md(Markdown 全形态渲染)、diff(结构化补丁)、perm(权限对话框)、all(完整回合 = 补丁 + Markdown)。可直接输入命令或输入 / 弹出补全列表选择。"; /// 打断当前回合(闲时 no-op)。 public void Interrupt() diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs index 05b97f8a06..5d3ac76575 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs @@ -202,6 +202,26 @@ public void Submit_UnknownCommand_RejectsWithGuidance(string input) Assert.DoesNotContain(events, e => e is BridgeEvent.TurnStarted); } + /// 斜杠前缀命令(TUI 补全接受形态):/md 等价 md 路由 — TurnStarted 而非 Error。 + [Theory] + [InlineData("/md")] + [InlineData("/all")] + public void Submit_SlashPrefixedCommand_RoutesLikeBareCommand(string input) + { + var engine = new SimulatedEngine(static () => 0L); + engine.Submit(input); + Assert.Equal([new BridgeEvent.TurnStarted()], engine.DrainEvents()); + } + + /// 斜杠前缀带尾随空格(Tab 接受形态 "/cmd "):trim 后等价路由。 + [Fact] + public void Submit_SlashCommandWithTrailingSpace_RoutesAfterTrim() + { + var engine = new SimulatedEngine(static () => 0L); + engine.Submit("/md "); + Assert.Equal([new BridgeEvent.TurnStarted()], engine.DrainEvents()); + } + /// 忙时 Interrupt:产出 TurnInterrupted,回合终止。 [Fact] public void Interrupt_WhileBusy_Emits_TurnInterrupted() From 8ae2b5911edc9e90f2fafe72c3dcb89eaa1014ff Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:38:57 +0800 Subject: [PATCH 19/43] =?UTF-8?q?docs:=20TASK056=E8=A1=A5=E5=85=85Demo?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=85=A5=E5=8F=A3=E6=8E=A5=E7=BA=BF=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" index 3a15bb02bd..f43c4a8413 100644 --- "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" +++ "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" @@ -14,7 +14,8 @@ | 4+5 | ReplView/ReplShell 集成 | 键位仲裁 + 建议区渲染 + 历史导航 + 提交入史 + 47 集成测试 | ✅ 6264f58 | | 6 | Host 组装 | `tui_assembly/SlashCommandSource.cs` + `HistorySeeder.cs` + 9 测试 | ✅ ea5bdf2 | | 6b | 渲染时序修复 | SyncSuggestionState 先于 EnsureScreens | ✅ e86a001 | -| 7 | 手动 exe 验收 | ADR 0080 方式(需真实终端交互) | ⬜ 待用户执行 | +| 6c | Demo 验收入口接线 | DemoCommands 注入 + SimulatedEngine 兼容 / 前缀路由 | ✅ c7bf4e7 | +| 7 | 手动 exe 验收 | ADR 0080 方式(需真实终端交互) | ⬜ 待用户执行(入口 NativeTui.Demo.exe) | ## 四列验收表 From c63b6bc395019235ab93aa24d747da01454f35cc Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:39:53 +0800 Subject: [PATCH 20/43] =?UTF-8?q?docs:=20TASK055=20M7-6=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E8=B0=83=E7=A0=94=E5=9D=97(?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E7=9C=9F=E7=9B=B8+=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E4=BE=A7=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9+=E6=96=B9?= =?UTF-8?q?=E6=A1=88ABC=E5=AF=B9=E6=AF=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 9b4ca976ae..2bfde9da4d 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -834,3 +834,41 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g +## M7-6 模型列表调研(2026-10-21,基准源码 + 本项目引擎侧现状盘点) + +### 基准 /model 链路真相(claude-code-rev-main 全读结论) + +| 项 | 基准实现 | 文件:行号 | +|----|---------|----------| +| 命令入口 | local-jsx 懒加载命令 + alt+p 热键内联打开 | commands/model/index.ts:1-16, PromptInput.tsx:2061 | +| 数据来源 | **本地硬编码主体**(5 套订阅层级列表)+ **bootstrap 后端增量**(GET /api/claude_cli/bootstrap → additional_model_options[{model,name,description}] → 磁盘缓存 isEqual 跳写 → 下次启动生效)+ 当前在用模型兜底追加 + 组织 allowlist 过滤 | modelOptions.ts:271-540, bootstrap.ts:63-137 | +| Picker 形态 | 单列平铺 Select **不分组**(排序隐含家族:Default→Opus→Sonnet→Haiku),min(10,n) 可见超出 "and N more…",✓ 选中标记 + ❯ 焦点指示,Default (recommended) 永远首项(value=null 哨兵) | ModelPicker.tsx:135-136/263-310 | +| effort 行 | 焦点行联动 ←→ 循环 low/medium/high/max,不支持时显示提示 | ModelPicker.tsx:327-336 | +| 切换链路 | setAppState(mainLoopModel) → onChangeAppState diff 自动持久化 userSettings → getRuntimeMainLoopModel(session→flag→env→settings→内置默认五级)→ 请求体 model | model.tsx:47-57, onChangeAppState.ts:94-112 | +| 附带逻辑 | fast mode 自动关(不支持时)+ effort max 降级 + 切换反馈 "Set model to X" + /model ? 显示当前 | model.tsx:63-80, ModelPicker.tsx | + +**对齐要点**:基准本质"本地清单优先、后端只做增量补充",非实时 listModels;本项目语境的"后端获取"= **从引擎(进程内 IModelCatalog)取列表**,非 TUI 本地配置直读(青蛙TUI 的做法已被否决)。 + +### 本项目引擎侧现状(可直接复用的资产) + +| 资产 | 位置 | 状态 | +|------|------|------| +| IModelCatalog(列表/别名/能力/窗口查询 12 方法) | modules/composition/slash/ai/model/IModelCatalog.cs | ✅ 接口齐备 | +| ModelCatalog 实现([Register] Singleton,注入 IProviderDefinitionRegistry + IModelConfigLoader) | modules/composition/slash/ai/model/ModelCatalog.cs:7-8 | ✅ 待验证 GUI 会话链注册 | +| /model 命令全逻辑(切换+持久化+fast mode 自关+effort 降级+20 条历史) | modules/composition/slash/ai/model/ModelCommand.cs | ✅ 但交互用 TerminalHelper | +| SlashCommandRunner(TUI/GUI 共享执行链路,接 IServiceProvider) | modules/apps/engine/commands/slash/SlashCommandRunner.cs | ✅ Host 可直接调 | +| Host 引擎 DI 暴露 | native_tui/apps/NativeTui.Host/EngineSession.cs:28(Services => _engine.Services) | ✅ 已就绪 | +| ReplShell 模态对话框先例 | M7-3 PermissionCoordinator + BeginPermissionRequest 回调注入(ReplShell.cs:35/89/177) | ✅ 模式可照抄 | + +### 核心冲突与方案对比 + +**核心冲突**:ModelCommand 的 ModelPicker 交互走 TerminalHelper.ReadKey/WriteRaw 直写终端——与 native_tui 的 raw mode + cell 网格 + 差分上屏渲染管线**根本冲突**(绕过渲染管线即破坏像素级对齐,同青蛙TUI 不可搬同理)。 + +| 方案 | 内容 | 优劣 | +|------|------|------| +| **A: TUI 原生 Picker(推荐)** | Core 加 ModelListItem DTO + ModelPickerCoordinator(照抄 M7-3 权限对话框模式:ReplShell 键盘模态 + driver 回调);Host 侧 Submit 拦截 /model → Services.GetService<IModelCatalog>() 取列表 → BeginModelPicker;选中后委托 SlashCommandRunner 执行 `/model <id>`(复用全部切换逻辑) | UI 像素级对齐基准 Picker(✓/❯/Default 首项/10 条/effort 行);切换逻辑零重写;Core 零依赖不受破坏 | +| B: 委托 ModelCommand 现成 Picker | Host 集成 SlashCommandRunner 直跑 /model | 零新 UI 代码;但 TerminalHelper 直写破坏渲染管线,非交互回退是文本列表——像素级对齐不可能 | +| C: 通用斜杠路由 + 交互命令特判 | 全部 / 命令走 SlashCommandRunner,仅 /model 类交互命令走 A 的原生 Picker | 覆盖面最大但超出 M7-6 范畴(通用斜杠执行属独立任务) | + + + From 0178239e08ec1cac7df9e8ba4720dc8b18225d74 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:40:47 +0800 Subject: [PATCH 21/43] =?UTF-8?q?docs:=20TASK056=E5=AE=8C=E7=BB=93(?= =?UTF-8?q?=E7=94=A8=E6=88=B7Demo+Host=E5=8F=8C=E5=85=A5=E5=8F=A3=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\250\344\270\216\345\216\206\345\217\262\347\216\257.md" | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" index f43c4a8413..d4cb0d41e5 100644 --- "a/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" +++ "b/docs/proposed/task/TASK056-TUI\350\276\223\345\205\245\346\240\217\346\226\234\346\235\240\350\241\245\345\205\250\344\270\216\345\216\206\345\217\262\347\216\257.md" @@ -1,6 +1,6 @@ # TASK056 — TUI 输入栏:斜杠命令补全 + 历史环 -> 状态:implemented(待手动 exe 验收) +> 状态:completed(2026-10-21 用户手动 exe 验收通过 — Demo + Host 双入口"非常完美") > 参考基准:`D:\project\claude-code-rust\claude-code-rev-main\src\`(useTypeahead.tsx / commandSuggestions.ts / useArrowKeyHistory.tsx / PromptInputFooterSuggestions.tsx) > 历史数据源:复用现有 `~/.jcc/sessions/{id}/transcript.json`(`ITranscriptService`,`TranscriptPersistMiddleware` 已自动落盘,无需新存储) @@ -15,7 +15,7 @@ | 6 | Host 组装 | `tui_assembly/SlashCommandSource.cs` + `HistorySeeder.cs` + 9 测试 | ✅ ea5bdf2 | | 6b | 渲染时序修复 | SyncSuggestionState 先于 EnsureScreens | ✅ e86a001 | | 6c | Demo 验收入口接线 | DemoCommands 注入 + SimulatedEngine 兼容 / 前缀路由 | ✅ c7bf4e7 | -| 7 | 手动 exe 验收 | ADR 0080 方式(需真实终端交互) | ⬜ 待用户执行(入口 NativeTui.Demo.exe) | +| 7 | 手动 exe 验收 | ADR 0080 方式(需真实终端交互) | ✅ 2026-10-21 用户跑 Demo + Host 双入口验收通过 | ## 四列验收表 @@ -27,7 +27,7 @@ | 键位仲裁(Autocomplete 上下文优先于 Chat)`ReplShell.HandleKeyPress/HandleSuggestionKey` | inline 模式 Up/Down=历史、Tab/Enter/Esc=补全 | ✅ | ✅ 集成测试(建议激活时 Up 归补全不翻历史等) | | 命令清单注入 `Host/tui_assembly/SlashCommandSource.cs`(DI 解析 Catalog 兜底直接实例化) | `Program.RunInteractiveAsync` 构造 ReplShell | ✅ | ✅ 单元 2 + selftest 引擎组装真实链路 | | 历史种子 `Host/tui_assembly/HistorySeeder.cs`(同项目最近5会话聚合+排除当前会话) | `Program.RunInteractiveAsync` SeedAsync | ✅ | ✅ 单元 6(项目过滤/时序/容量/降级) | -| 视觉/手感验收(建议浮层像素 + 历史翻阅手感) | 真实终端 | ✅ 代码 | ⬜ 用户手动:`dotnet run --project native_tui/apps/NativeTui.Host` | +| 视觉/手感验收(建议浮层像素 + 历史翻阅手感) | 真实终端 | ✅ 代码 | ✅ 用户 2026-10-21 跑 Demo.exe 与 Host.exe 验收:"非常完美" | ## 回归基线 From 05fabd6d08dc37d4cf1d077adbf258bd9809277c Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 01:55:33 +0800 Subject: [PATCH 22/43] =?UTF-8?q?feat:=20M7-6=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8Core=E5=B1=82(=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E8=87=AA=E7=A0=94)=20|=20=E5=86=B3=E7=AD=96:=20ModelPickerCoor?= =?UTF-8?q?dinator=E7=8A=B6=E6=80=81=E6=9C=BA(Begin=E7=84=A6=E7=82=B9?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=BD=93=E5=89=8D=E6=A8=A1=E5=9E=8B+clamp?= =?UTF-8?q?=E4=B8=8D=E7=8E=AF=E7=BB=95+=E6=BB=9A=E5=8A=A8=E7=AA=97?= =?UTF-8?q?=E5=8F=A3min(10,n)=E8=B7=9F=E9=9A=8F,=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=9F=BA=E5=87=86Select=E4=BA=A4=E4=BA=92),=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86ModelPicker(permission?= =?UTF-8?q?=E8=BE=B9=E6=A1=86+Select=20model=E6=A0=87=E9=A2=98+=E2=9D=AF?= =?UTF-8?q?=E7=84=A6=E7=82=B9suggestion+=E2=9C=93=E5=BD=93=E5=89=8Dsuccess?= =?UTF-8?q?+and=20N=20more=E6=8A=98=E5=8F=A0+Enter=20confirm=C2=B7Esc=20ex?= =?UTF-8?q?it=E5=BF=AB=E6=8D=B7=E9=94=AE=E8=A1=8C);ModelPickerEngine?= =?UTF-8?q?=E8=A3=85=E9=A5=B0=E5=99=A8=E6=8B=A6=E6=88=AA/model=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=80=89=E6=8B=A9=E5=99=A8(Attach=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=81=BF=E5=85=8D=E6=9E=84=E9=80=A0=E7=8E=AF?= =?UTF-8?q?),=E9=80=89=E4=B8=AD=E5=9B=9E=E8=B0=83=E8=87=AA=E6=8C=81?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E9=97=AD=E5=8C=85(coordinator=E5=B7=B2?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E6=97=B6=E5=8F=96displayName);=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E6=9D=83=E9=99=90>=E6=A8=A1=E5=9E=8B(?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E8=AF=B7=E6=B1=82=E4=BC=98=E5=85=88);PostInf?= =?UTF-8?q?o=E9=80=9A=E7=94=A8=E5=8F=8D=E9=A6=88=E9=9D=A2;=E5=85=A8?= =?UTF-8?q?=E9=87=8F672/672=E7=BB=BF=E5=90=AB27=E6=96=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/MessageItems.cs | 3 + .../NativeTui.Core/repl/MessageRenderer.cs | 68 +++ .../src/NativeTui.Core/repl/ModelPicker.cs | 99 ++++ .../NativeTui.Core/repl/ModelPickerDialog.cs | 84 +++ .../NativeTui.Core/repl/ModelPickerEngine.cs | 62 +++ .../src/NativeTui.Core/repl/ReplShell.cs | 82 ++- .../NativeTui.Tests/repl/ModelPickerTests.cs | 492 ++++++++++++++++++ 7 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/repl/ModelPicker.cs create mode 100644 native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs create mode 100644 native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs index 4f51183b5f..285426f1f8 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs @@ -19,6 +19,9 @@ public void Render(Screen screen, Rect frame, Theme theme) => /// 助手文本消息项(● text 色 + 正文)。 public sealed class AssistantMessageItem(string text) : IScrollItem { + /// 正文(宿主反馈消息断言面 — M7-6 模型切换反馈等)。 + public string Text => text; + /// 内容高度 = margin 1 + 正文 1。 public int MeasureHeight(int width) => 2; diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index 4a34dbcee0..9bd38b433d 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -166,6 +166,74 @@ public static void RenderPermissionEscHint(Screen screen, Rect frame, Theme them screen.WriteText(frame.Y, frame.X, " Esc to cancel", dim, frame.Right); } + /// 渲染模型选择器标题行 — 1 空格缩进 + bold permission 色(M7-6,基准 "Select model" remember bold)。 + public static void RenderModelTitle(Screen screen, Rect frame, Theme theme, string title) + { + var style = screen.Styles.GetOrAdd(new TextStyle { Bold = true, Foreground = theme.Permission }); + screen.WriteText(frame.Y, frame.X, $" {title}", style, frame.Right); + } + + /// 渲染模型选择器副标题行 — 1 空格缩进整行 dim(基准 "Switch between Claude models...")。 + public static void RenderModelSubtitle(Screen screen, Rect frame, Theme theme, string subtitle) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $" {subtitle}", dim, frame.Right); + } + + /// + /// 渲染模型列表行(M7-6,基准 CustomSelect + ListItem): + /// 焦点 = ❯ suggestion + "N. " dim + 名称 suggestion + 当前项尾 ✓ success; + /// 非焦点 = 空格指示 + "N. " dim + 名称默认样式(当前项尾 ✓ success 恒显)。 + /// + public static void RenderModelListRow(Screen screen, Rect frame, Theme theme, int index, string displayName, bool isCurrent, bool focused) + { + if (focused) + { + var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Suggestion }); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen[frame.Y, frame.X] = new Cell(new Rune('❯'), suggestion, CellWidth.Default); + screen.WriteText(frame.Y, frame.X + 1, $" {index + 1}. ", dim, frame.Right); + var col = screen.WriteText(frame.Y, frame.X + 5, displayName, suggestion, frame.Right); + if (isCurrent) + { + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Success }); + screen.WriteText(frame.Y, col, " ✓", success, frame.Right); + } + + return; + } + + var plain = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $" {index + 1}. ", plain, frame.Right); + var nameCol = screen.WriteText(frame.Y, frame.X + 5, displayName, 0, frame.Right); + if (isCurrent) + { + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Success }); + screen.WriteText(frame.Y, nameCol, " ✓", success, frame.Right); + } + } + + /// 渲染模型描述行 — 5 空格缩进(对齐名称行列起点)整行 dim(基准 description inactive 色)。 + public static void RenderModelDescription(Screen screen, Rect frame, Theme theme, string description) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $" {description}", dim, frame.Right); + } + + /// 渲染模型溢出行 — 1 空格缩进 dim "and N more…"(基准 hiddenCount 折叠提示)。 + public static void RenderModelOverflow(Screen screen, Rect frame, Theme theme, int hiddenCount) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $" and {hiddenCount} more…", dim, frame.Right); + } + + /// 渲染模型选择器快捷键行 — 1 空格缩进 dim(基准 Byline "Enter confirm · Esc exit")。 + public static void RenderModelShortcuts(Screen screen, Rect frame, Theme theme, string shortcuts) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(frame.Y, frame.X, $" {shortcuts}", dim, frame.Right); + } + /// /// 渲染 Markdown 物理行(M7-4)— 从指定列起逐段写,段样式经 MapMarkdownStyle 映射 ///(Code → permission 色前景,对齐基准 codespan color('permission');修饰开关直转 TextStyle)。 diff --git a/native_tui/src/NativeTui.Core/repl/ModelPicker.cs b/native_tui/src/NativeTui.Core/repl/ModelPicker.cs new file mode 100644 index 0000000000..bf77977c67 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/ModelPicker.cs @@ -0,0 +1,99 @@ +namespace NativeTui.Core.Repl; + +/// 模型选择项 DTO(M7-6)— 宿主从引擎 IModelCatalog 投影(后端获取),Core 零依赖。 +/// 模型标识(切换时回传宿主执行)。 +/// 显示名称(列表行)。 +/// 能力描述(名称行下方缩进行;空则折叠)。 +/// 是否当前在用模型(列表行尾 ✓ 标记 + Begin 焦点定位)。 +public sealed record ModelPickerItem(string Id, string DisplayName, string Description, bool IsCurrent); + +/// +/// 模型选择器协调器(M7-6,全部自研)— 对齐基准 ModelPicker 交互语义: +/// Begin 焦点定位当前模型(无则 0)、上下 clamp 不环绕(列表语义,区别于权限三档环绕)、 +/// 可见窗口 min(10, n) + 焦点跟随滚动(对齐基准 visibleOptionCount=Math.min(10, n))。 +/// 状态机同权限对话框(Begin 建立 / Complete 清空幂等);结果经回调传递(无引擎阻塞等待方, +/// 不需要 TCS 桥)。消费方:ReplShell 模态改道(↑↓/Enter/Esc)+ ModelPickerDialog 渲染投影。 +/// +public sealed class ModelPickerCoordinator +{ + /// 最大可见条数(基准 visibleOptionCount=Math.min(10, n))。 + public const int MaxVisible = 10; + + private IReadOnlyList _items = []; + + /// 当前清单(挂起期间有效)。 + public IReadOnlyList Items => _items; + + /// 焦点索引(clamp 于 [0, count-1])。 + public int FocusIndex { get; private set; } + + /// 是否挂起中(模态路由依据)。 + public bool IsPending => _items.Count > 0; + + /// 焦点项(未挂起为 null)。 + public ModelPickerItem? Focused => IsPending && FocusIndex < _items.Count ? _items[FocusIndex] : null; + + /// 可见条数 = min(10, n)。 + public int VisibleCount => Math.Min(MaxVisible, _items.Count); + + /// 折叠条数 = max(0, n - 10)("and N more…" 行依据)。 + public int HiddenCount => Math.Max(0, _items.Count - VisibleCount); + + /// 滚动窗口起点(0 起,保证焦点可见的最小窗口位移)。 + public int WindowStart { get; private set; } + + /// Begin 挂起 — 清单就位 + 焦点定位当前模型(无当前则 0)+ 窗口归零。 + public void Begin(IReadOnlyList items) + { + ArgumentNullException.ThrowIfNull(items); + if (items.Count == 0) + { + throw new ArgumentException("模型清单不能为空(宿主保证 — IModelCatalog.GetModelsForProvider + EnsureCurrentModelInList)。", nameof(items)); + } + + _items = items; + FocusIndex = IndexOfCurrent(items); + WindowStart = 0; + } + + /// 焦点移动 — clamp 不环绕(列表语义)+ 滚动窗口跟随。 + public void MoveFocus(int delta) + { + if (!IsPending) + { + return; + } + + FocusIndex = Math.Clamp(FocusIndex + delta, 0, _items.Count - 1); + if (FocusIndex < WindowStart) + { + WindowStart = FocusIndex; + } + else if (FocusIndex >= WindowStart + VisibleCount) + { + WindowStart = FocusIndex - VisibleCount + 1; + } + } + + /// Complete 清空状态(幂等 — 二次调用 no-op)。 + public void Complete() + { + _items = []; + FocusIndex = 0; + WindowStart = 0; + } + + /// 定位当前模型索引(无标记则 0)。 + private static int IndexOfCurrent(IReadOnlyList items) + { + for (var i = 0; i < items.Count; i++) + { + if (items[i].IsCurrent) + { + return i; + } + } + + return 0; + } +} diff --git a/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs b/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs new file mode 100644 index 0000000000..dd383e1ab0 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/ModelPickerDialog.cs @@ -0,0 +1,84 @@ +namespace NativeTui.Core.Repl; + +/// +/// 模型选择对话框消息项(M7-6,全部自研)— 对齐基准 ModelPicker 渲染结构 +/// (标题 "Select model" + 副标题 "Switch between Claude models..." + Select 单列列表 + +/// "and N more…" 溢出 + Byline 快捷键行): +/// margin 1 + 顶边框(permission 色全宽,仅 top 无角,同权限对话框)+ 标题(1 缩进 bold permission) +/// + 副标题(1 缩进 dim)+ 空行 + 每可见项 2 行(名称行:❯/空格指示 + "N. " dim + 名称 + +/// 当前项尾 ✓ success;描述行:5 空格缩进 dim,空描述折叠)+ 溢出行(1 缩进 dim "and N more…") +/// + 空行 + 快捷键行(1 缩进 dim "Enter confirm · Esc exit")。 +/// 状态源 = ModelPickerCoordinator(焦点/窗口/挂起),Begin 时插入 Messages,确认/取消时移除。 +/// +public sealed class ModelPickerDialog(ModelPickerCoordinator coordinator) : IScrollItem +{ + /// 固定标题(基准 ModelPicker title="Select model")。 + private const string Title = "Select model"; + + /// 固定副标题(基准 "Switch between Claude models. Applies to this session and future sessions.",本项目语境改模)。 + private const string Subtitle = "Switch between models. Applies to this session and future sessions."; + + /// 固定快捷键行(基准 Byline "Enter confirm · Esc exit")。 + private const string Shortcuts = "Enter confirm · Esc exit"; + + /// 溢出行前缀+后缀(基准 "and {hiddenCount} more…")。 + private const string OverflowPrefix = " and "; + private const string OverflowSuffix = " more…"; + + /// 协调器(焦点/窗口/挂起状态的唯一来源)。 + public ModelPickerCoordinator Coordinator { get; } = coordinator; + + /// 内容高度 = margin 1 + 边框 1 + 标题 1 + 副标题 1 + 空行 1 + + /// 可见项(名称 1 + 描述 0/1)+ 溢出 0/1 + 空行 1 + 快捷键 1。 + public int MeasureHeight(int width) => + 6 + VisibleItemHeights() + (Coordinator.HiddenCount > 0 ? 1 : 0) + 1; + + /// 渲染:按固定行序绘制(状态由 Coordinator 投影 — 挂起被清除时只画空 margin)。 + public void Render(Screen screen, Rect frame, Theme theme) + { + if (!Coordinator.IsPending) + { + return; + } + + var row = frame.Y + 1; + MessageRenderer.RenderPermissionBorder(screen, RowFrame(frame, row++), theme); + MessageRenderer.RenderModelTitle(screen, RowFrame(frame, row++), theme, Title); + MessageRenderer.RenderModelSubtitle(screen, RowFrame(frame, row++), theme, Subtitle); + row++; + + for (var i = 0; i < Coordinator.VisibleCount; i++) + { + var itemIndex = Coordinator.WindowStart + i; + var item = Coordinator.Items[itemIndex]; + MessageRenderer.RenderModelListRow(screen, RowFrame(frame, row++), theme, itemIndex, item.DisplayName, item.IsCurrent, itemIndex == Coordinator.FocusIndex); + if (!string.IsNullOrEmpty(item.Description)) + { + MessageRenderer.RenderModelDescription(screen, RowFrame(frame, row++), theme, item.Description); + } + } + + if (Coordinator.HiddenCount > 0) + { + MessageRenderer.RenderModelOverflow(screen, RowFrame(frame, row++), theme, Coordinator.HiddenCount); + } + + row++; + MessageRenderer.RenderModelShortcuts(screen, RowFrame(frame, row), theme, Shortcuts); + } + + /// 可见项总高度(名称行 + 非空描述行)。 + private int VisibleItemHeights() + { + var height = 0; + for (var i = 0; i < Coordinator.VisibleCount; i++) + { + height += string.IsNullOrEmpty(Coordinator.Items[Coordinator.WindowStart + i].Description) ? 1 : 2; + } + + return height; + } + + /// 单行 frame(Y 为该渲染行,高度 1)。 + private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 }; +} diff --git a/native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs b/native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs new file mode 100644 index 0000000000..0d7fd358f0 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/ModelPickerEngine.cs @@ -0,0 +1,62 @@ +namespace NativeTui.Core.Repl; + +/// +/// 模型选择引擎装饰器(M7-6,全部自研)— Submit 拦截 "/model"(精确匹配无参形态)打开 +/// ReplShell 模型选择器,其余输入透传内层引擎;选中后回调宿主 applyModel 执行切换 +/// (宿主直调引擎服务 — IFastModeService/IConfigurationService,不经 slash 命令系统)+ PostInfo 反馈。 +/// 组装:宿主先构造装饰器 → new ReplShell(装饰器) → 装饰器.Attach(shell)(延迟绑定, +/// 对齐 Host controller ??= 先例避免构造循环依赖)。 +/// +public sealed class ModelPickerEngine( + IReplEngine inner, + Func> listProvider, + Action applyModel) : IReplEngine +{ + /// 拦截的命令输入(基准 /model 无参形态打开选择器;带参形态透传引擎按未知输入处理)。 + private const string ModelCommand = "/model"; + + /// 外壳(延迟绑定 — Attach 注入)。 + private ReplShell? _shell; + + /// 绑定外壳(new ReplShell(装饰器) 之后调用一次)。 + public void Attach(ReplShell shell) => _shell = shell; + + /// 提交 — "/model" 拦截打开选择器(清单经 listProvider 从引擎投影);其余透传。 + public void Submit(string text) + { + if (_shell is not null && text.Trim() == ModelCommand) + { + var items = listProvider(); + _shell.BeginModelPicker(items, id => OnSelected(id, items)); + return; + } + + inner.Submit(text); + } + + /// 打断 — 透传内层。 + public void Interrupt() => inner.Interrupt(); + + /// 权限模式 — 透传内层。 + public void SetPermissionMode(string mode) => inner.SetPermissionMode(mode); + + /// 排空事件 — 透传内层。 + public IReadOnlyList DrainEvents() => inner.DrainEvents(); + + /// 存活 — 透传内层。 + public bool IsAlive => inner.IsAlive; + + /// 选中回调 — Esc 取消静默(基准同);确认执行宿主切换 + 反馈消息 + ///(displayName 取自自持清单闭包 — 回调触发时 coordinator 状态已清空)。 + private void OnSelected(string? id, IReadOnlyList items) + { + if (id is null) + { + return; + } + + applyModel(id); + var displayName = items.FirstOrDefault(m => m.Id == id)?.DisplayName ?? id; + _shell?.PostInfo($"已切换模型: {displayName}"); + } +} diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index fcc1c45128..4d9b53080b 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -34,6 +34,12 @@ public sealed class ReplShell /// 权限对话框协调器(M7-3 — 挂起时键盘模态改道)。 private readonly PermissionCoordinator _permission = new(); + /// 模型选择器协调器(M7-6 — 挂起时键盘模态改道,优先级低于权限对话框)。 + private readonly ModelPickerCoordinator _modelPicker = new(); + + /// 模型选择完成回调(BeginModelPicker 注入;参数 = 选中模型 id,Esc 取消为 null)。 + private Action? _modelPickerCallback; + /// 斜杠命令补全状态机(TASK056 — 宿主注入命令清单后启用;null = 无补全能力)。 private readonly SlashSuggester? _suggester; @@ -104,6 +110,9 @@ public ReplShell( /// 权限对话框协调器(宿主权限桥 Begin/Wait 消费面 + 测试断言面)。 public PermissionCoordinator Permission => _permission; + /// 模型选择器协调器(测试断言面)。 + internal ModelPickerCoordinator ModelPicker => _modelPicker; + /// 斜杠补全状态机(测试断言面;null = 无命令清单)。 internal SlashSuggester? Suggester => _suggester; @@ -184,9 +193,40 @@ public void BeginPermissionRequest(string toolName, string description) private void CompletePermission(PermissionChoice choice) { _permission.Complete(choice); + RemoveDialogItem(); + } + + /// + /// 挂起模型选择器(M7-6 — 宿主装饰器拦截 /model 后调此打开,全链自研不依赖 slash 命令系统): + /// 清单由宿主从引擎 IModelCatalog 投影(后端获取);完成回调参数 = 选中模型 id(Esc 取消为 null)。 + /// + public void BeginModelPicker(IReadOnlyList items, Action? onCompleted = null) + { + _modelPicker.Begin(items); + _modelPickerCallback = onCompleted; + AddMessage(new ModelPickerDialog(_modelPicker)); + } + + /// 完成模型选择并移除对话框项(id = null 表示 Esc 取消)。 + private void CompleteModelPicker(string? id) + { + _modelPicker.Complete(); + RemoveDialogItem(); + var callback = _modelPickerCallback; + _modelPickerCallback = null; + callback?.Invoke(id); + } + + /// 追加信息反馈消息到滚动区(宿主确认切换后的反馈面 — M7-6 起)。 + public void PostInfo(string text) => AddMessage(new AssistantMessageItem(text)); + + /// 移除滚动区内首个指定类型对话框项(权限/模型对话框共用收尾)。 + private void RemoveDialogItem() + where TDialog : class, IScrollItem + { for (var i = 0; i < _view.Messages.Count; i++) { - if (_view.Messages.Items[i] is PermissionRequestItem) + if (_view.Messages.Items[i] is TDialog) { _view.Messages.RemoveAt(i); return; @@ -231,7 +271,8 @@ internal void HandleKey(ConsoleKeyInfo keyInfo) } } - /// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框)→ 补全激活专属键 + /// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框)→ 模型选择器模态 + /// (M7-6:Esc/上下/Enter 路由选择器)→ 补全激活专属键 /// (Up/Down 导航/Tab 接受/Enter 执行/Esc 关闭,基准 Autocomplete 上下文优先于 Chat), /// 其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。 private void HandleKeyPress(KeyPress key) @@ -242,6 +283,12 @@ private void HandleKeyPress(KeyPress key) return; } + if (_modelPicker.IsPending) + { + HandleModelPickerKey(key); + return; + } + if (HandleSuggestionKey(key)) { return; @@ -467,6 +514,37 @@ private void HandlePermissionKey(KeyPress key) } } + /// + /// 模型选择器模态按键(M7-6,对齐基准 ModelPicker Select 交互): + /// Esc 取消(回调 null)+ Enter 选焦点项(回调携带 id)+ 上/下 clamp 移动焦点; + /// 其余按键全部吞掉(模态 — 不进输入缓冲、不提交、不打断)。 + /// + private void HandleModelPickerKey(KeyPress key) + { + if (key.Kind == KeyKind.Escape) + { + CompleteModelPicker(null); + return; + } + + if (key.Kind == KeyKind.Return) + { + CompleteModelPicker(_modelPicker.Focused?.Id); + return; + } + + if (key.Kind == KeyKind.UpArrow) + { + _modelPicker.MoveFocus(-1); + return; + } + + if (key.Kind == KeyKind.DownArrow) + { + _modelPicker.MoveFocus(1); + } + } + /// 视口模式滚动(inline 模式历史由终端滚轮原生回看,no-op)。 private void ScrollIfViewport(int rows) { diff --git a/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs b/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs new file mode 100644 index 0000000000..7d9e2925d1 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ModelPickerTests.cs @@ -0,0 +1,492 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 模型选择器测试(M7-6,全部自研)— 对齐基准 ModelPicker 形态: +/// ModelPickerCoordinator 状态机(Begin 焦点定位当前模型 + 上下 clamp + 滚动窗口 min(10,n) 跟随 + +/// Complete 幂等)+ ModelPickerDialog 渲染(permission 色顶边框 + bold 标题 "Select model" + dim 副标题 + +/// 列表行 ❯ 焦点 suggestion + 序号 dim + ✓ 当前 success + 描述缩进 dim + "and N more…" 溢出 + +/// "Enter confirm · Esc exit" 快捷键行)+ ReplShell 模态改道(挂起时 ↑↓/Enter/Esc 路由选择器; +/// 权限对话框优先级更高)+ ModelPickerEngine 装饰器(Submit "/model" 拦截打开选择器、其余透传、 +/// 确认回调 applyModel + PostInfo 反馈)。 +/// +public sealed class ModelPickerTests +{ + /// 测试模型清单(3 项,第 2 项为当前模型)。 + private static readonly ModelPickerItem[] SampleItems = + [ + new("gpt-x", "GPT X", "最强推理能力", IsCurrent: false), + new("gpt-mini", "GPT Mini", "快速响应", IsCurrent: true), + new("gpt-nano", "GPT Nano", "轻量便宜", IsCurrent: false), + ]; + + /// 构造挂起状态的选择器(焦点定位当前模型 index=1)。 + private static ModelPickerCoordinator BeginCoordinator() + { + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin(SampleItems); + return coordinator; + } + + // ---- ModelPickerCoordinator ---- + + /// Begin — IsPending + Items 就位 + 焦点定位当前模型(index=1)。 + [Fact] + public void Begin_SetsItemsAndFocusesCurrent() + { + var coordinator = BeginCoordinator(); + + Assert.True(coordinator.IsPending); + Assert.Equal(3, coordinator.Items.Count); + Assert.Equal(1, coordinator.FocusIndex); + Assert.Equal("gpt-mini", coordinator.Focused?.Id); + } + + /// Begin 无当前模型 — 焦点回退 0。 + [Fact] + public void Begin_NoCurrent_FocusesZero() + { + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin([new ModelPickerItem("a", "A", "", IsCurrent: false)]); + + Assert.Equal(0, coordinator.FocusIndex); + } + + /// 焦点移动 — 上下 clamp 不环绕(0 上保持 0;末位下保持末位)。 + [Fact] + public void MoveFocus_ClampsNotWraps() + { + var coordinator = BeginCoordinator(); + + coordinator.MoveFocus(-1); + Assert.Equal(0, coordinator.FocusIndex); + coordinator.MoveFocus(-1); + Assert.Equal(0, coordinator.FocusIndex); + + coordinator.MoveFocus(2); + Assert.Equal(2, coordinator.FocusIndex); + coordinator.MoveFocus(1); + Assert.Equal(2, coordinator.FocusIndex); + } + + /// 可见窗口 — min(10, n) 可见 + HiddenCount 溢出计数。 + [Fact] + public void VisibleCount_CapsAtTenAndCountsHidden() + { + var many = Enumerable.Range(0, 13) + .Select(i => new ModelPickerItem($"m{i}", $"M{i}", "", IsCurrent: false)) + .ToArray(); + + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin(many); + + Assert.Equal(10, coordinator.VisibleCount); + Assert.Equal(3, coordinator.HiddenCount); + + var small = BeginCoordinator(); + Assert.Equal(3, small.VisibleCount); + Assert.Equal(0, small.HiddenCount); + } + + /// 滚动窗口跟随 — 焦点移出窗口时窗口下移,移回顶部时上移。 + [Fact] + public void MoveFocus_ScrollsWindowToKeepFocusVisible() + { + var many = Enumerable.Range(0, 13) + .Select(i => new ModelPickerItem($"m{i}", $"M{i}", "", IsCurrent: false)) + .ToArray(); + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin(many); + + Assert.Equal(0, coordinator.WindowStart); + coordinator.MoveFocus(9); + Assert.Equal(9, coordinator.FocusIndex); + Assert.Equal(0, coordinator.WindowStart); + + coordinator.MoveFocus(1); + Assert.Equal(10, coordinator.FocusIndex); + Assert.Equal(1, coordinator.WindowStart); + + coordinator.MoveFocus(-9); + Assert.Equal(1, coordinator.FocusIndex); + Assert.Equal(1, coordinator.WindowStart); + coordinator.MoveFocus(-1); + Assert.Equal(0, coordinator.FocusIndex); + Assert.Equal(0, coordinator.WindowStart); + } + + /// Complete — 状态清空 + 幂等(二次 no-op)。 + [Fact] + public void Complete_ClearsAndIsIdempotent() + { + var coordinator = BeginCoordinator(); + + coordinator.Complete(); + Assert.False(coordinator.IsPending); + Assert.Null(coordinator.Focused); + + coordinator.Complete(); + Assert.False(coordinator.IsPending); + } + + /// Begin 空清单 — 断言(开心路径:宿主保证非空)。 + [Fact] + public void Begin_EmptyItems_Throws() + { + var coordinator = new ModelPickerCoordinator(); + + Assert.Throws(() => coordinator.Begin([])); + } + + // ---- ModelPickerDialog 渲染 ---- + + /// 渲染顶边框 + 标题 — permission 色全宽 ─ 行 + 1 缩进 bold permission "Select model"。 + [Fact] + public void Render_BorderAndTitle() + { + using var screen = new Screen(cols: 40, rows: 16); + var coordinator = BeginCoordinator(); + new ModelPickerDialog(coordinator).Render(screen, new Rect(0, 0, 40, 16), Theme.Dark); + + var border = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Permission }); + Assert.Equal(new string('─', 40), RowText.Of(screen, 1, 0, 40)); + Assert.Equal(border, screen[1, 0].StyleId); + + var title = screen.Styles.GetOrAdd(new TextStyle { Bold = true, Foreground = Theme.Dark.Permission }); + Assert.StartsWith(" Select model", RowText.Of(screen, 2, 0, 40)); + Assert.Equal(title, screen[2, 1].StyleId); + } + + /// 渲染副标题 — 1 缩进 dim。 + [Fact] + public void Render_SubtitleDim() + { + using var screen = new Screen(cols: 60, rows: 16); + var coordinator = BeginCoordinator(); + new ModelPickerDialog(coordinator).Render(screen, new Rect(0, 0, 60, 16), Theme.Dark); + + var text = RowText.Of(screen, 3, 0, 60); + Assert.StartsWith(" Switch between models.", text); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[3, 1].StyleId); + } + + /// 渲染列表行 — 焦点行 ❯ suggestion + 序号 dim + 名称;当前行尾 ✓ success;非焦点行空格指示。 + [Fact] + public void Render_ListRowsFocusTickAndIndices() + { + using var screen = new Screen(cols: 60, rows: 16); + var coordinator = BeginCoordinator(); + new ModelPickerDialog(coordinator).Render(screen, new Rect(0, 0, 60, 16), Theme.Dark); + + // row5 = 首项(非焦点):空格指示 + "1. " + 名称 + var first = RowText.Of(screen, 5, 0, 60); + Assert.StartsWith(" 1. GPT X", first); + + // row7 = 当前+焦点项:❯ + "2. " + 名称 + ✓ + var focused = RowText.Of(screen, 7, 0, 60); + Assert.StartsWith("❯ 2. GPT Mini ✓", focused); + var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion }); + Assert.Equal(suggestion, screen[7, 0].StyleId); + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Success }); + Assert.Equal(success, screen[7, "❯ 2. GPT Mini ".Length].StyleId); + + // row9 = 第三项(非焦点、无 ✓) + Assert.StartsWith(" 3. GPT Nano", RowText.Of(screen, 9, 0, 60)); + } + + /// 渲染描述行 — 名称行下一行缩进 dim。 + [Fact] + public void Render_DescriptionIndentedDim() + { + using var screen = new Screen(cols: 60, rows: 16); + var coordinator = BeginCoordinator(); + new ModelPickerDialog(coordinator).Render(screen, new Rect(0, 0, 60, 16), Theme.Dark); + + Assert.StartsWith(" 快速响应", RowText.Of(screen, 8, 0, 60)); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[8, 5].StyleId); + } + + /// 渲染溢出行 — 超出 10 条可见折叠 "and N more…"。 + [Fact] + public void Render_OverflowRowWhenHidden() + { + var many = Enumerable.Range(0, 13) + .Select(i => new ModelPickerItem($"m{i}", $"M{i}", "", IsCurrent: false)) + .ToArray(); + using var screen = new Screen(cols: 60, rows: 40); + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin(many); + new ModelPickerDialog(coordinator).Render(screen, new Rect(0, 0, 60, 40), Theme.Dark); + + // 13 项无描述:margin1 + 边框1 + 标题1 + 副标题1 + 空行1 + 可见10行 → 溢出行在 row15 + var overflow = RowText.Of(screen, 15, 0, 60); + Assert.StartsWith(" and 3 more…", overflow); + } + + /// 渲染快捷键行 — 尾行 "Enter confirm · Esc exit" dim。 + [Fact] + public void Render_ShortcutHintRow() + { + using var screen = new Screen(cols: 60, rows: 16); + var coordinator = BeginCoordinator(); + var dialog = new ModelPickerDialog(coordinator); + dialog.Render(screen, new Rect(0, 0, 60, 16), Theme.Dark); + + var hintRow = dialog.MeasureHeight(60) - 1; + Assert.StartsWith(" Enter confirm · Esc exit", RowText.Of(screen, hintRow, 0, 60)); + } + + /// MeasureHeight — 3 项带描述 = margin1 + 边框1 + 标题1 + 副标题1 + 空行1 + 3×2 + 空行1 + 快捷键1 = 13。 + [Fact] + public void MeasureHeight_WithDescriptions() + { + var coordinator = BeginCoordinator(); + Assert.Equal(13, new ModelPickerDialog(coordinator).MeasureHeight(60)); + } + + /// MeasureHeight — 无描述项高度减半。 + [Fact] + public void MeasureHeight_WithoutDescriptions() + { + var coordinator = new ModelPickerCoordinator(); + coordinator.Begin([ + new ModelPickerItem("a", "A", "", IsCurrent: false), + new ModelPickerItem("b", "B", "", IsCurrent: false), + ]); + Assert.Equal(9, new ModelPickerDialog(coordinator).MeasureHeight(60)); + } + + /// 挂起被清除后渲染 — 空输出(状态投影,同权限对话框)。 + [Fact] + public void Render_AfterComplete_NoOutput() + { + using var screen = new Screen(cols: 40, rows: 16); + var coordinator = BeginCoordinator(); + var dialog = new ModelPickerDialog(coordinator); + coordinator.Complete(); + + dialog.Render(screen, new Rect(0, 0, 40, 16), Theme.Dark); + + Assert.Equal(new string(' ', 40), RowText.Of(screen, 1, 0, 40)); + } + + // ---- ReplShell 模态改道 ---- + + /// 构造测试 shell(对齐 PermissionDialogTests 模式)。 + private static (ReplShell Shell, FakeModelEngine Engine) CreateShell() + { + var engine = new FakeModelEngine(); + var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); + var shell = new ReplShell(engine, banner, new StringWriter()); + shell.SeedContent(); + return (shell, engine); + } + + /// BeginModelPicker — 对话框插入滚动区 + 挂起。 + [Fact] + public void Shell_BeginModelPicker_InsertsDialog() + { + var (shell, _) = CreateShell(); + shell.BeginModelPicker(SampleItems); + + Assert.True(shell.ModelPicker.IsPending); + Assert.Contains(shell.View.Messages.Items, m => m is ModelPickerDialog); + } + + /// 上下箭头 — 焦点移动不进输入缓冲。 + [Fact] + public void Shell_ArrowsMoveFocusNotInput() + { + var (shell, _) = CreateShell(); + shell.BeginModelPicker(SampleItems); + Assert.Equal(1, shell.ModelPicker.FocusIndex); + + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false)); + Assert.Equal(0, shell.ModelPicker.FocusIndex); + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + Assert.Equal(1, shell.ModelPicker.FocusIndex); + + Assert.Equal(string.Empty, shell.Input.Text); + } + + /// Enter — 确认回调携带选中 id + 对话框移除。 + [Fact] + public void Shell_Enter_CompletesWithSelectedId() + { + var (shell, _) = CreateShell(); + var selected = new[] { (string?)"none" }; + shell.BeginModelPicker(SampleItems, id => selected[0] = id); + + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false)); + shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + Assert.Equal("gpt-x", selected[0]); + Assert.False(shell.ModelPicker.IsPending); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is ModelPickerDialog); + } + + /// Esc — 取消回调 null + 对话框移除。 + [Fact] + public void Shell_Escape_CompletesWithNull() + { + var (shell, _) = CreateShell(); + var selected = new[] { (string?)"sentinel" }; + shell.BeginModelPicker(SampleItems, id => selected[0] = id); + + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false)); + + Assert.Null(selected[0]); + Assert.False(shell.ModelPicker.IsPending); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is ModelPickerDialog); + } + + /// 其他字符 — 模态吞掉(不进输入缓冲、不提交)。 + [Fact] + public void Shell_OtherCharsSwallowedWhilePending() + { + var (shell, engine) = CreateShell(); + shell.BeginModelPicker(SampleItems); + + shell.HandleKey(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false)); + + Assert.Equal(string.Empty, shell.Input.Text); + Assert.Empty(engine.Submits); + Assert.True(shell.ModelPicker.IsPending); + } + + /// 权限挂起优先 — 权限对话框模态先于模型选择器(引擎请求优先级更高)。 + [Fact] + public async Task Shell_PermissionTakesPrecedenceOverModelPicker() + { + var (shell, _) = CreateShell(); + shell.BeginPermissionRequest("Bash", "run tests"); + shell.BeginModelPicker(SampleItems); + var permissionTask = shell.Permission.Wait(); + + shell.HandleKey(new ConsoleKeyInfo('1', ConsoleKey.D1, false, false, false)); + + Assert.Equal(PermissionChoice.Allow, await permissionTask); + Assert.True(shell.ModelPicker.IsPending); + } + + // ---- ModelPickerEngine 装饰器 ---- + + /// Submit "/model" — 拦截打开选择器,不透传内层引擎。 + [Fact] + public void Engine_SubmitModelCommand_OpensPickerNotForwarded() + { + var inner = new FakeModelEngine(); + var (shell, _) = CreateShell(); + var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { }); + pickerEngine.Attach(shell); + + pickerEngine.Submit("/model"); + + Assert.Empty(inner.Submits); + Assert.True(shell.ModelPicker.IsPending); + } + + /// Submit 其他输入 — 透传内层。 + [Fact] + public void Engine_SubmitOtherText_ForwardedToInner() + { + var inner = new FakeModelEngine(); + var (shell, _) = CreateShell(); + var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { }); + pickerEngine.Attach(shell); + + pickerEngine.Submit("/model mini"); + + Assert.Equal(["/model mini"], inner.Submits); + Assert.False(shell.ModelPicker.IsPending); + } + + /// 确认选择 — applyModel 收到 id + PostInfo 反馈消息入滚动区。 + [Fact] + public void Engine_ConfirmSelection_AppliesModelAndPostsFeedback() + { + var inner = new FakeModelEngine(); + var applied = new List(); + var (shell, _) = CreateShell(); + var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, applied.Add); + pickerEngine.Attach(shell); + + pickerEngine.Submit("/model"); + shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + Assert.Equal(["gpt-mini"], applied); + Assert.Contains(shell.View.Messages.Items, m => m is AssistantMessageItem { } a && a.Text.Contains("GPT Mini")); + } + + /// Esc 取消 — applyModel 不调用 + 无反馈消息。 + [Fact] + public void Engine_EscapeCancel_NoApplyNoFeedback() + { + var inner = new FakeModelEngine(); + var applied = new List(); + var (shell, _) = CreateShell(); + var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, applied.Add); + pickerEngine.Attach(shell); + + pickerEngine.Submit("/model"); + shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false)); + + Assert.Empty(applied); + Assert.DoesNotContain(shell.View.Messages.Items, m => m is AssistantMessageItem { } a && a.Text.Contains("切换")); + } + + /// 透传成员 — Interrupt/SetPermissionMode/DrainEvents/IsAlive 走内层。 + [Fact] + public void Engine_PassThroughMembers() + { + var inner = new FakeModelEngine(); + inner.Events.Enqueue(new BridgeEvent.TurnStarted()); + var pickerEngine = new ModelPickerEngine(inner, () => SampleItems, _ => { }); + + pickerEngine.Interrupt(); + pickerEngine.SetPermissionMode("plan"); + var drained = pickerEngine.DrainEvents(); + + Assert.Equal(1, inner.Interrupts); + Assert.Equal(["plan"], inner.PermissionModes); + Assert.Single(drained); + Assert.True(pickerEngine.IsAlive); + } + + /// 记录型假引擎(PermissionDialogTests.FakePermissionEngine 同构)。 + private sealed class FakeModelEngine : IReplEngine + { + /// 收到的提交文本。 + public List Submits { get; } = []; + + /// 收到的打断次数。 + public int Interrupts; + + /// 收到的权限模式。 + public List PermissionModes { get; } = []; + + /// 待排空事件队列。 + public Queue Events { get; } = new(); + + /// 记录提交。 + public void Submit(string text) => Submits.Add(text); + + /// 记录打断。 + public void Interrupt() => Interrupts++; + + /// 记录权限模式。 + public void SetPermissionMode(string mode) => PermissionModes.Add(mode); + + /// 排空事件队列。 + public IReadOnlyList DrainEvents() + { + var drained = Events.ToList(); + Events.Clear(); + return drained; + } + + /// 存活标记。 + public bool IsAlive => true; + } +} From d049fb8c8070b19c252e9c563fd4a416f93f75a5 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:05:02 +0800 Subject: [PATCH 23/43] =?UTF-8?q?feat:=20TranscriptFileWriter=E6=94=B9JSON?= =?UTF-8?q?L=E7=BA=AF=E8=BF=BD=E5=8A=A0+=E9=80=90=E8=A1=8C=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=AF=BB=20|=20=E5=86=B3=E7=AD=96:=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9ETranscriptJsonlContext=E7=B4=A7=E5=87=91=E8=A1=8C?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96(WriteIndented=3Dfalse),=E5=AD=98?= =?UTF-8?q?=E9=87=8F.json=E6=95=B0=E7=BB=84=E5=9B=9E=E9=80=80=E8=AF=BB?= =?UTF-8?q?=E5=8F=96,=E5=9D=8F=E8=A1=8C=E8=A1=8C=E7=BA=A7=E5=AE=B9?= =?UTF-8?q?=E9=94=99,ADR=200144?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0144-transcript-jsonl-append-streaming.md | 50 +++++++ ...17\347\256\200\345\214\226\351\241\271.md" | 23 +++ .../llm/chat/chat_core/TranscriptEntry.cs | 10 +- .../state/TranscriptFileWriterJsonlTests.cs | 135 ++++++++++++++++++ .../transcript/core/TranscriptFileWriter.cs | 83 +++++++---- 5 files changed, 275 insertions(+), 26 deletions(-) create mode 100644 docs/proposed/adr/0144-transcript-jsonl-append-streaming.md create mode 100644 "docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" create mode 100644 modules/storage/vault.other.tests/state/TranscriptFileWriterJsonlTests.cs diff --git a/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md b/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md new file mode 100644 index 0000000000..81a6ed1ab7 --- /dev/null +++ b/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md @@ -0,0 +1,50 @@ +# ADR 0144: 会话转录存储 JSON 数组改 JSONL 纯追加(按需流式读取) + +- 状态:proposed +- 日期:2026-10-21 +- 决策者:liuqihong(用户指令:会话改 jsonl 按需读取和追加,现在逻辑有问题) + +## 背景 + +### 问题:transcript.json 整存整取 + +当前 `~/.jcc/sessions/{id}/transcript.json` 是 JSON 数组格式,`TranscriptFileWriter` 的追加路径(`AppendEntryInternalAsync`/`AppendEntriesInternalAsync`)是"读旧数组 → Add → 写回全量": + +- **写入 O(n)**:每轮对话结束(`TranscriptPersistMiddleware` 差量落盘)都要全量读取 + 全量重写文件。大会话(数百条目 × 长内容)每轮提交代价线性增长,且全量重写有写入中途崩溃损坏整个文件的风险。 +- **读取 O(全量)**:`ListTranscriptsAsync`(GUI 侧栏/`/resume`/TUI HistorySeeder 高频调用)为了提取 entryCount + 最后一条 preview,全文 `ReadToEndAsync` + 整体反序列化整个数组。 +- `GetCustomTitleAsync` 同样全量加载后倒序扫一条元数据。 + +### 历史演进的两次反向决策(需正面回应) + +| Commit | 决策 | 动机 | 本次重新评估 | +|--------|------|------|-------------| +| #126(9a8bd2172) | JSONL → JSON 数组 | 人类可读(WriteIndented)+ 层次化目录 + 可读会话 ID | 目录组织与会话 ID **已保留且不变**;"人类可读"降级诉求(每行一条紧凑 JSON 仍可读)不足以抵消 O(n) 写入 | +| #163(b9b2ca711) | .jsonl 扩展名统一为 .json | 扩展名统一 | 恢复 `.jsonl` 名实相符;#163 的 55 处替换是格式随扩展名的联动,本次格式真的变了,名不符实(.json 文件装 JSONL 行)反而误导工具与人 | + +### JSONL 时代的两个已记录坑(规避方案现成) + +- FileMode.Append 文件不存在抛异常(PhysicalTests 考古)→ 用 `FileMode.OpenOrCreate + Seek(End)` +- 跨进程读-写冲突(#74 集群 E2E)→ 沿用 `FileShare.ReadWrite` 流读写(git show a924418df 版实现) + +## 决策 + +**transcript 存储从 JSON 数组整存整取改为 JSONL 每行一条纯追加 + 按需流式读取。** + +1. **写入 O(1) 纯追加**:`FileMode.OpenOrCreate + Seek(End)` 批量写行(`RelaxedJsonSerializer.Serialize(entry, TranscriptJsonContext.Default.TranscriptEntry)` 紧凑单行 + '\n');Actor 邮箱串行化保留;`MaybeOffloadToPasteStore` 卸载逻辑不变(逐条落盘前生效)。 +2. **读取逐行流式**:`ReadLinesAsync` 逐行反序列化 + `ResolveFromPasteStore` 还原;`LoadTranscriptAsync` 聚合(接口语义不变);`ListTranscriptsAsync` 只数行数 + 解析尾行提 preview(不再全文反序列化)。 +3. **新文件名 `transcript.jsonl`**;读取兼容回退:`.jsonl` 不存在时读存量 `transcript.json`(数组格式旧逻辑)——存量会话零迁移可用。 +4. **迁移幂等不删旧文件**(沿 MigrateLegacyAsync 红线):启动时把 ①旧扁平 `{id}.json`(JSONL 行)②子目录 `{id}/transcript.json`(数组)统一迁移到 `{id}/transcript.jsonl`;目标存在跳过。 +5. **改动封闭**:全部消费方(CLI/GUI/TUI 三端 + ForkCommand/ResumeCommand/子代理)经 `ITranscriptService`/`IAgentTranscriptService` 抽象,接口签名零变更;改动封闭在 `TranscriptFileWriter` + `TranscriptService`(路径/preview/迁移)+ `AgentTranscriptService`(子代理路径)内部。 + +## 替代方案(考虑过但放弃) + +- **保持 JSON 数组 + 内存缓存增量写**:写入仍需全量重写(崩溃窗口),且缓存丢失即丢数据;不解决 ListTranscripts 全量读。 +- **SQLite**:引入原生依赖(违反 NativeAOT 约束偏好),杀鸡用牛刀。 +- **保持 .json 文件名装 JSONL 内容**:名不符实,误导工具链与后续维护者。 + +## 影响面 + +- 约 10 个直接断言磁盘字节格式的测试用例随格式重写(TranscriptServiceTests/TranscriptFileWriterPasteStoreTests)。 +- `HousekeepingService` 按 mtime 清理,格式无关,零影响。 +- meta.json / usage-facet.json / diag / tool-results 与 transcript.json 零耦合,零影响。 +- 集成测试 `TranscriptPersistIntegrationTests` 走 service API,是最接近真实落盘语义的回归防线。 diff --git "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" new file mode 100644 index 0000000000..6e13e962f7 --- /dev/null +++ "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" @@ -0,0 +1,23 @@ +# TASK057 — 会话存储 JSONL 化 + TUI 输入栏简化项三连 + +> 状态:in-progress +> 前置:TASK056(斜杠补全+历史环)已完结验收 +> 用户指令(2026-10-21):①会话改为 jsonl 按需读取和追加(现在逻辑有问题)②推荐功能全部做 + +## 任务分解(渐进式:每步 编译 → 测试 → commit) + +| # | 任务 | 优先级 | 状态 | +|---|------|--------|------| +| 1 | **transcript 存储改 JSONL**(按需读取+纯追加,消除整存整取) | P0 用户指令 | ⬜ | +| 2 | 参数补全(/model xxx Argument 模式) | P1 | ⬜ | +| 3 | 幽灵文本(mid-input /xx 补全) | P2 | ⬜ | +| 4 | Ctrl+R 历史搜索 | P3 | ⬜ | + +## 任务1 背景(调研待补充) + +- 现状:`~/.jcc/sessions/{id}/transcript.json` 为 **JSON 数组整存整取**——写入是"读旧数组+追加+写回全量"(每次提交 O(n) 全量重写),读取是全量反序列化+逐条 paste-cache 还原(大会话性能灾难) +- 目标:JSONL 每行一条,追加 O(1),读取按需流式(分块倒序 — TS readLinesReverse 4KB 先例) +- 关键资产:`TranscriptFileWriter`(Actor 串行写)、`AgentTranscriptService`(subagents/agent-*.jsonl 已是 JSONL 先例)、`MigrateLegacyAsync`(历史迁移) +- **历史疑点**:旧扁平 {id}.jsonl 曾被迁移为 JSON 数组——需查 git 历史确认当初迁移动机,避免踩回旧坑(可能是 paste-cache 引用/原子性/损坏容错问题) + + diff --git a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/TranscriptEntry.cs b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/TranscriptEntry.cs index f0929fa1f5..f37c2ba9c9 100644 --- a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/TranscriptEntry.cs +++ b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/TranscriptEntry.cs @@ -78,4 +78,12 @@ public sealed class TranscriptSummary { [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SessionInfo))] -public sealed partial class TranscriptJsonContext : JsonSerializerContext; \ No newline at end of file +public sealed partial class TranscriptJsonContext : JsonSerializerContext; + +/// +/// JSONL 行序列化上下文 — 紧凑单行(WriteIndented=false,ADR 0144):transcript.jsonl 每行一条的写侧专用。 +/// 与 TranscriptJsonContext 同命名策略(CamelCase)与宽容读策略,仅缩进差异 — 反序列化两侧通用。 +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(TranscriptEntry))] +public sealed partial class TranscriptJsonlContext : JsonSerializerContext; \ No newline at end of file diff --git a/modules/storage/vault.other.tests/state/TranscriptFileWriterJsonlTests.cs b/modules/storage/vault.other.tests/state/TranscriptFileWriterJsonlTests.cs new file mode 100644 index 0000000000..7fdce00522 --- /dev/null +++ b/modules/storage/vault.other.tests/state/TranscriptFileWriterJsonlTests.cs @@ -0,0 +1,135 @@ +#pragma warning disable JCC3010, JCC3011, JCC3012 +namespace State.Tests; + +/// +/// TranscriptFileWriter JSONL 格式测试 — ADR 0144:纯追加写(O(1),读旧+写回全量已消除)+ +/// 逐行流式读(行级容错)+ 存量 .json 数组回退兼容。 +/// +public sealed class TranscriptFileWriterJsonlTests : IAsyncDisposable { + private readonly TestInMemFs _fs = new(); + private readonly string _tempDir; + private readonly TranscriptFileWriter _writer; + private bool _disposed; + + public TranscriptFileWriterJsonlTests() { + _tempDir = Path.Combine(Path.GetTempPath(), $"jsonl_test_{Guid.NewGuid():N}"); + _fs.CreateDirectory(_tempDir); + _writer = new TranscriptFileWriter(_fs, _tempDir); + } + + /// 两批追加:文件为两行 JSONL,顺序保持,每行独立可反序列化。 + [Fact] + public async Task Append_TwoBatches_WritesOneLinePerEntry() { + var filePath = Path.Combine(_tempDir, "s1", "transcript.jsonl"); + await _writer.AppendEntryAsync(filePath, NewEntry("user", "first")); + await _writer.AppendEntriesAsync(filePath, [NewEntry("assistant", "second"), NewEntry("user", "third")]); + + var lines = (await _fs.ReadAllTextAsync(filePath)).Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + var contents = new List(lines.Length); + foreach (var line in lines) { + if (RelaxedJsonSerializer.Deserialize(line, TranscriptJsonContext.Default.TranscriptEntry) is { } entry) { + contents.Add(entry.Content); + } else { + Assert.Fail($"JSONL 行反序列化失败: {line[..Math.Min(60, line.Length)]}"); + } + } + + Assert.Equal(["first", "second", "third"], contents); + } + + /// 纯追加不重写:首行字节在后续追加后不变(消除读旧数组+写回全量)。 + [Fact] + public async Task Append_IsPureAppend_FirstLineUnchanged() { + var filePath = Path.Combine(_tempDir, "s2", "transcript.jsonl"); + await _writer.AppendEntryAsync(filePath, NewEntry("user", "immutable-first")); + var firstLine = (await _fs.ReadAllTextAsync(filePath)).Split('\n', StringSplitOptions.RemoveEmptyEntries)[0]; + + await _writer.AppendEntryAsync(filePath, NewEntry("user", "second")); + var lines = (await _fs.ReadAllTextAsync(filePath)).Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, lines.Length); + Assert.Equal(firstLine, lines[0]); + } + + /// 读取 JSONL:逐行解析 + paste-cache 还原路径正常。 + [Fact] + public async Task Load_JsonlLines_ParsesEachEntry() { + var filePath = Path.Combine(_tempDir, "s3", "transcript.jsonl"); + await _writer.AppendEntriesAsync(filePath, [NewEntry("user", "a"), NewEntry("assistant", "b")]); + + var loaded = await _writer.LoadTranscriptAsync(filePath); + Assert.Equal(["a", "b"], loaded.Select(e => e.Content)); + } + + /// 存量回退:.jsonl 不存在但 .json(JSON 数组)存在 → 回退数组读取(存量会话零迁移可用)。 + [Fact] + public async Task Load_FallsBackToLegacyJsonArray_WhenJsonlAbsent() { + var dir = Path.Combine(_tempDir, "s4"); + _fs.CreateDirectory(dir); + var legacyPath = Path.Combine(dir, "transcript.json"); + await _fs.WriteAllTextAsync(legacyPath, "[{\"sessionId\":\"s4\",\"role\":\"user\",\"content\":\"legacy\",\"timestamp\":\"2026-01-01T00:00:00Z\"}]"); + + var jsonlPath = Path.Combine(dir, "transcript.jsonl"); + var loaded = await _writer.LoadTranscriptAsync(jsonlPath); + + Assert.Single(loaded); + Assert.Equal("legacy", loaded[0].Content); + } + + /// 双格式并存:.jsonl 优先(迁移后或新写入的会话)。 + [Fact] + public async Task Load_PrefersJsonl_WhenBothExist() { + var dir = Path.Combine(_tempDir, "s5"); + _fs.CreateDirectory(dir); + await _fs.WriteAllTextAsync(Path.Combine(dir, "transcript.json"), "[{\"sessionId\":\"s5\",\"role\":\"user\",\"content\":\"old-array\",\"timestamp\":\"2026-01-01T00:00:00Z\"}]"); + + var jsonlPath = Path.Combine(dir, "transcript.jsonl"); + await _writer.AppendEntryAsync(jsonlPath, NewEntry("user", "new-jsonl")); + + var loaded = await _writer.LoadTranscriptAsync(jsonlPath); + Assert.Single(loaded); + Assert.Equal("new-jsonl", loaded[0].Content); + } + + /// 行级容错:坏行跳过(JSONL 格式优势 — 数组格式整体失败返回空)。 + [Fact] + public async Task Load_SkipsMalformedLines() { + var dir = Path.Combine(_tempDir, "s6"); + _fs.CreateDirectory(dir); + var jsonlPath = Path.Combine(dir, "transcript.jsonl"); + await _fs.WriteAllTextAsync(jsonlPath, + RelaxedJsonSerializer.Serialize(NewEntry("user", "good"), TranscriptJsonlContext.Default) + "\n{broken json\n" + + RelaxedJsonSerializer.Serialize(NewEntry("assistant", "also-good"), TranscriptJsonlContext.Default) + "\n"); + + var loaded = await _writer.LoadTranscriptAsync(jsonlPath); + Assert.Equal(["good", "also-good"], loaded.Select(e => e.Content)); + } + + /// 文件尾残留空行(写入中断)不影响读取。 + [Fact] + public async Task Load_ToleratesTrailingNewlines() { + var dir = Path.Combine(_tempDir, "s7"); + _fs.CreateDirectory(dir); + var jsonlPath = Path.Combine(dir, "transcript.jsonl"); + var line = RelaxedJsonSerializer.Serialize(NewEntry("user", "only"), TranscriptJsonlContext.Default); + await _fs.WriteAllTextAsync(jsonlPath, line + "\n\n"); + + var loaded = await _writer.LoadTranscriptAsync(jsonlPath); + Assert.Single(loaded); + } + + private static TranscriptEntry NewEntry(string role, string content) => new() { + SessionId = "s", + Role = role, + Content = content, + Timestamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + public async ValueTask DisposeAsync() { + if (_disposed) return; + _disposed = true; + await _writer.DisposeAsync(); + } +} +#pragma warning restore JCC3010, JCC3011, JCC3012 diff --git a/modules/storage/vault/state/transcript/core/TranscriptFileWriter.cs b/modules/storage/vault/state/transcript/core/TranscriptFileWriter.cs index 8b07081fee..48c7c90924 100644 --- a/modules/storage/vault/state/transcript/core/TranscriptFileWriter.cs +++ b/modules/storage/vault/state/transcript/core/TranscriptFileWriter.cs @@ -3,8 +3,8 @@ namespace State; /// /// 共享的 Transcript 文件写入器 — 提取自 TranscriptService 和 AgentTranscriptService -/// 封装 JSONL 格式的追加写入和读取逻辑,消除两个服务间的重复代码 -/// 并发保护:Actor 邮箱管道串行化写操作,消除显式锁 — TASK001 +/// 封装 JSONL 格式的纯追加写入(O(1))和逐行流式读取,消除两个服务间的重复代码 — ADR 0144 +/// 读取兼容回退存量 .json(JSON 数组);并发保护:Actor 邮箱管道串行化写操作,消除显式锁 — TASK001 /// internal sealed class TranscriptFileWriter : IAsyncDisposable { private readonly TranscriptFileWriterActor _actor; @@ -50,7 +50,7 @@ private bool IsFileSystemRestricted { } /// - /// 追加单条记录到 JSON 文件(整存整取:读旧数组+追加+写新数组) + /// 追加单条记录到 JSONL 文件(纯追加,O(1) — ADR 0144) /// public async Task AppendEntryAsync(string filePath, TranscriptEntry entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); @@ -64,9 +64,8 @@ private async Task AppendEntryInternalAsync(string filePath, TranscriptEntry ent var entryToWrite = await MaybeOffloadToPasteStore(entry).ConfigureAwait(false); try { EnsureDirectoryExists(Path.GetDirectoryName(filePath)); - var entries = await ReadJsonAsync(filePath, cancellationToken).ConfigureAwait(false); - entries.Add(entryToWrite); - await WriteJsonAsync(filePath, entries, cancellationToken).ConfigureAwait(false); + var line = RelaxedJsonSerializer.Serialize(entryToWrite, TranscriptJsonlContext.Default); + await _fs.AppendAllTextAsync(filePath, line + '\n', cancellationToken).ConfigureAwait(false); } catch (UnauthorizedAccessException) { LogRestrictedWarning("[Transcript:WRITE:71]", filePath); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -75,7 +74,7 @@ private async Task AppendEntryInternalAsync(string filePath, TranscriptEntry ent } /// - /// 追加多条记录到 JSON 文件(整存整取:读旧数组+追加+写新数组) + /// 追加多条记录到 JSONL 文件(纯追加,批量拼行单次 IO — ADR 0144) /// public async Task AppendEntriesAsync(string filePath, IReadOnlyList entries, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entries); @@ -91,11 +90,13 @@ public async Task AppendEntriesAsync(string filePath, IReadOnlyList entries, CancellationToken cancellationToken) { try { EnsureDirectoryExists(Path.GetDirectoryName(filePath)); - var existing = await ReadJsonAsync(filePath, cancellationToken).ConfigureAwait(false); + var builder = new StringBuilder(); foreach (var entry in entries) { - existing.Add(await MaybeOffloadToPasteStore(entry).ConfigureAwait(false)); + var entryToWrite = await MaybeOffloadToPasteStore(entry).ConfigureAwait(false); + builder.Append(RelaxedJsonSerializer.Serialize(entryToWrite, TranscriptJsonlContext.Default)).Append('\n'); } - await WriteJsonAsync(filePath, existing, cancellationToken).ConfigureAwait(false); + + await _fs.AppendAllTextAsync(filePath, builder.ToString(), cancellationToken).ConfigureAwait(false); _logger?.LogDebug("{Count} transcript entries appended to {FilePath}", entries.Count, filePath); } catch (UnauthorizedAccessException) { LogRestrictedWarning("[Transcript:WRITE:108]", filePath); @@ -105,23 +106,62 @@ private async Task AppendEntriesInternalAsync(string filePath, IReadOnlyList - /// 从 JSON 文件加载所有记录(整存整取:一次反序列化为数组) + /// 从转录文件加载所有记录 — .jsonl 逐行流式解析(行级容错+paste-cache 还原,ADR 0144); + /// .jsonl 不存在时回退存量 .json(JSON 数组整体解析)。 /// public async Task> LoadTranscriptAsync(string filePath, CancellationToken cancellationToken = default) { - if (!_fs.FileExists(filePath)) { + var readablePath = ResolveReadablePath(filePath); + if (readablePath is null) { return Array.Empty(); } try { - var entries = await ReadJsonAsync(filePath, cancellationToken).ConfigureAwait(false); - return entries; + return IsJsonlPath(readablePath) + ? await ReadJsonlLinesAsync(readablePath, cancellationToken).ConfigureAwait(false) + : await ReadJsonAsync(readablePath, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger?.LogError(ex, "Failed to load transcript from {FilePath}", filePath); + _logger?.LogError(ex, "Failed to load transcript from {FilePath}", readablePath); return Array.Empty(); } } - /// 读 JSON 文件为 List(不存在返回空列表) + /// 可读路径解析 — .jsonl 优先;不存在时回退同目录 .json(存量数组格式);都不存在返回 null。 + private string? ResolveReadablePath(string filePath) { + if (_fs.FileExists(filePath)) return filePath; + var legacyPath = ToLegacyJsonPath(filePath); + return _fs.FileExists(legacyPath) ? legacyPath : null; + } + + /// .jsonl 路径 → 同目录 .json 存量路径;非 .jsonl 路径返回原值。 + private static string ToLegacyJsonPath(string filePath) => + filePath.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) ? filePath[..^6] + ".json" : filePath; + + /// 是否 JSONL 路径(扩展名判定 — 追加路径与回退路径的分派依据)。 + private static bool IsJsonlPath(string filePath) => + filePath.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase); + + /// 逐行流式解析 JSONL — 行级容错(坏行跳过) + 逐条 paste-cache 还原。 + private async Task> ReadJsonlLinesAsync(string filePath, CancellationToken cancellationToken) { + var json = await ReadAllTextWithWriteShareAsync(filePath, cancellationToken).ConfigureAwait(false); + var result = new List(); + if (json.Length == 0) return result; + + foreach (var line in json.Split('\n')) { + if (string.IsNullOrWhiteSpace(line)) continue; + try { + var entry = RelaxedJsonSerializer.Deserialize(line, TranscriptJsonContext.Default.TranscriptEntry); + if (entry is not null) { + result.Add(await ResolveFromPasteStore(entry).ConfigureAwait(false)); + } + } catch (JsonException ex) { + _logger?.LogWarning(ex, "Skipping malformed line in transcript {FilePath}", filePath); + } + } + + return result; + } + + /// 读 JSON 数组文件为 List(存量 .json 回退读取 — 不存在返回空列表)。 private async Task> ReadJsonAsync(string filePath, CancellationToken cancellationToken) { if (!_fs.FileExists(filePath)) return new List(); @@ -138,19 +178,12 @@ private async Task> ReadJsonAsync(string filePath, Cancell return result; } - /// 写 List 为 JSON 文件(带缩进,人类可读) - private async Task WriteJsonAsync(string filePath, List entries, CancellationToken cancellationToken) { - var json = RelaxedJsonSerializer.Serialize(entries, TranscriptJsonContext.Default); - await _fs.WriteAllTextAsync(filePath, json, cancellationToken).ConfigureAwait(false); - } - - /// 写空转录数组到文件(仅当文件不存在时)— 会话实体先于首条消息诞生 + /// 写空转录文件(仅当文件不存在时)— 会话实体先于首条消息诞生;JSONL 空文件即 0 字节文件(ADR 0144)。 public async Task WriteEmptyTranscriptAsync(string filePath, CancellationToken cancellationToken = default) { if (_fs.FileExists(filePath)) return; EnsureDirectoryExists(Path.GetDirectoryName(filePath)); - await WriteJsonAsync(filePath, [], cancellationToken).ConfigureAwait(false); + await _fs.WriteAllTextAsync(filePath, string.Empty, cancellationToken).ConfigureAwait(false); } - /// /// 验证 ID 只包含合法字符 /// From 42a2268815febb22f94b83db904f1af96c3f381d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:15:52 +0800 Subject: [PATCH 24/43] =?UTF-8?q?feat:=20TranscriptService/AgentTranscript?= =?UTF-8?q?Service=E5=88=87=E6=8D=A2jsonl=E8=B7=AF=E5=BE=84+=E6=B5=81?= =?UTF-8?q?=E5=BC=8Fpreview+=E5=8F=8C=E6=BA=90=E8=BF=81=E7=A7=BB=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20ListTranscripts=E6=8C=89jsonl=E8=A1=8C?= =?UTF-8?q?=E6=95=B0+=E5=B0=BE=E8=A1=8C=E8=A7=A3=E6=9E=90,TranscriptExists?= =?UTF-8?q?=E5=8F=8C=E6=89=A9=E5=B1=95=E5=90=8D=E6=A3=80=E6=9F=A5,?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=90=AB=E6=89=81=E5=B9=B3json=E4=B8=8E?= =?UTF-8?q?=E5=AD=90=E7=9B=AE=E5=BD=95=E6=95=B0=E7=BB=84=E4=B8=A4=E6=BA=90?= =?UTF-8?q?=E4=B8=94=E4=B8=8D=E5=88=A0=E6=97=A7=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TranscriptFileWriterPasteStoreTests.cs | 6 +- .../state/TranscriptServiceTests.cs | 58 ++++++- .../transcript/core/AgentTranscriptService.cs | 2 +- .../transcript/core/TranscriptService.cs | 156 ++++++++++++++---- 4 files changed, 177 insertions(+), 45 deletions(-) diff --git a/modules/storage/vault.other.tests/state/TranscriptFileWriterPasteStoreTests.cs b/modules/storage/vault.other.tests/state/TranscriptFileWriterPasteStoreTests.cs index 9b9f074093..d05a9843fa 100644 --- a/modules/storage/vault.other.tests/state/TranscriptFileWriterPasteStoreTests.cs +++ b/modules/storage/vault.other.tests/state/TranscriptFileWriterPasteStoreTests.cs @@ -20,7 +20,7 @@ public TranscriptFileWriterPasteStoreTests() { [Fact] public async Task AppendEntryAsync_SmallContent_ShouldStoreInline() { - var filePath = Path.Combine(_tempDir, "test.json"); + var filePath = Path.Combine(_tempDir, "test.jsonl"); var entry = new TranscriptEntry { SessionId = "s1", Role = "user", @@ -45,7 +45,7 @@ public async Task AppendEntryAsync_LargeContent_ShouldOffloadToPasteStore() { _pasteStore.Setup(p => p.HashPastedText(largeContent)).Returns(hash); _pasteStore.Setup(p => p.StorePastedText(hash, largeContent)); - var filePath = Path.Combine(_tempDir, "large.json"); + var filePath = Path.Combine(_tempDir, "large.jsonl"); var entry = new TranscriptEntry { SessionId = "s1", Role = "user", @@ -96,7 +96,7 @@ public async Task AppendEntryAsync_WithoutPasteStore_ShouldStoreInlineRegardless await using var writerNoPaste = new TranscriptFileWriter(_fs, _tempDir, NullLogger.Instance, pasteStore: null); var largeContent = new string('y', 2000); - var filePath = Path.Combine(_tempDir, "nopaste.json"); + var filePath = Path.Combine(_tempDir, "nopaste.jsonl"); var entry = new TranscriptEntry { SessionId = "s1", Role = "user", diff --git a/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs b/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs index e5531c940b..a802a37a80 100644 --- a/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs +++ b/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs @@ -12,12 +12,12 @@ public TranscriptServiceTests() { } [Fact] - public async Task AppendEntryAsync_Should_Create_Json_File() { + public async Task AppendEntryAsync_Should_Create_Jsonl_File() { var entry = NewEntry("user", "Hello world"); await _service.AppendEntryAsync("session-1", entry).ConfigureAwait(true); - var filePath = "/test/transcript/session-1/transcript.json"; + var filePath = "/test/transcript/session-1/transcript.jsonl"; Assert.True(_fs.FileExists(filePath)); var json = await _fs.ReadAllTextAsync(filePath).ConfigureAwait(true); @@ -68,10 +68,11 @@ public async Task LoadTranscriptAsync_Should_Return_Empty_For_Nonexistent_Sessio } [Fact] - public async Task LoadTranscriptAsync_Should_Read_Valid_Json_Array() { - var filePath = "/test/transcript/malformed/transcript.json"; - var json = "[{\"sessionId\":\"s\",\"role\":\"user\",\"content\":\"ok\",\"timestamp\":\"2025-01-01T00:00:00Z\"}]"; - await _fs.WriteAllTextAsync(filePath, json).ConfigureAwait(true); + public async Task LoadTranscriptAsync_Should_Read_Valid_Jsonl_Lines() { + var filePath = "/test/transcript/malformed/transcript.jsonl"; + _fs.CreateDirectory("/test/transcript/malformed"); + var line = RelaxedJsonSerializer.Serialize(new TranscriptEntry { SessionId = "malformed", Role = "user", Content = "ok", Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, TranscriptJsonlContext.Default); + await _fs.WriteAllTextAsync(filePath, line + "\n").ConfigureAwait(true); var transcript = await _service.LoadTranscriptAsync("malformed").ConfigureAwait(true); @@ -79,6 +80,20 @@ public async Task LoadTranscriptAsync_Should_Read_Valid_Json_Array() { Assert.Equal("ok", transcript[0].Content); } + /// 存量兼容:仅存在旧 .json(JSON 数组)的会话仍可读取(零迁移可用)。 + [Fact] + public async Task LoadTranscriptAsync_Should_Read_Legacy_Json_Array() { + var filePath = "/test/transcript/legacy-array/transcript.json"; + _fs.CreateDirectory("/test/transcript/legacy-array"); + var json = "[{\"sessionId\":\"legacy-array\",\"role\":\"user\",\"content\":\"legacy-ok\",\"timestamp\":\"2025-01-01T00:00:00Z\"}]"; + await _fs.WriteAllTextAsync(filePath, json).ConfigureAwait(true); + + var transcript = await _service.LoadTranscriptAsync("legacy-array").ConfigureAwait(true); + + Assert.Single(transcript); + Assert.Equal("legacy-ok", transcript[0].Content); + } + [Fact] public async Task ListTranscriptsAsync_Should_Return_Summaries() { await _service.AppendEntriesAsync("list-1", [NewEntry("user", "First")]).ConfigureAwait(true); @@ -107,7 +122,7 @@ public async Task ListTranscriptsAsync_Should_Be_Ordered_By_LastModified() { await _service.AppendEntryAsync("older", NewEntry("user", "old")).ConfigureAwait(true); // 等待时间戳变化 - 使用 SpinWait 替代 Task.Delay 反模式 - var olderTime = _fs.GetLastWriteTimeUtc("/test/transcript/older/transcript.json"); + var olderTime = _fs.GetLastWriteTimeUtc("/test/transcript/older/transcript.jsonl"); SpinWait.SpinUntil(() => DateTime.UtcNow > olderTime, TimeSpan.FromMilliseconds(100)); await _service.AppendEntryAsync("newer", NewEntry("user", "new")).ConfigureAwait(true); @@ -246,6 +261,7 @@ public async Task DeleteTranscriptAsync_Should_Remove_Session_Directory() { Assert.Null(await _service.GetSessionInfoAsync("dir-delete").ConfigureAwait(true)); } + /// 迁移源1:旧扁平 .json(JSONL 行格式) → {id}/transcript.jsonl。 [Fact] public async Task MigrateLegacyAsync_Should_Move_Flat_Jsonl_To_SessionDirectory() { var flatPath = "/test/transcript/legacy-migrate-1.json"; @@ -253,10 +269,31 @@ public async Task MigrateLegacyAsync_Should_Move_Flat_Jsonl_To_SessionDirectory( await _service.MigrateLegacyAsync().ConfigureAwait(true); - var newPath = "/test/transcript/legacy-migrate-1/transcript.json"; + var newPath = "/test/transcript/legacy-migrate-1/transcript.jsonl"; Assert.True(_fs.FileExists(newPath)); var content = await _fs.ReadAllTextAsync(newPath).ConfigureAwait(true); Assert.Contains("old-data", content); + // 迁移后可正常读取(含写入时 SessionId 等字段) + var loaded = await _service.LoadTranscriptAsync("legacy-migrate-1").ConfigureAwait(true); + Assert.Single(loaded); + Assert.Equal("old-data", loaded[0].Content); + } + + /// 迁移源2:子目录 transcript.json(JSON 数组) → 同目录 transcript.jsonl(ADR 0144 数组→JSONL)。 + [Fact] + public async Task MigrateLegacyAsync_Should_Convert_SessionJsonArray_To_Jsonl() { + _fs.CreateDirectory("/test/transcript/array-migrate"); + await _fs.WriteAllTextAsync("/test/transcript/array-migrate/transcript.json", + "[{\"sessionId\":\"array-migrate\",\"role\":\"user\",\"content\":\"array-entry\",\"timestamp\":\"2025-06-01T00:00:00Z\"},{\"sessionId\":\"array-migrate\",\"role\":\"assistant\",\"content\":\"reply\",\"timestamp\":\"2025-06-01T00:00:01Z\"}]").ConfigureAwait(true); + + await _service.MigrateLegacyAsync().ConfigureAwait(true); + + var jsonlPath = "/test/transcript/array-migrate/transcript.jsonl"; + Assert.True(_fs.FileExists(jsonlPath)); + var loaded = await _service.LoadTranscriptAsync("array-migrate").ConfigureAwait(true); + Assert.Equal(["array-entry", "reply"], loaded.Select(e => e.Content)); + // 幂等:旧 .json 保留(不删旧文件红线) + Assert.True(_fs.FileExists("/test/transcript/array-migrate/transcript.json")); } [Fact] @@ -267,8 +304,11 @@ public async Task MigrateLegacyAsync_Should_Be_Idempotent() { await _service.MigrateLegacyAsync().ConfigureAwait(true); await _service.MigrateLegacyAsync().ConfigureAwait(true); - var newPath = "/test/transcript/legacy-migrate-2/transcript.json"; + var newPath = "/test/transcript/legacy-migrate-2/transcript.jsonl"; Assert.True(_fs.FileExists(newPath)); + // 幂等:重复迁移不产生重复条目 + var loaded = await _service.LoadTranscriptAsync("legacy-migrate-2").ConfigureAwait(true); + Assert.Single(loaded); } private static TranscriptEntry NewEntry(string role, string content, string sessionId = "test") { diff --git a/modules/storage/vault/state/transcript/core/AgentTranscriptService.cs b/modules/storage/vault/state/transcript/core/AgentTranscriptService.cs index b735bf87a1..a903fbfdc6 100644 --- a/modules/storage/vault/state/transcript/core/AgentTranscriptService.cs +++ b/modules/storage/vault/state/transcript/core/AgentTranscriptService.cs @@ -147,7 +147,7 @@ private async Task SaveMetadataInternalAsync( private string GetAgentTranscriptPath(string sessionId, string agentId) { TranscriptFileWriter.ValidateId(sessionId, nameof(sessionId)); TranscriptFileWriter.ValidateId(agentId, nameof(agentId)); - return Path.Combine(_sessionsDirectory, sessionId, "subagents", agentId, "transcript.json"); + return Path.Combine(_sessionsDirectory, sessionId, "subagents", agentId, "transcript.jsonl"); } private string GetAgentMetadataPath(string sessionId, string agentId) { diff --git a/modules/storage/vault/state/transcript/core/TranscriptService.cs b/modules/storage/vault/state/transcript/core/TranscriptService.cs index db18eb94c5..4ac0fe8b76 100644 --- a/modules/storage/vault/state/transcript/core/TranscriptService.cs +++ b/modules/storage/vault/state/transcript/core/TranscriptService.cs @@ -77,8 +77,8 @@ public async Task> ListTranscriptsAsync(int lim var sessionId = Path.GetFileName(dir); if (string.IsNullOrEmpty(sessionId)) continue; - var transcriptPath = Path.Combine(dir, "transcript.json"); - if (!_fs.FileExists(transcriptPath)) continue; + var (transcriptPath, isJsonl) = ResolveTranscriptFile(dir); + if (transcriptPath is null) continue; var entryCount = 0; string? preview = null; @@ -86,8 +86,8 @@ public async Task> ListTranscriptsAsync(int lim try { await using var stream = _fs.CreateStream(transcriptPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); - var json = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - (entryCount, preview) = ExtractPreview(json); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + (entryCount, preview) = isJsonl ? ExtractJsonlPreview(text) : ExtractPreview(text); } catch (Exception ex) { // preview extraction 非关键,保留日志可见性 _logger?.LogDebug(ex, "TranscriptService: 会话摘要读取失败"); @@ -118,6 +118,44 @@ public async Task> ListTranscriptsAsync(int lim } } + /// 会话目录的转录文件解析 — .jsonl 优先,回退存量 .json(数组格式);都不存在返回 (null, false)。 + private (string? Path, bool IsJsonl) ResolveTranscriptFile(string sessionDir) { + var jsonlPath = Path.Combine(sessionDir, "transcript.jsonl"); + if (_fs.FileExists(jsonlPath)) return (jsonlPath, true); + var jsonPath = Path.Combine(sessionDir, "transcript.json"); + return _fs.FileExists(jsonPath) ? (jsonPath, false) : (null, false); + } + + /// + /// 从 JSONL 文本流式提取条目数与最后一条消息预览(截断至 80 字符) — 只解析尾行,不整块反序列化(ADR 0144)。 + /// + /// JSONL 文件全文。 + /// (条目数, 预览文本);文本为空时返回 (0, null)。 + private static (int EntryCount, string? Preview) ExtractJsonlPreview(string text) { + if (string.IsNullOrWhiteSpace(text)) return (0, null); + + var entryCount = 0; + string? lastLine = null; + foreach (var line in text.Split('\n')) { + if (string.IsNullOrWhiteSpace(line)) continue; + entryCount++; + lastLine = line; + } + + if (entryCount is 0 || lastLine is null) return (entryCount, null); + try { + var entry = RelaxedJsonSerializer.Deserialize(lastLine, TranscriptJsonContext.Default.TranscriptEntry); + if (entry is null) return (entryCount, null); + var preview = entry.Content; + if (preview is not null && preview.Length > 80) { + preview = preview[..80] + "..."; + } + return (entryCount, preview); + } catch (JsonException) { + return (entryCount, null); + } + } + /// /// 从转录 JSON 中提取条目数与最后一条消息预览(截断至 80 字符)。 /// @@ -158,8 +196,7 @@ public Task DeleteTranscriptAsync(string sessionId, CancellationToken canc /// public Task TranscriptExistsAsync(string sessionId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - var filePath = GetTranscriptPath(sessionId); - return Task.FromResult(_fs.FileExists(filePath)); + return Task.FromResult(_fs.FileExists(GetTranscriptPath(sessionId)) || _fs.FileExists(GetLegacyTranscriptPath(sessionId))); } /// @@ -244,6 +281,12 @@ public async Task InsertContentReplacementAsync(string sessionId, IReadOnlyList< } private string GetTranscriptPath(string sessionId) { + TranscriptFileWriter.ValidateId(sessionId, nameof(sessionId)); + return Path.Combine(_sessionsDirectory, sessionId, "transcript.jsonl"); + } + + /// 存量会话路径(JSON 数组格式,ADR 0144 前的会话) — 读取回退与迁移源。 + private string GetLegacyTranscriptPath(string sessionId) { TranscriptFileWriter.ValidateId(sessionId, nameof(sessionId)); return Path.Combine(_sessionsDirectory, sessionId, "transcript.json"); } @@ -298,7 +341,7 @@ public async Task SaveSessionInfoAsync(string sessionId, SessionInfo info, Cance } /// - /// 确保会话实体存在(幂等)— 建目录 + meta.json + 空 transcript.json;已存在不覆盖。 + /// 确保会话实体存在(幂等)— 建目录 + meta.json + 空 transcript.jsonl;已存在不覆盖。 /// public async Task EnsureSessionAsync(string sessionId, SessionInfo info, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); @@ -316,47 +359,96 @@ public async Task EnsureSessionAsync(string sessionId, SessionInfo info, C } /// - /// 迁移旧扁平 .json(直接在 sessions 根目录)到每会话子目录 {id}/transcript.json — 幂等,不删旧文件 - /// 旧格式为 JSONL(每行一个 JSON),新格式为 JSON 数组(带缩进,人类可读) + /// 迁移存量转录到 JSONL(ADR 0144) — 双源,幂等,不删旧文件: + /// ①旧扁平 .json(sessions 根目录,JSONL 行格式) → {id}/transcript.jsonl + /// ②子目录 {id}/transcript.json(JSON 数组) → 同目录 {id}/transcript.jsonl + /// 目标 .jsonl 已存在则跳过(幂等)。 /// public async Task MigrateLegacyAsync(CancellationToken cancellationToken = default) { if (!_fs.DirectoryExists(_sessionsDirectory)) return; + await MigrateFlatFilesAsync(cancellationToken).ConfigureAwait(false); + await MigrateSessionJsonArraysAsync(cancellationToken).ConfigureAwait(false); + } + + /// 迁移源①:旧扁平 .json(JSONL 行) → {id}/transcript.jsonl。 + private async Task MigrateFlatFilesAsync(CancellationToken cancellationToken) { foreach (var file in _fs.EnumerateFiles(_sessionsDirectory, "*.json", SearchOption.TopDirectoryOnly)) { cancellationToken.ThrowIfCancellationRequested(); var sessionId = Path.GetFileNameWithoutExtension(file); if (string.IsNullOrEmpty(sessionId)) continue; try { - var newDir = GetSessionDir(sessionId); - var newPath = Path.Combine(newDir, "transcript.json"); - if (_fs.FileExists(newPath)) continue; // 幂等 - - // 读旧 JSONL(逐行解析)转 JSON 数组 - var lines = await _fs.ReadAllLinesAsync(file, cancellationToken).ConfigureAwait(false); - var entries = new List(lines.Length); - foreach (var line in lines) { - if (string.IsNullOrWhiteSpace(line)) continue; - try { - var entry = RelaxedJsonSerializer.Deserialize(line, TranscriptJsonContext.Default.TranscriptEntry); - if (entry is not null) entries.Add(entry); - } catch (JsonException ex) { - _logger?.LogWarning(ex, "Skipping malformed line in legacy transcript {SessionId}", sessionId); - } - } - - if (!_fs.DirectoryExists(newDir)) { - DirectoryHelper.EnsureDirectoryExists(_fs, newDir); - } - var json = RelaxedJsonSerializer.Serialize(entries, TranscriptJsonContext.Default); - await _fs.WriteAllTextAsync(newPath, json, cancellationToken).ConfigureAwait(false); - _logger?.LogInformation("Migrated legacy transcript {SessionId} to session directory (jsonl→json)", sessionId); + var entries = await ReadLegacyJsonlEntriesAsync(file, cancellationToken).ConfigureAwait(false); + await WriteJsonlEntriesAsync(sessionId, entries, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger?.LogWarning(ex, "Failed to migrate legacy transcript {SessionId}", sessionId); } } } + /// 迁移源②:子目录 transcript.json(JSON 数组) → 同目录 transcript.jsonl。 + private async Task MigrateSessionJsonArraysAsync(CancellationToken cancellationToken) { + foreach (var dir in _fs.EnumerateDirectories(_sessionsDirectory, "*", SearchOption.TopDirectoryOnly)) { + cancellationToken.ThrowIfCancellationRequested(); + var sessionId = Path.GetFileName(dir); + if (string.IsNullOrEmpty(sessionId)) continue; + + try { + var jsonPath = Path.Combine(dir, "transcript.json"); + if (!_fs.FileExists(jsonPath)) continue; + + var json = await _fs.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(json)) continue; + + var entries = RelaxedJsonSerializer.Deserialize(json, TranscriptJsonContext.Default.ListTranscriptEntry); + if (entries is null || entries.Count == 0) continue; + + await WriteJsonlEntriesAsync(sessionId, entries, cancellationToken).ConfigureAwait(false); + _logger?.LogInformation("Migrated session transcript {SessionId} to JSONL (json→jsonl)", sessionId); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger?.LogWarning(ex, "Failed to migrate session transcript {SessionId} to JSONL", sessionId); + } + } + } + + /// 读旧 JSONL 行文件(逐行解析,坏行跳过)。 + private async Task> ReadLegacyJsonlEntriesAsync(string filePath, CancellationToken cancellationToken) { + var lines = await _fs.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false); + var entries = new List(lines.Length); + foreach (var line in lines) { + if (string.IsNullOrWhiteSpace(line)) continue; + try { + var entry = RelaxedJsonSerializer.Deserialize(line, TranscriptJsonContext.Default.TranscriptEntry); + if (entry is not null) entries.Add(entry); + } catch (JsonException ex) { + _logger?.LogWarning(ex, "Skipping malformed line in legacy transcript {FilePath}", filePath); + } + } + + return entries; + } + + /// 写 JSONL 条目到 {id}/transcript.jsonl — 目标已存在跳过(幂等);空条目列表跳过。 + private async Task WriteJsonlEntriesAsync(string sessionId, List entries, CancellationToken cancellationToken) { + var newDir = GetSessionDir(sessionId); + var newPath = GetTranscriptPath(sessionId); + if (_fs.FileExists(newPath)) return; // 幂等 + if (entries.Count == 0) return; + + if (!_fs.DirectoryExists(newDir)) { + DirectoryHelper.EnsureDirectoryExists(_fs, newDir); + } + + var builder = new StringBuilder(); + foreach (var entry in entries) { + builder.Append(RelaxedJsonSerializer.Serialize(entry, TranscriptJsonlContext.Default)).Append('\n'); + } + + await _fs.WriteAllTextAsync(newPath, builder.ToString(), cancellationToken).ConfigureAwait(false); + _logger?.LogInformation("Migrated legacy transcript {SessionId} to session directory JSONL", sessionId); + } + /// /// 释放转录文件写入器资源。 /// From fb56b1ef042c3d1fd022dc6f798112d1662a0698 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:04:35 +0800 Subject: [PATCH 25/43] =?UTF-8?q?feat:=20M7-6=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=20Host/Demo=20=E6=8E=A5=E7=BA=BF=20?= =?UTF-8?q?|=20=E5=86=B3=E7=AD=96:=20Host=20=E4=BB=8E=E5=BC=95=E6=93=8E=20?= =?UTF-8?q?IModelCatalog=20=E5=90=8E=E7=AB=AF=E8=8E=B7=E5=8F=96=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=8CApplyModel=20=E5=A4=8D=E5=88=BB=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=93=BE(SetPrimaryModel+settings=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96+fast=20mode=E8=87=AA=E5=85=B3)=EF=BC=9BDemo=20?= =?UTF-8?q?=E7=94=A8=E6=A8=A1=E6=8B=9F=E6=B8=85=E5=8D=95=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E8=B7=AF=E5=BE=84=EF=BC=9B=E5=9D=87=E7=BB=8F?= =?UTF-8?q?=20ModelPickerEngine=20=E8=A3=85=E9=A5=B0=E5=99=A8=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E7=BB=91=E5=AE=9A=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Demo/Program.cs | 16 +++++- .../apps/NativeTui.Host/GlobalUsings.cs | 2 + .../NativeTui.Host/ModelPickerAssembly.cs | 50 +++++++++++++++++++ native_tui/apps/NativeTui.Host/Program.cs | 25 +++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index 48f8062477..d8ee5c6cf7 100644 --- a/native_tui/apps/NativeTui.Demo/Program.cs +++ b/native_tui/apps/NativeTui.Demo/Program.cs @@ -68,20 +68,32 @@ private static int RunInteractive(string? model = null, bool viewport = false) var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0"; var info = SessionInfo.Create(version, model); var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory); - var shell = new ReplShell(new SimulatedEngine(), banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), commands: DemoCommands(), codeHighlighter: new TextMateCodeHighlighter(dark: true)); + // M7-6 模型选择器演示 — 装饰模拟引擎拦截 /model 打开 Picker(模拟清单,真实引擎见 NativeTui.Host) + var pickerEngine = new ModelPickerEngine(new SimulatedEngine(), DemoModelItems, _ => { }); + var shell = new ReplShell(pickerEngine, banner, Console.Out, inline: !viewport, seedMessages: SeedMessages(), commands: DemoCommands(), codeHighlighter: new TextMateCodeHighlighter(dark: true)); + pickerEngine.Attach(shell); shell.Run(); shell.WriteExit(); Console.WriteLine("NativeTui.Demo 已退出。"); return 0; } - /// Demo 演示命令清单(斜杠补全数据源)— 与 SimulatedEngine.ParseCommand 路由同源。 + /// Demo 演示命令清单(斜杠补全数据源)— 与 SimulatedEngine.ParseCommand 路由同源 + /model(ModelPickerEngine 装饰器拦截)。 private static IReadOnlyList DemoCommands() => [ new CommandSuggestion("md", "Markdown 全形态渲染", [], IsHidden: false), new CommandSuggestion("diff", "结构化补丁", [], IsHidden: false), new CommandSuggestion("perm", "权限对话框", [], IsHidden: false), new CommandSuggestion("all", "完整回合(补丁 + Markdown)", [], IsHidden: false), + new CommandSuggestion("model", "切换模型(M7-6 选择器)", [], IsHidden: false), + ]; + + /// Demo 模拟模型清单(M7-6 选择器演示 — 真实引擎从 IModelCatalog 后端获取,见 NativeTui.Host ModelPickerAssembly)。 + private static IReadOnlyList DemoModelItems() => + [ + new ModelPickerItem("gpt-5.3", "GPT 5.3", "最强推理 · $3/$0.22 per token", IsCurrent: true), + new ModelPickerItem("gpt-5.3-mini", "GPT 5.3 Mini", "日常任务最佳 · $0.4/$0.03 per token", IsCurrent: false), + new ModelPickerItem("gpt-nano", "GPT Nano", "最快响应 · 轻量便宜", IsCurrent: false), ]; /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染;末条为演示命令提示)。 diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs index 9dc05a6ae5..82e60d8777 100644 --- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs @@ -4,6 +4,8 @@ global using Core.Permission; global using Core.Utils; global using JoinCode.Abstractions.Entity; +global using JoinCode.Abstractions.Configuration.AppData; +global using JoinCode.Abstractions.Configuration.Providers; global using JoinCode.Abstractions.Interfaces; global using JoinCode.Abstractions.LLM.Chat; global using JoinCode.Abstractions.Models.Diff; diff --git a/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs b/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs new file mode 100644 index 0000000000..8b94eec14d --- /dev/null +++ b/native_tui/apps/NativeTui.Host/ModelPickerAssembly.cs @@ -0,0 +1,50 @@ +namespace NativeTui.Host; + +/// +/// 模型选择器装配(M7-6 全部自研)— 清单从引擎 IModelCatalog 投影(后端获取,青蛙TUI 本地拉取方案已否决); +/// 切换链自研不经 slash 命令系统(用户决策:不依赖既有命令链防隐藏 bug): +/// IFastModeService 内存切换 + IConfigurationService 持久化(键 "model" 与 SettingsJson 生成器 jsonName 一致, +/// 语义对齐 ModelCommand.ApplyModelSwitchAsync 同名路径)+ fast mode 自动关(新模型不支持时)。 +/// +internal static class ModelPickerAssembly +{ + /// 从引擎服务投影选择器清单 — catalog 缺失时单条降级项(不阻断 UI)。 + internal static IReadOnlyList LoadItems(IServiceProvider services) + { + var fastMode = services.GetService(); + var current = fastMode?.PrimaryModelId ?? string.Empty; + var catalog = services.GetService(); + if (catalog is null) + { + var fallback = current.Length > 0 ? current : "模型目录不可用"; + return [new ModelPickerItem(fallback, fallback, string.Empty, IsCurrent: true)]; + } + + var provider = CurrentProvider(); + var models = catalog.EnsureCurrentModelInList(catalog.GetModelsForProvider(provider), current); + return models + .Select(m => new ModelPickerItem(m.Id, m.DisplayName, m.Description, m.Id.Equals(current, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + } + + /// 自研切换链 — 内存主模型 + settings.json 持久化 + fast mode 自关(不支持新模型时)。 + internal static void ApplyModel(IServiceProvider services, string modelId) + { + var fastMode = services.GetService(); + fastMode?.SetPrimaryModel(modelId); + services.GetService()?.SetAsync("model", modelId).GetAwaiter().GetResult(); + + if (fastMode is { IsFastModeActive: true }) + { + var catalog = services.GetService(); + if (catalog is not null && !catalog.SupportsFastMode(modelId, CurrentProvider())) + { + fastMode.Deactivate(); + } + } + } + + /// 当前提供商 — 环境变量兜底链(对齐 ModelCommand.GetCurrentProvider 同款取值顺序,Host 无 ChatCommandContext)。 + private static string CurrentProvider() => + Environment.GetEnvironmentVariable(JccEnvVar.Vendor.ToValue()) ?? VendorKind.OpenAi.ToValue(); +} diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index cdba58bb4d..46328e885a 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -50,7 +50,13 @@ private static async Task RunInteractiveAsync() { // banner 占位值 — 首拍 session_info 种子事件覆盖为引擎真实数据(EngineSession.Enqueue) var banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory); var commands = SlashCommandSource.Load(session.Services); - var shell = new ReplShell(session.Client, banner, tuiOutput, commands: commands); + // M7-6 模型选择器 — 装饰引擎拦截 /model(清单从引擎 IModelCatalog 后端获取,切换链自研) + var pickerEngine = new ModelPickerEngine( + session.Client, + () => ModelPickerAssembly.LoadItems(session.Services), + id => ModelPickerAssembly.ApplyModel(session.Services, id)); + var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands); + pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); shell.Run(); @@ -83,6 +89,13 @@ private static async Task RunSelftestAsync() { events = session.Client.DrainEvents(); AssertEvent(events, e => e is BridgeEvent.Error err && err.Message.Contains("plan"), "无效模式 Error 诱导(含合法值提示)", failures); + // M7-6 — 模型选择器后端获取链路:引擎 DI 必须可解析 IModelCatalog(GUI 会话模块链注册验证) + var catalog = session.Services.GetService(); + if (catalog is null) + failures.Add("IModelCatalog 未在引擎 DI 注册(模型选择器将降级单条项)"); + else + AssertModelItems(ModelPickerAssembly.LoadItems(session.Services), failures); + if (failures.Count > 0) { foreach (var failure in failures) Console.Error.WriteLine("FAIL: " + failure); @@ -98,4 +111,14 @@ private static void AssertEvent(IReadOnlyList events, Func e.GetType().Name))})"); } + + /// 断言模型选择器清单有效(M7-6 — 非空 + 含当前模型标记 + 显示名非空)。 + private static void AssertModelItems(IReadOnlyList items, List failures) { + if (items.Count == 0) + failures.Add("模型清单为空(GetModelsForProvider 返回空且降级项未生效)"); + else if (items.All(m => !m.IsCurrent)) + failures.Add($"模型清单无当前模型标记({items.Count} 项,EnsureCurrentModelInList 语义失效)"); + if (items.Any(m => string.IsNullOrWhiteSpace(m.DisplayName))) + failures.Add("模型清单存在空 DisplayName"); + } } From f4d7c65e516dcd32a527384f443f39918e843c4d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:05:35 +0800 Subject: [PATCH 26/43] =?UTF-8?q?docs:=20TASK055=20M7-6=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=E5=99=A8=E8=90=BD=E5=9C=B0=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E5=9D=97(=E5=9B=9B=E5=88=97=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=A1=A8+=E5=9F=BA=E5=87=86=E5=AF=B9=E9=BD=90=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9+=E8=87=AA=E7=A0=94=E5=86=B3=E7=AD=96=E8=AE=B0?= =?UTF-8?q?=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 2bfde9da4d..9ebcbb8155 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -872,3 +872,51 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g +## M7-6 模型选择器落地(2026-10-21,用户决策"全部自研") + +### 方案决策(用户原话:"全部自研啊,之前的功能说不定有什么隐藏的bug,我们自己干才是最好的") + +| 维度 | 落地选择 | 与调研方案 A 的差异 | +|------|---------|-------------------| +| UI 渲染 | 自研 ModelPickerDialog(IScrollItem + ReplShell 键盘模态) | 同 A | +| 数据来源 | 引擎进程内 IModelCatalog(后端获取) | 同 A | +| 切换链路 | **自研 ApplyModel**(SetPrimaryModel + IConfigurationService.SetAsync("model") 持久化 + IFastModeService 自关),**不经 SlashCommandRunner/ModelCommand** | A 原推荐委托 SlashCommandRunner 执行 /model <id>,用户否决——避免继承 TerminalHelper 隐藏 bug 面 | +| 装配方式 | ModelPickerEngine 装饰 IReplEngine 拦截无参 "/model"(带参透传),Attach(shell) 延迟绑定(new 后调,避免构造环) | A 未细化 | + +### 实施记录(2 commit 链) + +| 阶段 | commit | 内容 | +|------|--------|------| +| Core 层 | d0b07e441 | ModelPickerItem DTO + Coordinator(MaxVisible=10 窗口滚动 clamp)+ ModelPickerDialog(IScrollItem 渲染:边框/标题/副标题/列表行 ✓❯/描述/溢出 "and N more…"/快捷键行)+ ModelPickerEngine 装饰器(拦截+Attach+确认回调自持清单闭包)+ ReplShell 三方法(BeginModelPicker/CompleteModelPicker/PostInfo)+ HandleModelPickerKey 模态(优先级:权限对话框 > 模型选择器);全量 672/672 绿(含 27 新测试) | +| Host/Demo 接线 | c56ae3dc3 | Host:ModelPickerAssembly(LoadItems 投影 IModelCatalog→ModelPickerItem + ApplyModel 三步切换链)+ pickerEngine 装配 + selftest 加模型清单断言(IModelCatalog 可解析实证);Demo:RunInteractive 同装配 + DemoCommands 加 model 条目 + DemoModelItems 模拟清单 | + +### 实现期实证 + +- **IModelCatalog 在引擎 DI 可解析**(Host selftest 断言实证,GUI 会话模块链注册确认——调研块存疑项闭环) +- 键名实证:IConfigurationService.SetAsync("model", id) 的 "model" 与 SettingsJson jsonName 一致(ModelCommand.ApplyModelSwitchAsync 同源) +- ModelPickerCoordinator 用**回调而非 TCS**(区别于权限对话框——无引擎侧阻塞等待方,选中即回调解册) +- 测试坑:IDE0007 强制 var → 可空捕获用 `var selected = new[] { (string?)"sentinel" }` 数组模式;RowText.Of 返回全宽含尾空格 → 行文本断言用 StartsWith + +### 四列验收表(AGENTS.md 强制) + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| Core ModelPicker.cs(Item DTO + Coordinator:MaxVisible=10/窗口滚动/clamp) | ModelPickerEngine 装饰器 + ReplShell.BeginModelPicker | ✅ | ✅ ModelPickerTests 7/7(Coordinator 窗口/边界) | +| Core ModelPickerDialog.cs(边框/标题/列表行 ✓❯/描述/溢出/快捷键行渲染) | ReplShell 滚动区对话框项 | ✅ | ✅ ModelPickerTests 9/9(渲染断言含溢出行/当前标记) | +| Core ModelPickerEngine.cs("/model" 无参拦截 + Attach 延迟绑定 + 回调闭包) | Host/Demo Program.cs 装配(装饰 session.Client / SimulatedEngine) | ✅ | ✅ ModelPickerTests 5/5(拦截/透传/确认链) | +| Core ReplShell 模态(HandleModelPickerKey 优先级在权限后 + CompleteModelPicker 收尾) | 键盘路由 + ModelPickerEngine 确认回调 | ✅ | ✅ ModelPickerTests 6/6(模态打开/Esc 取消/Enter 确认)+ 全量 672/672 | +| Host ModelPickerAssembly(LoadItems ← IModelCatalog + ApplyModel 三步切换链) | Host Program.cs pickerEngine 装配 | ✅ | ✅ ADR 0080:Host --selftest 真实 exe 绿(含 IModelCatalog 可解析断言);真实终端 /model 交互验收待用户跑 NativeTui.Host.exe 确认 | +| Demo 装配(DemoModelItems 模拟清单 + DemoCommands 加 model 条目) | Demo Program.cs RunInteractive | ✅ | ✅ ADR 0080:Demo --selftest 绿;真实终端 /model 交互验收待用户跑 NativeTui.Demo.exe 确认 | + +### 基准对齐要点核对(对照调研块 Picker 形态行) + +单列平铺不分组 ✅ / min(10,n) 可见 ✅(MaxVisible=10)/ 超出 "and N more…" ✅ / ✓ 当前标记 + ❯ 焦点指示 ✅ / 切换反馈(PostInfo 消息)✅ / fast mode 自关 ✅(ApplyModel 三步之一)。未对齐项:effort 行(基准 ModelPicker.tsx:327-336 的 low/medium/high/max 循环——本项目 effort 概念尚未进入模型配置,属后续里程碑)。 + + + + + + + + + From 49bc9a1b07e1185a4a108e70e0895cc88fd94163 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:16:42 +0800 Subject: [PATCH 27/43] =?UTF-8?q?docs:=20TASK055=20M7-7=20=E5=A4=9A?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E5=88=86=E7=BB=84=E8=B0=83=E7=A0=94?= =?UTF-8?q?=E5=9D=97(=E5=9F=BA=E5=87=86=E7=BB=84=E5=A4=B4=E4=B8=89?= =?UTF-8?q?=E6=80=81+=E9=80=90=E8=A1=8C=E6=9C=BA=E5=88=B6+=E5=88=86?= =?UTF-8?q?=E7=BB=84=E9=94=AEmessageId=E7=9C=9F=E7=9B=B8+=E6=9C=AC?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=85=AB=E8=89=B2=E6=9D=BF/ModelPicker?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 9ebcbb8155..b3ddbb574f 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -920,3 +920,46 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g +## M7-7 多子代理分组调研(2026-10-21,基准源码 + 本项目渲染现状双 explore) + +### 基准机制真相(claude-code-rev-main 全读结论) + +| 项 | 基准实现 | 文件:行号 | +|----|---------|----------| +| 分组数据结构 | `GroupedToolUseMessage {toolName, messages[], results[]}`;分组键 = `${messageId}:${toolName}`,**同一条 assistant API 响应内 ≥2 个同类 tool_use 才聚合**;verbose 跳过分组 | utils/groupToolUses.ts:54-181, 76, 91 | +| 分发接口 | Tool.ts 可选 `renderGroupedToolUse`,**目前仅 AgentTool 实现** | Tool.ts:678-694, messageActions.tsx:121 | +| 组头三态 | `● Running N {type} agents…`(运行中,圆点闪烁)/ `N {type} agents finished`(全完成,绿)/ `N background agents launched (↓ manage)`(全后台);全组同类型时组头带类型名、行内隐藏类型 | AgentTool/UI.tsx:732-756, ToolUseLoader.tsx:11-41 | +| 逐代理行 | 两行式:`├─ Type (desc) · N tool uses · M tokens` + `│ ⎿ `,最后一行 `└─`/` ⎿`;运行中整行 dim | AgentProgressLine.tsx:23-135 | +| 组内规模 | **组内行数无截断**(N 代理 N 行),靠消息级虚拟化 + 行级 React memo + OffscreenFreeze 静态冻结扛 | Messages.tsx:307, VirtualMessageList.tsx:289-343 | +| 状态聚合 | 组级 anyUnresolved/anyError/allComplete → 组头文案与 loader 颜色(错误红);**无数值汇总**(无 "N succeeded, M failed" 字样),失败仅组级视觉 | AgentTool/UI.tsx:728-737 | +| 单代理进度截断 | 行内只显示最后 3 条工具活动 + `+N more tool uses (ctrl+o to expand)` | AgentTool/UI.tsx:33, 564-567 | +| agent 配色 | 8 色板(red/blue/green/yellow/purple/orange/pink/cyan)按 agentType 全局分配,general-purpose 无色 | agentColorManager.ts:14-50 | +| 数据流 | 子代理每条消息 → `agent_progress` 流式 append 进主消息数组(**禁止替换**否则卡死 Initializing);UI 每次 commit 整组重算整组替换 | AgentTool.tsx:795-804, REPL.tsx:2647-2650 | +| 小终端自适应 | 行数 < 并发数×9+7 时单代理进度塌缩单行 `In progress… · N tool uses · M tokens` | AgentTool/UI.tsx:181-182, 469-503 | + +**关键边界**:分组单元是"一次 API 响应内的 tool_use 批次"——300 个子代理跨多条 assistant 消息派发会拆成多组。 + +### 本项目现状(可复用资产 + 核心缺口) + +| 资产 | 位置 | 状态 | +|------|------|------| +| ModelPicker "Coordinator + Dialog 投影"整套模式(窗口/HiddenCount/"and N more…"/clamp) | repl/ModelPicker.cs:17-99 + ModelPickerDialog.cs:13-84 | ✅ 分组项直接模板 | +| ReplBridgeDriver 唯一翻译层(Apply switch) | bridge/ReplBridgeDriver.cs:48-111 | ✅ 接线点明确 | +| **Theme 子代理八色板(已定义已赋值零消费者)** | Theme.cs:107-130, :259-266 | ✅ 就等消费 | +| SpinnerAnimator 时钟注入 + Render 自绘 | ReplShell.cs:690-692 + SpinnerItem.cs:105-116 | ⚠️ 单时钟硬编码给 spinner,需扩展广播 | +| FooterState.AgentsHint 提示位 | FooterState.cs:19 + FooterBar.cs:41 | ✅ 已埋 | +| 溢出行文案先例(" and N more…" dim / "… +N lines") | MessageRenderer.cs:223-228, :95-99 | ✅ 照抄 | + +**核心缺口**:① BridgeEvent 无子代理粒度事件(ToolCall 仅 Name+Args,tool 名 "Task" 与普通工具同路径——300 子代理=1200 行逐条刷屏,即本任务要解决的问题);② 无消息级虚拟化(inline 模式全量屏 EnsureScreens,300 组行=600+ 行 Screen,分组折叠同时是性能需求);③ ScrollBox 无"替换/合并已有项"API(分组项需原地合并非追加);④ SimulatedEngine 单阶段 int 状态机,并行子回合需改列表。 + +### 方案对比(组内 300 行渲染策略) + +| 方案 | 内容 | 优劣 | +|------|------|------| +| **A: 组内可见窗口(推荐)** | SubagentGroupItem 复用 ModelPicker 模式:MaxVisible 常量 + WindowStart 滚动 + " and N more…" 溢出行 | 性能稳(高度 O(MaxVisible) 不随 N 膨胀,300 规模下 inline 全量屏不炸);与基准"组头+逐行"视觉一致;**有意偏离基准**(基准组内不截断,但靠 React 虚拟化扛——我们没有虚拟化,窗口是对等机制) | +| B: 组内全量渲染 | 对齐基准 N 代理 N 行全渲染 | 像素级对齐最彻底;但 300×2 行全量屏每帧 O(n) 测量+渲染,且 ScrollBox 内容仍在 sticky 视口外仍测量;性能风险实际存在 | + +**分组语义(自主决策)**:本项目 bridge 协议无 messageId 概念,采用**按 turn 分组**——TurnStarted 建组、TurnCompleted 定格(一组 = 一轮用户提交的全部 Task 类子代理)。基准的 messageId 分组跨轮也拆组,语义近似(一次响应 ≈ 一轮内的一个批次),且 turn 边界事件已存在协议中,零新增协议负担。 + + + From ec6ec6ffd37717c99e1c2dfefeb46422784333f5 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:20:39 +0800 Subject: [PATCH 28/43] =?UTF-8?q?docs:=20ADR=200144=20accepted+TASK057?= =?UTF-8?q?=E4=BB=BB=E5=8A=A11=E5=AE=8C=E7=BB=93(=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E4=BC=9A=E8=AF=9D=E8=BF=81=E7=A7=BB=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E8=AE=B0=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/proposed/adr/0144-transcript-jsonl-append-streaming.md | 2 +- ...\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md b/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md index 81a6ed1ab7..8980fbf344 100644 --- a/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md +++ b/docs/proposed/adr/0144-transcript-jsonl-append-streaming.md @@ -1,6 +1,6 @@ # ADR 0144: 会话转录存储 JSON 数组改 JSONL 纯追加(按需流式读取) -- 状态:proposed +- 状态:accepted(2026-10-21 落地验证:单元 86 + 集成 2 + 真实存量会话迁移验收 OK;写入侧经真实引擎链路产出 transcript.jsonl 确认) - 日期:2026-10-21 - 决策者:liuqihong(用户指令:会话改 jsonl 按需读取和追加,现在逻辑有问题) diff --git "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" index 6e13e962f7..ab7d74665f 100644 --- "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" +++ "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" @@ -8,7 +8,7 @@ | # | 任务 | 优先级 | 状态 | |---|------|--------|------| -| 1 | **transcript 存储改 JSONL**(按需读取+纯追加,消除整存整取) | P0 用户指令 | ⬜ | +| 1 | **transcript 存储改 JSONL**(按需读取+纯追加,消除整存整取) | P0 用户指令 | ✅ 49f3b6e+0160ed8(86+5+2+5 测试,真实存量会话迁移验收 OK,ADR 0144 accepted) | | 2 | 参数补全(/model xxx Argument 模式) | P1 | ⬜ | | 3 | 幽灵文本(mid-input /xx 补全) | P2 | ⬜ | | 4 | Ctrl+R 历史搜索 | P3 | ⬜ | From 8a13dadaf011f50f57d93d593e291c7474af4006 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:27:43 +0800 Subject: [PATCH 29/43] =?UTF-8?q?feat:=20M7-7=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=88=86=E7=BB=84Core=E5=B1=82(=E7=BB=84=E5=A4=B4?= =?UTF-8?q?=E4=B8=89=E6=80=81+=E9=80=90=E4=BB=A3=E7=90=86=E4=B8=A4?= =?UTF-8?q?=E8=A1=8C=E5=BC=8F+=E5=8F=AF=E8=A7=81=E7=AA=97=E5=8F=A3)=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20SubagentGroupCoordinator=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA(=E4=BA=8B=E4=BB=B6=E9=A9=B1=E5=8A=A8+Finaliz?= =?UTF-8?q?ePending=E5=9B=9E=E5=90=88=E5=AE=9A=E6=A0=BC+=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E8=B7=9F=E9=9A=8F=E6=BB=9A=E5=8A=A8,?= =?UTF-8?q?=E5=8C=BA=E5=88=AB=E4=BA=8E=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E7=84=A6=E7=82=B9=E8=B7=9F=E9=9A=8F)=E7=BB=84?= =?UTF-8?q?=E5=A4=B4=E2=97=8F=E4=B8=89=E6=80=81=E8=89=B2(=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=BA=A2/=E5=AE=8C=E6=88=90=E7=BB=BF/=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=ADSuggestion)+Running=20N=20agents=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=85=A8=E5=90=8C=E5=9E=8B=E5=B8=A6=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=90=8D;SubagentGroupItem=E4=B8=A4=E8=A1=8C=E5=BC=8F=E6=A0=91?= =?UTF-8?q?=E5=BD=A2=E2=94=9C=E2=94=80=E2=94=94=E2=94=80+=E5=85=AB?= =?UTF-8?q?=E8=89=B2=E6=9D=BFFNV-1a=E7=A8=B3=E5=AE=9A=E5=8F=96=E8=89=B2(Th?= =?UTF-8?q?eme.SubagentColor=E9=A6=96=E4=B8=AA=E6=B6=88=E8=B4=B9=E8=80=85)?= =?UTF-8?q?+and=20N=20more=E6=BA=A2=E5=87=BA=E5=A4=8D=E7=94=A8;=E7=BB=84?= =?UTF-8?q?=E5=86=85=E7=AA=97=E5=8F=A3=E6=96=B9=E6=A1=88A(=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=A1=AE=E8=AE=A4,=E5=9F=BA=E5=87=86=E9=9D=A0React?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E5=8C=96=E6=9C=AC=E9=A1=B9=E7=9B=AE=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E4=B8=BA=E5=AF=B9=E7=AD=89=E6=9C=BA=E5=88=B6);BridgeE?= =?UTF-8?q?vent=E5=8A=A03=E4=BA=8B=E4=BB=B6(TokenGain=E9=81=BF=E5=BC=80?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E7=B1=BB=E5=9E=8B=E6=92=9E=E5=90=8D)+Driver?= =?UTF-8?q?=E6=83=B0=E6=80=A7=E5=BB=BA=E7=BB=84=E8=B7=AF=E7=94=B1+?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=AE=9A=E6=A0=BC=E8=84=B1=E7=A6=BB;?= =?UTF-8?q?=E5=85=A8=E9=87=8F694/694=E7=BB=BF=E5=90=AB22=E6=96=B0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/src/NativeTui.Core/Theme.cs | 30 ++ .../src/NativeTui.Core/bridge/BridgeEvent.cs | 18 + .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 38 +- .../NativeTui.Core/repl/MessageRenderer.cs | 56 +++ .../src/NativeTui.Core/repl/SubagentGroup.cs | 138 +++++++ .../NativeTui.Core/repl/SubagentGroupItem.cs | 53 +++ .../repl/SubagentGroupTests.cs | 371 ++++++++++++++++++ 7 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/repl/SubagentGroup.cs create mode 100644 native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs diff --git a/native_tui/src/NativeTui.Core/Theme.cs b/native_tui/src/NativeTui.Core/Theme.cs index 767cce9592..6af41a92c5 100644 --- a/native_tui/src/NativeTui.Core/Theme.cs +++ b/native_tui/src/NativeTui.Core/Theme.cs @@ -222,6 +222,36 @@ public sealed record Theme /// 彩虹紫 shimmer。 public Color RainbowVioletShimmer { get; init; } + /// + /// 子代理类型取色(M7-7)— 八色板按 AgentType 稳定哈希分配(基准 agentColorManager 按 agentType + /// 全局分配 8 色板;本项目 FNV-1a 哈希 → 色板索引,同类型恒同色、进程间稳定)。 + /// + public Color SubagentColor(string agentType) => + (Fnv1a(agentType) % 8) switch + { + 0 => RedForSubagentsOnly, + 1 => BlueForSubagentsOnly, + 2 => GreenForSubagentsOnly, + 3 => YellowForSubagentsOnly, + 4 => PurpleForSubagentsOnly, + 5 => OrangeForSubagentsOnly, + 6 => PinkForSubagentsOnly, + _ => CyanForSubagentsOnly, + }; + + /// FNV-1a 稳定哈希(跨进程确定 — 字符串 GetHashCode 进程随机化不可用于稳定取色)。 + private static uint Fnv1a(string text) + { + var hash = 2166136261u; + foreach (var c in text) + { + hash ^= c; + hash *= 16777619; + } + + return hash; + } + /// 暗色主题(默认)— 全量对齐基准 theme.ts darkTheme。 public static Theme Dark { get; } = new() { diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index bcd64659f3..9a0837df20 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -19,6 +19,9 @@ namespace NativeTui.Core.Bridge; [JsonDerivedType(typeof(AssistantMessage), "assistant_message")] [JsonDerivedType(typeof(TurnCompleted), "turn_completed")] [JsonDerivedType(typeof(TurnInterrupted), "turn_interrupted")] +[JsonDerivedType(typeof(SubagentStarted), "subagent_started")] +[JsonDerivedType(typeof(SubagentToolUse), "subagent_tool_use")] +[JsonDerivedType(typeof(SubagentFinished), "subagent_finished")] [JsonDerivedType(typeof(Error), "error")] public abstract record BridgeEvent { @@ -72,6 +75,21 @@ public sealed record TurnCompleted() : BridgeEvent; /// 回合被打断(idle)。 public sealed record TurnInterrupted() : BridgeEvent; + /// + /// 子代理启动(M7-7 分组渲染入口)— Driver 惰性建组(回合内首个子代理创建组项落位滚动区), + /// 同回合内全部子代理归一组(分组语义 = 按 turn,基准按 messageId 批次 — 本项目 bridge 协议无消息边界)。 + /// + public sealed record SubagentStarted(string SubagentId, string AgentType, string Description) : BridgeEvent; + + /// + /// 子代理工具活动(进度增量)— 组内条目计数/令牌累计 + 最后活动更新(基准 agent_progress 流式 append 语义; + /// 本项目路由到组条目原地更新,不逐条刷屏)。TokenGain = 本次活动新增令牌(参数名避开嵌套类型 TokenDelta)。 + /// + public sealed record SubagentToolUse(string SubagentId, string ToolName, string Summary, int TokenGain) : BridgeEvent; + + /// 子代理完成 — 条目定格(isError 聚合组头 ● 红;基准基准 agent 完成态 Done)。 + public sealed record SubagentFinished(string SubagentId, bool IsError) : BridgeEvent; + /// 错误(含诱导说明)。 public sealed record Error(string Message) : BridgeEvent; } diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index f9830295d5..3c130749c8 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -21,6 +21,9 @@ public sealed class ReplBridgeDriver /// 代码高亮器(宿主注入的卫星插件 — null = 高亮禁用降级纯文本,MarkdownMessageItem 消费)。 private readonly ICodeHighlighter? _codeHighlighter; + /// 活动子代理组(M7-7)— 回合内惰性建组,TurnCompleted/Interrupted 定格脱离;历史组留滚动区。 + private SubagentGroupCoordinator? _activeSubagentGroup; + /// 创建应用器(view/spinner/banner 由调用方构造并挂载,spinner 须为 Messages 末项、banner 为首项; /// onPermissionRequest = 权限请求挂起钩子,null = 忽略权限请求事件(纯视图断言测试场景); /// codeHighlighter = 代码高亮插件(M7-5),null = 高亮禁用)。 @@ -103,6 +106,20 @@ public void Apply(BridgeEvent bridgeEvent) break; case BridgeEvent.TurnInterrupted: EndTurn(); + break; + case BridgeEvent.SubagentStarted(var subagentId, var agentType, var description): + ApplySubagentStarted(subagentId, agentType, description); + break; + case BridgeEvent.SubagentToolUse(var subagentId, var toolName, var summary, var tokenGain): + _activeSubagentGroup?.RecordToolUse(subagentId, toolName, summary, tokenGain); + break; + case BridgeEvent.SubagentFinished(var subagentId, var isError): + _activeSubagentGroup?.FinishAgent(subagentId, isError); + if (_activeSubagentGroup is { AllComplete: true }) + { + _spinner.HasActiveTools = false; + } + break; case BridgeEvent.Error(var message): AddMessage(new ErrorMessageItem(message)); @@ -110,6 +127,20 @@ public void Apply(BridgeEvent bridgeEvent) } } + /// 子代理启动 — 惰性建组(组项落位 spinner 前)+ 条目追加 + spinner 切 ToolUse(子代理运行中主循环有活跃工具)。 + private void ApplySubagentStarted(string subagentId, string agentType, string description) + { + if (_activeSubagentGroup is null) + { + _activeSubagentGroup = new SubagentGroupCoordinator(); + AddMessage(new SubagentGroupItem(_activeSubagentGroup)); + } + + _activeSubagentGroup.AddAgent(subagentId, agentType, description); + _spinner.Mode = SpinnerMode.ToolUse; + _spinner.HasActiveTools = true; + } + /// 应用会话握手:banner 换引擎真实数据(模型 null → "当前模型未设置")+ footer 初始模式 + bypass 可用性。 private void ApplySessionInfo(string version, string? model, string permissionMode, bool bypassAvailable, string cwd) { @@ -123,10 +154,11 @@ private void ApplySessionInfo(string version, string? model, string permissionMo } } - /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置)。 + /// 开始回合:busy + spinner 挂载(Thinking 乐观模式,动词重采样,停滞计时重置);防御性脱离子代理组(引擎漏发回合收尾时不串组)。 public void BeginTurn() { IsBusy = true; + _activeSubagentGroup = null; _view.Footer = _view.Footer with { Busy = true }; _spinner.Message = string.Empty; _spinner.ResetVerb(); @@ -136,10 +168,12 @@ public void BeginTurn() _spinner.HasActiveTools = false; } - /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加)。 + /// 结束回合:idle + spinner 复位(消息由事件驱动,interrupted 不额外追加);子代理组定格脱离(未完成条目定格为非错误完成)。 public void EndTurn() { IsBusy = false; + _activeSubagentGroup?.FinalizePending(); + _activeSubagentGroup = null; _view.Footer = _view.Footer with { Busy = false }; _spinner.Message = string.Empty; _spinner.ResponseLength = 0; diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index 9bd38b433d..4e26420680 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -234,6 +234,62 @@ public static void RenderModelShortcuts(Screen screen, Rect frame, Theme theme, screen.WriteText(frame.Y, frame.X, $" {shortcuts}", dim, frame.Right); } + /// + /// 渲染子代理组头行(M7-7,基准 ToolUseLoader + 组头文案): + /// ● + 空格 + 文案("Running N {type} agents…" / "N {type} agents finished")。 + /// ● 三态色:任一错误 Error 红(基准 anyError loader 变红)/ 全完成 Success 绿 / 运行中 Suggestion。 + /// + public static void RenderSubagentHeader(Screen screen, Rect frame, Theme theme, string text, bool isDone, bool hasError) + { + var bulletColor = hasError ? theme.Error : isDone ? theme.Success : theme.Suggestion; + var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = bulletColor }); + screen.WriteText(frame.Y, frame.X, $"{Figures.MessageBullet} ", bullet, frame.Right); + screen.WriteText(frame.Y, frame.X + 2, text, 0, frame.Right); + } + + /// + /// 渲染子代理状态行(M7-7,基准 AgentProgressLine 首行): + /// 树形前缀 "├─ "/"└─ "(组末条目收口)dim + 类型名八色(hideType 时描述提升行首、空描述兜底显示类型)+ + /// " (描述)" + " · N tool uses" + " · M tokens" 统计 dim(零令牌省略 token 段); + /// 运行中整行 dim(各段样式叠加 Dim=true)。 + /// + public static void RenderSubagentStatusRow(Screen screen, Rect frame, Theme theme, bool isLastOfGroup, SubagentEntry entry, bool isRunning, bool hideType) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var typeStyle = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.SubagentColor(entry.AgentType), Dim = isRunning }); + var bodyStyle = isRunning ? dim : 0; + screen.WriteText(frame.Y, frame.X, isLastOfGroup ? "└─ " : "├─ ", dim, frame.Right); + var col = hideType && !string.IsNullOrEmpty(entry.Description) + ? screen.WriteText(frame.Y, frame.X + 3, entry.Description, bodyStyle, frame.Right) + : screen.WriteText(frame.Y, frame.X + 3, entry.AgentType, typeStyle, frame.Right); + if (!hideType && !string.IsNullOrEmpty(entry.Description)) + { + col = screen.WriteText(frame.Y, col, $" ({entry.Description})", bodyStyle, frame.Right); + } + + col = screen.WriteText(frame.Y, col, $" · {entry.ToolUseCount} tool uses", dim, frame.Right); + if (entry.TokenCount > 0) + { + screen.WriteText(frame.Y, col, $" · {FormatTokens(entry.TokenCount)} tokens", dim, frame.Right); + } + } + + /// + /// 渲染子代理活动行(M7-7,基准 AgentProgressLine 次行): + /// 树延续前缀 "│ ⎿ "/" ⎿ "(组末条目空格收口)dim + 活动文本(运行中 dim — 基准整行 dim)。 + /// + public static void RenderSubagentActivityRow(Screen screen, Rect frame, Theme theme, bool isLastOfGroup, string text, bool isRunning) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var prefix = isLastOfGroup ? " ⎿ " : "│ ⎿ "; + screen.WriteText(frame.Y, frame.X, prefix, dim, frame.Right); + screen.WriteText(frame.Y, frame.X + prefix.Length, text, isRunning ? dim : 0, frame.Right); + } + + /// 令牌数格式化 — 0 省略 / <1000 原数 / ≥1000 "N.Nk"(基准 "34.5k tokens" 形态)。 + internal static string FormatTokens(int tokens) => + tokens < 1000 ? tokens.ToString() : $"{tokens / 1000.0:0.#}k"; + /// /// 渲染 Markdown 物理行(M7-4)— 从指定列起逐段写,段样式经 MapMarkdownStyle 映射 ///(Code → permission 色前景,对齐基准 codespan color('permission');修饰开关直转 TextStyle)。 diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs new file mode 100644 index 0000000000..309d1dd089 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/SubagentGroup.cs @@ -0,0 +1,138 @@ +namespace NativeTui.Core.Repl; + +/// +/// 子代理条目(M7-7)— 一个并行派发子代理的渲染状态投影(不可变 record,Coordinator 替换式更新): +/// 身份(Id/AgentType/Description)+ 进度累计(ToolUseCount/TokenCount/LastToolName/LastToolSummary)+ +/// 终态(IsDone/IsError)。对齐基准 AgentProgressLine 行数据:状态行 "Type (desc) · N tool uses · M tokens" +/// + 活动行 "lastToolName: summary | Initializing… | Done"。 +/// +/// 子代理标识(引擎侧生成,事件路由键)。 +/// 代理类型(组头同型拼接 + 行内八色取色键)。 +/// 任务描述(状态行括号段;全同型组内提升为行首)。 +/// 子代理内工具使用次数(RecordToolUse 累计)。 +/// 令牌消耗累计(TokenDelta 逐次累加)。 +/// 最后工具名(活动行前缀;空 = 未开始 → Initializing…)。 +/// 最后工具摘要(活动行正文)。 +/// 是否定格(FinishAgent 或回合收尾 FinalizePending)。 +/// 是否失败(组头 ● Error 红聚合)。 +public sealed record SubagentEntry( + string Id, + string AgentType, + string Description, + int ToolUseCount, + int TokenCount, + string LastToolName, + string LastToolSummary, + bool IsDone, + bool IsError); + +/// +/// 子代理分组协调器(M7-7)— 对齐基准 AgentTool 分组渲染语义(groupToolUses + AgentProgressLine): +/// 事件驱动状态机(AddAgent 追加未完成条目 / RecordToolUse 累计进度 / FinishAgent 定格 / +/// FinalizePending 回合收尾把未完成条目定格为非错误)+ 组级聚合(AnyUnresolved/AnyError/AllComplete)+ +/// 组头文案派生(Running N {type} agents… / N {type} agents finished,全同型带类型名)+ +/// 可见窗口 min(10, n) + 活动跟随滚动(最新活动条目滚入窗口 — 区别于模型选择器的焦点跟随,分组非模态无焦点)。 +/// 与基准的有意偏离:基准组内行数无截断靠 React 虚拟化扛 300 规模,本项目无虚拟化(inline 全量屏), +/// 窗口 + "and N more…" 溢出是对等机制(方案 A 用户确认)。消费方:ReplBridgeDriver 路由 + SubagentGroupItem 渲染投影。 +/// +public sealed class SubagentGroupCoordinator +{ + /// 最大可见条数(对齐模型选择器 visibleOptionCount=Math.min(10, n) 先例;基准组内无截断靠虚拟化,本项目窗口为对等机制)。 + public const int MaxVisible = 10; + + private readonly List _agents = []; + + /// 条目清单(渲染与断言投影)。 + public IReadOnlyList Agents => _agents; + + /// 条目总数。 + public int Count => _agents.Count; + + /// 可见条数 = min(10, n)。 + public int VisibleCount => Math.Min(MaxVisible, _agents.Count); + + /// 折叠条数 = max(0, n - 10)("and N more…" 行依据)。 + public int HiddenCount => Math.Max(0, _agents.Count - VisibleCount); + + /// 滚动窗口起点(0 起,活动跟随保证最新活动条目可见)。 + public int WindowStart { get; private set; } + + /// 有未完成条目(组头 Running 态依据)。 + public bool AnyUnresolved => _agents.Exists(static agent => !agent.IsDone); + + /// 任一条目失败(组头 ● Error 红依据 — 基准 anyError loader 变红)。 + public bool AnyError => _agents.Exists(static agent => agent.IsError); + + /// 全部完成(组头 finished 态依据)。 + public bool AllComplete => _agents.Count > 0 && _agents.TrueForAll(static agent => agent.IsDone); + + /// 全组同类型(组头带类型名 + 行内隐藏类型 — 基准 allSameType/commonType)。 + public bool AllSameType => _agents.Count > 0 && _agents.TrueForAll(agent => agent.AgentType == _agents[0].AgentType); + + /// 组头文案 — 运行中 "Running N {type} agents…" / 完成 "N {type} agents finished"(全同型带类型名)。 + public string HeaderText => + (AllComplete ? $"{Count} " : $"Running {Count} ") + (AllSameType ? $"{_agents[0].AgentType} " : "") + "agents" + (AllComplete ? " finished" : "…"); + + /// 追加子代理条目(未完成态)+ 窗口滚动到该条目(活动跟随)。 + public void AddAgent(string id, string agentType, string description) + { + _agents.Add(new SubagentEntry(id, agentType, description, ToolUseCount: 0, TokenCount: 0, LastToolName: string.Empty, LastToolSummary: string.Empty, IsDone: false, IsError: false)); + EnsureVisible(_agents.Count - 1); + } + + /// 记录子代理工具活动 — 计数/令牌累计 + 最后活动更新 + 窗口跟随。 + public void RecordToolUse(string id, string toolName, string summary, int tokenDelta) + { + UpdateEntry(id, entry => entry with + { + ToolUseCount = entry.ToolUseCount + 1, + TokenCount = entry.TokenCount + tokenDelta, + LastToolName = toolName, + LastToolSummary = summary, + }); + } + + /// 定格子代理条目(isError 聚合组头 ● 红)+ 窗口跟随。 + public void FinishAgent(string id, bool isError) + { + UpdateEntry(id, entry => entry with { IsDone = true, IsError = isError }); + } + + /// 回合收尾 — 未完成条目定格为非错误完成(TurnCompleted/TurnInterrupted 调用;幂等 — 全完成 no-op)。 + public void FinalizePending() + { + for (var i = 0; i < _agents.Count; i++) + { + if (!_agents[i].IsDone) + { + _agents[i] = _agents[i] with { IsDone = true }; + } + } + } + + /// 替换式更新条目(定位 + with 投影)+ 窗口跟随。 + private void UpdateEntry(string id, Func update) + { + var index = _agents.FindIndex(entry => entry.Id == id); + if (index < 0) + { + throw new KeyNotFoundException($"子代理条目不存在:{id}(Driver 保证 SubagentStarted 先于进度/完成事件 — 开心路径断言)。"); + } + + _agents[index] = update(_agents[index]); + EnsureVisible(index); + } + + /// 窗口跟随 — 活动条目滚入可见窗口(上边界推进)。 + private void EnsureVisible(int index) + { + if (index < WindowStart) + { + WindowStart = index; + } + else if (index >= WindowStart + VisibleCount) + { + WindowStart = index - VisibleCount + 1; + } + } +} diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs new file mode 100644 index 0000000000..a085ce3ba4 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs @@ -0,0 +1,53 @@ +namespace NativeTui.Core.Repl; + +/// +/// 子代理分组消息项(M7-7)— 对齐基准 renderGroupedAgentToolUse + AgentProgressLine 渲染结构: +/// margin 1 + 组头 1 行(● 三态色:任一错误 Error 红 / 全完成 Success 绿 / 运行中 Suggestion + 文案) +/// + 每可见代理 2 行(状态行:树形 ├─/└─ + 八色类型名(全同型组内隐藏,空描述兜底显示)+ (描述) + +/// " · N tool uses · M tokens" 统计 dim;活动行:"│ ⎿ "/" ⎿ " 前缀 + lastTool: summary | +/// Initializing… | Done)+ 溢出行(" and N more…" 复用模型选择器形态)。 +/// 运行中条目整行 dim(基准 AgentProgressLine isResolved 前提色);树形收口 └─ 按"组最后一个条目"判定 +/// (窗口截断时可见条目均不收口,树延续到溢出行之后语义)。状态源 = SubagentGroupCoordinator(投影, +/// Driver 每事件更新即生效 — 无需失效通知,ScrollBox 每帧全量重渲)。 +/// +public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem +{ + /// 协调器(分组状态的唯一来源 — Driver 事件路由更新)。 + public SubagentGroupCoordinator Coordinator { get; } = coordinator; + + /// 内容高度 = margin 1 + 组头 1 + 可见条目 ×2 + 溢出 0/1。 + public int MeasureHeight(int width) => + 2 + Coordinator.VisibleCount * 2 + (Coordinator.HiddenCount > 0 ? 1 : 0); + + /// 渲染:margin 留空 → 组头(● 三态色 + 文案)→ 可见条目两行式 → 溢出行(如有)。 + public void Render(Screen screen, Rect frame, Theme theme) + { + var row = frame.Y + 1; + MessageRenderer.RenderSubagentHeader(screen, RowFrame(frame, row++), theme, Coordinator.HeaderText, Coordinator.AllComplete, Coordinator.AnyError); + + var hideType = Coordinator.AllSameType; + for (var i = 0; i < Coordinator.VisibleCount; i++) + { + var index = Coordinator.WindowStart + i; + var entry = Coordinator.Agents[index]; + var isLastOfGroup = index == Coordinator.Count - 1; + var isRunning = !entry.IsDone; + MessageRenderer.RenderSubagentStatusRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, entry, isRunning, hideType); + MessageRenderer.RenderSubagentActivityRow(screen, RowFrame(frame, row++), theme, isLastOfGroup, ActivityText(entry), isRunning); + } + + if (Coordinator.HiddenCount > 0) + { + MessageRenderer.RenderModelOverflow(screen, RowFrame(frame, row), theme, Coordinator.HiddenCount); + } + } + + /// 活动行文本 — 完成 Done / 无活动 Initializing… / 最后工具 "名: 摘要"。 + private static string ActivityText(SubagentEntry entry) => + entry.IsDone ? "Done" + : string.IsNullOrEmpty(entry.LastToolName) ? "Initializing…" + : $"{entry.LastToolName}: {entry.LastToolSummary}"; + + /// 单行 frame(Y 为该渲染行,高度 1)。 + private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 }; +} diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs new file mode 100644 index 0000000000..b06920f670 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs @@ -0,0 +1,371 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 子代理分组测试(M7-7)— 对齐基准 AgentTool/UI.tsx 分组渲染形态: +/// SubagentGroupCoordinator 状态机(AddAgent 追加未完成条目 + RecordToolUse 累计工具数/令牌/最后活动 + +/// FinishAgent 定格 + FinalizePending 回合收尾 + 窗口 min(10,n) 活动跟随滚动)+ 组头三态文案 +/// (Running N {type} agents… / N {type} agents finished,全同型带类型名)+ SubagentGroupItem 渲染 +/// (● 组头三态色 + 逐代理两行式树形 ├─/└─ + │ ⎿ 活动行 + Initializing…/Done 状态 + "and N more…" 溢出 + +/// 运行中整行 dim + 八色板类型色)+ ReplBridgeDriver 路由(SubagentStarted 惰性建组落位 + 事件更新组 + +/// TurnCompleted 定格脱离 + 多回合多组共存)。 +/// +public sealed class SubagentGroupTests +{ + /// 构造三代理混合类型组(1 个有活动、1 个完成、1 个待初始化)。 + private static SubagentGroupCoordinator CreateMixedGroup() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索渲染链"); + coordinator.RecordToolUse("a1", "Read", "file.cs", 1200); + coordinator.RecordToolUse("a1", "Grep", "pattern", 50); + coordinator.AddAgent("a2", "reviewer", "审查补丁"); + coordinator.FinishAgent("a2", isError: false); + coordinator.AddAgent("a3", "builder", "编译卫星"); + return coordinator; + } + + // ---- SubagentGroupCoordinator ---- + + /// AddAgent — 追加未完成条目 + AnyUnresolved 聚合。 + [Fact] + public void AddAgent_AppendsPendingEntry() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + + Assert.Equal(1, coordinator.Count); + Assert.Equal("a1", coordinator.Agents[0].Id); + Assert.Equal("explore", coordinator.Agents[0].AgentType); + Assert.False(coordinator.Agents[0].IsDone); + Assert.True(coordinator.AnyUnresolved); + Assert.False(coordinator.AllComplete); + } + + /// RecordToolUse — 工具数/令牌累计 + 最后活动更新。 + [Fact] + public void RecordToolUse_AccumulatesCountsAndActivity() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.RecordToolUse("a1", "Read", "file.cs", 1200); + coordinator.RecordToolUse("a1", "Grep", "pattern", 50); + + var agent = coordinator.Agents[0]; + Assert.Equal(2, agent.ToolUseCount); + Assert.Equal(1250, agent.TokenCount); + Assert.Equal("Grep", agent.LastToolName); + Assert.Equal("pattern", agent.LastToolSummary); + } + + /// FinishAgent — 定格完成 + 错误聚合 + AllComplete。 + [Fact] + public void FinishAgent_MarksDoneAndAggregatesError() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "reviewer", "审查"); + coordinator.FinishAgent("a1", isError: true); + coordinator.FinishAgent("a2", isError: false); + + Assert.True(coordinator.Agents[0].IsError); + Assert.True(coordinator.AnyError); + Assert.True(coordinator.AllComplete); + Assert.False(coordinator.AnyUnresolved); + } + + /// FinalizePending — 回合收尾把未完成条目定格为非错误完成。 + [Fact] + public void FinalizePending_CompletesUnfinishedAsNonError() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "reviewer", "审查"); + coordinator.FinishAgent("a2", isError: false); + + coordinator.FinalizePending(); + + Assert.True(coordinator.AllComplete); + Assert.True(coordinator.Agents[0].IsDone); + Assert.False(coordinator.Agents[0].IsError); + } + + /// 组头文案 — 运行中 "Running N agents…",完成 "N agents finished"(混合类型不带类型名)。 + [Fact] + public void HeaderText_MixedTypesRunningAndFinished() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "reviewer", "审查"); + Assert.Equal("Running 2 agents…", coordinator.HeaderText); + + coordinator.FinalizePending(); + Assert.Equal("2 agents finished", coordinator.HeaderText); + } + + /// 组头文案 — 全同类型带类型名(基准 commonType 拼接)。 + [Fact] + public void HeaderText_SameTypeIncludesTypeName() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "explore", "深挖"); + Assert.Equal("Running 2 explore agents…", coordinator.HeaderText); + + coordinator.FinalizePending(); + Assert.Equal("2 explore agents finished", coordinator.HeaderText); + } + + /// 可见窗口 — min(10, n) 可见 + HiddenCount 溢出计数。 + [Fact] + public void VisibleCount_CapsAtTenAndCountsHidden() + { + var coordinator = new SubagentGroupCoordinator(); + for (var i = 0; i < 13; i++) + { + coordinator.AddAgent($"a{i}", "explore", $"任务{i}"); + } + + Assert.Equal(10, coordinator.VisibleCount); + Assert.Equal(3, coordinator.HiddenCount); + } + + /// 窗口活动跟随 — 第 11 个代理追加后窗口下移保证其可见。 + [Fact] + public void WindowFollowsLatestActivity() + { + var coordinator = new SubagentGroupCoordinator(); + for (var i = 0; i < 10; i++) + { + coordinator.AddAgent($"a{i}", "explore", $"任务{i}"); + } + + Assert.Equal(0, coordinator.WindowStart); + coordinator.AddAgent("a10", "explore", "新任务"); + Assert.Equal(1, coordinator.WindowStart); + } + + /// 未知代理 id — 断言(开心路径:Driver 保证先 Started 后进度)。 + [Fact] + public void RecordToolUse_UnknownAgent_Throws() + { + var coordinator = new SubagentGroupCoordinator(); + + Assert.Throws(() => coordinator.RecordToolUse("ghost", "Read", "f", 1)); + Assert.Throws(() => coordinator.FinishAgent("ghost", isError: false)); + } + + // ---- SubagentGroupItem 渲染 ---- + + /// 渲染组头 — ● 三态色(运行中 Suggestion)+ "Running N agents…" 文案。 + [Fact] + public void Render_HeaderBulletAndRunningText() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.StartsWith("● Running 3 agents…", RowText.Of(screen, 1, 0, 60)); + var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion }); + Assert.Equal(suggestion, screen[1, 0].StyleId); + } + + /// 渲染组头完成态 — ● Success 绿 + "N agents finished"。 + [Fact] + public void Render_HeaderDoneGreen() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + coordinator.FinalizePending(); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.StartsWith("● 3 agents finished", RowText.Of(screen, 1, 0, 60)); + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Success }); + Assert.Equal(success, screen[1, 0].StyleId); + } + + /// 渲染组头错误态 — 任一错误 ● Error 红(基准 anyError loader 变红)。 + [Fact] + public void Render_HeaderErrorRed() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.FinishAgent("a1", isError: true); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error }); + Assert.Equal(error, screen[1, 0].StyleId); + } + + /// 渲染状态行 — 树形 ├─/└─ + 八色类型名 + (描述) + 统计 dim(基准 AgentProgressLine)。 + [Fact] + public void Render_StatusRowTreeTypeAndStats() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + // row2 = 首代理(运行中 dim):├─ explore (搜索渲染链) · 2 tool uses · 1.3k tokens(1250 → 1.3k 四舍五入) + Assert.StartsWith("├─ explore (搜索渲染链) · 2 tool uses · 1.3k tokens", RowText.Of(screen, 2, 0, 60)); + var typeColor = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.SubagentColor("explore"), Dim = true }); + Assert.Equal(typeColor, screen[2, 3].StyleId); + + // row6 = 末代理(└─ 收口树形) + Assert.StartsWith("└─ builder (编译卫星)", RowText.Of(screen, 6, 0, 60)); + } + + /// 渲染活动行 — "│ ⎿ 活动"(中间)/" ⎿ Done"(末条目完成收口)。 + [Fact] + public void Render_ActivityRowIndentAndStates() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + // row3 = 首代理活动行:│ ⎿ Grep: pattern + Assert.StartsWith("│ ⎿ Grep: pattern", RowText.Of(screen, 3, 0, 60)); + // row5 = 次代理(已完成):│ ⎿ Done + Assert.StartsWith("│ ⎿ Done", RowText.Of(screen, 5, 0, 60)); + // row7 = 末代理(无活动):树形收口前缀 " ⎿ Initializing…" + Assert.StartsWith(" ⎿ Initializing…", RowText.Of(screen, 7, 0, 60)); + } + + /// 渲染溢出行 — 超出 10 条可见折叠 "and N more…"(复用模型选择器溢出形态)。 + [Fact] + public void Render_OverflowRowWhenHidden() + { + var coordinator = new SubagentGroupCoordinator(); + for (var i = 0; i < 13; i++) + { + coordinator.AddAgent($"a{i}", "explore", $"任务{i}"); + } + + using var screen = new Screen(cols: 60, rows: 30); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 30), Theme.Dark); + + // margin1 + 组头1 + 可见10×2 = 溢出行在 row22 + Assert.StartsWith(" and 3 more…", RowText.Of(screen, 22, 0, 60)); + } + + /// MeasureHeight — margin 1 + 组头 1 + 可见 N×2 + 溢出 0/1。 + [Fact] + public void MeasureHeight_FollowsWindowState() + { + var small = new SubagentGroupItem(CreateMixedGroup()); + Assert.Equal(8, small.MeasureHeight(60)); + + var coordinator = new SubagentGroupCoordinator(); + for (var i = 0; i < 13; i++) + { + coordinator.AddAgent($"a{i}", "explore", $"任务{i}"); + } + + Assert.Equal(23, new SubagentGroupItem(coordinator).MeasureHeight(60)); + } + + /// 运行中行 dim / 完成行不 dim(基准 AgentProgressLine 运行中整行 dim)。 + [Fact] + public void Render_RunningRowsDimDoneRowsNot() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + // row2 运行中代理:类型色带 Dim + var dimType = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.SubagentColor("explore"), Dim = true }); + Assert.Equal(dimType, screen[2, 3].StyleId); + // row4 已完成代理:类型色无 Dim + var plainType = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.SubagentColor("reviewer") }); + Assert.Equal(plainType, screen[4, 3].StyleId); + } + + /// 全同型组 — 行内隐藏类型名只显示描述(基准 hideType 去重)。 + [Fact] + public void Render_SameTypeHidesTypeInRows() + { + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "explore", "深挖"); + + using var screen = new Screen(cols: 60, rows: 8); + new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 8), Theme.Dark); + + Assert.StartsWith("├─ 搜索", RowText.Of(screen, 2, 0, 60)); + Assert.StartsWith("└─ 深挖", RowText.Of(screen, 4, 0, 60)); + } + + // ---- ReplBridgeDriver 路由 ---- + + /// 构造测试 Driver(对齐 ReplBridgeDriverTests 模式)。 + private static (ReplBridgeDriver Driver, ReplView View, SpinnerItem Spinner) CreateDriver() + { + var view = new ReplView(); + var spinner = new SpinnerItem(); + var banner = new BannerItem("0.0.0", "占位模型", "API 按量计费", @"C:\placeholder"); + var driver = new ReplBridgeDriver(view, spinner, banner); + view.Messages.Add(banner); + view.Messages.Add(spinner); + return (driver, view, spinner); + } + + /// SubagentStarted — 惰性建组:组项落位滚动区 + spinner 切 ToolUse。 + [Fact] + public void Driver_SubagentStarted_CreatesGroupItem() + { + var (driver, view, spinner) = CreateDriver(); + + driver.Apply(new BridgeEvent.SubagentStarted("a1", "explore", "搜索")); + + Assert.Contains(view.Messages.Items, m => m is SubagentGroupItem); + Assert.Equal(SpinnerMode.ToolUse, spinner.Mode); + Assert.True(spinner.HasActiveTools); + } + + /// 子代理事件链 — 进度/完成路由到组条目(不逐条刷屏)。 + [Fact] + public void Driver_SubagentEvents_UpdateGroupInPlace() + { + var (driver, view, _) = CreateDriver(); + + 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)); + + var group = view.Messages.Items.OfType().Single(); + Assert.Equal(1, group.Coordinator.Count); + Assert.Equal(1, group.Coordinator.Agents[0].ToolUseCount); + Assert.True(group.Coordinator.AllComplete); + // 一个代理 = 组项 1 个(无逐条 ToolCallItem/ToolResultItem 刷屏) + Assert.DoesNotContain(view.Messages.Items, m => m is ToolCallItem); + } + + /// TurnCompleted — 组定格脱离 + 新回合新组(两组共存滚动区)。 + [Fact] + public void Driver_TurnCompleted_FinalizesAndNewTurnCreatesNewGroup() + { + var (driver, view, _) = CreateDriver(); + + driver.Apply(new BridgeEvent.TurnStarted()); + driver.Apply(new BridgeEvent.SubagentStarted("a1", "explore", "搜索")); + driver.Apply(new BridgeEvent.TurnCompleted()); + driver.Apply(new BridgeEvent.TurnStarted()); + driver.Apply(new BridgeEvent.SubagentStarted("b1", "reviewer", "审查")); + + var groups = view.Messages.Items.OfType().ToList(); + Assert.Equal(2, groups.Count); + Assert.True(groups[0].Coordinator.AllComplete); + Assert.Equal("b1", groups[1].Coordinator.Agents[0].Id); + } + + /// 无组子代理事件 — 容忍忽略(Finish/ToolUse 先于 Started 的健壮性)。 + [Fact] + 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)); + + Assert.DoesNotContain(view.Messages.Items, m => m is SubagentGroupItem); + } +} From 9bd50612a15079be067bbc74a0b6027e56eda4ad Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:31:46 +0800 Subject: [PATCH 30/43] =?UTF-8?q?feat:=20M7-7=20agents=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC+Demo=E6=8E=A5=E7=BA=BF=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20SimulatedEngine=E5=8A=A0Subagents=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=B5=B0=E7=8B=AC=E7=AB=8B=E5=B9=B6=E8=A1=8C=E5=AD=90?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=88=97=E8=A1=A8(=E5=8C=BA=E5=88=AB?= =?UTF-8?q?=E4=BA=8E=E4=B8=BB=E5=9B=9E=E5=90=88=E4=B8=89=E6=AE=B5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA,=E4=BA=A4=E9=94=99=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BFi*25ms=E6=BC=94=E7=A4=BA=E7=AA=97=E5=8F=A3=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E8=B7=9F=E9=9A=8F),agents=E9=BB=98=E8=AE=A412/?= =?UTF-8?q?=E5=8F=AF=E5=B8=A6=E6=95=B0=E9=87=8F1..999,=E6=AF=8F=E7=AC=AC7?= =?UTF-8?q?=E4=B8=AA=E5=A4=B1=E8=B4=A5=E6=BC=94=E7=A4=BA=E7=BB=84=E5=A4=B4?= =?UTF-8?q?=E7=BA=A2=E6=80=81;Demo=E8=A1=A5=E5=85=A8=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E7=A7=8D=E5=AD=90=E6=8F=90=E7=A4=BA=E5=8A=A0agents?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE;=E5=85=A8=E9=87=8F698/698=E7=BB=BF=E5=90=AB4?= =?UTF-8?q?=E6=96=B0engine=E6=B5=8B=E8=AF=95+Demo=20selftest=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Demo/Program.cs | 3 +- .../NativeTui.Core/bridge/SimulatedEngine.cs | 149 +++++++++++++++++- .../repl/SubagentGroupTests.cs | 84 ++++++++++ 3 files changed, 232 insertions(+), 4 deletions(-) diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs index d8ee5c6cf7..5628fcd51a 100644 --- a/native_tui/apps/NativeTui.Demo/Program.cs +++ b/native_tui/apps/NativeTui.Demo/Program.cs @@ -85,6 +85,7 @@ private static IReadOnlyList DemoCommands() => new CommandSuggestion("diff", "结构化补丁", [], IsHidden: false), new CommandSuggestion("perm", "权限对话框", [], IsHidden: false), new CommandSuggestion("all", "完整回合(补丁 + Markdown)", [], IsHidden: false), + new CommandSuggestion("agents", "并行子代理分组(可带数量 agents 300)", [], IsHidden: false), new CommandSuggestion("model", "切换模型(M7-6 选择器)", [], IsHidden: false), ]; @@ -115,7 +116,7 @@ private static IReadOnlyList SeedMessages() => ])), new ToolCallItem("Read", "native_tui/src/NativeTui.Core/repl/ReplView.cs"), new ToolResultItem("41 行 — REPL 整屏视图(footer + 输入框 + 消息滚动区)"), - new AssistantMessageItem("演示命令:md(Markdown 全形态)/ diff(结构化补丁)/ perm(权限对话框)/ all(完整回合)— 输入命令回车触发;其他输入回 Error 诱导。"), + new AssistantMessageItem("演示命令:md(Markdown 全形态)/ diff(结构化补丁)/ perm(权限对话框)/ all(完整回合)/ agents(并行子代理分组,300 规模可带数量)— 输入命令回车触发;其他输入回 Error 诱导。"), ]; /// diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index a1432c4110..de33fa3d16 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -25,6 +25,30 @@ public sealed class SimulatedEngine : IReplEngine /// Responding 阶段每拍 token 增量。 private const int TokenDeltaPerTick = 9; + /// 子代理批量启动时刻(ms)— Submit 后一拍内全部 SubagentStarted(_pending 队列承载 300 规模)。 + private const long SubagentStartedAtMs = 200; + + /// 子代理首个工具活动基准时刻(ms)— 后续按序号错开(i×25ms 交错演示窗口活动跟随)。 + private const long SubagentFirstToolAtMs = 500; + + /// 子代理相邻工具活动间隔(ms)。 + private const long SubagentToolIntervalMs = 350; + + /// 子代理每代理工具活动次数。 + private const int SubagentToolUsesPerAgent = 2; + + /// 子代理完成时刻基准(ms)— 末次工具活动后 300ms 交错完成。 + private const long SubagentFinishBaseMs = SubagentFirstToolAtMs + SubagentToolUsesPerAgent * SubagentToolIntervalMs + 300; + + /// agents 默认子代理数量(不带参数)。 + private const int DefaultSubagentCount = 12; + + /// 子代理类型池(轮转分配 — 混合类型组演示 hideType=false 形态与八色板多色)。 + private static readonly string[] SubagentTypes = ["explore", "reviewer", "builder"]; + + /// 子代理工具名池(轮转 — 活动行文本变化演示)。 + private static readonly string[] SubagentToolNames = ["Read", "Grep", "Edit"]; + /// 权限演示工具名(对话框标题)。 private const string PermissionToolName = "Bash"; @@ -43,6 +67,15 @@ public sealed class SimulatedEngine : IReplEngine /// 当前回合演示脚本(Submit 命令路由产物 — 决定阶段迁移事件序列)。 private TurnScript _script = TurnScript.Full; + /// agents 脚本并行子代理列表(Submit 时建列表,200ms 批量发启动事件)。 + private List _subagents = []; + + /// agents 脚本是否已发批量启动事件。 + private bool _subagentStartedSent; + + /// agents 脚本是否已发回合收束(全部完成后一次)。 + private bool _subagentTurnCompleted; + /// 待排空事件队列(Submit/Interrupt 立即入队,DrainEvents 阶段迁移追加)。 private readonly Queue _pending = new(); @@ -63,6 +96,34 @@ private enum TurnScript /// all — 完整回合:补丁 + Markdown(既有默认演示)。 Full, + + /// agents — 并行子代理分组演示(M7-7):批量启动 + 交错工具活动 + 交错完成(每第 7 个失败)。 + Subagents, + } + + /// 模拟子代理(agents 脚本并行子回合 — 独立时间线推进,区别于主回合三段状态机)。 + private sealed class SimulatedSubagent + { + /// 序号(事件 id 与交错时刻基数)。 + public required int Index { get; init; } + + /// 代理类型(类型池轮转)。 + public required string AgentType { get; init; } + + /// 任务描述。 + public required string Description { get; init; } + + /// 剩余工具活动次数。 + public int ToolUsesLeft { get; set; } + + /// 下次工具活动时刻(ms,回合内偏移)。 + public long NextToolAtMs { get; set; } + + /// 完成时刻(ms,回合内偏移)。 + public long FinishAtMs { get; init; } + + /// 是否已完成。 + public bool IsDone { get; set; } } /// @@ -163,10 +224,18 @@ public void Submit(string text) _inTurn = true; _stage = 0; _turnStart = _clock(); + if (script is TurnScript.Subagents) + { + _subagents = CreateSubagents(ParseSubagentCount(text)); + _subagentStartedSent = false; + _subagentTurnCompleted = false; + } + _pending.Enqueue(new BridgeEvent.TurnStarted()); } - /// 演示命令路由(trim 后匹配,兼容斜杠前缀 /md 形态 — TUI 补全接受后提交带 /)— null = 未知命令。 + /// 演示命令路由(trim 后匹配,兼容斜杠前缀 /md 形态 — TUI 补全接受后提交带 /)— null = 未知命令; + /// agents 支持 "agents" / "agents N"(N=1..999,非法后缀拒绝)。 private static TurnScript? ParseCommand(string text) { var command = text.Trim(); @@ -175,6 +244,11 @@ public void Submit(string text) command = command[1..]; } + if (command == "agents" || (command.StartsWith("agents ", StringComparison.Ordinal) && int.TryParse(command["agents ".Length..], out var count) && count is >= 1 and <= 999)) + { + return TurnScript.Subagents; + } + return command switch { "md" => TurnScript.Markdown, @@ -185,9 +259,28 @@ public void Submit(string text) }; } + /// 解析子代理数量 — "agents" 默认 12;"agents N" 取 N(调用方已校验合法)。 + private static int ParseSubagentCount(string text) + { + var command = text.Trim().TrimStart('/'); + return int.TryParse(command["agents".Length..].Trim(), out var count) ? count : DefaultSubagentCount; + } + + /// 构造并行子代理列表(类型/工具名轮转 + 交错时间线 + 每 7 个一个失败演示组头红态)。 + private static List CreateSubagents(int count) => + Enumerable.Range(0, count).Select(i => new SimulatedSubagent + { + Index = i, + AgentType = SubagentTypes[i % SubagentTypes.Length], + Description = $"并行任务 {i + 1}", + ToolUsesLeft = SubagentToolUsesPerAgent, + NextToolAtMs = SubagentFirstToolAtMs + i * 25, + FinishAtMs = SubagentFinishBaseMs + i * 25, + }).ToList(); + /// 未知命令 Error 诱导 — 列出全部演示命令与各自用途(含怎么做,提示可输入 / 弹补全列表)。 private static string UnknownCommandMessage(string input) => - $"未知演示命令「{input}」。可用命令:md(Markdown 全形态渲染)、diff(结构化补丁)、perm(权限对话框)、all(完整回合 = 补丁 + Markdown)。可直接输入命令或输入 / 弹出补全列表选择。"; + $"未知演示命令「{input}」。可用命令:md(Markdown 全形态渲染)、diff(结构化补丁)、perm(权限对话框)、all(完整回合 = 补丁 + Markdown)、agents(并行子代理分组,可带数量如 agents 300)。可直接输入命令或输入 / 弹出补全列表选择。"; /// 打断当前回合(闲时 no-op)。 public void Interrupt() @@ -214,7 +307,8 @@ public IReadOnlyList DrainEvents() return drained; } - /// 按已流逝时间推进阶段状态机(阶段迁移按脚本分派事件;Responding 阶段非权限脚本每拍 token 增量)。 + /// 按已流逝时间推进阶段状态机(阶段迁移按脚本分派事件;Responding 阶段非权限脚本每拍 token 增量; + /// agents 脚本走独立并行子回合推进)。 private void AdvanceStages() { if (!_inTurn) @@ -223,11 +317,60 @@ private void AdvanceStages() } var elapsed = _clock() - _turnStart; + if (_script is TurnScript.Subagents) + { + AdvanceSubagents(elapsed); + return; + } + AdvanceThinking(elapsed); AdvanceToolUse(elapsed); AdvanceResponding(elapsed); } + /// agents 脚本并行子回合推进:批量启动(一拍)→ 交错工具活动(各自时间线)→ 交错完成(每第 7 个失败)→ 全部完成收束回合。 + private void AdvanceSubagents(long elapsed) + { + if (!_subagentStartedSent && elapsed >= SubagentStartedAtMs) + { + _subagentStartedSent = true; + foreach (var subagent in _subagents) + { + _pending.Enqueue(new BridgeEvent.SubagentStarted($"agent-{subagent.Index}", subagent.AgentType, subagent.Description)); + } + } + + foreach (var subagent in _subagents.Where(s => !s.IsDone)) + { + while (subagent.ToolUsesLeft > 0 && subagent.NextToolAtMs <= elapsed) + { + var useIndex = SubagentToolUsesPerAgent - subagent.ToolUsesLeft; + _pending.Enqueue(new BridgeEvent.SubagentToolUse( + $"agent-{subagent.Index}", + SubagentToolNames[(subagent.Index + useIndex) % SubagentToolNames.Length], + $"demo/file{subagent.Index % 5}.cs", + TokenGain: 100 + (subagent.Index * 37) % 800)); + subagent.ToolUsesLeft--; + subagent.NextToolAtMs += SubagentToolIntervalMs; + } + + if (subagent.ToolUsesLeft == 0 && subagent.FinishAtMs <= elapsed) + { + subagent.IsDone = true; + _pending.Enqueue(new BridgeEvent.SubagentFinished($"agent-{subagent.Index}", IsError: subagent.Index % 7 == 6)); + } + } + + if (!_subagentTurnCompleted && _subagents.All(static subagent => subagent.IsDone)) + { + _subagentTurnCompleted = true; + _inTurn = false; + var failures = _subagents.Count(static subagent => subagent.Index % 7 == 6); + _pending.Enqueue(new BridgeEvent.AssistantMessage($"演示回复 — {_subagents.Count} 个子代理并行完成({failures} 个失败)分组渲染链路运行正常。")); + _pending.Enqueue(new BridgeEvent.TurnCompleted()); + } + } + /// Thinking → 下一阶段(1.2s 到点):工具脚本产 ToolCall,权限脚本产 PermissionRequest,md 无事件直入。 private void AdvanceThinking(long elapsed) { diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs index b06920f670..7378496bbf 100644 --- a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs @@ -368,4 +368,88 @@ public void Driver_SubagentEventsWithoutGroup_Ignored() Assert.DoesNotContain(view.Messages.Items, m => m is SubagentGroupItem); } + + // ---- SimulatedEngine agents 脚本(虚拟时钟确定性验证)---- + + /// 虚拟时钟引擎(推进时刻数组捕获 — 可变捕获模式)。 + private static SimulatedEngine CreateEngineAt(long elapsedMs) + { + var clock = new long[] { 0 }; + var engine = new SimulatedEngine(() => clock[0]); + clock[0] = elapsedMs; + return engine; + } + + /// agents 脚本 — 250ms 批量 SubagentStarted(类型轮转混合组)+ 后续交错工具活动。 + [Fact] + public void Engine_Agents_BatchStartedThenStaggeredToolUse() + { + var clock = new long[] { 0 }; + var engine = new SimulatedEngine(() => clock[0]); + engine.Submit("agents 3"); + clock[0] = 250; + + var events = engine.DrainEvents(); + + Assert.Equal(3, events.Count(e => e is BridgeEvent.SubagentStarted)); + Assert.Contains(events, e => e is BridgeEvent.SubagentStarted(var _, var type, _) && type == "explore"); + Assert.Contains(events, e => e is BridgeEvent.SubagentStarted(var _, var type, _) && type == "reviewer"); + + clock[0] = 600; + var toolUses = engine.DrainEvents().OfType().ToList(); + Assert.NotEmpty(toolUses); + Assert.All(toolUses, e => Assert.False(string.IsNullOrEmpty(e.ToolName))); + } + + /// agents 脚本 — 全部完成后 AssistantMessage + TurnCompleted 收束(错误子代理 IsError 传递)。 + [Fact] + public void Engine_Agents_CompletesWhenAllAgentsFinished() + { + var clock = new long[] { 0 }; + var engine = new SimulatedEngine(() => clock[0]); + engine.Submit("agents 8"); + clock[0] = 5000; + + var events = engine.DrainEvents(); + + Assert.Equal(8, events.Count(e => e is BridgeEvent.SubagentStarted)); + // i % 7 == 6 → 第 7 个(i=6)失败 + Assert.Equal(1, events.Count(e => e is BridgeEvent.SubagentFinished { IsError: true })); + Assert.Equal(8, events.Count(e => e is BridgeEvent.SubagentFinished)); + Assert.Contains(events, e => e is BridgeEvent.AssistantMessage { Text: var text } && text.Contains("子代理")); + Assert.Contains(events, e => e is BridgeEvent.TurnCompleted); + } + + /// agents 300 规模 — 一拍批量产出 300 启动 + 300 完成(_pending 队列承载)。 + [Fact] + public void Engine_Agents300_BatchesAllAgentsInOneDrain() + { + var clock = new long[] { 0 }; + var engine = new SimulatedEngine(() => clock[0]); + engine.Submit("agents 300"); + clock[0] = 250; + Assert.Equal(300, engine.DrainEvents().Count(e => e is BridgeEvent.SubagentStarted)); + + clock[0] = 10000; + var completion = engine.DrainEvents(); + Assert.Equal(300, completion.Count(e => e is BridgeEvent.SubagentFinished)); + Assert.Contains(completion, e => e is BridgeEvent.TurnCompleted); + } + + /// agents 命令路由 — 纯 "agents" 与 "agents N" 接受;非法后缀回 Error 诱导。 + [Fact] + public void Engine_AgentsCommandParsing() + { + var engine = CreateEngineAt(0); + engine.Submit("agents"); + Assert.True(engine.IsAlive); + + engine = CreateEngineAt(0); + engine.Submit("agents 5"); + Assert.DoesNotContain(engine.DrainEvents(), e => e is BridgeEvent.Error); + + engine = CreateEngineAt(0); + engine.Submit("agents xyz"); + Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.Error); + } } From a212f2645e7e8e650e899f7223c25c387197e157 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:33:27 +0800 Subject: [PATCH 31/43] =?UTF-8?q?docs:=20TASK055=20M7-7=20=E5=A4=9A?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E5=88=86=E7=BB=84=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E5=9D=97(=E5=9B=9B=E5=88=97=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E8=A1=A8+=E5=9F=BA=E5=87=86=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=81=8F=E7=A6=BB=E8=A1=A8+Host=E6=8E=A5=E7=BA=BF=E5=90=8E?= =?UTF-8?q?=E7=BD=AE=E8=AE=B0=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index b3ddbb574f..69fb0be098 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -963,3 +963,50 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g +## M7-7 多子代理分组落地(2026-10-21,方案 A 可见窗口全链贯通) + +### 实施记录(2 commit 链) + +| 阶段 | commit | 内容 | +|------|--------|------| +| Core 层 | 6fa2d10cc | SubagentEntry + SubagentGroupCoordinator(事件驱动状态机 + FinalizePending 回合定格 + 窗口 min(10,n) **活动跟随滚动** — 区别于模型选择器的焦点跟随,分组非模态无焦点)+ SubagentGroupItem(两行式树形 ├─/└─ + "│ ⎿"/" ⎿" 活动行 + Initializing…/Done 状态 + and N more 溢出复用)+ MessageRenderer 三方法(组头●三态色:任一错误 Error/全完成 Success/运行中 Suggestion + Running N {type} agents 文案 + FormatTokens "N.Nk")+ Theme.SubagentColor(**八色板首个消费者**,FNV-1a 稳定哈希)+ BridgeEvent 3 事件(SubagentStarted/ToolUse/Finished,参数名 TokenGain 避开嵌套类型撞名)+ Driver 惰性建组路由(回合定格脱离 + 多回合多组共存);全量 694/694 绿(含 22 新测试) | +| Demo 接线 | 14619cd87 | SimulatedEngine 加 Subagents 脚本(**独立并行子回合列表** — 区别于主回合三段状态机;交错时间线 i×25ms 演示窗口活动跟随;agents 默认 12 / agents N 1..999;每第 7 个失败演示组头红态)+ DemoCommands/种子提示加 agents 条目;全量 698/698 绿(含 4 新 engine 测试)+ Demo selftest 绿 | + +### 与基准的对齐与偏离(决策记录) + +| 项 | 基准 | 本项目 | 依据 | +|----|------|--------|------| +| 组内 300 行渲染 | 不截断,React 虚拟化扛 | **可见窗口 min(10,n) + "and N more…"** | 方案 A 用户确认 — 本项目无虚拟化(inline 全量屏),窗口是对等机制 | +| 分组键 | messageId+toolName(一次 API 响应内 ≥2) | **按 turn**(TurnStarted~TurnCompleted 归一组) | bridge 协议无消息边界;turn 边界事件已存在零新增协议负担;基准跨轮拆组语义近似 | +| 组头动画 | ● 闪烁(ToolUseLoader) | **静态三态色** | 第一版开心路径;闪烁需时钟广播改造(ReplShell 单 spinner 硬编码 → 多动画项广播),归后续增强 | +| 错误汇总 | 无数值汇总(loader 红 + 行级呈现) | 同基准(● Error 红 + 行级 Done) | 一致对齐 | +| 状态聚合 | anyUnresolved/anyError/allComplete 组头三态 | 同基准 | 一致对齐 | +| 同型去重 | allSameType 组头带类型名 + 行内 hideType | 同基准 | 一致对齐 | +| 子代理事件流 | agent_progress 流式 append + UI 整组重算 | 事件路由组条目**原地更新** | ScrollBox 每帧全量重渲 = 无需失效通知,Coordinator 更新即生效 | + +### 四列验收表(AGENTS.md 强制) + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| Core SubagentGroup.cs(Entry + Coordinator:事件驱动/FinalizePending/窗口活动跟随) | ReplBridgeDriver 路由 + SubagentGroupItem 投影 | ✅ | ✅ SubagentGroupTests 9/9(状态机/聚合/窗口/断言) | +| Core SubagentGroupItem.cs + MessageRenderer 三方法(组头三态/树形两行式/溢出) | Driver 建组落位(AddMessage Insert spinner 前) | ✅ | ✅ SubagentGroupTests 8/8(渲染断言含树形收口/dim/八色/溢出) | +| Core Theme.SubagentColor(八色板 FNV-1a 稳定取色) | RenderSubagentStatusRow 类型段 | ✅ | ✅ 渲染测试样式断言(SubagentColor("explore") 取色一致) | +| Core BridgeEvent 3 事件 + Driver 路由(惰性建组/回合定格脱离/多组共存) | SimulatedEngine agents 脚本(Demo)+ Host 未来接线 | ✅ | ✅ SubagentGroupTests 4/4(Driver 路由)+ 全量 698/698 | +| SimulatedEngine Subagents 脚本(并行子回合交错时间线) | Demo agents 命令(真实终端输入 agents / agents 300) | ✅ | ✅ SubagentGroupTests 4/4(虚拟时钟确定性:批量启动/交错活动/完成收束/300 规模一拍批量)+ Demo --selftest 绿;真实终端 agents 交互验收待用户跑 NativeTui.Demo.exe 确认 | +| Host 接线 | — | ⏸ 后置 | 引擎侧子代理事件源未确认(引擎是否发 subagent_* 事件待实测),Core 事件契约已就绪 | + +### 遗留项 + +1. 组头 ● 闪烁动画(需 ReplShell 时钟注入点从单 spinner 扩展为多动画项广播) +2. Host 接线(前置:引擎侧子代理事件源实测确认) +3. 基准 P2 细节未对齐:ctrl+o transcript 展开模式 / 小终端自适应单行塌缩 / lastToolInfo "Searched/Read N files" 聚合摘要 +4. 用户真实终端验收(agents 12 默认 + agents 300 规模) + + + + + + + + + From 813a50928daa32c212ab33fb9f2041bda7f6f128 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:44:59 +0800 Subject: [PATCH 32/43] =?UTF-8?q?fix:=20EnsureSessionAsync=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=AD=E8=A8=80=E5=88=87jsonl=E8=B7=AF=E5=BE=84=20|?= =?UTF-8?q?=20=E5=86=B3=E7=AD=96:=20rebase=E9=9B=86=E6=88=90=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=90=88=E5=B9=B6=E4=BF=9D=E7=95=99=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8transcript.json=E6=96=AD=E8=A8=80,=E4=B8=8E0160ed828?= =?UTF-8?q?=E9=87=8D=E6=94=BE=E7=9A=84.jsonl=E8=B7=AF=E5=BE=84=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=B8=8D=E4=B8=80=E8=87=B4,=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=B8=BAtranscript.jsonl;88/88=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/vault.other.tests/state/TranscriptServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs b/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs index a802a37a80..5b91d417ce 100644 --- a/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs +++ b/modules/storage/vault.other.tests/state/TranscriptServiceTests.cs @@ -225,7 +225,7 @@ public async Task EnsureSessionAsync_Should_Create_Meta_And_Empty_Transcript() { var created = await _service.EnsureSessionAsync("ensure-1", info).ConfigureAwait(true); Assert.True(created); - Assert.True(_fs.FileExists("/test/transcript/ensure-1/transcript.json")); + Assert.True(_fs.FileExists("/test/transcript/ensure-1/transcript.jsonl")); Assert.Empty(await _service.LoadTranscriptAsync("ensure-1").ConfigureAwait(true)); var loaded = await _service.GetSessionInfoAsync("ensure-1").ConfigureAwait(true); Assert.NotNull(loaded); From b6f43093916ea7e1f9cba189273b165b3a55fb88 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:51:12 +0800 Subject: [PATCH 33/43] =?UTF-8?q?feat:=20TUI=E5=8F=82=E6=95=B0=E8=A1=A5?= =?UTF-8?q?=E5=85=A8Argument=E6=A8=A1=E5=BC=8F=20|=20=E5=86=B3=E7=AD=96:?= =?UTF-8?q?=20CommandSuggestion=E5=8A=A0ArgumentHint/Arguments=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE,SlashSuggester=E5=8F=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=88=86=E6=B4=BE(=E5=91=BD=E4=BB=A4=E5=90=8D=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E6=8E=92=E5=BA=8F/=E5=8F=82=E6=95=B0=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E8=BF=87=E6=BB=A4),=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E8=A1=A5ArgumentHint=E6=8F=90=E5=8F=96,Host=E9=9D=99=E6=80=81?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=B8=85=E5=8D=95=E4=B8=8EGUI=20CommandArgum?= =?UTF-8?q?entProvider=E5=90=8C=E5=80=BC(theme/effort/config),=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E9=93=BE=E5=91=BD=E4=BB=A4=E6=A8=A1=E5=BC=8F=E8=A1=A5?= =?UTF-8?q?=E5=B0=BE=E7=A9=BA=E6=A0=BC=E8=BF=9B=E5=8F=82=E6=95=B0=E6=80=81?= =?UTF-8?q?/=E5=8F=82=E6=95=B0=E6=A8=A1=E5=BC=8F=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../abs_hands/shell/SlashCommandCatalog.cs | 3 + .../CommandRegistrationGenerator.cs | 2 +- .../tui_assembly/SlashCommandSource.cs | 32 +++++++-- .../src/NativeTui.Core/repl/ReplShell.cs | 24 ++++++- .../src/NativeTui.Core/repl/ReplView.cs | 5 +- .../repl/suggestions/CommandSuggestion.cs | 14 +++- .../repl/suggestions/SlashSuggester.cs | 61 +++++++++++++--- .../repl/suggestions/SuggestionBox.cs | 7 +- .../HistorySeederTests.cs | 12 ++++ .../SlashCommandSourceTests.cs | 32 ++++++++- .../repl/ReplShellSuggestionsTests.cs | 58 +++++++++++++-- .../repl/suggestions/SlashSuggesterTests.cs | 72 +++++++++++++++++-- 12 files changed, 289 insertions(+), 33 deletions(-) diff --git a/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs b/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs index 8d78b3aafb..81a523f1a1 100644 --- a/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs +++ b/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs @@ -17,6 +17,9 @@ public sealed record SlashCommandMetadata { /// 命令别名 public string[] Aliases { get; init; } = []; + /// 参数提示(命令后空格时的补全提示,如 "model-id")— 从 [ChatCommand] 特性提取 + public string ArgumentHint { get; init; } = string.Empty; + /// 是否隐藏(隐藏命令不在面板展示) public bool IsHidden { get; init; } diff --git a/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs b/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs index c237956a40..6951c67042 100644 --- a/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs +++ b/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs @@ -225,7 +225,7 @@ private static void GenerateSlashCommandCatalog(SourceProductionContext context, sb.AppendLine($" [\"{EscapeString(catName)}\"] = ["); foreach (var cmd in orderedCmds) { var aliases = string.Join(", ", cmd.Aliases.Select(a => $"\"{EscapeString(a)}\"")); - sb.AppendLine($" new SlashCommandMetadata {{ Name = \"/{EscapeString(cmd.Name)}\", Description = \"{EscapeString(cmd.Description)}\", Usage = \"{EscapeString(cmd.Usage)}\", Aliases = [{aliases}], IsHidden = {cmd.IsHidden.ToString().ToLowerInvariant()}, IsEnabled = {cmd.IsEnabled.ToString().ToLowerInvariant()}, Category = \"{EscapeString(catName)}\" }},"); + sb.AppendLine($" new SlashCommandMetadata {{ Name = \"/{EscapeString(cmd.Name)}\", Description = \"{EscapeString(cmd.Description)}\", Usage = \"{EscapeString(cmd.Usage)}\", Aliases = [{aliases}], ArgumentHint = \"{EscapeString(cmd.ArgumentHint)}\", IsHidden = {cmd.IsHidden.ToString().ToLowerInvariant()}, IsEnabled = {cmd.IsEnabled.ToString().ToLowerInvariant()}, Category = \"{EscapeString(catName)}\" }},"); } sb.AppendLine(" ],"); } diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs index 687efbc086..8fe7020d2d 100644 --- a/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs +++ b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs @@ -3,10 +3,20 @@ namespace NativeTui.Host; /// /// 斜杠命令清单源 — 从引擎 DI 解析 ISlashCommandCatalog(源码生成器在 Composition 中生成), /// 未注册时兜底直接实例化 GeneratedSlashCommandCatalog(CLI SlashCommandExecutors 先例); -/// 过滤 IsEnabled=false,转为 NativeTui.Core 零依赖的 CommandSuggestion 纯数据。 +/// 过滤 IsEnabled=false,转为 NativeTui.Core 零依赖的 CommandSuggestion 纯数据; +/// 参数补全元数据:ArgumentHint 透传 [ChatCommand] 特性,静态参数清单与 GUI CommandArgumentProvider 同源 +/// (/theme /effort /config 三组静态值;/model /provider 动态清单 TUI 侧走 Hint 提示)。 /// public static class SlashCommandSource { + /// 静态参数值清单(与 GUI CommandArgumentProvider 同值 — 跨端共享提升到 abstractions 是后续重构项)。 + private static readonly IReadOnlyDictionary ArgumentValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["theme"] = ["dark", "light", "auto"], + ["effort"] = ["auto", "low", "medium", "high", "max"], + ["config"] = ["get", "set", "list", "remove"], + }; + /// 加载命令清单(name 去 / 前缀)— 无目录时返回空列表(TUI 无补全能力降级)。 public static IReadOnlyList Load(IServiceProvider services) { @@ -14,11 +24,21 @@ public static IReadOnlyList Load(IServiceProvider services) return catalog.ByCategory.Values .SelectMany(category => category) .Where(command => command.IsEnabled) - .Select(command => new CommandSuggestion( - command.Name.StartsWith('/') ? command.Name[1..] : command.Name, - command.Description, - command.Aliases, - command.IsHidden)) + .Select(ToSuggestion) .ToList(); } + + /// 元数据 → 补全建议项(name 去 / 前缀 + 参数元数据注入)。 + private static CommandSuggestion ToSuggestion(SlashCommandMetadata command) + { + var name = command.Name.StartsWith('/') ? command.Name[1..] : command.Name; + ArgumentValues.TryGetValue(name, out var arguments); + return new CommandSuggestion( + name, + command.Description, + command.Aliases, + command.IsHidden, + ArgumentHint: command.ArgumentHint ?? string.Empty, + Arguments: arguments); + } } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 4d9b53080b..fd8c5f4335 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -586,8 +586,8 @@ private bool HandleSuggestionKey(KeyPress key) } } - /// 接受选中建议(基准 applyCommandSuggestion)— Tab 补全为 "/命令 "(带尾随空格进入参数态,不执行); - /// Enter 直接提交 "/命令"(不带尾随空格,历史与解析干净)。 + /// 接受选中建议(基准 applyCommandSuggestion)— 命令模式:Tab 补全 "/命令 "(尾随空格进参数态)/ + /// Enter 直接提交 "/命令";参数模式(IsArgumentMode):命令名取自输入前缀,补全 "/命令 参数 " 或提交 "/命令 参数"。 private void AcceptSuggestion(bool execute) { if (_suggester?.Selected is not { } selected) @@ -595,7 +595,24 @@ private void AcceptSuggestion(bool execute) return; } - FillInput(execute ? $"/{selected.Name}" : $"/{selected.Name} ", cursorToStart: false); + string completed; + if (_suggester.IsArgumentMode) + { + var spaceIndex = _input.Text.IndexOf(' '); + if (spaceIndex <= 1) + { + return; + } + + var commandName = _input.Text[1..spaceIndex]; + completed = $"/{commandName} {selected.Name}"; + } + else + { + completed = $"/{selected.Name}"; + } + + FillInput(execute ? completed : completed + " ", cursorToStart: false); UpdateSuggestions(); if (execute) { @@ -727,6 +744,7 @@ private void SyncSuggestionState() _view.Suggestions = _suggester is { IsActive: true } ? _suggester.Items : null; _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; + _view.SuggestionArgumentMode = _suggester?.IsArgumentMode ?? false; } /// diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index 9f70c3f4f0..cf894816ff 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -31,6 +31,9 @@ public sealed class ReplView /// 建议命令列宽(全量命令最长名 + 6,防布局跳动)。 public int SuggestionNameColumnWidth { get; set; } + /// 参数补全模式(建议项为参数值,渲染不带 / 前缀)。 + public bool SuggestionArgumentMode { get; set; } + /// footer 状态(模式/busy/agents 提示/登录错误/通知)。 public FooterState Footer { get; set; } = new(); @@ -68,7 +71,7 @@ public void Render(Screen screen, Theme theme) if (suggestionHeight > 0 && Suggestions is { Count: > 0 }) { var suggestionFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight, screen.Cols, suggestionHeight); - _suggestionBox.Render(screen, suggestionFrame, theme, Suggestions, SuggestionIndex, SuggestionNameColumnWidth); + _suggestionBox.Render(screen, suggestionFrame, theme, Suggestions, SuggestionIndex, SuggestionNameColumnWidth, SuggestionArgumentMode); } var inputFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight - 3, screen.Cols, 3); diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs b/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs index b18fabbc1a..4527c313d7 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/CommandSuggestion.cs @@ -5,4 +5,16 @@ namespace NativeTui.Core.Repl; /// 单行描述(建议列表右列)。 /// 别名(精确/前缀/子序列切名匹配用)。 /// 隐藏命令(默认不参与建议,输入全名时前插)。 -public sealed record CommandSuggestion(string Name, string Description, string[] Aliases, bool IsHidden); +/// 参数提示(命令后空格时显示的单条提示,如 "model-id";无静态参数清单的命令用)。 +/// 静态参数值清单(如 theme 的 dark/light/auto — 参数模式前缀过滤用;null 视为空)。 +public sealed record CommandSuggestion( + string Name, + string Description, + string[] Aliases, + bool IsHidden, + string ArgumentHint = "", + string[]? Arguments = null) +{ + /// 静态参数值清单(null 安全化 — 空数组)。 + public string[] ArgumentValues => Arguments ?? []; +} diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs index 2a2c951b92..7bc6dc428b 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs @@ -25,6 +25,9 @@ public sealed class SlashSuggester /// 选中项(无建议时 null)。 public CommandSuggestion? Selected => Items.Count > 0 ? Items[SelectedIndex] : null; + /// 参数补全模式("/cmd 参数前缀" 态 — 建议项为参数值或提示,渲染不带 / 前缀)。 + public bool IsArgumentMode { get; private set; } + /// 命令列宽 — 全量命令最长名 + 6(含 / 与列间距,基准预计算防过滤时布局跳动)。 public int NameColumnWidth { get; } @@ -41,21 +44,19 @@ public void Update(string input) _currentInput = input; if (input == _suppressedInput) { - Items = []; - SelectedIndex = 0; + (Items, SelectedIndex, IsArgumentMode) = ([], 0, false); return; } _suppressedInput = string.Empty; - (Items, SelectedIndex) = (Match(input), 0); + (Items, SelectedIndex, IsArgumentMode) = (Match(input), 0, IsArgumentInput(input)); } /// Esc 关闭建议并抑制同一输入(基准 autocomplete:dismiss — dismissedForInputRef)。 public void Dismiss() { _suppressedInput = _currentInput; - Items = []; - SelectedIndex = 0; + (Items, SelectedIndex, IsArgumentMode) = ([], 0, false); } /// 选中索引循环移动(正 = 向下,负 = 向上;越界回绕,基准循环语义)。 @@ -69,15 +70,29 @@ public void Move(int delta) SelectedIndex = ((SelectedIndex + delta) % Items.Count + Items.Count) % Items.Count; } - /// 匹配与排序 — 返回新建议列表;不触发(非 / 开头/含空格/空输入)时为空。 + /// 匹配与排序 — 命令模式("/前缀")或参数模式("/cmd 参数前缀"); + /// 不触发(非 / 开头/第二空格/未知命令/无参数元数据)时为空。 private List Match(string input) { - if (input.Length == 0 || input[0] != '/' || input.Contains(' ')) + if (input.Length == 0 || input[0] != '/') { return []; } - var query = input[1..]; + var firstSpace = input.IndexOf(' '); + return firstSpace < 0 ? MatchCommand(input[1..]) : MatchArgument(input, firstSpace); + } + + /// 参数模式输入判定(供 IsArgumentMode 同步)。 + private bool IsArgumentInput(string input) + { + var firstSpace = input.IndexOf(' '); + return firstSpace >= 0 && MatchArgument(input, firstSpace).Count > 0; + } + + /// 命令名模式匹配(无空格 — 二级排序链)。 + private List MatchCommand(string query) + { if (query.Length == 0) { return _commands.Where(c => !c.IsHidden) @@ -103,6 +118,36 @@ private List Match(string input) return ranked; } + /// 参数模式匹配 — "/cmd 参数前缀"(恰好一个空格;第二空格不触发): + /// 有静态参数清单 → 前缀过滤参数值项;仅 ArgumentHint → 单条提示项;均无 → 不触发。 + private List MatchArgument(string input, int firstSpace) + { + var commandName = input[1..firstSpace]; + var argumentPrefix = input[(firstSpace + 1)..]; + if (argumentPrefix.Contains(' ')) + { + return []; + } + + var command = _commands.FirstOrDefault(c => c.Name == commandName); + if (command is null) + { + return []; + } + + if (command.ArgumentValues.Length > 0) + { + return command.ArgumentValues + .Where(a => a.StartsWith(argumentPrefix, StringComparison.OrdinalIgnoreCase)) + .Select(a => new CommandSuggestion(a, command.Description, [], IsHidden: false)) + .ToList(); + } + + return command.ArgumentHint.Length > 0 + ? [new CommandSuggestion(command.ArgumentHint, command.Description, [], IsHidden: false)] + : []; + } + /// 精确匹配(名或切名等于查询)。 private static bool MatchesExactly(CommandSuggestion command, string query) => command.Name == query || command.Aliases.Contains(query, StringComparer.OrdinalIgnoreCase); diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs index 933683b89b..e7eca473da 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SuggestionBox.cs @@ -14,8 +14,8 @@ public sealed class SuggestionBox public int MeasureHeight(int itemCount) => Math.Min(itemCount, MaxVisibleItems); /// 渲染建议列表 — frame 内自顶向下每行一条;选中项 Suggestion 色,未选中 dim; - /// 命令名自 frame.X 起,描述自 frame.X + nameColumnWidth 起(超宽截断)。 - public void Render(Screen screen, Rect frame, Theme theme, IReadOnlyList items, int selectedIndex, int nameColumnWidth) + /// 命令模式命令名带 / 前缀自 frame.X 起,参数模式裸参数值;描述自 frame.X + nameColumnWidth 起(超宽截断)。 + public void Render(Screen screen, Rect frame, Theme theme, IReadOnlyList items, int selectedIndex, int nameColumnWidth, bool argumentMode = false) { if (items.Count == 0) { @@ -26,13 +26,14 @@ public void Render(Screen screen, Rect frame, Theme theme, IReadOnlyList 0) { screen.WriteText(frame.Y + row, frame.X + nameColumnWidth, item.Description, style, frame.Right); diff --git a/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs b/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs index 8d36810591..8581773695 100644 --- a/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/HistorySeederTests.cs @@ -52,6 +52,18 @@ public Task> LoadContentReplacementsAsyn public Task SaveSessionInfoAsync(string sessionId, SessionInfo info, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnsureSessionAsync(string sessionId, SessionInfo info, CancellationToken cancellationToken = default) + { + var index = Sessions.FindIndex(s => s.SessionId == sessionId); + if (index >= 0) + { + return Task.FromResult(false); + } + + Sessions.Add((sessionId, info.ProjectPath, [])); + return Task.FromResult(true); + } + public Task MigrateLegacyAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } diff --git a/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs index cf2d625103..9421b99f77 100644 --- a/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs @@ -21,6 +21,9 @@ private sealed class FakeCatalog : ISlashCommandCatalog ["Session"] = [ new SlashCommandMetadata { Name = "clear", Description = "清屏" }, + new SlashCommandMetadata { Name = "theme", Description = "主题" }, + new SlashCommandMetadata { Name = "effort", Description = "力度" }, + new SlashCommandMetadata { Name = "config", Description = "配置" }, ], }; } @@ -34,7 +37,7 @@ private static IServiceProvider Services() => public void Load_ConvertsMetadataToSuggestions() { var commands = SlashCommandSource.Load(Services()); - Assert.Equal(["clear", "help", "secret"], commands.Select(c => c.Name).OrderBy(n => n).ToArray()); + Assert.Equal(["clear", "config", "effort", "help", "secret", "theme"], commands.Select(c => c.Name).OrderBy(n => n).ToArray()); var help = commands.Single(c => c.Name == "help"); Assert.Equal("显示帮助", help.Description); Assert.Equal(["h"], help.Aliases); @@ -42,6 +45,33 @@ public void Load_ConvertsMetadataToSuggestions() Assert.True(commands.Single(c => c.Name == "secret").IsHidden); } + /// 参数元数据注入:ArgumentHint 透传特性;theme/effort/config 静态参数清单注入。 + [Fact] + public void Load_InjectsArgumentMetadata() + { + var commands = SlashCommandSource.Load(Services()); + var theme = commands.Single(c => c.Name == "theme"); + Assert.Equal(["dark", "light", "auto"], theme.ArgumentValues); + var effort = commands.Single(c => c.Name == "effort"); + Assert.Equal(5, effort.ArgumentValues.Length); + var config = commands.Single(c => c.Name == "config"); + Assert.Equal(["get", "set", "list", "remove"], config.ArgumentValues); + Assert.Empty(commands.Single(c => c.Name == "clear").ArgumentValues); + } + + /// ArgumentHint 透传:特性声明的参数提示进建议项。 + [Fact] + public void Load_PassesThroughArgumentHint() + { + var catalog = new FakeCatalog(); + var services = new ServiceCollection().AddSingleton(catalog).BuildServiceProvider(); + var commands = SlashCommandSource.Load(services); + // FakeCatalog 未声明 ArgumentHint → 空串;真实生成目录的 model 命令带 "model-id"(生成器提取) + Assert.All(commands, c => Assert.NotNull(c.ArgumentHint)); + var generated = SlashCommandSource.Load(new ServiceCollection().BuildServiceProvider()); + Assert.Contains(generated, c => c.Name == "model" && c.ArgumentHint.Length > 0); + } + /// DI 未注册目录:兜底直接实例化生成目录(真实生成器输出,非空)。 [Fact] public void Load_NoCatalog_FallsBackToGenerated() diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs index ec6fd2d8d3..bf91802a07 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs @@ -13,6 +13,7 @@ public sealed class ReplShellSuggestionsTests new("help", "显示帮助", [], IsHidden: false), new("hello", "问候", [], IsHidden: false), new("compact", "压缩上下文", [], IsHidden: false), + new("theme", "切换主题", [], IsHidden: false, Arguments: ["dark", "light", "auto"]), ]; /// 构造带命令清单的测试 shell(SeedContent 就绪消息区)。 @@ -45,7 +46,7 @@ public void SlashInput_ActivatesSuggestions() var (shell, _) = CreateShell(); Type(shell, "/"); Assert.True(shell.Suggester?.IsActive); - Assert.Equal(3, shell.Suggester?.Items.Count); + Assert.Equal(4, shell.Suggester?.Items.Count); Assert.Equal("compact", shell.Suggester?.Selected?.Name); } @@ -55,7 +56,7 @@ public void PrefixInput_FiltersSuggestions() { var (shell, _) = CreateShell(); Type(shell, "/he"); - Assert.Equal(["help", "hello"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + Assert.Equal(["help", "hello", "theme"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); } /// 普通文本输入不触发建议。 @@ -67,7 +68,7 @@ public void PlainInput_NoSuggestions() Assert.False(shell.Suggester?.IsActive); } - /// 退格后建议刷新:/c 仅 compact;退格回 / 后全量 3 条。 + /// 退格后建议刷新:/c 仅 compact;退格回 / 后全量 4 条。 [Fact] public void Backspace_RefreshesSuggestions() { @@ -75,7 +76,7 @@ public void Backspace_RefreshesSuggestions() Type(shell, "/c"); Assert.Equal(["compact"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); Press(shell, ConsoleKey.Backspace); - Assert.Equal(3, shell.Suggester!.Items.Count); + Assert.Equal(4, shell.Suggester!.Items.Count); } /// 建议激活时 Up/Down 循环导航(不消费历史)。 @@ -89,7 +90,7 @@ public void SuggestionsActive_ArrowKeys_NavigateSuggestions() Press(shell, ConsoleKey.UpArrow); Assert.Equal(0, shell.Suggester?.SelectedIndex); Press(shell, ConsoleKey.UpArrow); - Assert.Equal(1, shell.Suggester?.SelectedIndex); + Assert.Equal(2, shell.Suggester?.SelectedIndex); } /// Tab 接受:补全为 "/help "(带尾随空格),不提交引擎,建议关闭。 @@ -162,4 +163,51 @@ public void HistoryNavigation_DoesNotTriggerSuggestions() Assert.Equal("/help", shell.Input.Text); Assert.False(shell.Suggester?.IsActive); } + + /// 参数补全(TASK057-2):命令后空格弹参数建议,Tab 接受为 "/命令 参数 "。 + [Fact] + public void ArgumentCompletion_Tab_AcceptsParameterValue() + { + var (shell, _) = CreateShell(); + Type(shell, "/theme "); + Assert.True(shell.Suggester?.IsArgumentMode); + Assert.Equal(["dark", "light", "auto"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + Press(shell, ConsoleKey.Tab); + Assert.Equal("/theme dark ", shell.Input.Text); + Assert.False(shell.Suggester?.IsActive); + } + + /// 参数前缀过滤 + Tab 接受:/theme l → light。 + [Fact] + public void ArgumentCompletion_PrefixFilter_AcceptsFiltered() + { + var (shell, _) = CreateShell(); + Type(shell, "/theme l"); + Assert.Equal(["light"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + Press(shell, ConsoleKey.Tab); + Assert.Equal("/theme light ", shell.Input.Text); + } + + /// 参数模式 Enter:接受并提交 "/命令 参数"。 + [Fact] + public void ArgumentCompletion_Enter_AcceptsAndSubmits() + { + var (shell, engine) = CreateShell(); + Type(shell, "/theme d"); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["/theme dark"], engine.Submits); + Assert.Equal(string.Empty, shell.Input.Text); + } + + /// 命令接受后进入参数态:Tab 接受命令 → 参数建议自动弹出。 + [Fact] + public void CommandAccept_TabThenArguments_Chain() + { + var (shell, _) = CreateShell(); + Type(shell, "/the"); + Press(shell, ConsoleKey.Tab); + Assert.Equal("/theme ", shell.Input.Text); + Assert.True(shell.Suggester?.IsArgumentMode); + Assert.Equal(["dark", "light", "auto"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs index 3e0ab1a8f4..863391cea1 100644 --- a/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs @@ -16,6 +16,8 @@ private static List Catalog() => new("help-me", "求助", ["hm"], IsHidden: false), new("compact", "压缩上下文", [], IsHidden: false), new("secret", "隐藏命令", [], IsHidden: true), + new("theme", "切换主题", [], IsHidden: false, Arguments: ["dark", "light", "auto"]), + new("model", "切换模型", [], IsHidden: false, ArgumentHint: "model-id"), ]; /// 构造 suggester 并指定输入。 @@ -31,7 +33,7 @@ private static SlashSuggester Suggester(string input) public void Update_SlashOnly_AllVisibleAlphabetical() { var suggester = Suggester("/"); - Assert.Equal(["compact", "hello", "help", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + Assert.Equal(["compact", "hello", "help", "help-me", "model", "theme"], suggester.Items.Select(i => i.Name).ToArray()); } /// 隐藏命令默认不参与建议。 @@ -47,7 +49,7 @@ public void Update_Hidden_Excluded() public void Update_PrefixQuery_MatchesPrefixCommands() { var suggester = Suggester("/he"); - Assert.Equal(["help", "hello", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + Assert.Equal(["help", "hello", "help-me", "theme"], suggester.Items.Select(i => i.Name).ToArray()); } /// 精确名优先:/help 排在 help-me 与 hello 之前。 @@ -181,6 +183,8 @@ public void Move_WrapsAround() suggester.Move(1); Assert.Equal(2, suggester.SelectedIndex); suggester.Move(1); + Assert.Equal(3, suggester.SelectedIndex); + suggester.Move(1); Assert.Equal(0, suggester.SelectedIndex); } @@ -190,7 +194,7 @@ public void Move_Backward_WrapsToLast() { var suggester = Suggester("/he"); suggester.Move(-1); - Assert.Equal(2, suggester.SelectedIndex); + Assert.Equal(3, suggester.SelectedIndex); } /// 列表更新后选中回零(简单回退,基准按 id 找回的简化)。 @@ -208,6 +212,66 @@ public void Update_NewQuery_ResetsSelection() public void Update_CaseInsensitive() { var suggester = Suggester("/HE"); - Assert.Equal(["help", "hello", "help-me"], suggester.Items.Select(i => i.Name).ToArray()); + Assert.Equal(["help", "hello", "help-me", "theme"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 参数模式(TASK057-2):命令后空格触发 — /theme 弹全部参数值。 + [Fact] + public void Update_CommandWithSpace_EntersArgumentMode() + { + var suggester = Suggester("/theme "); + Assert.True(suggester.IsArgumentMode); + Assert.Equal(["dark", "light", "auto"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 参数模式前缀过滤:/theme d → dark。 + [Fact] + public void Update_ArgumentPrefix_FiltersArguments() + { + var suggester = Suggester("/theme d"); + Assert.True(suggester.IsArgumentMode); + Assert.Equal(["dark"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// 参数模式大小写不敏感过滤。 + [Fact] + public void Update_ArgumentPrefix_CaseInsensitive() + { + var suggester = Suggester("/theme L"); + Assert.Equal(["light"], suggester.Items.Select(i => i.Name).ToArray()); + } + + /// Hint-only 命令(无静态参数清单):/model 显示 ArgumentHint 单条提示。 + [Fact] + public void Update_HintOnlyCommand_ShowsArgumentHint() + { + var suggester = Suggester("/model "); + Assert.True(suggester.IsArgumentMode); + var item = Assert.Single(suggester.Items); + Assert.Equal("model-id", item.Name); + } + + /// 无参数元数据的命令:命令后空格不触发建议。 + [Fact] + public void Update_CommandWithoutArguments_Inactive() + { + var suggester = Suggester("/help "); + Assert.False(suggester.IsActive); + } + + /// 参数已含空格(第二参数态):不触发。 + [Fact] + public void Update_ArgumentWithSpace_Inactive() + { + var suggester = Suggester("/theme dark "); + Assert.False(suggester.IsActive); + } + + /// 未知命令后空格:不触发。 + [Fact] + public void Update_UnknownCommandWithSpace_Inactive() + { + var suggester = Suggester("/zzz "); + Assert.False(suggester.IsActive); } } From c41a369b662335c8ed069c851ca88d3f0a7d1834 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:00:36 +0800 Subject: [PATCH 34/43] =?UTF-8?q?feat:=20TUI=E5=B9=BD=E7=81=B5=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E8=A1=A5=E5=85=A8=20|=20=E5=86=B3=E7=AD=96:=20GhostSu?= =?UTF-8?q?ffix=E5=8F=96=E6=9C=80=E5=90=8E=E7=A9=BA=E7=99=BD=E5=90=8E/?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E7=9A=84=E9=A6=96=E4=B8=AA=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=91=BD=E4=BB=A4=E5=89=A9=E4=BD=99=E6=AE=B5?= =?UTF-8?q?,=E4=B8=8B=E6=8B=89=E4=BA=92=E6=96=A5,RightArrow=E5=85=89?= =?UTF-8?q?=E6=A0=87=E6=9C=AB=E5=B0=BE=E6=8E=A5=E5=8F=97,Esc=E4=B8=8E?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=85=B1=E7=94=A8Dismiss=E6=8A=91=E5=88=B6,?= =?UTF-8?q?=E6=B8=B2=E6=9F=93dim=E8=89=B2=E6=96=87=E6=9C=AC=E5=90=8E+?= =?UTF-8?q?=E5=85=89=E6=A0=87=E8=A6=86=E7=9B=96ghost=E9=A6=96=E5=AD=97?= =?UTF-8?q?=E7=AC=A6(=E5=9F=BA=E5=87=86=E5=AF=B9=E9=BD=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/PromptInputBox.cs | 11 +++- .../src/NativeTui.Core/repl/ReplShell.cs | 56 +++++++++++----- .../src/NativeTui.Core/repl/ReplView.cs | 5 +- .../repl/suggestions/SlashSuggester.cs | 45 +++++++++++-- .../repl/ReplComponentTests.cs | 38 +++++++++++ .../repl/ReplShellSuggestionsTests.cs | 58 +++++++++++++++++ .../repl/suggestions/SlashSuggesterTests.cs | 64 +++++++++++++++++++ 7 files changed, 253 insertions(+), 24 deletions(-) diff --git a/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs b/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs index 0bfa123afc..4b2179f848 100644 --- a/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs +++ b/native_tui/src/NativeTui.Core/repl/PromptInputBox.cs @@ -12,10 +12,11 @@ public sealed class PromptInputBox public const int PromptWidth = 2; /// - /// 渲染到指定帧区域:row 0 顶部分隔线,row 1 提示符 + 文本 + 光标,row 2 底部分隔线。 + /// 渲染到指定帧区域:row 0 顶部分隔线,row 1 提示符 + 文本 + 光标(+ ghost 幽灵后缀),row 2 底部分隔线。 /// cursorCol 为文本内偏移(0 = 首字符之前),光标屏幕列 = frame.X + PromptWidth + cursorCol。 + /// ghostSuffix 非空时以 dim 色渲染在文本之后(帧右界截断 — 基准 ghost text dim 提示)。 /// - public void Render(Screen screen, Rect frame, Theme theme, string text, int cursorCol, bool busy = false) + public void Render(Screen screen, Rect frame, Theme theme, string text, int cursorCol, bool busy = false, string ghostSuffix = "") { var border = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.PromptBorder }); BorderPainter.DrawRule(screen, frame.Y, frame.X, frame.Width, border); @@ -25,6 +26,12 @@ public void Render(Screen screen, Rect frame, Theme theme, string text, int curs var row = frame.Y + 1; screen.WriteText(row, frame.X, $"{Figures.Pointer} ", promptStyle, frame.Right); screen.WriteText(row, frame.X + PromptWidth, text, 0, frame.Right); + if (ghostSuffix.Length > 0) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(row, frame.X + PromptWidth + text.Length, ghostSuffix, dim, frame.Right); + } + DrawCursor(screen, frame, row, cursorCol); } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index fd8c5f4335..c34761fdd8 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -555,35 +555,56 @@ private void ScrollIfViewport(int rows) } /// 补全激活时的专属键拦截(基准 Autocomplete 上下文): - /// Up/Down 循环导航、Tab 接受不执行、Enter 接受并提交、Esc 仅关建议(优先于打断/清输入/退出); + /// 下拉激活:Up/Down 循环导航、Tab 接受不执行、Enter 接受并提交、Esc 仅关建议(优先于打断/清输入/退出); + /// ghost 激活(下拉互斥为空):RightArrow(光标在末尾)接受幽灵补全、Esc 仅关 ghost; /// 其余键返回 false 走正常编辑路径(编辑后刷新建议)。 private bool HandleSuggestionKey(KeyPress key) { - if (_suggester is not { IsActive: true } suggester) + if (_suggester is not { } suggester) { return false; } - switch (key.Kind) + if (suggester.IsActive) { - case KeyKind.UpArrow: - suggester.Move(-1); - return true; - case KeyKind.DownArrow: - suggester.Move(1); - return true; - case KeyKind.Tab: - AcceptSuggestion(execute: false); - return true; - case KeyKind.Return: - AcceptSuggestion(execute: true); + switch (key.Kind) + { + case KeyKind.UpArrow: + suggester.Move(-1); + return true; + case KeyKind.DownArrow: + suggester.Move(1); + return true; + case KeyKind.Tab: + AcceptSuggestion(execute: false); + return true; + case KeyKind.Return: + AcceptSuggestion(execute: true); + return true; + case KeyKind.Escape: + suggester.Dismiss(); + return true; + default: + return false; + } + } + + if (suggester.GhostSuffix.Length > 0) + { + if (key.Kind == KeyKind.RightArrow && _input.CursorCol == _input.Text.Length) + { + FillInput(_input.Text + suggester.GhostSuffix, cursorToStart: false); return true; - case KeyKind.Escape: + } + + if (key.Kind == KeyKind.Escape) + { suggester.Dismiss(); return true; - default: - return false; + } } + + return false; } /// 接受选中建议(基准 applyCommandSuggestion)— 命令模式:Tab 补全 "/命令 "(尾随空格进参数态)/ @@ -745,6 +766,7 @@ private void SyncSuggestionState() _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; _view.SuggestionArgumentMode = _suggester?.IsArgumentMode ?? false; + _view.GhostSuffix = _suggester?.GhostSuffix ?? string.Empty; } /// diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index cf894816ff..cd8a742867 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -34,6 +34,9 @@ public sealed class ReplView /// 参数补全模式(建议项为参数值,渲染不带 / 前缀)。 public bool SuggestionArgumentMode { get; set; } + /// 幽灵文本后缀(输入中途 "/前缀" 的命令补全剩余部分 — dim 渲染在文本后)。 + public string GhostSuffix { get; set; } = string.Empty; + /// footer 状态(模式/busy/agents 提示/登录错误/通知)。 public FooterState Footer { get; set; } = new(); @@ -75,7 +78,7 @@ public void Render(Screen screen, Theme theme) } var inputFrame = new Rect(0, screen.Rows - footerHeight - suggestionHeight - 3, screen.Cols, 3); - _inputBox.Render(screen, inputFrame, theme, Input, CursorCol, Footer.Busy); + _inputBox.Render(screen, inputFrame, theme, Input, CursorCol, Footer.Busy, GhostSuffix); var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - 4); var messagesFrame = new Rect(0, 0, screen.Cols, messagesHeight); diff --git a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs index 7bc6dc428b..cf2708f6d8 100644 --- a/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs +++ b/native_tui/src/NativeTui.Core/repl/suggestions/SlashSuggester.cs @@ -28,6 +28,10 @@ public sealed class SlashSuggester /// 参数补全模式("/cmd 参数前缀" 态 — 建议项为参数值或提示,渲染不带 / 前缀)。 public bool IsArgumentMode { get; private set; } + /// 幽灵文本后缀(基准 ghost text — 输入中途 " /前缀" token 的命令补全剩余部分,如 "act"; + /// 激活时下拉互斥为空;空串 = 未激活)。 + public string GhostSuffix { get; private set; } = string.Empty; + /// 命令列宽 — 全量命令最长名 + 6(含 / 与列间距,基准预计算防过滤时布局跳动)。 public int NameColumnWidth { get; } @@ -44,19 +48,21 @@ public void Update(string input) _currentInput = input; if (input == _suppressedInput) { - (Items, SelectedIndex, IsArgumentMode) = ([], 0, false); + (Items, SelectedIndex, IsArgumentMode, GhostSuffix) = ([], 0, false, string.Empty); return; } _suppressedInput = string.Empty; - (Items, SelectedIndex, IsArgumentMode) = (Match(input), 0, IsArgumentInput(input)); + var items = Match(input); + GhostSuffix = items.Count == 0 && input.Length > 0 && input[0] != '/' ? MatchGhostSuffix(input) : string.Empty; + (Items, SelectedIndex, IsArgumentMode) = (items, 0, IsArgumentInput(input)); } - /// Esc 关闭建议并抑制同一输入(基准 autocomplete:dismiss — dismissedForInputRef)。 + /// Esc 关闭建议并抑制同一输入(基准 autocomplete:dismiss — dismissedForInputRef,ghost 与下拉共用抑制)。 public void Dismiss() { _suppressedInput = _currentInput; - (Items, SelectedIndex, IsArgumentMode) = ([], 0, false); + (Items, SelectedIndex, IsArgumentMode, GhostSuffix) = ([], 0, false, string.Empty); } /// 选中索引循环移动(正 = 向下,负 = 向上;越界回绕,基准循环语义)。 @@ -70,6 +76,37 @@ public void Move(int delta) SelectedIndex = ((SelectedIndex + delta) % Items.Count + Items.Count) % Items.Count; } + /// 幽灵后缀计算(基准 getBestCommandMatch)— 取最后一个空白后 "/" 起始的尾部 token 查询, + /// 找第一个前缀匹配命令名的剩余部分;无 token/空前缀/无匹配返回空串。 + private string MatchGhostSuffix(string input) + { + var queryStart = -1; + for (var i = input.Length - 1; i >= 0; i--) + { + var ch = input[i]; + if (ch == '/' && (i == 0 || char.IsWhiteSpace(input[i - 1]))) + { + queryStart = i + 1; + break; + } + + if (char.IsWhiteSpace(ch)) + { + break; + } + } + + if (queryStart <= 0 || queryStart >= input.Length) + { + return string.Empty; + } + + var query = input[queryStart..]; + var command = _commands.FirstOrDefault(c => + !c.IsHidden && c.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase) && c.Name.Length > query.Length); + return command is null ? string.Empty : command.Name[query.Length..]; + } + /// 匹配与排序 — 命令模式("/前缀")或参数模式("/cmd 参数前缀"); /// 不触发(非 / 开头/第二空格/未知命令/无参数元数据)时为空。 private List Match(string input) diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs index b32bd9550b..4930dc1c04 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplComponentTests.cs @@ -144,6 +144,44 @@ public void Render_DimPointerWhenBusy() Assert.Equal(new Rune(Figures.Pointer[0]), screen[1, 0].Value); Assert.Equal(dim, screen[1, 0].StyleId); } + + [Fact] + public void Render_GhostSuffixDimmedAfterText() + { + using var screen = new Screen(cols: 20, rows: 3); + var box = new PromptInputBox(); + box.Render(screen, new Rect(0, 0, 20, 3), Theme.Dark, text: "run /comp", cursorCol: 9, ghostSuffix: "act"); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + var inverse = screen.Styles.GetOrAdd(new TextStyle { Inverse = true }); + // ghost "act" 自文本末尾列(2 前缀 + 9 文本 = 列 11)起;光标也在列 11 → 首字符 'a' 反色(基准光标覆盖 ghost 起点) + Assert.Equal("act", RowText.Of(screen, 1, 11, 3)); + Assert.Equal(inverse, screen[1, 11].StyleId); + // 后续 ghost 字符 dim 渲染 + Assert.Equal(dim, screen[1, 12].StyleId); + Assert.Equal(dim, screen[1, 13].StyleId); + // 正文保持默认样式 + Assert.Equal(0, screen[1, 2].StyleId); + } + + [Fact] + public void Render_GhostSuffixTruncatedToFrame() + { + using var screen = new Screen(cols: 8, rows: 3); + var box = new PromptInputBox(); + // 文本 "hi"(占列 2-3),ghost "xyz" 从列 4 起,frame 右界 8 → 只渲染 "xyz" 完整放下前 4 列 + box.Render(screen, new Rect(0, 0, 8, 3), Theme.Dark, text: "hi", cursorCol: 2, ghostSuffix: "xyzw"); + Assert.Equal("xyzw", RowText.Of(screen, 1, 4, 4)); + } + + [Fact] + public void Render_EmptyGhostSuffix_NoExtraCells() + { + using var screen = new Screen(cols: 12, rows: 3); + var box = new PromptInputBox(); + box.Render(screen, new Rect(0, 0, 12, 3), Theme.Dark, text: "hi", cursorCol: 2, ghostSuffix: ""); + // 空 ghost:光标列(4)之外无渲染(列 5 为空白) + Assert.Equal(Cell.Blank, screen[1, 5]); + } } /// diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs index bf91802a07..083858a230 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellSuggestionsTests.cs @@ -210,4 +210,62 @@ public void CommandAccept_TabThenArguments_Chain() Assert.True(shell.Suggester?.IsArgumentMode); Assert.Equal(["dark", "light", "auto"], shell.Suggester!.Items.Select(i => i.Name).ToArray()); } + + /// 幽灵文本(TASK057-3):中途 "/comp" token 计算出 ghost,不弹下拉。 + [Fact] + public void GhostText_MidInputSlash_ComputedWithoutPopup() + { + var (shell, _) = CreateShell(); + Type(shell, "run /comp"); + Assert.Equal("act", shell.Suggester?.GhostSuffix); + Assert.False(shell.Suggester?.IsActive); + } + + /// 幽灵文本右箭头接受:光标在末尾 + ghost 激活 → RightArrow 补全文本。 + [Fact] + public void GhostText_RightArrow_AtEnd_AcceptsGhost() + { + var (shell, _) = CreateShell(); + Type(shell, "run /comp"); + Press(shell, ConsoleKey.RightArrow); + Assert.Equal("run /compact", shell.Input.Text); + Assert.Equal("run /compact".Length, shell.Input.CursorCol); + } + + /// 光标不在末尾:RightArrow 保持正常光标移动(不接受 ghost)。 + [Fact] + public void GhostText_RightArrow_MidText_MovesCursorOnly() + { + var (shell, _) = CreateShell(); + Type(shell, "run /comp"); + Press(shell, ConsoleKey.LeftArrow); + Press(shell, ConsoleKey.RightArrow); + Assert.Equal("run /comp", shell.Input.Text); + } + + /// 无 ghost:RightArrow 在末尾为 no-op(文本不变)。 + [Fact] + public void GhostText_Absent_RightArrowAtEnd_NoOp() + { + var (shell, _) = CreateShell(); + Type(shell, "plain"); + Press(shell, ConsoleKey.RightArrow); + Assert.Equal("plain", shell.Input.Text); + } + + /// Esc 关闭 ghost:同输入抑制(与下拉共用 dismiss)。 + [Fact] + public void GhostText_Escape_DismissesAndSuppresses() + { + var (shell, _) = CreateShell(); + Type(shell, "run /comp"); + Assert.Equal("act", shell.Suggester?.GhostSuffix); + Press(shell, ConsoleKey.Escape); + Assert.Equal(string.Empty, shell.Suggester?.GhostSuffix); + Assert.Equal("run /comp", shell.Input.Text); + Assert.True(shell.Running); + Press(shell, ConsoleKey.LeftArrow); + Press(shell, ConsoleKey.RightArrow); + Assert.Equal("run /comp", shell.Input.Text); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs index 863391cea1..4f796965df 100644 --- a/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/suggestions/SlashSuggesterTests.cs @@ -274,4 +274,68 @@ public void Update_UnknownCommandWithSpace_Inactive() var suggester = Suggester("/zzz "); Assert.False(suggester.IsActive); } + + /// 幽灵文本(TASK057-3):中途 " /com" token → 前缀匹配第一个命令,GhostSuffix 为剩余部分。 + [Fact] + public void Update_MidInputSlashToken_ComputesGhostSuffix() + { + var suggester = Suggester("hello /comp"); + Assert.Equal("act", suggester.GhostSuffix); + Assert.False(suggester.IsActive); + } + + /// 输入以 / 开头:走下拉建议,无 ghost。 + [Fact] + public void Update_LeadingSlash_NoGhost() + { + var suggester = Suggester("/comp"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + Assert.True(suggester.IsActive); + } + + /// token 无匹配命令:无 ghost。 + [Fact] + public void Update_MidInputNoMatch_NoGhost() + { + var suggester = Suggester("hello /zzz"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + } + + /// 仅 " /"(空前缀):无 ghost(查询非空才触发)。 + [Fact] + public void Update_EmptyMidInputPrefix_NoGhost() + { + var suggester = Suggester("hello /"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + } + + /// 非斜杠输入:无 ghost。 + [Fact] + public void Update_PlainText_NoGhost() + { + var suggester = Suggester("plain text"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + } + + /// 多词中途 token:取最后一个空白后的 / 查询。 + [Fact] + public void Update_LastTokenAfterWhitespace_Wins() + { + var suggester = Suggester("run /he then /theme"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + var exact = Suggester("run /he then /the"); + Assert.Equal("me", exact.GhostSuffix); + } + + /// ghost 激活时 Esc dismiss 后同输入不再触发(含 ghost 与下拉共用的抑制)。 + [Fact] + public void Dismiss_SuppressesGhostForSameInput() + { + var suggester = Suggester("hello /comp"); + Assert.Equal("act", suggester.GhostSuffix); + suggester.Dismiss(); + Assert.Equal(string.Empty, suggester.GhostSuffix); + suggester.Update("hello /comp"); + Assert.Equal(string.Empty, suggester.GhostSuffix); + } } From dd91bd100bdd7b0a4ba8497667008efb45483aca Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 02:59:22 +0800 Subject: [PATCH 35/43] =?UTF-8?q?feat:=20M7-7=20=E7=BB=84=E5=A4=B4?= =?UTF-8?q?=E2=97=8F=E9=97=AA=E7=83=81=E5=8A=A8=E7=94=BB(600ms=E6=96=B9?= =?UTF-8?q?=E6=B3=A2+=E6=97=B6=E9=92=9F=E5=B9=BF=E6=92=AD)=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20IAnimatable=E6=8E=A5=E5=8F=A3+ReplShell=E6=AF=8F?= =?UTF-8?q?=E6=8B=8D=E5=B9=BF=E6=92=AD(=E5=9F=BA=E5=87=86useBlink=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=97=B6=E9=92=9F=E5=85=A8=E5=AE=9E=E4=BE=8B=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=97=AA=E7=83=81,spinner=E4=B8=8E=E7=BB=84=E5=A4=B4?= =?UTF-8?q?=E5=90=8C=E6=BA=90)+=E8=BF=90=E8=A1=8C=E4=B8=AD=E8=89=B2?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3dim=E9=BB=98=E8=AE=A4=E5=89=8D=E6=99=AF(?= =?UTF-8?q?=E5=9F=BA=E5=87=86ToolUseLoader=20isUnresolved=E8=89=B2?= =?UTF-8?q?=E4=BC=98=E5=85=88=E4=BA=8EisError,Suggestion=E7=B3=BB=E8=AF=AF?= =?UTF-8?q?=E7=94=A8)+=E9=94=99=E8=AF=AF=E5=AE=8C=E6=88=90=E6=81=92?= =?UTF-8?q?=E4=BA=AE=E4=B8=8D=E9=97=AA;714/714=E7=BB=BF=E5=90=AB5=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95+Demo=20selftest=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/IAnimatable.cs | 12 ++++ .../NativeTui.Core/repl/MessageRenderer.cs | 18 +++-- .../src/NativeTui.Core/repl/ReplShell.cs | 20 +++++- .../src/NativeTui.Core/repl/SpinnerItem.cs | 2 +- .../NativeTui.Core/repl/SubagentGroupItem.cs | 10 ++- .../NativeTui.Tests/repl/ReplShellTests.cs | 32 +++++++++ .../repl/SubagentGroupTests.cs | 65 +++++++++++++++++-- 7 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/repl/IAnimatable.cs diff --git a/native_tui/src/NativeTui.Core/repl/IAnimatable.cs b/native_tui/src/NativeTui.Core/repl/IAnimatable.cs new file mode 100644 index 0000000000..2bc4514fc2 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/IAnimatable.cs @@ -0,0 +1,12 @@ +namespace NativeTui.Core.Repl; + +/// +/// 动画时钟消费者 — ReplShell 每拍(50ms tick)推进 AnimTimeMs 并广播给滚动区全部动画项 +/// (对齐基准 useBlink 共享动画时钟:全部实例同相位同步闪烁)。 +/// 消费方在 Render 内依据 AnimTimeMs 计算帧相位(如组头 ● 600ms 方波闪烁,基准 BLINK_INTERVAL_MS)。 +/// +public interface IAnimatable +{ + /// 动画时钟(50ms 帧累计,ReplShell 每帧广播注入;测试直接赋值验证确定性相位)。 + long AnimTimeMs { get; set; } +} diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs index 4e26420680..f380192dc1 100644 --- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs +++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs @@ -235,15 +235,21 @@ public static void RenderModelShortcuts(Screen screen, Rect frame, Theme theme, } /// - /// 渲染子代理组头行(M7-7,基准 ToolUseLoader + 组头文案): + /// 渲染子代理组头行(M7-7,基准 ToolUseLoader + useBlink): /// ● + 空格 + 文案("Running N {type} agents…" / "N {type} agents finished")。 - /// ● 三态色:任一错误 Error 红(基准 anyError loader 变红)/ 全完成 Success 绿 / 运行中 Suggestion。 + /// 闪烁:运行中且无错时 600ms 方波 ●↔空格(基准 BLINK_INTERVAL_MS,布局不跳动 — 文案恒 X+2 起); + /// 错误恒亮、完成恒亮(基准 t1 = isError || !isUnresolved || isBlinking ? ● : " ")。 + /// 色:运行中 dim 默认前景(基准 isUnresolved → undefined+dimColor,优先于错误红)/ 错误 Error 红 / 完成 Success 绿。 /// - public static void RenderSubagentHeader(Screen screen, Rect frame, Theme theme, string text, bool isDone, bool hasError) + public static void RenderSubagentHeader(Screen screen, Rect frame, Theme theme, string text, bool isDone, bool hasError, long animTimeMs) { - var bulletColor = hasError ? theme.Error : isDone ? theme.Success : theme.Suggestion; - var bullet = screen.Styles.GetOrAdd(new TextStyle { Foreground = bulletColor }); - screen.WriteText(frame.Y, frame.X, $"{Figures.MessageBullet} ", bullet, frame.Right); + const int BlinkIntervalMs = 600; + var isRunning = !isDone; + var bullet = hasError || isDone || animTimeMs / BlinkIntervalMs % 2 == 0 ? Figures.MessageBullet : " "; + var bulletStyle = isRunning + ? screen.Styles.GetOrAdd(new TextStyle { Dim = true }) + : screen.Styles.GetOrAdd(new TextStyle { Foreground = hasError ? theme.Error : theme.Success }); + screen.WriteText(frame.Y, frame.X, $"{bullet} ", bulletStyle, frame.Right); screen.WriteText(frame.Y, frame.X + 2, text, 0, frame.Right); } diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index c34761fdd8..f221f3f21e 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -725,9 +725,7 @@ private void RenderAndFlush() SyncSuggestionState(); EnsureScreens(); _back.Clear(); - _animTimeMs += TickMs; - _spinner.AnimTimeMs = _animTimeMs; - _spinner.WallNowMs = Environment.TickCount64; + AdvanceAnimClock(); _view.Input = _input.Text; _view.CursorCol = _input.CursorCol; _view.Render(_back, _theme); @@ -759,6 +757,22 @@ private void RenderAndFlush() _frontValid = true; } + /// 动画时钟推进 + 广播:每拍 +50ms,喂给滚动区全部 IAnimatable(基准 useBlink 共享时钟 — 全实例同步闪烁); + /// spinner 专属墙钟一并注入(elapsed 暂停感知,语义区别于动画相位时钟)。 + internal void AdvanceAnimClock() + { + _animTimeMs += TickMs; + foreach (var item in _view.Messages.Items) + { + if (item is IAnimatable animatable) + { + animatable.AnimTimeMs = _animTimeMs; + } + } + + _spinner.WallNowMs = Environment.TickCount64; + } + /// 同步补全建议到视图(渲染前状态投影 — EnsureScreens 屏高计算依赖建议区高度,必须先行)。 private void SyncSuggestionState() { diff --git a/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs b/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs index 7daf33ed68..d979ab93ae 100644 --- a/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs +++ b/native_tui/src/NativeTui.Core/repl/SpinnerItem.cs @@ -8,7 +8,7 @@ namespace NativeTui.Core.Repl; /// 整体 dim 括号包裹(thinkingOnly 例外:单 part shimmer 色无括号); /// 渐进宽度门控 availableSpace = columns - messageWidth - 5(对齐基准 Progressive width gating)。 /// -public sealed class SpinnerItem : IScrollItem +public sealed class SpinnerItem : IScrollItem, IAnimatable { /// 思考 shimmer 常量(基准 THINKING_INACTIVE,非 theme 键)。 private static readonly Color ThinkingInactive = Color.FromRgb(153, 153, 153); diff --git a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs index a085ce3ba4..978a5a4906 100644 --- a/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs +++ b/native_tui/src/NativeTui.Core/repl/SubagentGroupItem.cs @@ -9,21 +9,25 @@ namespace NativeTui.Core.Repl; /// 运行中条目整行 dim(基准 AgentProgressLine isResolved 前提色);树形收口 └─ 按"组最后一个条目"判定 /// (窗口截断时可见条目均不收口,树延续到溢出行之后语义)。状态源 = SubagentGroupCoordinator(投影, /// Driver 每事件更新即生效 — 无需失效通知,ScrollBox 每帧全量重渲)。 +/// IAnimatable — ReplShell 每帧广播动画时钟,组头 ● 600ms 方波闪烁(基准 ToolUseLoader + useBlink)。 /// -public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem +public sealed class SubagentGroupItem(SubagentGroupCoordinator coordinator) : IScrollItem, IAnimatable { /// 协调器(分组状态的唯一来源 — Driver 事件路由更新)。 public SubagentGroupCoordinator Coordinator { get; } = coordinator; + /// 动画时钟(ReplShell 每帧广播;600ms 方波相位计算)。 + public long AnimTimeMs { get; set; } + /// 内容高度 = margin 1 + 组头 1 + 可见条目 ×2 + 溢出 0/1。 public int MeasureHeight(int width) => 2 + Coordinator.VisibleCount * 2 + (Coordinator.HiddenCount > 0 ? 1 : 0); - /// 渲染:margin 留空 → 组头(● 三态色 + 文案)→ 可见条目两行式 → 溢出行(如有)。 + /// 渲染:margin 留空 → 组头(● 闪烁三态 + 文案)→ 可见条目两行式 → 溢出行(如有)。 public void Render(Screen screen, Rect frame, Theme theme) { var row = frame.Y + 1; - MessageRenderer.RenderSubagentHeader(screen, RowFrame(frame, row++), theme, Coordinator.HeaderText, Coordinator.AllComplete, Coordinator.AnyError); + MessageRenderer.RenderSubagentHeader(screen, RowFrame(frame, row++), theme, Coordinator.HeaderText, Coordinator.AllComplete, Coordinator.AnyError, AnimTimeMs); var hideType = Coordinator.AllSameType; for (var i = 0; i < Coordinator.VisibleCount; i++) diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs index bef0e9b5ac..2638efc038 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs @@ -208,4 +208,36 @@ public void CyclePermissionMode_ForwardsToEngine() Assert.Single(engine.PermissionModes); Assert.Equal(PermissionMode.Auto, shell.View.Footer.Mode); } + + /// AdvanceAnimClock:动画时钟广播给滚动区全部 IAnimatable — spinner 与注入项同值(基准 useBlink 共享时钟 → 全实例同步闪烁);每拍 +50ms。 + [Fact] + public void AdvanceAnimClock_BroadcastsToAllAnimatableItems() + { + var (shell, _) = CreateShell(); + shell.SeedContent(); + var fake = new FakeAnimatableItem(); + shell.View.Messages.Insert(shell.View.Messages.Count - 1, fake); + + shell.AdvanceAnimClock(); + Assert.Equal(50, fake.AnimTimeMs); + var spinner = (SpinnerItem)shell.View.Messages.Items[^1]; + Assert.Equal(50, spinner.AnimTimeMs); + Assert.Equal(fake.AnimTimeMs, spinner.AnimTimeMs); + + shell.AdvanceAnimClock(); + Assert.Equal(100, fake.AnimTimeMs); + Assert.Equal(fake.AnimTimeMs, spinner.AnimTimeMs); + } +} + +/// 测试注入用动画项(IAnimatable 消费面 — 广播路由断言)。 +internal sealed class FakeAnimatableItem : IScrollItem, IAnimatable +{ + public long AnimTimeMs { get; set; } + + public int MeasureHeight(int width) => 1; + + public void Render(Screen screen, Rect frame, Theme theme) + { + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs index 7378496bbf..ebd712d9a4 100644 --- a/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/SubagentGroupTests.cs @@ -156,17 +156,74 @@ public void RecordToolUse_UnknownAgent_Throws() // ---- SubagentGroupItem 渲染 ---- - /// 渲染组头 — ● 三态色(运行中 Suggestion)+ "Running N agents…" 文案。 + /// 渲染组头 — 运行中 ● dim 默认色(基准 ToolUseLoader isUnresolved → undefined+dim)+ 600ms 方波亮相位 ● + "Running N agents…" 文案。 [Fact] public void Render_HeaderBulletAndRunningText() { using var screen = new Screen(cols: 60, rows: 10); var coordinator = CreateMixedGroup(); - new SubagentGroupItem(coordinator).Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + new SubagentGroupItem(coordinator) { AnimTimeMs = 0 }.Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); Assert.StartsWith("● Running 3 agents…", RowText.Of(screen, 1, 0, 60)); - var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion }); - Assert.Equal(suggestion, screen[1, 0].StyleId); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[1, 0].StyleId); + } + + /// 组头闪烁 — 600ms 暗相位运行中 ● 隐藏为空格(基准 useBlink 方波;布局不跳动:文案仍 X+2 起)。 + [Fact] + public void Render_HeaderBulletBlinks_OffPhaseHidden() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + new SubagentGroupItem(coordinator) { AnimTimeMs = 600 }.Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.Equal(new Rune(' '), screen[1, 0].Value); + Assert.StartsWith(" Running 3 agents…", RowText.Of(screen, 1, 0, 60)); + } + + /// 组头闪烁 — 暗相位错误恒亮 ● 红(基准 t1 isError 优先恒亮;全完成态)。 + [Fact] + public void Render_HeaderBulletSolidAtOffPhase_WhenError() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.FinishAgent("a1", isError: true); + new SubagentGroupItem(coordinator) { AnimTimeMs = 600 }.Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.Equal(new Rune('●'), screen[1, 0].Value); + var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error }); + Assert.Equal(error, screen[1, 0].StyleId); + } + + /// 组头闪烁 — 暗相位完成恒亮 ● 绿(基准 !isUnresolved 恒亮)。 + [Fact] + public void Render_HeaderBulletSolidAtOffPhase_WhenDone() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = CreateMixedGroup(); + coordinator.FinalizePending(); + new SubagentGroupItem(coordinator) { AnimTimeMs = 600 }.Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.Equal(new Rune('●'), screen[1, 0].Value); + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Success }); + Assert.Equal(success, screen[1, 0].StyleId); + } + + /// 组头闪烁 — 部分错误+运行中:dim 色 + 恒亮 ●(基准 isUnresolved 色优先于 isError;t1 isError 恒亮不闪)。 + [Fact] + public void Render_HeaderPartialErrorRunning_DimSolidBullet() + { + using var screen = new Screen(cols: 60, rows: 10); + var coordinator = new SubagentGroupCoordinator(); + coordinator.AddAgent("a1", "explore", "搜索"); + coordinator.AddAgent("a2", "explore", "深挖"); + coordinator.FinishAgent("a1", isError: true); + new SubagentGroupItem(coordinator) { AnimTimeMs = 600 }.Render(screen, new Rect(0, 0, 60, 10), Theme.Dark); + + Assert.Equal(new Rune('●'), screen[1, 0].Value); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[1, 0].StyleId); } /// 渲染组头完成态 — ● Success 绿 + "N agents finished"。 From bde7f4976f03dd465678f25e4f7cb2115b786a31 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:00:27 +0800 Subject: [PATCH 36/43] =?UTF-8?q?docs:=20TASK055=20M7-7=20=E7=BB=84?= =?UTF-8?q?=E5=A4=B4=E9=97=AA=E7=83=81=E8=90=BD=E5=9C=B0=E8=AE=B0=E5=BD=95?= =?UTF-8?q?(=E5=81=8F=E7=A6=BB=E8=A1=A8=E6=9B=B4=E6=96=B0+=E5=9B=9B?= =?UTF-8?q?=E5=88=97=E9=AA=8C=E6=94=B6=E8=A1=A8=E8=A1=A5=E5=B9=BF=E6=92=AD?= =?UTF-8?q?/=E9=97=AA=E7=83=81=E8=A1=8C+=E9=81=97=E7=95=99=E9=A1=B9?= =?UTF-8?q?=E9=87=8D=E6=8E=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\247\345\257\271\351\275\220ClaudeCode.md" | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 69fb0be098..4a74fe8a9d 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -978,7 +978,7 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g |----|------|--------|------| | 组内 300 行渲染 | 不截断,React 虚拟化扛 | **可见窗口 min(10,n) + "and N more…"** | 方案 A 用户确认 — 本项目无虚拟化(inline 全量屏),窗口是对等机制 | | 分组键 | messageId+toolName(一次 API 响应内 ≥2) | **按 turn**(TurnStarted~TurnCompleted 归一组) | bridge 协议无消息边界;turn 边界事件已存在零新增协议负担;基准跨轮拆组语义近似 | -| 组头动画 | ● 闪烁(ToolUseLoader) | **静态三态色** | 第一版开心路径;闪烁需时钟广播改造(ReplShell 单 spinner 硬编码 → 多动画项广播),归后续增强 | +| 组头动画 | ● 闪烁(ToolUseLoader + useBlink 600ms 方波) | **同基准:600ms 方波 ●↔空格 + IAnimatable 时钟广播(全实例同步闪烁)** | bab57cbfc 落地 — ReplShell 每拍广播 AnimTimeMs 给滚动区全部 IAnimatable(基准共享动画时钟等价物);运行中色修正为 dim 默认前景(基准 isUnresolved → undefined+dimColor,原 Suggestion 系误用);错误/完成恒亮不闪(基准 t1 isError 优先) | | 错误汇总 | 无数值汇总(loader 红 + 行级呈现) | 同基准(● Error 红 + 行级 Done) | 一致对齐 | | 状态聚合 | anyUnresolved/anyError/allComplete 组头三态 | 同基准 | 一致对齐 | | 同型去重 | allSameType 组头带类型名 + 行内 hideType | 同基准 | 一致对齐 | @@ -992,15 +992,16 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g | Core SubagentGroupItem.cs + MessageRenderer 三方法(组头三态/树形两行式/溢出) | Driver 建组落位(AddMessage Insert spinner 前) | ✅ | ✅ SubagentGroupTests 8/8(渲染断言含树形收口/dim/八色/溢出) | | Core Theme.SubagentColor(八色板 FNV-1a 稳定取色) | RenderSubagentStatusRow 类型段 | ✅ | ✅ 渲染测试样式断言(SubagentColor("explore") 取色一致) | | Core BridgeEvent 3 事件 + Driver 路由(惰性建组/回合定格脱离/多组共存) | SimulatedEngine agents 脚本(Demo)+ Host 未来接线 | ✅ | ✅ SubagentGroupTests 4/4(Driver 路由)+ 全量 698/698 | +| Core IAnimatable 接口 + ReplShell.AdvanceAnimClock 广播(50ms/拍喂全部动画项) | SpinnerItem(glimmer/shimmer 相位)+ SubagentGroupItem(组头 ● 600ms 方波闪烁) | ✅ | ✅ ReplShellTests 广播同步断言(spinner 与注入项同值)+ SubagentGroupTests 4/4 相位断言(0 亮/600 暗/错误完成恒亮);全量 714/714 | +| MessageRenderer.RenderSubagentHeader 闪烁三态(运行中 dim 闪烁 / 错误恒红 / 完成恒绿) | SubagentGroupItem.Render 投影(AnimTimeMs 相位) | ✅ | ✅ 部分错误+运行中 dim 恒亮(基准 isUnresolved 色优先)断言;真实终端闪烁视觉验收归用户 | | SimulatedEngine Subagents 脚本(并行子回合交错时间线) | Demo agents 命令(真实终端输入 agents / agents 300) | ✅ | ✅ SubagentGroupTests 4/4(虚拟时钟确定性:批量启动/交错活动/完成收束/300 规模一拍批量)+ Demo --selftest 绿;真实终端 agents 交互验收待用户跑 NativeTui.Demo.exe 确认 | | Host 接线 | — | ⏸ 后置 | 引擎侧子代理事件源未确认(引擎是否发 subagent_* 事件待实测),Core 事件契约已就绪 | ### 遗留项 -1. 组头 ● 闪烁动画(需 ReplShell 时钟注入点从单 spinner 扩展为多动画项广播) -2. Host 接线(前置:引擎侧子代理事件源实测确认) -3. 基准 P2 细节未对齐:ctrl+o transcript 展开模式 / 小终端自适应单行塌缩 / lastToolInfo "Searched/Read N files" 聚合摘要 -4. 用户真实终端验收(agents 12 默认 + agents 300 规模) +1. Host 接线(前置:引擎侧子代理事件源实测确认) +2. 基准 P2 细节未对齐:ctrl+o transcript 展开模式 / 小终端自适应单行塌缩 / lastToolInfo "Searched/Read N files" 聚合摘要 / 失焦暂停闪烁(基准 useBlink useTerminalFocus) +3. 用户真实终端验收(agents 12 默认 + agents 300 规模 + 组头闪烁视觉) @@ -1008,5 +1009,11 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g - + + + + + + + From 061d4f273e5909f586d65cda0b6ada0aa9e7e864 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:10:46 +0800 Subject: [PATCH 37/43] =?UTF-8?q?feat:=20TUI=E5=8E=86=E5=8F=B2=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E6=90=9C=E7=B4=A2Ctrl+R=20|=20=E5=86=B3=E7=AD=96:=20H?= =?UTF-8?q?istorySearch=E7=8A=B6=E6=80=81=E6=9C=BA(=E5=AD=90=E4=B8=B2?= =?UTF-8?q?=E5=8C=B9=E9=85=8D+=E6=96=87=E6=9C=AC=E5=8E=BB=E9=87=8D+?= =?UTF-8?q?=E5=AE=B9=E9=87=8F100+=E6=96=B0=E2=86=92=E6=97=A7),=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=80=81=E6=A8=A1=E6=80=81=E6=8B=A6=E6=88=AA=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E6=9D=83=E9=99=90>=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8>=E6=90=9C=E7=B4=A2>=E8=A1=A5?= =?UTF-8?q?=E5=85=A8,=E6=9F=A5=E8=AF=A2=E7=BC=96=E8=BE=91=E8=AE=BE?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=8C=96=E6=A0=87=E8=AE=B0=E6=8A=91=E5=88=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=A8,ghost=E6=B8=B2=E6=9F=93=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=8C=B9=E9=85=8D,Enter=E6=8E=A5=E5=8F=97=E6=8F=90=E4=BA=A4/Es?= =?UTF-8?q?c=E6=81=A2=E5=A4=8D=E8=8D=89=E7=A8=BF/=E5=86=8D=E6=8C=89Ctrl+R?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/ReplShell.cs | 91 ++++++++++++++- .../repl/history/HistorySearch.cs | 88 ++++++++++++++ .../repl/ReplShellHistoryTests.cs | 97 ++++++++++++++++ .../repl/history/HistorySearchTests.cs | 108 ++++++++++++++++++ 4 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 native_tui/src/NativeTui.Core/repl/history/HistorySearch.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/history/HistorySearchTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index f221f3f21e..ecae383864 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -52,6 +52,12 @@ public sealed class ReplShell /// 程序化填充标记(历史导航/补全接受 — HandleKey 尾部跳过建议刷新)。 private bool _programmaticFill; + /// 历史反向搜索态(Ctrl+R — TASK057-4;null = 未激活)。 + private HistorySearch? _search; + + /// 进入搜索前的输入草稿(Esc 退出恢复)。 + private string _preSearchDraft = string.Empty; + /// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。 private readonly BannerItem _banner; @@ -116,6 +122,9 @@ public ReplShell( /// 斜杠补全状态机(测试断言面;null = 无命令清单)。 internal SlashSuggester? Suggester => _suggester; + /// 历史反向搜索激活态(Ctrl+R — 测试断言面)。 + internal bool IsSearching => _search is not null; + /// 输入历史环(测试断言面)。 internal HistoryRing History => _history; @@ -289,6 +298,12 @@ private void HandleKeyPress(KeyPress key) return; } + if (_search is not null) + { + HandleSearchKey(key); + return; + } + if (HandleSuggestionKey(key)) { return; @@ -422,6 +437,9 @@ private void HandleCtrlKey(string name) case "k": _input.KillToLineEnd(); break; + case "r": + BeginOrCycleSearch(); + break; case "n": ScrollIfViewport(3); break; @@ -554,6 +572,77 @@ private void ScrollIfViewport(int rows) } } + /// 进入或循环搜索(Ctrl+R)— 未激活:以当前输入为查询进入(暂存草稿); + /// 已激活由 HandleSearchKey 拦截(本分支不会命中,防御保留)。 + private void BeginOrCycleSearch() + { + if (_search is not null) + { + _search.Next(); + return; + } + + _preSearchDraft = _input.Text; + _search = HistorySearch.Begin(_input.Text, _history.Entries); + } + + /// 搜索态按键模态(基准 ctrl+r 反向搜索交互): + /// Ctrl+R 循环下一个匹配;Enter 接受当前匹配(填入输入并提交,无匹配 no-op); + /// Esc 退出恢复草稿;Up/Down 匹配间导航;字符/退格编辑查询(刷新匹配、重置最新); + /// 其余键吞掉(模态 — 查询编辑设程序化标记防触发斜杠补全)。 + private void HandleSearchKey(KeyPress key) + { + var search = _search; + if (search is null) + { + return; + } + + if (key.Ctrl && key is { Kind: KeyKind.Char, Input: "r" }) + { + search.Next(); + return; + } + + switch (key.Kind) + { + case KeyKind.Return: + if (search.Current.Length > 0) + { + var accepted = search.Current; + _search = null; + FillInput(accepted, cursorToStart: false); + Submit(); + } + + return; + case KeyKind.Escape: + var draft = _preSearchDraft; + _search = null; + FillInput(draft, cursorToStart: false); + return; + case KeyKind.UpArrow: + search.Previous(); + return; + case KeyKind.DownArrow: + search.Next(); + return; + case KeyKind.Backspace: + _input.Backspace(); + search.UpdateQuery(_input.Text); + _programmaticFill = true; + return; + case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input.Length == 1: + _input.Insert(key.Input[0]); + search.UpdateQuery(_input.Text); + _programmaticFill = true; + return; + default: + // 模态:其余键吞掉(Home/End/Delete 等编辑能力后续按需开放) + return; + } + } + /// 补全激活时的专属键拦截(基准 Autocomplete 上下文): /// 下拉激活:Up/Down 循环导航、Tab 接受不执行、Enter 接受并提交、Esc 仅关建议(优先于打断/清输入/退出); /// ghost 激活(下拉互斥为空):RightArrow(光标在末尾)接受幽灵补全、Esc 仅关 ghost; @@ -780,7 +869,7 @@ private void SyncSuggestionState() _view.SuggestionIndex = _suggester?.SelectedIndex ?? 0; _view.SuggestionNameColumnWidth = _suggester?.NameColumnWidth ?? 0; _view.SuggestionArgumentMode = _suggester?.IsArgumentMode ?? false; - _view.GhostSuffix = _suggester?.GhostSuffix ?? string.Empty; + _view.GhostSuffix = _search is { Current.Length: > 0 } ? _search.Current : _suggester?.GhostSuffix ?? string.Empty; } /// diff --git a/native_tui/src/NativeTui.Core/repl/history/HistorySearch.cs b/native_tui/src/NativeTui.Core/repl/history/HistorySearch.cs new file mode 100644 index 0000000000..02b0c671d5 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/history/HistorySearch.cs @@ -0,0 +1,88 @@ +namespace NativeTui.Core.Repl; + +/// +/// 输入历史反向搜索(Ctrl+R)— 对齐基准 ctrl+r 搜索(getTimestampedHistory 语义): +/// 子串匹配(大小写不敏感)+ 按文本去重 + 新→旧排序 + 容量 100 截断; +/// 查询变化重置到最新匹配;Next 向旧循环(越界回绕最新);HistoryRing 注入旧→新序。 +/// +public sealed class HistorySearch +{ + /// 匹配窗口容量(基准 MAX_HISTORY_ITEMS)。 + public const int Capacity = 100; + + /// 当前查询。 + public string Query { get; private set; } = string.Empty; + + /// 匹配列表(新→旧,已去重截断)。 + public IReadOnlyList Matches { get; private set; } = []; + + /// 当前选中匹配索引(0 = 最新)。 + public int MatchIndex { get; private set; } + + /// 当前选中匹配(无匹配为空串)。 + public string Current => MatchIndex < Matches.Count ? Matches[MatchIndex] : string.Empty; + + private readonly IReadOnlyList _history; + + private HistorySearch(IReadOnlyList history) + { + _history = history; + } + + /// 进入搜索 — history 为旧→新序(HistoryRing.Entries 注入);查询变化经 UpdateQuery 重过滤。 + public static HistorySearch Begin(string query, IReadOnlyList history) + { + var search = new HistorySearch(history); + search.UpdateQuery(query); + return search; + } + + /// 查询变化 — 重新过滤并重置到最新匹配(新条目入史后即时生效 — 引用同源)。 + public void UpdateQuery(string query) + { + Query = query; + Matches = Filter(query, _history); + MatchIndex = 0; + } + + /// 下一个匹配(向旧方向;越过最旧回绕最新)。 + public void Next() + { + if (Matches.Count == 0) + { + return; + } + + MatchIndex = (MatchIndex + 1) % Matches.Count; + } + + /// 上一个匹配(向新方向;越过最新回绕最旧 — Up 导航对称)。 + public void Previous() + { + if (Matches.Count == 0) + { + return; + } + + MatchIndex = (MatchIndex - 1 + Matches.Count) % Matches.Count; + } + + /// 过滤 — 新→旧遍历(history 旧→新倒序)、子串大小写不敏感、文本去重、容量截断。 + private static List Filter(string query, IReadOnlyList history) + { + var seen = new HashSet(); + var matches = new List(); + for (var i = history.Count - 1; i >= 0 && matches.Count < Capacity; i--) + { + var entry = history[i]; + if (!entry.Contains(query, StringComparison.OrdinalIgnoreCase) || !seen.Add(entry)) + { + continue; + } + + matches.Add(entry); + } + + return matches; + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs index d6b19e658c..ff6c5b375a 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellHistoryTests.cs @@ -30,6 +30,10 @@ private static void Type(ReplShell shell, string text) } } + /// Ctrl+R 按键注入。 + private static void PressCtrlR(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('', ConsoleKey.R, true, false, false)); + /// 提交两条并回到空输入态。 private static void SubmitTwo(ReplShell shell) { @@ -168,4 +172,97 @@ public void SubmitAfterNavigating_RecordsNewText() Press(shell, ConsoleKey.UpArrow); Assert.Equal("second", shell.Input.Text); } + + /// Ctrl+R 反向搜索(TASK057-4):以当前输入为查询,Enter 接受最新匹配并提交。 + [Fact] + public void CtrlR_Search_AcceptsNewestMatchOnEnter() + { + var (shell, engine) = CreateShell(); + SubmitTwo(shell); + Type(shell, "fir"); + PressCtrlR(shell); + Assert.True(shell.IsSearching); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["first", "second", "first"], engine.Submits); + Assert.False(shell.IsSearching); + } + + /// Ctrl+R 循环:再按 Ctrl+R 跳到下一个匹配。 + [Fact] + public void CtrlR_Repeat_CyclesToNextMatch() + { + var (shell, engine) = CreateShell(); + shell.SeedHistory(["git a", "git b", "git c"]); + Type(shell, "git"); + PressCtrlR(shell); + PressCtrlR(shell); + PressCtrlR(shell); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["git a"], engine.Submits); + } + + /// 搜索态查询编辑:输入字符细化查询。 + [Fact] + public void CtrlR_Typing_RefinesQuery() + { + var (shell, engine) = CreateShell(); + shell.SeedHistory(["git status", "git commit", "run x"]); + PressCtrlR(shell); + Type(shell, "git"); + Type(shell, " co"); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["git commit"], engine.Submits); + } + + /// Esc 退出搜索:恢复进入前草稿,不提交。 + [Fact] + public void CtrlR_Escape_RestoresDraftWithoutSubmit() + { + var (shell, engine) = CreateShell(); + SubmitTwo(shell); + Type(shell, "draft"); + PressCtrlR(shell); + Press(shell, ConsoleKey.Escape); + Assert.False(shell.IsSearching); + Assert.Equal("draft", shell.Input.Text); + Assert.Equal(2, engine.Submits.Count); + } + + /// 搜索态 Up/Down 在匹配间导航(Up = 向旧 = git a)。 + [Fact] + public void CtrlR_ArrowsNavigate_Matches() + { + var (shell, engine) = CreateShell(); + shell.SeedHistory(["git a", "git b"]); + Type(shell, "git"); + PressCtrlR(shell); + Press(shell, ConsoleKey.UpArrow); + Press(shell, ConsoleKey.Enter); + Assert.Equal(["git a"], engine.Submits); + } + + /// 搜索态下补全不触发(查询 "fir" 前无 /,且优先级搜索最高)。 + [Fact] + public void CtrlR_SearchSuppressesSuggestions() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("1.0.0", "m", "API 按量计费", "/tmp"), new StringWriter(), + commands: [new("first-cmd", "d", [], IsHidden: false)]); + shell.SeedContent(); + shell.SeedHistory(["first-cmd ran"]); + Type(shell, "fir"); + PressCtrlR(shell); + Assert.False(shell.Suggester?.IsActive); + } + + /// 空历史 Ctrl+R:进入搜索态但无匹配,Enter 无提交、Esc 恢复。 + [Fact] + public void CtrlR_EmptyHistory_EnterSubmitsNothing() + { + var (shell, engine) = CreateShell(); + PressCtrlR(shell); + Type(shell, "q"); + Press(shell, ConsoleKey.Enter); + Assert.Empty(engine.Submits); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/history/HistorySearchTests.cs b/native_tui/tests/NativeTui.Tests/repl/history/HistorySearchTests.cs new file mode 100644 index 0000000000..4c09d70621 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/history/HistorySearchTests.cs @@ -0,0 +1,108 @@ +namespace NativeTui.Tests.Repl.History; + +/// +/// HistorySearch 单元测试 — 对齐基准 ctrl+r 反向搜索(getTimestampedHistory 语义): +/// 子串匹配(大小写不敏感)+ 文本去重 + 新→旧排序;查询变化重置到最新匹配; +/// Next 向旧循环;Matches 截断容量。 +/// +public sealed class HistorySearchTests +{ + /// 构造搜索(历史旧→新)。 + private static HistorySearch Search(string query, params string[] history) => + HistorySearch.Begin(query, history); + + /// 基础匹配:查询子串命中,新→旧排序。 + [Fact] + public void Begin_SubstringMatch_NewestFirst() + { + var search = Search("git", "git status", "hello world", "git commit", "git push"); + Assert.Equal(["git push", "git commit", "git status"], search.Matches); + } + + /// 大小写不敏感匹配。 + [Fact] + public void Begin_CaseInsensitive() + { + var search = Search("GIT", "git status", "GIT COMMIT"); + Assert.Equal(["GIT COMMIT", "git status"], search.Matches); + } + + /// 文本去重(同文本只保留最新一条 — 基准 getTimestampedHistory)。 + [Fact] + public void Begin_DeduplicatesByText() + { + var search = Search("x", "run x", "run x", "run x"); + Assert.Single(search.Matches); + } + + /// 空查询:全部历史(去重后新→旧)。 + [Fact] + public void Begin_EmptyQuery_AllHistory() + { + var search = Search("", "a", "b", "a"); + Assert.Equal(["a", "b"], search.Matches); + } + + /// 当前匹配:MatchIndex 指向的条目。 + [Fact] + public void Current_FollowsMatchIndex() + { + var search = Search("git", "git status", "git commit"); + Assert.Equal("git commit", search.Current); + search.Next(); + Assert.Equal("git status", search.Current); + } + + /// Next 循环:越过最旧回到最新。 + [Fact] + public void Next_WrapsAround() + { + var search = Search("git", "git a", "git b", "git c"); + Assert.Equal("git c", search.Current); + search.Next(); + Assert.Equal("git b", search.Current); + search.Next(); + Assert.Equal("git a", search.Current); + search.Next(); + Assert.Equal("git c", search.Current); + } + + /// 查询变化:重置到最新匹配重新过滤。 + [Fact] + public void UpdateQuery_ResetsToNewestMatch() + { + var search = Search("git", "git a", "git b", "run gitlab"); + search.Next(); + search.UpdateQuery("gitlab"); + Assert.Equal(["run gitlab"], search.Matches); + Assert.Equal("run gitlab", search.Current); + } + + /// 无匹配:Matches 空、Current 空串(调用方保持查询)。 + [Fact] + public void NoMatch_EmptyMatches() + { + var search = Search("zzz", "git a"); + Assert.Empty(search.Matches); + Assert.Equal(string.Empty, search.Current); + } + + /// 容量截断:最多 100 条匹配(对齐基准窗口)。 + [Fact] + public void Begin_TruncatesToCapacity() + { + var history = Enumerable.Range(0, 150).Select(i => $"cmd-{i}").ToArray(); + var search = Search("cmd", history); + Assert.Equal(HistorySearch.Capacity, search.Matches.Count); + Assert.Equal($"cmd-149", search.Matches[0]); + } + + /// 去重先于容量截断。 + [Fact] + public void Begin_DedupBeforeTruncate() + { + var history = Enumerable.Range(0, 60).Select(i => $"cmd-{i % 30}").ToArray(); + var search = Search("cmd", history); + Assert.Equal(30, search.Matches.Count); + } +} From 23fe4a435e0ff30a86d2cd2bb8596142c18a02f0 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:09:09 +0800 Subject: [PATCH 38/43] =?UTF-8?q?feat:=20resize=E4=BA=8B=E4=BB=B6=E5=8C=96?= =?UTF-8?q?(ITerminalSizeSource=E6=B3=A8=E5=85=A5)=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20=E5=B0=BA=E5=AF=B8=E6=BA=90=E6=8A=BD=E8=B1=A1+Cons?= =?UTF-8?q?ole=E8=BD=AE=E8=AF=A2=E9=BB=98=E8=AE=A4=E5=AE=9E=E7=8E=B0(?= =?UTF-8?q?=E5=9F=BA=E5=87=86useTerminalSize/Node=20stdout=20resize?= =?UTF-8?q?=E7=AD=89=E4=BB=B7=E5=A5=91=E7=BA=A6,Unix=E6=9C=AA=E6=9D=A5SIGW?= =?UTF-8?q?INCH=E5=90=8C=E5=A5=91=E7=BA=A6=E6=9B=BF=E6=8D=A2)+ReplShell?= =?UTF-8?q?=E4=B8=89=E5=A4=84Console.Window*=E6=B6=88=E8=B4=B9=E7=82=B9?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B5=B0=E6=B3=A8=E5=85=A5=E6=BA=90+EnsureSc?= =?UTF-8?q?reens/RenderAndFlush=20internal=E5=8C=96=E6=9A=B4=E9=9C=B2resiz?= =?UTF-8?q?e=E6=B5=8B=E8=AF=95=E9=A9=B1=E5=8A=A8=E9=9D=A2+inline=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E9=92=89=E6=AD=BB(=E5=B1=8F=E5=AE=BD=3Dcols=E6=95=8F?= =?UTF-8?q?=E6=84=9F=E7=BB=B4=E5=BA=A6,=E5=B1=8F=E9=AB=98=3D=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E9=AB=98=E4=B8=8E=E7=BB=88=E7=AB=AFrows=E6=97=A0?= =?UTF-8?q?=E5=85=B3);734/734=E7=BB=BF=E5=90=AB5=E6=96=B0resize=E6=B5=8B?= =?UTF-8?q?=E8=AF=95+Demo=20selftest=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/NativeTui.Core/repl/ReplShell.cs | 42 ++++--- .../NativeTui.Core/repl/TerminalSizeSource.cs | 20 ++++ .../repl/ReplShellResizeTests.cs | 107 ++++++++++++++++++ 3 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/repl/TerminalSizeSource.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/ReplShellResizeTests.cs diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index ecae383864..16b70d415b 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -43,6 +43,9 @@ public sealed class ReplShell /// 斜杠命令补全状态机(TASK056 — 宿主注入命令清单后启用;null = 无补全能力)。 private readonly SlashSuggester? _suggester; + /// 终端尺寸源(resize 事件化注入面 — 每拍取尺寸,变化触发屏重建跨尺寸 diff)。 + private readonly ITerminalSizeSource _sizeSource; + /// 建议列表渲染组件。 private readonly SuggestionBox _suggestionBox = new(); @@ -82,7 +85,8 @@ public sealed class ReplShell /// 创建外壳 — engine=引擎交互面;banner=启动横幅(首项);output=渲染 writer; /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前); /// commands=斜杠命令清单(宿主注入;null = 无补全能力); - /// codeHighlighter=代码高亮插件(M7-5 卫星注入;null = 高亮禁用降级纯文本)。 + /// codeHighlighter=代码高亮插件(M7-5 卫星注入;null = 高亮禁用降级纯文本); + /// sizeSource=终端尺寸源(resize 事件化注入面;null = Console 轮询默认实现)。 public ReplShell( IReplEngine engine, BannerItem banner, @@ -90,7 +94,8 @@ public ReplShell( bool inline = true, IReadOnlyList? seedMessages = null, IReadOnlyList? commands = null, - ICodeHighlighter? codeHighlighter = null) + ICodeHighlighter? codeHighlighter = null, + ITerminalSizeSource? sizeSource = null) { _engine = engine; _banner = banner; @@ -98,6 +103,7 @@ public ReplShell( _inline = inline; _seedMessages = seedMessages ?? []; _suggester = commands is { Count: > 0 } ? new SlashSuggester(commands) : null; + _sizeSource = sizeSource ?? new ConsoleTerminalSizeSource(); _driver = new ReplBridgeDriver(_view, _spinner, _banner, BeginPermissionRequest, codeHighlighter); } @@ -808,8 +814,9 @@ internal void Submit() _engine.Submit(text); } - /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。 - private void RenderAndFlush() + /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。 + /// internal = resize/渲染测试的单帧驱动面。 + internal void RenderAndFlush() { SyncSuggestionState(); EnsureScreens(); @@ -820,7 +827,7 @@ private void RenderAndFlush() _view.Render(_back, _theme); if (_inline) { - var viewportRows = Math.Max(8, Console.WindowHeight); + var viewportRows = _sizeSource.Current.Rows; var frameEnd = _view.InputCursorPos(_back.Rows, _back.Cols, _input.CursorCol); var prev = _frontValid ? _front : _prevOverride; var output = _inlineComposer.Compose(prev, _back, viewportRows, _cursor, frameEnd, out _cursor); @@ -862,6 +869,12 @@ internal void AdvanceAnimClock() _spinner.WallNowMs = Environment.TickCount64; } + /// 后缓冲(resize 测试断言面 — 屏尺寸/重建复用判定)。 + internal Screen? BackBuffer => _back; + + /// resize 跨尺寸 diff 旧帧(测试断言面 — 重建时保留旧屏供 InlineComposer append/fullReset)。 + internal Screen? PrevOverride => _prevOverride; + /// 同步补全建议到视图(渲染前状态投影 — EnsureScreens 屏高计算依赖建议区高度,必须先行)。 private void SyncSuggestionState() { @@ -873,23 +886,24 @@ private void SyncSuggestionState() } /// - /// 尺寸计算(含首帧)成对重建双缓冲 — inline 屏高 = 内容总高(消息全量展示,可超视口), - /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset);视口模式屏高 = 终端行数。 + /// 尺寸计算(含首帧)成对重建双缓冲 — 尺寸取自注入源(resize 事件化,不读 Console 静态环境); + /// inline 屏高 = 内容总高(消息全量展示,可超视口 — 终端 rows 非 resize 敏感维度), + /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset);视口模式屏高 = 尺寸源行数。 /// - private void EnsureScreens() + internal void EnsureScreens() { - var cols = Math.Max(20, Console.WindowWidth); - var rows = _inline + var (cols, rows) = _sizeSource.Current; + var backRows = _inline ? Math.Max(8, _view.MeasureContentHeight(cols)) - : Math.Max(8, Console.WindowHeight); - if (_back is not null && cols == _back.Cols && rows == _back.Rows) + : rows; + if (_back is not null && cols == _back.Cols && backRows == _back.Rows) { return; } _prevOverride = _inline && _frontValid ? _front : null; - _front = new Screen(cols, rows, sharedStyles: _pool); - _back = new Screen(cols, rows, sharedStyles: _pool); + _front = new Screen(cols, backRows, sharedStyles: _pool); + _back = new Screen(cols, backRows, sharedStyles: _pool); _frontValid = false; } diff --git a/native_tui/src/NativeTui.Core/repl/TerminalSizeSource.cs b/native_tui/src/NativeTui.Core/repl/TerminalSizeSource.cs new file mode 100644 index 0000000000..39b7a43f89 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/TerminalSizeSource.cs @@ -0,0 +1,20 @@ +namespace NativeTui.Core.Repl; + +/// +/// 终端尺寸源 — resize 事件化抽象(基准 useTerminalSize / Node stdout resize 事件的等价契约): +/// ReplShell 每拍从此源取当前尺寸,变化时重建双缓冲走跨尺寸 diff(inline 增长 append / 宽度变 fullReset)。 +/// 物理实现按平台替换:Windows/默认 = Console.Window* 轮询(50ms 拍内响应);Unix 未来可接 SIGWINCH 同契约。 +/// 注入面 — 测试用 fake 源确定性驱动 resize 场景(摆脱 Console 静态环境依赖)。 +/// +public interface ITerminalSizeSource +{ + /// 当前终端尺寸(已夹紧最小 20×8 — 超窄终端保底可渲)。 + (int Cols, int Rows) Current { get; } +} + +/// Console 轮询实现(生产默认 — Console.WindowWidth/Height 每拍读取)。 +public sealed class ConsoleTerminalSizeSource : ITerminalSizeSource +{ + /// 当前终端尺寸(夹紧 20×8)。 + public (int Cols, int Rows) Current => (Math.Max(20, Console.WindowWidth), Math.Max(8, Console.WindowHeight)); +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellResizeTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellResizeTests.cs new file mode 100644 index 0000000000..f65cefddc7 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellResizeTests.cs @@ -0,0 +1,107 @@ +namespace NativeTui.Tests.Repl; + +/// +/// ReplShell resize 事件化测试 — ITerminalSizeSource 注入把终端尺寸从静态环境依赖(Console.Window*) +/// 解耦为可测事件源:inline 模式屏宽 = 尺寸源 cols(resize 敏感维度)、屏高 = 内容高(终端 rows 无关 — +/// 唯 rows 不触发重建);视口模式 cols/rows 均取自尺寸源;尺寸变化重建双缓冲并保留旧帧跨尺寸 diff +/// (_prevOverride = 旧 front,供 InlineComposer 增长 append / 宽度变 fullReset)。 +/// +public sealed class ReplShellResizeTests +{ + /// 构造测试 shell(inline 默认,StringWriter 收渲染输出,尺寸源可注入可控)。 + private static (ReplShell Shell, FakeTerminalSizeSource Size) CreateShell(bool inline = true) + { + var engine = new FakeReplEngine(); + var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp"); + var size = new FakeTerminalSizeSource(80, 24); + var shell = new ReplShell(engine, banner, new StringWriter(), inline: inline, sizeSource: size); + return (shell, size); + } + + /// 尺寸注入 — inline 屏宽取尺寸源 cols、屏高取内容高(不读 Console.Window*)。 + [Fact] + public void EnsureScreens_Inline_ColsFromSize_RowsFromContent() + { + var (shell, _) = CreateShell(); + shell.SeedContent(); + shell.EnsureScreens(); + + Assert.Equal(80, shell.BackBuffer!.Cols); + Assert.Equal(Math.Max(8, shell.View.MeasureContentHeight(80)), shell.BackBuffer!.Rows); + } + + /// cols 变化 — 双缓冲按新宽重建(引用变化)+ 保留旧帧跨尺寸 diff(prevOverride = 旧屏)。 + [Fact] + public void EnsureScreens_ColsChanged_RebuildsAndKeepsPrevFrame() + { + var (shell, size) = CreateShell(); + shell.SeedContent(); + shell.RenderAndFlush(); + var before = shell.BackBuffer!; + + size.Cols = 100; + shell.EnsureScreens(); + + Assert.Equal(100, shell.BackBuffer!.Cols); + Assert.NotSame(before, shell.BackBuffer); + Assert.NotNull(shell.PrevOverride); + } + + /// 尺寸不变 — 屏对象复用(不重建、无 prevOverride)。 + [Fact] + public void EnsureScreens_SizeUnchanged_ReusesBuffers() + { + var (shell, _) = CreateShell(); + shell.SeedContent(); + shell.RenderAndFlush(); + var before = shell.BackBuffer!; + + shell.EnsureScreens(); + + Assert.Same(before, shell.BackBuffer); + Assert.Null(shell.PrevOverride); + } + + /// inline 语义 — 终端 rows 变化不触发重建(屏高 = 内容高与终端行数无关,resize 敏感维度唯 cols)。 + [Fact] + public void EnsureScreens_Inline_TerminalRowsChange_DoesNotRebuild() + { + var (shell, size) = CreateShell(); + shell.SeedContent(); + shell.RenderAndFlush(); + var before = shell.BackBuffer!; + + size.Rows = 40; + shell.EnsureScreens(); + + Assert.Same(before, shell.BackBuffer); + } + + /// 视口模式 — cols/rows 均取自尺寸源,任一变化触发重建。 + [Fact] + public void EnsureScreens_Viewport_RowsFromSize_RebuildsOnChange() + { + var (shell, size) = CreateShell(inline: false); + shell.SeedContent(); + shell.RenderAndFlush(); + var before = shell.BackBuffer!; + + Assert.Equal(24, before.Rows); + + size.Rows = 30; + shell.EnsureScreens(); + + Assert.Equal(30, shell.BackBuffer!.Rows); + Assert.NotSame(before, shell.BackBuffer); + } +} + +/// 测试用终端尺寸源(可变 Current — resize 场景确定性注入)。 +internal sealed class FakeTerminalSizeSource(int cols, int rows) : ITerminalSizeSource +{ + public int Cols { get; set; } = cols; + + public int Rows { get; set; } = rows; + + public (int Cols, int Rows) Current => (Math.Max(20, Cols), Math.Max(8, Rows)); +} From 2b35dece7580a7cb42bf8de6b67e941df8643f75 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:09:59 +0800 Subject: [PATCH 39/43] =?UTF-8?q?docs:=20TASK055=20resize=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=8C=96=E8=90=BD=E5=9C=B0=E8=AE=B0=E5=BD=95(?= =?UTF-8?q?=E5=9B=9B=E5=88=97=E9=AA=8C=E6=94=B6=E8=A1=A8=E8=A1=A5=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E6=BA=90=E8=A1=8C+=E5=86=B3=E7=AD=96=E5=9D=97+?= =?UTF-8?q?=E9=81=97=E7=95=99=E9=A1=B9=E6=9B=B4=E6=96=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...47\272\247\345\257\271\351\275\220ClaudeCode.md" | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" index 4a74fe8a9d..43652eab2b 100644 --- "a/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" +++ "b/docs/proposed/task/TASK055-TUI\345\203\217\347\264\240\347\272\247\345\257\271\351\275\220ClaudeCode.md" @@ -995,13 +995,14 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g | Core IAnimatable 接口 + ReplShell.AdvanceAnimClock 广播(50ms/拍喂全部动画项) | SpinnerItem(glimmer/shimmer 相位)+ SubagentGroupItem(组头 ● 600ms 方波闪烁) | ✅ | ✅ ReplShellTests 广播同步断言(spinner 与注入项同值)+ SubagentGroupTests 4/4 相位断言(0 亮/600 暗/错误完成恒亮);全量 714/714 | | MessageRenderer.RenderSubagentHeader 闪烁三态(运行中 dim 闪烁 / 错误恒红 / 完成恒绿) | SubagentGroupItem.Render 投影(AnimTimeMs 相位) | ✅ | ✅ 部分错误+运行中 dim 恒亮(基准 isUnresolved 色优先)断言;真实终端闪烁视觉验收归用户 | | SimulatedEngine Subagents 脚本(并行子回合交错时间线) | Demo agents 命令(真实终端输入 agents / agents 300) | ✅ | ✅ SubagentGroupTests 4/4(虚拟时钟确定性:批量启动/交错活动/完成收束/300 规模一拍批量)+ Demo --selftest 绿;真实终端 agents 交互验收待用户跑 NativeTui.Demo.exe 确认 | +| Core ITerminalSizeSource + ConsoleTerminalSizeSource(resize 事件化注入面,a8ab838f1) | ReplShell 三处 Console.Window* 消费点(EnsureScreens cols/rows + RenderAndFlush viewportRows) | ✅ | ✅ ReplShellResizeTests 5/5(尺寸注入/cols 变化重建+旧帧保留/不变复用/inline rows 无关/视口 rows 取源);全量 734/734 | | Host 接线 | — | ⏸ 后置 | 引擎侧子代理事件源未确认(引擎是否发 subagent_* 事件待实测),Core 事件契约已就绪 | ### 遗留项 1. Host 接线(前置:引擎侧子代理事件源实测确认) -2. 基准 P2 细节未对齐:ctrl+o transcript 展开模式 / 小终端自适应单行塌缩 / lastToolInfo "Searched/Read N files" 聚合摘要 / 失焦暂停闪烁(基准 useBlink useTerminalFocus) -3. 用户真实终端验收(agents 12 默认 + agents 300 规模 + 组头闪烁视觉) +2. 基准 P2 细节未对齐:ctrl+o transcript 展开模式 / 小终端自适应单行塌缩 / lastToolInfo "Searched/Read N files" 聚合摘要 / 失焦暂停闪烁(基准 useBlink useTerminalFocus)/ Unix SIGWINCH 物理事件源(当前 Console 轮询已满足 50ms 拍内响应) +3. 用户真实终端验收(agents 12 默认 + agents 300 规模 + 组头闪烁视觉 + resize 拖拽窗口) @@ -1015,5 +1016,11 @@ ReferenceScene.cs 不含任何 Markdown 渲染调用(纯手工 cell 构造,g - + + + + + + + From 5d2555d66fd19500d4edf0100da3b9ad22629f85 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:12:20 +0800 Subject: [PATCH 40/43] =?UTF-8?q?feat:=20Demo=E6=8E=A5=E7=BA=BF=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=A1=A5=E5=85=A8=E6=BC=94=E7=A4=BA(theme=E5=8F=82?= =?UTF-8?q?=E6=95=B0+model=E6=8F=90=E7=A4=BA)=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?SimulatedEngine=E5=85=BC=E5=AE=B9/theme/model=E8=B7=AF=E7=94=B1?= =?UTF-8?q?,=E5=8F=8C=E5=85=A5=E5=8F=A3=E5=AE=88=E5=88=99=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs index de33fa3d16..b12c592dd0 100644 --- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs +++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs @@ -255,6 +255,8 @@ public void Submit(string text) "diff" => TurnScript.Diff, "perm" => TurnScript.Permission, "all" => TurnScript.Full, + "theme" => TurnScript.Markdown, + "model" => TurnScript.Full, _ => null, }; } From 12aa340c257407aa9410a9217f8e26887160a706 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:12:23 +0800 Subject: [PATCH 41/43] =?UTF-8?q?docs:=20TASK057=E5=9B=9B=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=AE=8C=E7=BB=93=E8=AE=B0=E5=BD=95(=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF751+45+Demo=E5=8F=8C=E8=87=AA=E6=A3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...17\347\256\200\345\214\226\351\241\271.md" | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" index ab7d74665f..86b5dc4720 100644 --- "a/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" +++ "b/docs/proposed/task/TASK057-\344\274\232\350\257\235\345\255\230\345\202\250JSONL\345\214\226\344\270\216\350\276\223\345\205\245\346\240\217\347\256\200\345\214\226\351\241\271.md" @@ -1,6 +1,6 @@ # TASK057 — 会话存储 JSONL 化 + TUI 输入栏简化项三连 -> 状态:in-progress +> 状态:implemented(2026-10-21 全部四任务落地;剩余用户真实终端手感验收) > 前置:TASK056(斜杠补全+历史环)已完结验收 > 用户指令(2026-10-21):①会话改为 jsonl 按需读取和追加(现在逻辑有问题)②推荐功能全部做 @@ -9,9 +9,9 @@ | # | 任务 | 优先级 | 状态 | |---|------|--------|------| | 1 | **transcript 存储改 JSONL**(按需读取+纯追加,消除整存整取) | P0 用户指令 | ✅ 49f3b6e+0160ed8(86+5+2+5 测试,真实存量会话迁移验收 OK,ADR 0144 accepted) | -| 2 | 参数补全(/model xxx Argument 模式) | P1 | ⬜ | -| 3 | 幽灵文本(mid-input /xx 补全) | P2 | ⬜ | -| 4 | Ctrl+R 历史搜索 | P3 | ⬜ | +| 2 | 参数补全(/model xxx Argument 模式) | P1 | ✅ b6f4309(生成器补 ArgumentHint 提取 + Host 静态清单 theme/effort/config + Demo 演示接线) | +| 3 | 幽灵文本(mid-input /xx 补全) | P2 | ✅ c41a369(GhostSuffix 前缀匹配 + RightArrow 接受 + dim 渲染光标覆盖首字符) | +| 4 | Ctrl+R 历史搜索 | P3 | ✅ 061d4f2(HistorySearch 子串+去重+容量100 + 模态优先级链 + ghost 显示当前匹配) | ## 任务1 背景(调研待补充) @@ -21,3 +21,16 @@ - **历史疑点**:旧扁平 {id}.jsonl 曾被迁移为 JSON 数组——需查 git 历史确认当初迁移动机,避免踩回旧坑(可能是 paste-cache 引用/原子性/损坏容错问题) + +## 最终基线 + +- NativeTui.Tests:698 → **751**(+53:参数补全 11 + 幽灵文本 12 + 历史搜索 17 + Demo 引擎 5 + Host fake 修复等) +- NativeTui.Host.Tests:43 → **45** +- Demo selftest + bridge-selftest 双通过 +- 历史存储:vault.other.tests 88/88 + 集成 2/2 + GUI 571 + 真实存量会话迁移验收(ADR 0144 accepted) + + + + + + From 0e19963cbb090269f63e2b7fde900d0a5c08991f Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:48:53 +0800 Subject: [PATCH 42/43] =?UTF-8?q?fix:=20=E5=8F=82=E6=95=B0=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E5=80=BC=E5=9F=9F=E5=8F=8C=E6=BA=90=E6=B6=88=E9=99=A4?= =?UTF-8?q?(=E4=BF=AE=E5=A4=8D/theme=20d=E9=AA=8C=E6=94=B6=E7=BC=BA?= =?UTF-8?q?=E9=99=B7)=20|=20=E5=86=B3=E7=AD=96:=20SlashCommandMetadata?= =?UTF-8?q?=E5=8A=A0Arguments=E5=AD=97=E6=AE=B5,=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8=E4=BB=8E[ChatCommandArg].Enum=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E6=B3=A8=E5=85=A5(=E9=A6=96=E4=B8=AA=E5=B8=A6Enum=E7=9A=84arg)?= =?UTF-8?q?,=E5=88=A0TUI=E7=A1=AC=E7=BC=96=E7=A0=81=E6=B8=85=E5=8D=95,them?= =?UTF-8?q?e7=E5=80=BC/effort6=E5=80=BC/config4=E5=80=BC=E5=85=A8=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=BC=95=E6=93=8E=E7=9C=9F=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../abs_hands/shell/SlashCommandCatalog.cs | 4 +++ .../CommandRegistrationGenerator.cs | 4 ++- .../tui_assembly/SlashCommandSource.cs | 19 ++++--------- .../SlashCommandSourceTests.cs | 28 ++++++++++++++++--- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs b/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs index 81a523f1a1..d8451f44b9 100644 --- a/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs +++ b/modules/abstractions/abstractions/abs_hands/shell/SlashCommandCatalog.cs @@ -20,6 +20,10 @@ public sealed record SlashCommandMetadata { /// 参数提示(命令后空格时的补全提示,如 "model-id")— 从 [ChatCommand] 特性提取 public string ArgumentHint { get; init; } = string.Empty; + /// 首个参数的枚举值域(如 theme 的 7 主题值)— 从 [ChatCommandArg].Enum 特性提取, + /// 参数补全唯一数据源(GUI/TUI 补全消费,替代各自硬编码清单) + public string[] Arguments { get; init; } = []; + /// 是否隐藏(隐藏命令不在面板展示) public bool IsHidden { get; init; } diff --git a/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs b/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs index 6951c67042..bfdcfcbb8e 100644 --- a/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs +++ b/modules/generators/mcp_tool_dispatch.generator/CommandRegistrationGenerator.cs @@ -225,7 +225,9 @@ private static void GenerateSlashCommandCatalog(SourceProductionContext context, sb.AppendLine($" [\"{EscapeString(catName)}\"] = ["); foreach (var cmd in orderedCmds) { var aliases = string.Join(", ", cmd.Aliases.Select(a => $"\"{EscapeString(a)}\"")); - sb.AppendLine($" new SlashCommandMetadata {{ Name = \"/{EscapeString(cmd.Name)}\", Description = \"{EscapeString(cmd.Description)}\", Usage = \"{EscapeString(cmd.Usage)}\", Aliases = [{aliases}], ArgumentHint = \"{EscapeString(cmd.ArgumentHint)}\", IsHidden = {cmd.IsHidden.ToString().ToLowerInvariant()}, IsEnabled = {cmd.IsEnabled.ToString().ToLowerInvariant()}, Category = \"{EscapeString(catName)}\" }},"); + var arguments = string.Join(", ", (cmd.Args.FirstOrDefault(a => a.Enum is { Length: > 0 })?.Enum ?? []) + .Select(v => $"\"{EscapeString(v)}\"")); + sb.AppendLine($" new SlashCommandMetadata {{ Name = \"/{EscapeString(cmd.Name)}\", Description = \"{EscapeString(cmd.Description)}\", Usage = \"{EscapeString(cmd.Usage)}\", Aliases = [{aliases}], ArgumentHint = \"{EscapeString(cmd.ArgumentHint)}\", Arguments = [{arguments}], IsHidden = {cmd.IsHidden.ToString().ToLowerInvariant()}, IsEnabled = {cmd.IsEnabled.ToString().ToLowerInvariant()}, Category = \"{EscapeString(catName)}\" }},"); } sb.AppendLine(" ],"); } diff --git a/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs index 8fe7020d2d..607bf7f78f 100644 --- a/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs +++ b/native_tui/apps/NativeTui.Host/tui_assembly/SlashCommandSource.cs @@ -4,19 +4,11 @@ namespace NativeTui.Host; /// 斜杠命令清单源 — 从引擎 DI 解析 ISlashCommandCatalog(源码生成器在 Composition 中生成), /// 未注册时兜底直接实例化 GeneratedSlashCommandCatalog(CLI SlashCommandExecutors 先例); /// 过滤 IsEnabled=false,转为 NativeTui.Core 零依赖的 CommandSuggestion 纯数据; -/// 参数补全元数据:ArgumentHint 透传 [ChatCommand] 特性,静态参数清单与 GUI CommandArgumentProvider 同源 -/// (/theme /effort /config 三组静态值;/model /provider 动态清单 TUI 侧走 Hint 提示)。 +/// 参数补全元数据全部来自 [ChatCommand]/[ChatCommandArg] 特性(ArgumentHint 提示 + Arguments 枚举值域, +/// 生成器提取注入 — 唯一数据源,消除 TUI/GUI/引擎三方各自硬编码)。 /// public static class SlashCommandSource { - /// 静态参数值清单(与 GUI CommandArgumentProvider 同值 — 跨端共享提升到 abstractions 是后续重构项)。 - private static readonly IReadOnlyDictionary ArgumentValues = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["theme"] = ["dark", "light", "auto"], - ["effort"] = ["auto", "low", "medium", "high", "max"], - ["config"] = ["get", "set", "list", "remove"], - }; - /// 加载命令清单(name 去 / 前缀)— 无目录时返回空列表(TUI 无补全能力降级)。 public static IReadOnlyList Load(IServiceProvider services) { @@ -28,17 +20,16 @@ public static IReadOnlyList Load(IServiceProvider services) .ToList(); } - /// 元数据 → 补全建议项(name 去 / 前缀 + 参数元数据注入)。 + /// 元数据 → 补全建议项(name 去 / 前缀 + 参数元数据透传)。 private static CommandSuggestion ToSuggestion(SlashCommandMetadata command) { var name = command.Name.StartsWith('/') ? command.Name[1..] : command.Name; - ArgumentValues.TryGetValue(name, out var arguments); return new CommandSuggestion( name, command.Description, command.Aliases, command.IsHidden, - ArgumentHint: command.ArgumentHint ?? string.Empty, - Arguments: arguments); + ArgumentHint: command.ArgumentHint, + Arguments: command.Arguments); } } diff --git a/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs index 9421b99f77..b721adc86a 100644 --- a/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/SlashCommandSourceTests.cs @@ -21,9 +21,9 @@ private sealed class FakeCatalog : ISlashCommandCatalog ["Session"] = [ new SlashCommandMetadata { Name = "clear", Description = "清屏" }, - new SlashCommandMetadata { Name = "theme", Description = "主题" }, - new SlashCommandMetadata { Name = "effort", Description = "力度" }, - new SlashCommandMetadata { Name = "config", Description = "配置" }, + new SlashCommandMetadata { Name = "theme", Description = "主题", Arguments = ["dark", "light", "auto"] }, + new SlashCommandMetadata { Name = "effort", Description = "力度", Arguments = ["auto", "low", "medium", "high", "max"] }, + new SlashCommandMetadata { Name = "config", Description = "配置", Arguments = ["get", "set", "list", "remove"] }, ], }; } @@ -45,7 +45,7 @@ public void Load_ConvertsMetadataToSuggestions() Assert.True(commands.Single(c => c.Name == "secret").IsHidden); } - /// 参数元数据注入:ArgumentHint 透传特性;theme/effort/config 静态参数清单注入。 + /// 参数元数据注入:ArgumentHint 透传特性;参数值域来自元数据 Arguments 字段(生成目录注入引擎真值)。 [Fact] public void Load_InjectsArgumentMetadata() { @@ -59,6 +59,26 @@ public void Load_InjectsArgumentMetadata() Assert.Empty(commands.Single(c => c.Name == "clear").ArgumentValues); } + /// 真实生成目录参数值域(修复验收缺陷:/theme d 无效果)— theme 7 值(含 daltonized/ansi)、 + /// effort 6 值(含 unset)、config 4 值,全部来自引擎 [ChatCommandArg].Enum 特性(唯一数据源,双源消除)。 + [Fact] + public void Load_GeneratedCatalog_ArgumentValuesFromEngineTruth() + { + var commands = SlashCommandSource.Load(new ServiceCollection().BuildServiceProvider()); + + var theme = commands.Single(c => c.Name == "theme"); + Assert.Equal( + ["dark", "light", "auto", "dark-daltonized", "light-daltonized", "dark-ansi", "light-ansi"], + theme.ArgumentValues); + + var effort = commands.Single(c => c.Name == "effort"); + Assert.Equal(6, effort.ArgumentValues.Length); + Assert.Contains("unset", effort.ArgumentValues); + + var config = commands.Single(c => c.Name == "config"); + Assert.Equal(["get", "set", "list", "remove"], config.ArgumentValues); + } + /// ArgumentHint 透传:特性声明的参数提示进建议项。 [Fact] public void Load_PassesThroughArgumentHint() From e7d92f421520041435a5dfadd9dfe3fbcb26f48e Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Wed, 21 Oct 2026 03:50:11 +0800 Subject: [PATCH 43/43] =?UTF-8?q?fix:=20CI=E7=BB=84=E4=BB=B6=E5=8C=85?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E9=99=88=E6=97=A7=E8=87=B4CS0117=20|=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96:=20setup-test-env=E4=B8=8B=E8=BD=BDartifact?= =?UTF-8?q?=E5=90=8E=E5=88=A0.nuget/packages/joincode.*=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E4=BB=8Elocal-feed=E9=87=8D=E8=A7=A3=E6=9E=90=E6=9C=80?= =?UTF-8?q?=E6=96=B0pack;=E6=A0=B9=E5=9B=A0=3Dnuget=E7=BC=93=E5=AD=98key?= =?UTF-8?q?=E5=8F=AA=E5=93=88=E5=B8=8Ccsproj+props=E4=B8=8D=E5=90=AB.cs?= =?UTF-8?q?=E6=BA=90=E7=A0=81,=E5=8F=AA=E6=94=B9=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E7=9A=84PR=E5=91=BD=E4=B8=AD=E6=97=A7=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=8C=85(=E7=89=88=E6=9C=AC=E6=81=921.0.0?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=BE=A8=E6=96=B0=E6=97=A7)=E8=80=8C?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8=E6=81=92=E6=96=B0?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=BC=95=E7=94=A8=E6=96=B0API;=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=A4=A7=E5=8C=85=E7=BC=93=E5=AD=98=E7=85=A7?= =?UTF-8?q?=E5=B8=B8=E5=91=BD=E4=B8=AD=E4=BB=85=E6=9C=AC=E5=9C=B0=E9=87=8D?= =?UTF-8?q?=E6=8B=B7=E7=A7=92=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/setup-test-env/action.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/actions/setup-test-env/action.yml b/.github/actions/setup-test-env/action.yml index e28a3baf13..5f7b0fc368 100644 --- a/.github/actions/setup-test-env/action.yml +++ b/.github/actions/setup-test-env/action.yml @@ -33,6 +33,25 @@ runs: name: ${{ inputs.nuget-artifact-name }} path: artifacts/nuget/ + # NuGet 缓存 key 只哈希 csproj+props,不含 .cs 源码 — 只改源码的 PR 会命中旧缓存里的 + # 过期 JoinCode 组件包(版本恒 1.0.0 无法靠版本号辨新旧),而源码生成器(analyzer 恒新) + # 输出引用新 API → CS0117 编译错。删缓存里的自家组件包强制从 local-feed(本次 pack 的 + # 最新 artifact)重解析;第三方大包(网络下载大头)缓存照常命中,仅本地磁盘重拷秒级。 + - name: Purge Stale JoinCode Component Packages from Cache + shell: pwsh + run: | + $pkgRoot = Join-Path $env:GITHUB_WORKSPACE ".nuget" "packages" + if (Test-Path $pkgRoot) { + $stale = Get-ChildItem -Path $pkgRoot -Directory -Filter "joincode.*" + if ($stale) { + $stale | ForEach-Object { Write-Host "purging: $($_.Name)" } + Remove-Item -Path $stale.FullName -Recurse -Force + Write-Host "Purged $($stale.Count) JoinCode package(s) — restore re-resolves from local feed (latest pack)" + } else { + Write-Host "No JoinCode packages in cache — nothing to purge" + } + } + - name: Add Local NuGet Feed shell: pwsh run: |