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: |