From 003062da023866736d80ca1c5cc5ba84cfca4509 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 20:27:33 +0800
Subject: [PATCH 01/16] =?UTF-8?q?refactor:=20ReplShell=20=E6=8F=90?=
=?UTF-8?q?=E5=8F=96=20=E2=80=94=20HostSession/DemoSession=20=E5=90=88?=
=?UTF-8?q?=E5=B9=B6=E4=B8=BA=E5=85=B1=E4=BA=AB=20REPL=20=E5=A4=96?=
=?UTF-8?q?=E5=A3=B3=EF=BC=88M7=20=E5=89=8D=E7=BD=AE=EF=BC=89=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20IReplEngine=20=E6=8E=A5=E5=8F=A3(Submit/I?=
=?UTF-8?q?nterrupt/SetPermissionMode/DrainEvents/DrainDiagnostics/IsAlive?=
=?UTF-8?q?)=E7=BB=9F=E4=B8=80=E5=BC=95=E6=93=8E=E4=BA=A4=E4=BA=92?=
=?UTF-8?q?=E9=9D=A2=EF=BC=8C=E6=A8=A1=E6=8B=9F=E5=9B=9E=E5=90=88=E6=94=B9?=
=?UTF-8?q?=E4=BA=8B=E4=BB=B6=E9=A9=B1=E5=8A=A8=EF=BC=88SimulatedEngine=20?=
=?UTF-8?q?=E6=97=B6=E9=92=9F=E6=B3=A8=E5=85=A5+=E5=BF=99=E6=97=B6?=
=?UTF-8?q?=E6=8B=92=E7=BB=9D=20Error=20=E8=AF=B1=E5=AF=BC=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=20EngineTurnClient=EF=BC=89=EF=BC=8C=E6=9D=83?=
=?UTF-8?q?=E9=99=90=E6=9C=AC=E5=9C=B0=E5=9B=9E=E6=89=A7=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E8=B5=B0=E4=BA=8B=E4=BB=B6=EF=BC=8Cspinner=20Message=20?=
=?UTF-8?q?=E7=9B=B4=E6=94=B9=E5=BC=83=E7=94=A8=EF=BC=88=E7=9C=9F=E5=AE=9E?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=9C=AC=E5=B0=B1=E7=A9=BA=E6=B6=88=E6=81=AF?=
=?UTF-8?q?+=E9=9A=8F=E6=9C=BA=E5=8A=A8=E8=AF=8D=EF=BC=89=EF=BC=9B?=
=?UTF-8?q?=E5=87=80=E5=88=A0=20919=20=E8=A1=8C=E9=87=8D=E5=A4=8D=E4=BB=A3?=
=?UTF-8?q?=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_tui/apps/NativeTui.Demo/Program.cs | 625 +-----------------
.../apps/NativeTui.Host/EngineTurnClient.cs | 10 +-
native_tui/apps/NativeTui.Host/Program.cs | 8 +-
.../src/NativeTui.Core/NativeTui.Core.csproj | 4 +
.../src/NativeTui.Core/bridge/BridgeEngine.cs | 36 +
.../src/NativeTui.Core/bridge/IReplEngine.cs | 28 +
.../NativeTui.Core/bridge/SimulatedEngine.cs | 124 ++++
.../NativeTui.Core/repl/ReplShell.cs} | 296 ++++++---
.../bridge/SimulatedEngineTests.cs | 142 ++++
.../NativeTui.Tests/repl/ReplShellTests.cs | 235 +++++++
10 files changed, 820 insertions(+), 688 deletions(-)
create mode 100644 native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
create mode 100644 native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
create mode 100644 native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
rename native_tui/{apps/NativeTui.Host/HostSession.cs => src/NativeTui.Core/repl/ReplShell.cs} (53%)
create mode 100644 native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs
diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs
index 7d431d8ea..4f7b47462 100644
--- a/native_tui/apps/NativeTui.Demo/Program.cs
+++ b/native_tui/apps/NativeTui.Demo/Program.cs
@@ -41,7 +41,7 @@ public static int Main(string[] args)
: RunInteractive(model, viewport);
}
- /// 交互模式:进 raw mode 跑主循环(inline 模式退出只清尾部动态区,历史留终端 scrollback)。
+ /// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 事件驱动模拟回合;inline 模式退出只清尾部动态区,历史留终端 scrollback)。
private static int RunInteractive(string? model = null, bool viewport = false)
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
@@ -53,13 +53,24 @@ private static int RunInteractive(string? model = null, bool viewport = false)
Console.OutputEncoding = Encoding.UTF8;
using var raw = new RawConsoleMode();
var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0";
- var session = new DemoSession(session: SessionInfo.Create(version, model), inline: !viewport);
- session.Run();
- session.WriteExit();
+ 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());
+ shell.Run();
+ shell.WriteExit();
Console.WriteLine("NativeTui.Demo 已退出。");
return 0;
}
+ /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区)。
+ private static IReadOnlyList SeedMessages() =>
+ [
+ new UserMessageItem("这个 TUI 是像素级对齐 Claude Code 的自研实现吗?"),
+ new AssistantMessageItem("是的 — 纯 C# 自研渲染管线:Screen cell 网格 + CellDiffer 差分 + DEC 2026 原子上屏。"),
+ new ToolCallItem("Read", "native_tui/src/NativeTui.Core/repl/ReplView.cs"),
+ new ToolResultItem("41 行 — REPL 整屏视图(footer + 输入框 + 消息滚动区)"),
+ ];
+
///
/// bridge 交互模式(ADR 0141 / ADR 0080 真实进程验收)— 拉起引擎子进程(stdio 管道),
/// 主循环内排空事件 → ReplBridgeDriver 应用到视图;提交/打断写子进程 stdin。
@@ -84,14 +95,16 @@ private static int RunBridgeInteractive(string exePath, string? model = null, bo
return 1;
}
- using (client)
+ using (var engine = new BridgeEngine(client))
{
Console.OutputEncoding = Encoding.UTF8;
using var raw = new RawConsoleMode();
var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0";
- var session = new DemoSession(client, SessionInfo.Create(version, model), inline: !viewport);
- session.Run();
- session.WriteExit();
+ 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);
+ shell.Run();
+ shell.WriteExit();
}
Console.WriteLine("NativeTui.Demo bridge 会话已退出。");
@@ -435,599 +448,3 @@ internal sealed record SessionInfo(string Version, string Model, string Billing,
public static SessionInfo Create(string version, string? model = null) =>
new(version, model ?? NoModel, "API 按量计费", Environment.CurrentDirectory);
}
-
-///
-/// demo 会话 — 持有全部运行时状态(视图/输入/双缓冲/回合状态机),
-/// 主循环每 50ms 一拍:排空键盘 → 推进回合(模拟状态机或 bridge 事件排空)→ 渲染差分上屏 → 帧率对齐。
-/// bridge 模式(传入 BridgeClient):回合生命周期由引擎事件驱动(ReplBridgeDriver 应用),
-/// 提交/打断写子进程 stdin;引擎退出时追加错误消息并结束会话。
-///
-internal sealed class DemoSession
-{
- /// 动画 tick 周期(基准 spinner setInterval 50ms)。
- private const int TickMs = 50;
-
- /// 模拟回合的阶段。
- private enum TurnPhase
- {
- /// 空闲(无回合)。
- Idle,
-
- /// 思考中(0-1.2s)。
- Thinking,
-
- /// 工具执行中(1.2-2.4s)。
- ToolUse,
-
- /// 响应输出中(2.4-5.4s)。
- Responding,
- }
-
- private readonly Theme _theme = Theme.Dark;
- private readonly ReplView _view = new();
- private readonly InputBuffer _input = new();
- private readonly SpinnerItem _spinner = new() { Message = string.Empty };
- private readonly FrameComposer _composer = new();
- private readonly StylePool _pool = new();
- private readonly Stopwatch _clock = Stopwatch.StartNew();
-
- /// bridge 引擎客户端(null = 模拟回合模式)。
- private readonly BridgeClient? _client;
-
- /// bridge 事件应用器(bridge 模式构造)。
- private readonly ReplBridgeDriver? _driver;
-
- /// 启动 banner(消息首项 — bridge 模式由 session_info 事件原地更新真实数据)。
- private readonly BannerItem _banner;
-
- private Screen _front = null!;
- private Screen _back = null!;
- private bool _frontValid;
- private long _animTimeMs;
- private TurnPhase _phase = TurnPhase.Idle;
- private long _turnStartMs;
- private bool _running = true;
-
- /// 创建会话(传 client 进入 bridge 模式:回合由引擎事件驱动;inline 走 log-update 主屏渲染)。
- public DemoSession(BridgeClient? client = null, SessionInfo? session = null, bool inline = true)
- {
- _client = client;
- _inline = inline;
- _session = session ?? SessionInfo.Create("0.0.0");
- _banner = new BannerItem(_session.Version, _session.Model, _session.Billing, _session.WorkingDirectory);
- _driver = client is null ? null : new ReplBridgeDriver(_view, _spinner, _banner);
- }
-
- /// 会话信息(banner 真实数据源)。
- private readonly SessionInfo _session;
-
- /// inline(主屏 log-update)渲染模式 — 默认;false = 视口全屏 diff(--viewport 旧模式)。
- private readonly bool _inline;
-
- /// inline 帧组装器。
- private readonly InlineComposer _inlineComposer = new();
-
- /// inline 模式上一帧尾光标(内容坐标)。
- private CursorPos _cursor;
-
- /// inline 模式尺寸重建后的旧帧(跨尺寸 diff:增长走 append、宽度变走 fullReset)。
- private Screen? _prevOverride;
-
- /// 主循环 — 每拍:排空键盘 → 推进回合(模拟或 bridge)→ 渲染差分上屏 → 对齐 50ms。
- public void Run()
- {
- SeedContent();
- while (_running)
- {
- var tickStart = _clock.ElapsedMilliseconds;
- DrainInput();
- if (_client is null)
- {
- AdvanceTurn();
- }
- else
- {
- DrainBridge();
- }
-
- RenderAndFlush();
- PaceTick(tickStart);
- }
- }
-
- /// 初始内容 — 启动 banner(两种模式共有,对齐基准 LogoHeader 挂消息列表头部)+ 模拟模式 seed 几条示例消息;bridge 模式滚动区留空(spinner 常驻尾部),banner 真实数据待 session_info 事件。
- private void SeedContent()
- {
- _view.Footer = new FooterState
- {
- Mode = PermissionMode.Auto,
- AgentsHint = "← agents",
- LoginError = "未登录 · 运行 /login",
- Notification = $"{Figures.EffortMedium} 中等 · /effort",
- };
- _view.Messages.Add(_banner);
- if (_client is null)
- {
- _view.Messages.Add(new UserMessageItem("这个 TUI 是像素级对齐 Claude Code 的自研实现吗?"));
- _view.Messages.Add(new AssistantMessageItem("是的 — 纯 C# 自研渲染管线:Screen cell 网格 + CellDiffer 差分 + DEC 2026 原子上屏。"));
- _view.Messages.Add(new ToolCallItem("Read", "native_tui/src/NativeTui.Core/repl/ReplView.cs"));
- _view.Messages.Add(new ToolResultItem("41 行 — REPL 整屏视图(footer + 输入框 + 消息滚动区)"));
- }
-
- _view.Messages.Add(_spinner);
- }
-
- /// 排空 bridge 事件泵:事件应用视图 + 诊断记 stderr + 引擎退出检测。
- private void DrainBridge()
- {
- foreach (var bridgeEvent in _client!.DrainEvents())
- {
- _driver!.Apply(bridgeEvent);
- }
-
- foreach (var diagnostic in _client.DrainDiagnostics())
- {
- Console.Error.WriteLine(diagnostic);
- }
-
- if (!_client.IsEngineAlive && !_driver!.IsBusy)
- {
- _view.Messages.Insert(_view.Messages.Count - 1, new ErrorMessageItem("引擎进程已退出,会话结束。"));
- _running = false;
- }
- }
-
- /// 新消息插入到常驻 spinner 之前(spinner 恒为滚动区最后一项)。
- private void AddMessage(IScrollItem item) =>
- _view.Messages.Insert(_view.Messages.Count - 1, item);
-
- /// 排空键盘事件(非阻塞)— ReadKey 归一 KeyPress 后统一分派。
- private void DrainInput()
- {
- while (_running && Console.KeyAvailable)
- {
- HandleKey(Console.ReadKey(intercept: true));
- }
- }
-
- /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序)。
- private void HandleKey(ConsoleKeyInfo keyInfo)
- {
- var key = ConsoleKeyAdapter.ToKeyPress(keyInfo);
- if (key is null)
- {
- return;
- }
-
- HandleKeyPress(key);
- }
-
- /// 按键分派 — Emacs 编辑(Ctrl/Meta 词操作 + kill ring)优先,控制键走编辑/滚动,字符插入。
- private void HandleKeyPress(KeyPress key)
- {
- if (key.Kind == KeyKind.Escape)
- {
- HandleEscape();
- return;
- }
-
- if (key.Ctrl && key.Kind is KeyKind.LeftArrow or KeyKind.RightArrow)
- {
- MoveWord(key.Kind == KeyKind.LeftArrow ? -1 : 1);
- return;
- }
-
- if (key.Kind == KeyKind.Backspace)
- {
- if (key.Meta || key.Ctrl)
- {
- _input.KillWordBefore();
- }
- else
- {
- _input.Backspace();
- }
-
- return;
- }
-
- if (key.Kind == KeyKind.Delete)
- {
- if (key.Meta)
- {
- _input.KillToLineEnd();
- }
- else
- {
- _input.Delete();
- }
-
- return;
- }
-
- if (key.Ctrl && key.Kind == KeyKind.Char)
- {
- HandleCtrlKey(key.Input);
- return;
- }
-
- if (key.Meta && key.Kind == KeyKind.Char)
- {
- HandleMetaKey(key.Input);
- return;
- }
-
- switch (key.Kind)
- {
- case KeyKind.Return:
- Submit();
- break;
- case KeyKind.BackTab:
- CyclePermissionMode();
- break;
- case KeyKind.LeftArrow:
- _input.Left();
- break;
- case KeyKind.RightArrow:
- _input.Right();
- break;
- case KeyKind.UpArrow:
- // inline 模式历史由终端滚轮原生回看(基准主屏行为),无视口滚动
- if (!_inline)
- {
- _view.Messages.ScrollBy(-3);
- }
-
- break;
- case KeyKind.DownArrow:
- if (!_inline)
- {
- _view.Messages.ScrollBy(3);
- }
-
- break;
- case KeyKind.Home:
- _input.Home();
- break;
- case KeyKind.End:
- _input.End();
- break;
- case KeyKind.Char:
- if (key.Input.Length == 1)
- {
- _input.Insert(key.Input[0]);
- }
-
- break;
- default:
- // Tab/PageUp/PageDown/Unknown — 输入域 no-op(对齐基准)
- break;
- }
- }
-
- /// Ctrl 组合分派(对齐基准 handleCtrl 表)。
- private void HandleCtrlKey(string name)
- {
- switch (name)
- {
- case "a":
- _input.Home();
- break;
- case "b":
- _input.Left();
- break;
- case "c":
- _running = false;
- break;
- case "d":
- if (_input.Text.Length == 0)
- {
- _running = false;
- }
- else
- {
- _input.Delete();
- }
-
- break;
- case "e":
- _input.End();
- break;
- case "f":
- _input.Right();
- break;
- case "h":
- _input.Backspace();
- break;
- case "k":
- _input.KillToLineEnd();
- break;
- case "n":
- if (!_inline)
- {
- _view.Messages.ScrollBy(3);
- }
-
- break;
- case "p":
- if (!_inline)
- {
- _view.Messages.ScrollBy(-3);
- }
-
- break;
- case "u":
- _input.KillToLineStart();
- break;
- case "w":
- _input.KillWordBefore();
- break;
- case "y":
- _input.Yank();
- break;
- default:
- break;
- }
- }
-
- /// Meta/Alt 组合分派(对齐基准 handleMeta 表)。
- private void HandleMetaKey(string name)
- {
- switch (name)
- {
- case "b":
- MoveWord(-1);
- break;
- case "f":
- MoveWord(1);
- break;
- case "d":
- _input.KillWordAfter();
- break;
- case "y":
- _input.YankPop();
- break;
- default:
- break;
- }
- }
-
- /// 词移动统一入口。
- private void MoveWord(int direction)
- {
- if (direction < 0)
- {
- _input.WordLeft();
- }
- else
- {
- _input.WordRight();
- }
- }
-
- /// Esc:忙时打断当前回合;闲时有输入清空(对齐基准双击清空的单击近似),无输入退出。
- private void HandleEscape()
- {
- if (IsBusy)
- {
- if (_client is not null)
- {
- _client.Interrupt();
- return;
- }
-
- FinishTurn(interrupted: true);
- return;
- }
-
- if (_input.Text.Length > 0)
- {
- _input.Clear();
- return;
- }
-
- _running = false;
- }
-
- /// 回合进行中(模拟:阶段非空闲;bridge:driver.IsBusy)。
- private bool IsBusy => _client is null ? _phase != TurnPhase.Idle : _driver!.IsBusy;
-
- /// Shift+Tab 循环权限模式(jcc 引擎模式集合):bridge 模式下发 set_permission_mode 请求、footer 以引擎回执为准;seed 模式本地切换。
- private void CyclePermissionMode()
- {
- var bypassAvailable = _driver?.BypassAvailable ?? true;
- var next = PermissionModeCycler.Next(_view.Footer.Mode, bypassAvailable);
- if (_client is not null)
- {
- _client.SetPermissionMode(PermissionModeInfo.ToValue(next));
- return;
- }
-
- _view.Footer = _view.Footer with { Mode = next };
- }
-
- /// 提交输入:空文本忽略;否则追加用户消息并开始回合(模拟状态机或引擎提交)。
- private void Submit()
- {
- var text = _input.Text;
- if (text.Length == 0)
- {
- return;
- }
-
- _input.Clear();
- AddMessage(new UserMessageItem(text));
- if (_client is null)
- {
- StartTurn();
- }
- else
- {
- _driver!.BeginTurn();
- _client.Submit(text);
- }
- }
-
- /// 开始回合:进入 Thinking 阶段,busy + spinner 启动(消息留空 → 随机动词,对齐基准挂载采样)。
- private void StartTurn()
- {
- _phase = TurnPhase.Thinking;
- _turnStartMs = Environment.TickCount64;
- _view.Footer = _view.Footer with { Busy = true };
- _spinner.Message = string.Empty;
- _spinner.ResetVerb();
- _spinner.Mode = SpinnerMode.Thinking;
- _spinner.LoadingStartMs = _turnStartMs;
- _spinner.ResponseLength = 0;
- _spinner.HasActiveTools = false;
- }
-
- /// 推进模拟回合状态机(时间驱动)。
- private void AdvanceTurn()
- {
- if (_phase == TurnPhase.Idle)
- {
- return;
- }
-
- var elapsed = Environment.TickCount64 - _turnStartMs;
- if (_phase == TurnPhase.Thinking && elapsed >= 1200)
- {
- _phase = TurnPhase.ToolUse;
- AddMessage(new ToolCallItem("Read", "demo/simulated.cs"));
- _spinner.Message = "Reading files…";
- _spinner.Mode = SpinnerMode.ToolUse;
- _spinner.HasActiveTools = true;
- }
-
- if (_phase == TurnPhase.ToolUse && elapsed >= 2400)
- {
- _phase = TurnPhase.Responding;
- AddMessage(new ToolResultItem("7 lines (simulated)"));
- _spinner.Message = "Writing response…";
- _spinner.Mode = SpinnerMode.Responding;
- _spinner.HasActiveTools = false;
- }
-
- if (_phase == TurnPhase.Responding && elapsed >= 5400)
- {
- FinishTurn(interrupted: false);
- return;
- }
-
- if (_phase == TurnPhase.Responding)
- {
- _spinner.ResponseLength += 9;
- }
- }
-
- /// 结束回合:正常完成追加助手消息;打断则直接回到空闲。
- private void FinishTurn(bool interrupted)
- {
- if (!interrupted)
- {
- AddMessage(new AssistantMessageItem("演示回复 — diff 渲染管线运行正常。"));
- }
-
- _phase = TurnPhase.Idle;
- _view.Footer = _view.Footer with { Busy = false };
- _spinner.Message = string.Empty;
- _spinner.ResponseLength = 0;
- _spinner.HasActiveTools = false;
- }
-
- /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback);视口走绝对定位 diff。
- private void RenderAndFlush()
- {
- EnsureScreens();
- _back.Clear();
- _animTimeMs += TickMs;
- _spinner.AnimTimeMs = _animTimeMs;
- _spinner.WallNowMs = Environment.TickCount64;
- _view.Input = _input.Text;
- _view.CursorCol = _input.CursorCol;
- _view.Render(_back, _theme);
- if (_inline)
- {
- var viewportRows = Math.Max(8, Console.WindowHeight);
- 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);
- _prevOverride = null;
- if (output.Length > 0)
- {
- Console.Out.Write(Csi.SynchronizedOutputBegin() + output + Csi.SynchronizedOutputEnd());
- }
- }
- else
- {
- var output = _composer.Compose(_frontValid ? _front : null, _back);
- if (output.Length > 0)
- {
- Console.Out.Write(output);
- }
-
- PositionCursor();
- }
-
- Console.Out.Flush();
- (_front, _back) = (_back, _front);
- _frontValid = true;
- }
-
- ///
- /// 尺寸计算(含首帧)成对重建双缓冲 — inline 屏高 = 内容总高(消息全量展示,可超视口),
- /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset);视口模式屏高 = 终端行数。
- ///
- private void EnsureScreens()
- {
- var cols = Math.Max(20, Console.WindowWidth);
- var rows = _inline
- ? Math.Max(8, _view.MeasureContentHeight(cols))
- : Math.Max(8, Console.WindowHeight);
- if (_back is not null && cols == _back.Cols && rows == _back.Rows)
- {
- return;
- }
-
- _prevOverride = _inline && _frontValid ? _front : null;
- _front = new Screen(cols, rows, sharedStyles: _pool);
- _back = new Screen(cols, rows, sharedStyles: _pool);
- _frontValid = false;
- }
-
- /// 退出输出 — inline:清尾部动态区(间隔+输入区+footer),历史留终端 scrollback 可复制;视口:清屏复位。
- public void WriteExit()
- {
- if (!_inline)
- {
- Console.Out.Write(Sgr.Reset + Csi.EraseScreen() + Csi.CursorPosition(1, 1));
- Console.Out.Flush();
- return;
- }
-
- // 光标在输入位(上帧 frameEnd)→ 上移 2 行到顶线 → 清 TailHeight 行 → 内容底行换行
- var tail = _view.TailHeight;
- Console.Out.Write(Sgr.Reset + Csi.CursorUp(2) + "\r" + string.Concat(Enumerable.Repeat(Csi.EraseLine() + "\r\n", tail - 1)) + Csi.EraseLine() + "\r\n");
- Console.Out.Flush();
- }
-
- /// 终端光标落到输入文本行(0 基 Rows-2 → 1 基 Rows-1),列随编辑偏移。
- private void PositionCursor()
- {
- var row = _back.Rows - 1;
- var col = Math.Min(_back.Cols, PromptInputBox.PromptWidth + _input.CursorCol + 1);
- Console.Out.Write(Csi.CursorPosition(row, col));
- }
-
- /// 对齐 50ms 拍长(忙完剩余时间睡眠)。
- private void PaceTick(long tickStart)
- {
- var elapsedMs = _clock.ElapsedMilliseconds - tickStart;
- var sleepMs = TickMs - (int)elapsedMs;
- if (sleepMs > 0)
- {
- Thread.Sleep(sleepMs);
- }
- }
-}
diff --git a/native_tui/apps/NativeTui.Host/EngineTurnClient.cs b/native_tui/apps/NativeTui.Host/EngineTurnClient.cs
index b8fe9eb86..00ab67d1d 100644
--- a/native_tui/apps/NativeTui.Host/EngineTurnClient.cs
+++ b/native_tui/apps/NativeTui.Host/EngineTurnClient.cs
@@ -4,10 +4,10 @@ namespace NativeTui.Host;
/// 引擎回合客户端 — BridgeServeLoop 的进程内直连版本(ADR 0143 阶段二):
/// 回合生命周期事件(turn_started/completed/interrupted/error)由本类统一发出,回合内部事件由
/// InProcessEventConsumer 发(共享队列 FIFO 保序:turn_started 必先于回合内部事件,终态必最后)。
-/// 主循环消费面与 bridge 形态的 BridgeClient 对齐(DrainEvents 排空模式)。
+/// 实现 IReplEngine 供 ReplShell 消费(主循环消费面与 bridge 形态的 BridgeClient 对齐 — DrainEvents 排空模式)。
/// 委托注入回合执行器与权限模式应用器 — 组装层绑定 SessionController/PermissionChecker,本类零 engine DI 依赖。
///
-public sealed class EngineTurnClient : IDisposable {
+public sealed class EngineTurnClient : IReplEngine, IDisposable {
/// 合法权限模式值清单(Error 诱导提示用,对齐 bridge 协议与 abs_guard [EnumValue])。
private const string ValidPermissionModes = "plan, auto, ask, bypass, unattended";
@@ -88,6 +88,12 @@ public IReadOnlyList DrainEvents() {
return (IReadOnlyList?)drained ?? [];
}
+ /// 进程内引擎 — 恒存活(引擎异常经回合 Error 事件路径报告)。
+ bool IReplEngine.IsAlive => true;
+
+ /// 显式接口实现 — ReplShell 消费面(bool 版本保留供 selftest 断言)。
+ void IReplEngine.SetPermissionMode(string mode) => SetPermissionMode(mode);
+
/// 执行单个回合 — 终态翻译对齐 BridgeServeCommand.RunTurnAsync:成功 → turn_completed;
/// 取消 → turn_interrupted;异常 → error(异常消息含诱导上下文)。终态事件入队先于回合 Task 完成(无竞态窗口)。
private async Task RunTurnAsync(string input, CancellationToken ct) {
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index 4ed7b8ddb..bfe9e7ba9 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -47,9 +47,11 @@ private static async Task RunInteractiveAsync() {
}
await using (session) {
- var host = new HostSession(session.Client, tuiOutput);
- host.Run();
- host.WriteExit();
+ // banner 占位值 — 首拍 session_info 种子事件覆盖为引擎真实数据(EngineSession.Enqueue)
+ var banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory);
+ var shell = new ReplShell(session.Client, banner, tuiOutput);
+ shell.Run();
+ shell.WriteExit();
}
} finally {
Console.SetOut(originalOut);
diff --git a/native_tui/src/NativeTui.Core/NativeTui.Core.csproj b/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
index f6f3cc45b..47af519a0 100644
--- a/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
+++ b/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
@@ -8,4 +8,8 @@
false
+
+
+
+
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
new file mode 100644
index 000000000..23f2d9826
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
@@ -0,0 +1,36 @@
+namespace NativeTui.Core.Bridge;
+
+///
+/// bridge 引擎适配器 — BridgeClient(子进程 stdio 协议)→ IReplEngine(ReplShell 消费面):
+/// 提交/打断/权限模式直通请求写 stdin,事件/诊断排空直通事件泵,存活检测看子进程。
+/// 诊断旁路由 shell 经 DrainDiagnostics 写 stderr;引擎退出由 shell 经 IsAlive 检测追加退出消息。
+///
+public sealed class BridgeEngine : IReplEngine, IDisposable
+{
+ /// bridge 子进程客户端(持有事件泵与 stdin 写面)。
+ private readonly BridgeClient _client;
+
+ /// 创建适配器 — client=已启动的 bridge 子进程客户端(Dispose 归本类管)。
+ public BridgeEngine(BridgeClient client) => _client = client;
+
+ /// 子进程存活(退出后 shell 空闲时追加退出消息并结束)。
+ public bool IsAlive => _client.IsEngineAlive;
+
+ /// 提交回合 — 写子进程 stdin(submit 请求)。
+ public void Submit(string text) => _client.Submit(text);
+
+ /// 打断当前回合 — 写子进程 stdin(interrupt 请求)。
+ public void Interrupt() => _client.Interrupt();
+
+ /// 请求权限模式切换 — 写子进程 stdin(回执事件为准)。
+ public void SetPermissionMode(string mode) => _client.SetPermissionMode(mode);
+
+ /// 排空事件泵待应用事件。
+ public IReadOnlyList DrainEvents() => _client.DrainEvents();
+
+ /// 排空事件泵诊断旁路(协议解析警告等 — shell 写 stderr)。
+ public IReadOnlyList DrainDiagnostics() => _client.DrainDiagnostics();
+
+ /// 释放 — 关闭 stdin 发 quit 并回收子进程(归 BridgeClient 语义)。
+ public void Dispose() => _client.Dispose();
+}
diff --git a/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs b/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
new file mode 100644
index 000000000..f2dfb2c5b
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
@@ -0,0 +1,28 @@
+namespace NativeTui.Core.Bridge;
+
+///
+/// REPL 引擎抽象 — ReplShell 的回合交互面(ADR 0143 阶段二 ReplShell 提取):
+/// 提交/打断/权限模式请求 + 事件排空(拉模型,每拍一次)。
+/// 实现者:进程内引擎适配(EngineTurnClient)、子进程 bridge(BridgeClient)、模拟引擎(SimulatedEngine)。
+/// 回合生命周期全事件驱动 — shell 不乐观预挂 busy,以 TurnStarted/TurnCompleted/TurnInterrupted 事件为准。
+///
+public interface IReplEngine
+{
+ /// 提交回合(空文本由 shell 过滤;忙时由实现拒绝并回 Error 事件诱导)。
+ void Submit(string text);
+
+ /// 打断当前回合(实现负责回 turn_interrupted;闲时 no-op)。
+ void Interrupt();
+
+ /// 请求权限模式切换(footer 以 permission_mode_changed 回执为准 — 本地实现立即回执)。
+ void SetPermissionMode(string mode);
+
+ /// 排空待应用事件(shell 每拍调用 — 实现可在此推进内部状态机产出到期事件)。
+ IReadOnlyList DrainEvents();
+
+ /// 排空诊断旁路(shell 写 stderr;无诊断实现返回空 — 默认空)。
+ IReadOnlyList DrainDiagnostics() => Array.Empty();
+
+ /// 引擎存活(进程内恒 true;子进程退出后 false — shell 空闲时追加退出消息并结束)。
+ bool IsAlive { get; }
+}
diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
new file mode 100644
index 000000000..1f1334767
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
@@ -0,0 +1,124 @@
+namespace NativeTui.Core.Bridge;
+
+///
+/// 模拟引擎 — Demo 模拟回合的事件化改造(ReplShell 提取后替代 DemoSession 内嵌状态机):
+/// 时间驱动三阶段(Thinking 1.2s → ToolUse 2.4s → Responding 5.4s),
+/// 阶段迁移产出 BridgeEvent(ToolCall/ToolResult/TokenDelta/TurnCompleted),
+/// 与真实 bridge 路径共用同一事件协议(ReplBridgeDriver 唯一翻译层)。
+/// 时钟注入(默认墙钟)— 单元测试可注入虚拟时钟做确定性验证。
+/// spinner Message 不经事件协议(真实路径空消息+随机动词采样,模拟直改属可弃糖)。
+///
+public sealed class SimulatedEngine : IReplEngine
+{
+ /// Thinking → ToolUse 迁移时刻(ms)。
+ private const long ToolUseAtMs = 1200;
+
+ /// ToolUse → Responding 迁移时刻(ms)。
+ private const long RespondingAtMs = 2400;
+
+ /// 回合完成时刻(ms)。
+ private const long CompletedAtMs = 5400;
+
+ /// Responding 阶段每拍 token 增量。
+ private const int TokenDeltaPerTick = 9;
+
+ /// 回合计时起点(clock 值)。
+ private long _turnStart;
+
+ /// 已推进到的阶段(0=Thinking / 1=ToolUse / 2=Responding / 3=完成)。
+ private int _stage;
+
+ /// 回合进行中。
+ private bool _inTurn;
+
+ /// 待排空事件队列(Submit/Interrupt 立即入队,DrainEvents 阶段迁移追加)。
+ private readonly Queue _pending = new();
+
+ /// 时钟(默认墙钟 — 测试注入虚拟时钟)。
+ private readonly Func _clock;
+
+ /// 默认时间源 — 墙钟毫秒。
+ private static long DefaultClock() => Environment.TickCount64;
+
+ /// 创建模拟引擎(clock=时间源,毫秒)。
+ public SimulatedEngine(Func? clock = null) => _clock = clock ?? DefaultClock;
+
+ /// 进程内模拟 — 恒存活。
+ public bool IsAlive => true;
+
+ /// 提交回合:忙时拒绝回 Error 诱导(对齐 EngineTurnClient 语义),否则入队 TurnStarted 并开始计时。
+ public void Submit(string text)
+ {
+ if (_inTurn)
+ {
+ _pending.Enqueue(new BridgeEvent.Error("回合进行中,请等待完成或按 Esc 打断后再提交。"));
+ return;
+ }
+
+ _inTurn = true;
+ _stage = 0;
+ _turnStart = _clock();
+ _pending.Enqueue(new BridgeEvent.TurnStarted());
+ }
+
+ /// 打断当前回合(闲时 no-op)。
+ public void Interrupt()
+ {
+ if (!_inTurn)
+ {
+ return;
+ }
+
+ _inTurn = false;
+ _pending.Enqueue(new BridgeEvent.TurnInterrupted());
+ }
+
+ /// 权限模式本地立即回执(统一事件语义 — 与真实引擎回执路径一致)。
+ public void SetPermissionMode(string mode) =>
+ _pending.Enqueue(new BridgeEvent.PermissionModeChanged(mode));
+
+ /// 排空事件:先推进时间驱动状态机(产出到期迁移事件),再清空队列。
+ public IReadOnlyList DrainEvents()
+ {
+ AdvanceStages();
+ var drained = _pending.ToArray();
+ _pending.Clear();
+ return drained;
+ }
+
+ /// 按已流逝时间推进阶段状态机(到期迁移入队事件;Responding 阶段每拍 token 增量)。
+ private void AdvanceStages()
+ {
+ if (!_inTurn)
+ {
+ return;
+ }
+
+ var elapsed = _clock() - _turnStart;
+ if (_stage == 0 && elapsed >= ToolUseAtMs)
+ {
+ _stage = 1;
+ _pending.Enqueue(new BridgeEvent.ToolCall("Read", "demo/simulated.cs"));
+ }
+
+ if (_stage == 1 && elapsed >= RespondingAtMs)
+ {
+ _stage = 2;
+ _pending.Enqueue(new BridgeEvent.ToolResult("7 lines (simulated)"));
+ }
+
+ if (_stage == 2 && elapsed >= CompletedAtMs)
+ {
+ _stage = 3;
+ _inTurn = false;
+ _pending.Enqueue(new BridgeEvent.AssistantMessage("演示回复 — diff 渲染管线运行正常。"));
+ _pending.Enqueue(new BridgeEvent.TurnCompleted());
+ return;
+ }
+
+ if (_stage == 2)
+ {
+ _pending.Enqueue(new BridgeEvent.TokenDelta(TokenDeltaPerTick));
+ }
+ }
+}
diff --git a/native_tui/apps/NativeTui.Host/HostSession.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
similarity index 53%
rename from native_tui/apps/NativeTui.Host/HostSession.cs
rename to native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 6209f55bc..2be687a05 100644
--- a/native_tui/apps/NativeTui.Host/HostSession.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -1,13 +1,15 @@
-namespace NativeTui.Host;
+namespace NativeTui.Core.Repl;
///
-/// 宿主 REPL 会话 — DemoSession bridge 模式的进程内直连版(ADR 0143 阶段二,inline 渲染独占):
-/// 主循环每 50ms 一拍:排空键盘 → 排空引擎事件(ReplBridgeDriver 应用到视图)→ inline 差分上屏 → 帧率对齐。
-/// 回合生命周期全事件驱动:TurnStarted 事件驱动 BeginTurn(进程内零延迟,无需乐观预挂 busy),
-/// 忙时提交由 EngineTurnClient 拒绝(Error 诱导),Esc 忙时打断/闲时清输入或退出,Shift+Tab 权限循环(回执为准)。
-/// 渲染走注入的独立 stdout writer — 引擎内部输出已被 Program 重定向 stderr,不污染渲染流。
+/// 共享 REPL 外壳 — HostSession/DemoSession 提取的统一交互外壳(TASK055 M7 前置,ADR 0143 阶段二):
+/// 主循环每 50ms 一拍:排空键盘 → 排空引擎事件(ReplBridgeDriver 应用到视图)→ 差分上屏 → 帧率对齐。
+/// 回合生命周期全事件驱动(TurnStarted 挂 busy,TurnCompleted/TurnInterrupted 解除 — 不乐观预挂);
+/// 引擎交互经 IReplEngine(提交/打断/权限模式),渲染双模式(inline 主屏 log-update / viewport 全屏 diff);
+/// Esc 忙时打断/闲时清输入或退出,Shift+Tab 权限循环(引擎回执为准)。
+/// 渲染走注入的独立 writer — 宿主负责隔离引擎内部输出(Host 重定向 stderr)。
///
-internal sealed class HostSession {
+public sealed class ReplShell
+{
/// 动画 tick 周期(基准 spinner setInterval 50ms)。
private const int TickMs = 50;
@@ -18,19 +20,26 @@ internal sealed class HostSession {
private readonly StylePool _pool = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
private readonly InlineComposer _inlineComposer = new();
+ private readonly FrameComposer _composer = new();
- /// 引擎回合客户端(Submit/Interrupt/SetPermissionMode/DrainEvents)。
- private readonly EngineTurnClient _client;
+ /// 引擎交互面(提交/打断/权限/事件排空/存活)。
+ private readonly IReplEngine _engine;
- /// 独立渲染 writer(Console.Out 重定向前捕获 — 引擎内部输出走 stderr)。
+ /// 渲染 writer(宿主注入 — Host 为重定向前捕获的独立 writer)。
private readonly TextWriter _output;
/// bridge 事件应用器(引擎事件 → 视图状态唯一翻译层)。
private readonly ReplBridgeDriver _driver;
- /// 启动 banner(首项 — 首拍 DrainEngine 应用 session_info 事件更新引擎真实数据)。
+ /// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。
private readonly BannerItem _banner;
+ /// 种子消息(Demo 模拟模式示例内容;真实引擎为空)。
+ private readonly IReadOnlyList _seedMessages;
+
+ /// inline(主屏 log-update)渲染模式 — 默认;false = 视口全屏 diff(旧模式)。
+ private readonly bool _inline;
+
private Screen _front = null!;
private Screen _back = null!;
private bool _frontValid;
@@ -43,18 +52,36 @@ internal sealed class HostSession {
/// inline 模式尺寸重建后的旧帧(跨尺寸 diff:增长走 append、宽度变走 fullReset)。
private Screen? _prevOverride;
- /// 创建会话 — client=引擎回合客户端;output=独立渲染 writer。
- public HostSession(EngineTurnClient client, TextWriter output) {
- _client = client;
+ /// 创建外壳 — engine=引擎交互面;banner=启动横幅(首项);output=渲染 writer;
+ /// inline=渲染模式(默认主屏 log-update);seedMessages=种子消息(banner 之后、spinner 之前)。
+ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool inline = true, IReadOnlyList? seedMessages = null)
+ {
+ _engine = engine;
+ _banner = banner;
_output = output;
- _banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory);
+ _inline = inline;
+ _seedMessages = seedMessages ?? [];
_driver = new ReplBridgeDriver(_view, _spinner, _banner);
}
- /// 主循环 — 每拍:排空键盘 → 排空引擎事件 → inline 差分上屏 → 对齐 50ms。
- public void Run() {
+ /// 视图(测试断言面)。
+ internal ReplView View => _view;
+
+ /// 输入缓冲(测试注入面)。
+ internal InputBuffer Input => _input;
+
+ /// 回合进行中(turn_started 后未 completed/interrupted — 事件驱动)。
+ internal bool IsBusy => _driver.IsBusy;
+
+ /// 主循环运行标记(false = Esc/Ctrl+C/引擎死亡请求退出)。
+ internal bool Running => _running;
+
+ /// 主循环 — 每拍:排空键盘 → 排空引擎事件 → 差分上屏 → 对齐 50ms。
+ public void Run()
+ {
SeedContent();
- while (_running) {
+ while (_running)
+ {
var tickStart = _clock.ElapsedMilliseconds;
DrainInput();
DrainEngine();
@@ -63,23 +90,44 @@ public void Run() {
}
}
- /// 初始内容 — 启动 banner + 常驻 spinner(滚动区留空,回合消息全事件驱动);
- /// footer 权限模式为占位值,首拍 session_info 事件覆盖为引擎实际值。
- private void SeedContent() {
- _view.Footer = new FooterState {
+ /// 初始内容 — footer 种子 + banner 首项 + 种子消息 + 常驻 spinner(滚动区末项);
+ /// footer 权限模式为占位值,真实引擎由首拍 session_info 事件覆盖。
+ internal void SeedContent()
+ {
+ _view.Footer = new FooterState
+ {
Mode = PermissionMode.Auto,
AgentsHint = "← agents",
LoginError = "未登录 · 运行 /login",
Notification = $"{Figures.EffortMedium} 中等 · /effort",
};
_view.Messages.Add(_banner);
+ foreach (var message in _seedMessages)
+ {
+ _view.Messages.Add(message);
+ }
+
_view.Messages.Add(_spinner);
}
- /// 排空引擎事件:全部经 ReplBridgeDriver 应用到视图(回合错误以 Error 事件到达,进程内引擎无退出检测)。
- private void DrainEngine() {
- foreach (var bridgeEvent in _client.DrainEvents())
+ /// 排空引擎事件:全部经 ReplBridgeDriver 应用到视图 + 诊断旁路写 stderr + 引擎死亡检测(非忙时追加退出消息并结束)。
+ internal void DrainEngine()
+ {
+ foreach (var bridgeEvent in _engine.DrainEvents())
+ {
_driver.Apply(bridgeEvent);
+ }
+
+ foreach (var diagnostic in _engine.DrainDiagnostics())
+ {
+ Console.Error.WriteLine(diagnostic);
+ }
+
+ if (!_engine.IsAlive && !_driver.IsBusy)
+ {
+ AddMessage(new ErrorMessageItem("引擎进程已退出,会话结束。"));
+ _running = false;
+ }
}
/// 新消息插入到常驻 spinner 之前(spinner 恒为滚动区最后一项)。
@@ -87,63 +135,83 @@ private void AddMessage(IScrollItem item) =>
_view.Messages.Insert(_view.Messages.Count - 1, item);
/// 排空键盘事件(非阻塞)— ReadKey 归一 KeyPress 后统一分派。
- private void DrainInput() {
+ private void DrainInput()
+ {
while (_running && Console.KeyAvailable)
+ {
HandleKey(Console.ReadKey(intercept: true));
+ }
}
/// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序)。
- private void HandleKey(ConsoleKeyInfo keyInfo) {
+ private void HandleKey(ConsoleKeyInfo keyInfo)
+ {
var key = ConsoleKeyAdapter.ToKeyPress(keyInfo);
if (key is null)
+ {
return;
+ }
HandleKeyPress(key);
}
/// 按键分派 — Emacs 编辑(Ctrl/Meta 词操作 + kill ring)优先,控制键走编辑/提交/权限,字符插入。
- private void HandleKeyPress(KeyPress key) {
- if (key.Kind == KeyKind.Escape) {
+ private void HandleKeyPress(KeyPress key)
+ {
+ if (key.Kind == KeyKind.Escape)
+ {
HandleEscape();
return;
}
- if (key.Ctrl && key.Kind is KeyKind.LeftArrow or KeyKind.RightArrow) {
+ if (key.Ctrl && key.Kind is KeyKind.LeftArrow or KeyKind.RightArrow)
+ {
MoveWord(key.Kind == KeyKind.LeftArrow ? -1 : 1);
return;
}
- if (key.Kind == KeyKind.Backspace) {
- if (key.Meta || key.Ctrl) {
+ if (key.Kind == KeyKind.Backspace)
+ {
+ if (key.Meta || key.Ctrl)
+ {
_input.KillWordBefore();
- } else {
+ }
+ else
+ {
_input.Backspace();
}
return;
}
- if (key.Kind == KeyKind.Delete) {
- if (key.Meta) {
+ if (key.Kind == KeyKind.Delete)
+ {
+ if (key.Meta)
+ {
_input.KillToLineEnd();
- } else {
+ }
+ else
+ {
_input.Delete();
}
return;
}
- if (key.Ctrl && key.Kind == KeyKind.Char) {
+ if (key.Ctrl && key.Kind == KeyKind.Char)
+ {
HandleCtrlKey(key.Input);
return;
}
- if (key.Meta && key.Kind == KeyKind.Char) {
+ if (key.Meta && key.Kind == KeyKind.Char)
+ {
HandleMetaKey(key.Input);
return;
}
- switch (key.Kind) {
+ switch (key.Kind)
+ {
case KeyKind.Return:
Submit();
break;
@@ -157,8 +225,10 @@ private void HandleKeyPress(KeyPress key) {
_input.Right();
break;
case KeyKind.UpArrow:
+ ScrollIfViewport(-3);
+ break;
case KeyKind.DownArrow:
- // inline 模式历史由终端滚轮原生回看(基准主屏行为),无视口滚动
+ ScrollIfViewport(3);
break;
case KeyKind.Home:
_input.Home();
@@ -167,7 +237,8 @@ private void HandleKeyPress(KeyPress key) {
_input.End();
break;
case KeyKind.Char:
- if (key.Input.Length == 1) {
+ if (key.Input.Length == 1)
+ {
_input.Insert(key.Input[0]);
}
@@ -179,8 +250,10 @@ private void HandleKeyPress(KeyPress key) {
}
/// Ctrl 组合分派(对齐基准 handleCtrl 表)。
- private void HandleCtrlKey(string name) {
- switch (name) {
+ private void HandleCtrlKey(string name)
+ {
+ switch (name)
+ {
case "a":
_input.Home();
break;
@@ -191,9 +264,12 @@ private void HandleCtrlKey(string name) {
_running = false;
break;
case "d":
- if (_input.Text.Length == 0) {
+ if (_input.Text.Length == 0)
+ {
_running = false;
- } else {
+ }
+ else
+ {
_input.Delete();
}
@@ -210,6 +286,12 @@ private void HandleCtrlKey(string name) {
case "k":
_input.KillToLineEnd();
break;
+ case "n":
+ ScrollIfViewport(3);
+ break;
+ case "p":
+ ScrollIfViewport(-3);
+ break;
case "u":
_input.KillToLineStart();
break;
@@ -225,8 +307,10 @@ private void HandleCtrlKey(string name) {
}
/// Meta/Alt 组合分派(对齐基准 handleMeta 表)。
- private void HandleMetaKey(string name) {
- switch (name) {
+ private void HandleMetaKey(string name)
+ {
+ switch (name)
+ {
case "b":
MoveWord(-1);
break;
@@ -245,22 +329,38 @@ private void HandleMetaKey(string name) {
}
/// 词移动统一入口。
- private void MoveWord(int direction) {
- if (direction < 0) {
+ private void MoveWord(int direction)
+ {
+ if (direction < 0)
+ {
_input.WordLeft();
- } else {
+ }
+ else
+ {
_input.WordRight();
}
}
+ /// 视口模式滚动(inline 模式历史由终端滚轮原生回看,no-op)。
+ private void ScrollIfViewport(int rows)
+ {
+ if (!_inline)
+ {
+ _view.Messages.ScrollBy(rows);
+ }
+ }
+
/// Esc:忙时打断当前回合;闲时有输入清空(对齐基准双击清空的单击近似),无输入退出。
- private void HandleEscape() {
- if (IsBusy) {
- _client.Interrupt();
+ internal void HandleEscape()
+ {
+ if (IsBusy)
+ {
+ _engine.Interrupt();
return;
}
- if (_input.Text.Length > 0) {
+ if (_input.Text.Length > 0)
+ {
_input.Clear();
return;
}
@@ -268,28 +368,30 @@ private void HandleEscape() {
_running = false;
}
- /// 回合进行中(turn_started 后未 completed/interrupted — 事件驱动)。
- private bool IsBusy => _driver.IsBusy;
-
/// Shift+Tab 循环权限模式(jcc 引擎模式集合)— 发引擎应用请求,footer 以 permission_mode_changed 回执为准。
- private void CyclePermissionMode() {
+ internal void CyclePermissionMode()
+ {
var next = PermissionModeCycler.Next(_view.Footer.Mode, _driver.BypassAvailable);
- _client.SetPermissionMode(PermissionModeInfo.ToValue(next));
+ _engine.SetPermissionMode(PermissionModeInfo.ToValue(next));
}
- /// 提交输入:空文本忽略;否则追加用户消息并提交引擎(TurnStarted 事件驱动 busy,忙时由 client 拒绝并 Error 诱导)。
- private void Submit() {
+ /// 提交输入:空文本忽略;否则追加用户消息并提交引擎(TurnStarted 事件驱动 busy,忙时由实现拒绝并 Error 诱导)。
+ internal void Submit()
+ {
var text = _input.Text;
if (text.Length == 0)
+ {
return;
+ }
_input.Clear();
AddMessage(new UserMessageItem(text));
- _client.Submit(text);
+ _engine.Submit(text);
}
- /// 渲染一帧并 inline 差分上屏(相对光标 append,历史滚入终端 scrollback)+ DEC 2026 原子上屏。
- private void RenderAndFlush() {
+ /// 渲染一帧并差分上屏:inline 走相对光标 + append(历史滚入 scrollback)+ DEC 2026 原子上屏;视口走绝对定位 diff。
+ private void RenderAndFlush()
+ {
EnsureScreens();
_back.Clear();
_animTimeMs += TickMs;
@@ -298,14 +400,27 @@ private void RenderAndFlush() {
_view.Input = _input.Text;
_view.CursorCol = _input.CursorCol;
_view.Render(_back, _theme);
+ if (_inline)
+ {
+ var viewportRows = Math.Max(8, Console.WindowHeight);
+ 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);
+ _prevOverride = null;
+ if (output.Length > 0)
+ {
+ _output.Write(Csi.SynchronizedOutputBegin() + output + Csi.SynchronizedOutputEnd());
+ }
+ }
+ else
+ {
+ var output = _composer.Compose(_frontValid ? _front : null, _back);
+ if (output.Length > 0)
+ {
+ _output.Write(output);
+ }
- var viewportRows = Math.Max(8, Console.WindowHeight);
- 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);
- _prevOverride = null;
- if (output.Length > 0) {
- _output.Write(Csi.SynchronizedOutputBegin() + output + Csi.SynchronizedOutputEnd());
+ PositionCursor();
}
_output.Flush();
@@ -315,33 +430,56 @@ private void RenderAndFlush() {
///
/// 尺寸计算(含首帧)成对重建双缓冲 — inline 屏高 = 内容总高(消息全量展示,可超视口),
- /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset)。
+ /// 重建时保留旧帧跨尺寸 diff(增长 append / 宽度变 fullReset);视口模式屏高 = 终端行数。
///
- private void EnsureScreens() {
+ private void EnsureScreens()
+ {
var cols = Math.Max(20, Console.WindowWidth);
- var rows = Math.Max(8, _view.MeasureContentHeight(cols));
+ var rows = _inline
+ ? Math.Max(8, _view.MeasureContentHeight(cols))
+ : Math.Max(8, Console.WindowHeight);
if (_back is not null && cols == _back.Cols && rows == _back.Rows)
+ {
return;
+ }
- _prevOverride = _frontValid ? _front : null;
+ _prevOverride = _inline && _frontValid ? _front : null;
_front = new Screen(cols, rows, sharedStyles: _pool);
_back = new Screen(cols, rows, sharedStyles: _pool);
_frontValid = false;
}
- /// 退出输出 — inline:清尾部动态区(间隔+输入区+footer),历史留终端 scrollback 可复制。
- public void WriteExit() {
+ /// 退出输出 — inline:清尾部动态区(间隔+输入区+footer),历史留终端 scrollback 可复制;视口:清屏复位。
+ public void WriteExit()
+ {
+ if (!_inline)
+ {
+ _output.Write(Sgr.Reset + Csi.EraseScreen() + Csi.CursorPosition(1, 1));
+ _output.Flush();
+ return;
+ }
+
// 光标在输入位(上帧 frameEnd)→ 上移 2 行到顶线 → 清 TailHeight 行 → 内容底行换行
var tail = _view.TailHeight;
_output.Write(Sgr.Reset + Csi.CursorUp(2) + "\r" + string.Concat(Enumerable.Repeat(Csi.EraseLine() + "\r\n", tail - 1)) + Csi.EraseLine() + "\r\n");
_output.Flush();
}
+ /// 终端光标落到输入文本行(视口模式 — 0 基 Rows-2 → 1 基 Rows-1),列随编辑偏移。
+ private void PositionCursor()
+ {
+ var row = _back.Rows - 1;
+ var col = Math.Min(_back.Cols, PromptInputBox.PromptWidth + _input.CursorCol + 1);
+ _output.Write(Csi.CursorPosition(row, col));
+ }
+
/// 对齐 50ms 拍长(忙完剩余时间睡眠)。
- private void PaceTick(long tickStart) {
+ private void PaceTick(long tickStart)
+ {
var elapsedMs = _clock.ElapsedMilliseconds - tickStart;
var sleepMs = TickMs - (int)elapsedMs;
- if (sleepMs > 0) {
+ if (sleepMs > 0)
+ {
Thread.Sleep(sleepMs);
}
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
new file mode 100644
index 000000000..5b2260f17
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
@@ -0,0 +1,142 @@
+namespace NativeTui.Tests.Bridge;
+
+///
+/// SimulatedEngine 单元测试 — Demo 模拟回合的事件化改造(时钟注入确定性验证):
+/// Submit 立即产 TurnStarted、时间推进产阶段事件(ToolCall/ToolResult/TokenDelta/TurnCompleted)、
+/// Interrupt 忙时打断、忙时提交拒绝(Error 诱导)、权限模式本地回执统一走事件。
+///
+public sealed class SimulatedEngineTests
+{
+ /// 虚拟时钟 — 手动推进毫秒,驱动状态机确定性跳段。
+ private sealed class VirtualClock
+ {
+ private long _ms;
+
+ /// 当前时刻(ms)。
+ public long Now => _ms;
+
+ /// 推进指定毫秒。
+ public void Advance(long ms) => _ms += ms;
+ }
+
+ /// Submit 后首拍排空:立即产出 TurnStarted。
+ [Fact]
+ public void Submit_Drains_TurnStartedImmediately()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.Submit("hello");
+ var events = engine.DrainEvents();
+ Assert.Equal([new BridgeEvent.TurnStarted()], events);
+ }
+
+ /// 虚拟时钟 1.3s:进入 ToolUse 阶段,产出 ToolCall 事件。
+ [Fact]
+ public void Advance_ToToolUse_Emits_ToolCall()
+ {
+ var clock = new VirtualClock();
+ var engine = new SimulatedEngine(() => clock.Now);
+ engine.Submit("hello");
+ engine.DrainEvents();
+
+ clock.Advance(1300);
+ Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.ToolCall { Name: "Read" });
+ }
+
+ /// 虚拟时钟 2.5s:进入 Responding 阶段,产出 ToolResult + TokenDelta。
+ [Fact]
+ public void Advance_ToResponding_Emits_ToolResultAndTokenDelta()
+ {
+ var clock = new VirtualClock();
+ var engine = new SimulatedEngine(() => clock.Now);
+ engine.Submit("hello");
+ engine.DrainEvents();
+
+ clock.Advance(2500);
+ var events = engine.DrainEvents();
+ Assert.Contains(events, e => e is BridgeEvent.ToolResult);
+ Assert.Contains(events, e => e is BridgeEvent.TokenDelta { Length: 9 });
+ }
+
+ /// 虚拟时钟 5.5s:回合完成,产出 AssistantMessage + TurnCompleted,后续拍空。
+ [Fact]
+ public void Advance_ToCompletion_Emits_AssistantMessageAndTurnCompleted()
+ {
+ var clock = new VirtualClock();
+ var engine = new SimulatedEngine(() => clock.Now);
+ engine.Submit("hello");
+ engine.DrainEvents();
+
+ clock.Advance(5500);
+ var events = engine.DrainEvents();
+ Assert.Contains(events, e => e is BridgeEvent.AssistantMessage { Text.Length: > 0 });
+ Assert.Contains(events, e => e is BridgeEvent.TurnCompleted);
+
+ Assert.Empty(engine.DrainEvents());
+ }
+
+ /// 忙时 Interrupt:产出 TurnInterrupted,回合终止。
+ [Fact]
+ public void Interrupt_WhileBusy_Emits_TurnInterrupted()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.Submit("hello");
+ engine.DrainEvents();
+
+ engine.Interrupt();
+ Assert.Equal([new BridgeEvent.TurnInterrupted()], engine.DrainEvents());
+ Assert.Empty(engine.DrainEvents());
+ }
+
+ /// 忙时再次 Submit:拒绝并回 Error 诱导(对齐 EngineTurnClient 忙时语义)。
+ [Fact]
+ public void Submit_WhileBusy_RejectsWithError()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.Submit("first");
+ engine.DrainEvents();
+
+ engine.Submit("second");
+ Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.Error { Message.Length: > 0 });
+ }
+
+ /// 打断后再 Submit:可开始新回合(状态机复位)。
+ [Fact]
+ public void Submit_AfterInterrupt_StartsNewTurn()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.Submit("first");
+ engine.DrainEvents();
+ engine.Interrupt();
+ engine.DrainEvents();
+
+ engine.Submit("second");
+ Assert.Equal([new BridgeEvent.TurnStarted()], engine.DrainEvents());
+ }
+
+ /// SetPermissionMode:本地立即回执 PermissionModeChanged(统一事件语义)。
+ [Fact]
+ public void SetPermissionMode_Emits_PermissionModeChanged()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.SetPermissionMode("plan");
+ Assert.Equal([new BridgeEvent.PermissionModeChanged("plan")], engine.DrainEvents());
+ }
+
+ /// IsAlive 恒 true + DrainDiagnostics 默认空(接口默认实现)。
+ [Fact]
+ public void AliveForever_And_NoDiagnostics()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ Assert.True(engine.IsAlive);
+ Assert.Empty(((IReplEngine)engine).DrainDiagnostics());
+ }
+
+ /// 闲时 Interrupt:无回合可打断,no-op 不产事件。
+ [Fact]
+ public void Interrupt_WhileIdle_IsNoOp()
+ {
+ var engine = new SimulatedEngine(static () => 0L);
+ engine.Interrupt();
+ Assert.Empty(engine.DrainEvents());
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs
new file mode 100644
index 000000000..3eb9f8a90
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellTests.cs
@@ -0,0 +1,235 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// ReplShell 单元测试 — 共享 REPL 外壳(HostSession/DemoSession 提取,TASK055 M7 前置):
+/// 种子内容挂载顺序、引擎事件应用链(经 ReplBridgeDriver 唯一翻译层)、引擎死亡检测、
+/// Esc 三态分派(忙打断/闲清输入/闲退出)、提交路径(用户消息 + 引擎 Submit)、权限循环透传。
+///
+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)
+ {
+ var engine = new FakeEngine();
+ var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp");
+ var shell = new ReplShell(engine, banner, new StringWriter(), seedMessages: seed);
+ return (shell, engine);
+ }
+
+ /// 模拟键入(逐字符插入输入缓冲)。
+ private static void TypeText(ReplShell shell, string text)
+ {
+ foreach (var c in text)
+ {
+ shell.Input.Insert(c);
+ }
+ }
+
+ /// SeedContent:banner 首项、seed 消息居中、spinner 恒为滚动区末项,footer 四字段就位。
+ [Fact]
+ public void SeedContent_BannerFirst_SpinnerLast_SeedMessagesBetween()
+ {
+ var (shell, _) = CreateShell(seed: [new UserMessageItem("seed q"), new AssistantMessageItem("seed a")]);
+ shell.SeedContent();
+
+ var messages = shell.View.Messages.Items;
+ Assert.Equal(4, messages.Count);
+ Assert.IsType(messages[0]);
+ Assert.IsType(messages[1]);
+ Assert.IsType(messages[2]);
+ Assert.IsType(messages[^1]);
+
+ Assert.Equal(PermissionMode.Auto, shell.View.Footer.Mode);
+ Assert.Equal("未登录 · 运行 /login", shell.View.Footer.LoginError);
+ Assert.NotNull(shell.View.Footer.AgentsHint);
+ Assert.NotNull(shell.View.Footer.Notification);
+ }
+
+ /// DrainEngine:TurnStarted 事件驱动 busy(footer.Busy + 内部忙标记)。
+ [Fact]
+ public void DrainEngine_TurnStarted_SetsBusy()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ Assert.False(shell.IsBusy);
+
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+ Assert.True(shell.IsBusy);
+ Assert.True(shell.View.Footer.Busy);
+ }
+
+ /// DrainEngine:TurnCompleted 解除 busy。
+ [Fact]
+ public void DrainEngine_TurnCompleted_ClearsBusy()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+
+ engine.Events.Enqueue(new BridgeEvent.TurnCompleted());
+ shell.DrainEngine();
+ Assert.False(shell.IsBusy);
+ Assert.False(shell.View.Footer.Busy);
+ }
+
+ /// DrainEngine:权限回执事件更新 footer 模式(引擎回执为准)。
+ [Fact]
+ public void DrainEngine_PermissionModeChanged_UpdatesFooter()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ Assert.Equal(PermissionMode.Auto, shell.View.Footer.Mode);
+
+ engine.Events.Enqueue(new BridgeEvent.PermissionModeChanged("plan"));
+ shell.DrainEngine();
+ Assert.Equal(PermissionMode.Plan, shell.View.Footer.Mode);
+ }
+
+ /// DrainEngine:引擎死亡(非忙)— 追加错误消息并请求停止。
+ [Fact]
+ public void DrainEngine_EngineDead_AppendsErrorAndStops()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ engine.Alive = false;
+
+ shell.DrainEngine();
+ Assert.False(shell.Running);
+ Assert.Contains(shell.View.Messages.Items, m => m is ErrorMessageItem);
+ }
+
+ /// DrainEngine:忙时引擎死亡不立即退出(回合收尾事件优先)。
+ [Fact]
+ public void DrainEngine_EngineDeadWhileBusy_KeepsRunning()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+
+ engine.Alive = false;
+ shell.DrainEngine();
+ Assert.True(shell.Running);
+ }
+
+ /// Esc 忙时:请求引擎打断(Interrupt 透传)。
+ [Fact]
+ public void Escape_WhileBusy_InterruptsEngine()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+
+ shell.HandleEscape();
+ Assert.Equal(1, engine.Interrupts);
+ Assert.True(shell.Running);
+ }
+
+ /// Esc 闲时有输入:清空输入不退出。
+ [Fact]
+ public void Escape_IdleWithText_ClearsInput()
+ {
+ var (shell, _) = CreateShell();
+ shell.SeedContent();
+ TypeText(shell, "draft");
+
+ shell.HandleEscape();
+ Assert.True(shell.Running);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// Esc 闲时无输入:请求退出。
+ [Fact]
+ public void Escape_IdleEmpty_Stops()
+ {
+ var (shell, _) = CreateShell();
+ shell.SeedContent();
+
+ shell.HandleEscape();
+ Assert.False(shell.Running);
+ }
+
+ /// 提交:空文本忽略(不产用户消息、不透传引擎)。
+ [Fact]
+ public void Submit_EmptyText_Ignored()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+
+ shell.Submit();
+ Assert.Empty(engine.Submits);
+ Assert.DoesNotContain(shell.View.Messages.Items, m => m is UserMessageItem);
+ }
+
+ /// 提交:非空文本 — 用户消息插入 spinner 之前 + 透传引擎 + 输入清空。
+ [Fact]
+ public void Submit_Text_AddsUserMessageAndForwards()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+ TypeText(shell, "hello engine");
+
+ shell.Submit();
+ Assert.Equal(["hello engine"], engine.Submits);
+ Assert.Contains(shell.View.Messages.Items, m => m is UserMessageItem);
+ Assert.Equal(string.Empty, shell.Input.Text);
+
+ var spinnerIndex = shell.View.Messages.Count - 1;
+ var userIndex = shell.View.Messages.Items.ToList().FindIndex(m => m is UserMessageItem);
+ Assert.True(userIndex < spinnerIndex);
+ }
+
+ /// Shift+Tab 权限循环:透传引擎请求,footer 不本地直改(回执事件为准)。
+ [Fact]
+ public void CyclePermissionMode_ForwardsToEngine()
+ {
+ var (shell, engine) = CreateShell();
+ shell.SeedContent();
+
+ shell.CyclePermissionMode();
+ Assert.Single(engine.PermissionModes);
+ Assert.Equal(PermissionMode.Auto, shell.View.Footer.Mode);
+ }
+}
From 7537c36a13f9e989afe3e4210157ef139eed4f6b Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 20:29:17 +0800
Subject: [PATCH 02/16] =?UTF-8?q?docs:=20ADR=200143/TASK055=20ReplShell=20?=
=?UTF-8?q?=E6=8F=90=E5=8F=96=E9=81=97=E7=95=99=E6=9D=A1=E7=9B=AE=E5=85=B3?=
=?UTF-8?q?=E9=97=AD=EF=BC=88commit=20a92bc1a16=20=E5=AE=8C=E6=88=90?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../0143-engine-library-multi-host-architecture.md | 9 +++++++--
...240\347\272\247\345\257\271\351\275\220ClaudeCode.md" | 8 ++++++--
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/docs/implemented/adr/architecture/0143-engine-library-multi-host-architecture.md b/docs/implemented/adr/architecture/0143-engine-library-multi-host-architecture.md
index 9d9ad091d..957cac37e 100644
--- a/docs/implemented/adr/architecture/0143-engine-library-multi-host-architecture.md
+++ b/docs/implemented/adr/architecture/0143-engine-library-multi-host-architecture.md
@@ -197,5 +197,10 @@ CliSession → EngineSession,实施时定名);`CreateMainAgent` 的 intern
- M7 特性改道:session_info 已进程内直连;agent_progress / 权限对话框待 M7 走进程内
事件(不再扩展 ADR 0141 事件表);权限挂起首版按打断处理
(PermissionPendingConfirmationException 翻译 OCE + 诱导文案)
-- ReplShell 提取:HostSession 与 DemoSession 键处理/渲染循环结构重复(约 270 行),
- M7 权限对话框前置任务(TASK055)
+- ~~ReplShell 提取~~ 已完成(commit a92bc1a16):IReplEngine 接口
+ (Submit/Interrupt/SetPermissionMode/DrainEvents/DrainDiagnostics/IsAlive)统一引擎
+ 交互面 — HostSession/DemoSession 删除归档 `.xxx/`,Host 直连 EngineTurnClient、
+ Demo 走 SimulatedEngine(时钟注入+忙时拒绝 Error 诱导)与 BridgeEngine(子进程适配),
+ 模拟回合改事件驱动(spinner Message 直改弃用 — 真实路径本就空消息+随机动词)、
+ 权限本地回执统一走事件;Core 新增 22 单元测试(480/480),净删约 920 行重复代码。
+ M7 权限对话框可直接在 ReplShell 单点扩展
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 99080af73..8c13df3fd 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"
@@ -570,8 +570,12 @@ native_tui/ # 顶层独立工程,零 modules/libs 依
-
-
+
+
+
+
+
+
### ADR 0143 阶段二手动验收(ADR 0080,待用户执行)
From 6bc242610520159edc0bfbf8fb6e2d4c1df0f22a Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:06:29 +0800
Subject: [PATCH 03/16] =?UTF-8?q?feat:=20M7-1=20=E6=88=AA=E5=9B=BE?=
=?UTF-8?q?=E6=A0=A1=E5=87=86=E5=B7=A5=E5=85=B7=E9=93=BE=E8=90=BD=E5=9C=B0?=
=?UTF-8?q?=20=E2=80=94=20=E5=A4=8D=E5=88=B6=E9=9D=92=E8=9B=99TUI=20?=
=?UTF-8?q?=E5=9B=9B=E4=BB=B6=E5=A5=97+=E8=87=AA=E7=A0=94=E9=87=87?=
=?UTF-8?q?=E9=9B=86=E7=AB=AF=20|=20=E5=86=B3=E7=AD=96:=20TS=20=E5=8F=82?=
=?UTF-8?q?=E8=80=83=E5=B8=A7(esbuild=20=E7=BC=96=E8=AF=91=E5=9F=BA?=
=?UTF-8?q?=E5=87=86=20Clawd/PromptInput)=E4=B8=8E=20C#=20=E9=87=87?=
=?UTF-8?q?=E9=9B=86=E5=B8=A7(ReferenceScene=2080=C3=9724=20fixture)?=
=?UTF-8?q?=E5=90=8C=E5=AD=97=E4=BD=93=E6=A0=85=E6=A0=BC=E5=8C=96=E5=90=8E?=
=?UTF-8?q?=E5=83=8F=E7=B4=A0=20diff,=20clawd=209=C3=973=20=E4=B8=8E=20pro?=
=?UTF-8?q?mpt=20=E8=BE=B9=E6=A1=86=2080=C3=973=20=E4=B8=A4=20region=20?=
=?UTF-8?q?=E9=AA=8C=E6=94=B6=200=20=E5=AD=97=E7=AC=A6=200=20=E5=83=8F?=
=?UTF-8?q?=E7=B4=A0=E5=B7=AE=E5=BC=82;=20py=20cells()=20=E8=A7=A3?=
=?UTF-8?q?=E6=9E=90=E5=99=A8=20inverse=20=E7=9B=B2=E5=8C=BA=E4=B8=BA?=
=?UTF-8?q?=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6;=20Markdown=20=E6=B8=B2?=
=?UTF-8?q?=E6=9F=93=E5=B7=B2=E5=86=B3=E7=AD=96=E5=BC=95=E5=85=A5=20Markdi?=
=?UTF-8?q?g?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...\247\345\257\271\351\275\220ClaudeCode.md" | 37 +
native_tui/.gitignore | 1 +
native_tui/apps/NativeTui.Demo/Program.cs | 12 +
.../src/NativeTui.Core/repl/ReferenceScene.cs | 47 +
.../tests/NativeTui.Tests/GlobalUsings.cs | 1 +
.../snapshot/ReferenceSceneTests.cs | 45 +
.../snapshot/SnapshotVerifier.cs | 36 +
.../snapshot/SnapshotVerifierTests.cs | 69 ++
.../snapshot/reference_scene.approved.txt | 24 +
native_tui/tool/screenshot/compare/README.md | 29 +
.../screenshot/compare/compare_reference.py | 113 ++
.../tool/screenshot/reference/.gitignore | 3 +
.../tool/screenshot/reference/README.md | 23 +
.../tool/screenshot/reference/bridge.tsx | 5 +
.../tool/screenshot/reference/build.mjs | 13 +
native_tui/tool/screenshot/reference/env.ts | 1 +
.../screenshot/reference/package-lock.json | 1023 +++++++++++++++++
.../tool/screenshot/reference/package.json | 1 +
.../tool/screenshot/reference/reference.tsx | 20 +
19 files changed, 1503 insertions(+)
create mode 100644 native_tui/.gitignore
create mode 100644 native_tui/src/NativeTui.Core/repl/ReferenceScene.cs
create mode 100644 native_tui/tests/NativeTui.Tests/snapshot/ReferenceSceneTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs
create mode 100644 native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/snapshot/reference_scene.approved.txt
create mode 100644 native_tui/tool/screenshot/compare/README.md
create mode 100644 native_tui/tool/screenshot/compare/compare_reference.py
create mode 100644 native_tui/tool/screenshot/reference/.gitignore
create mode 100644 native_tui/tool/screenshot/reference/README.md
create mode 100644 native_tui/tool/screenshot/reference/bridge.tsx
create mode 100644 native_tui/tool/screenshot/reference/build.mjs
create mode 100644 native_tui/tool/screenshot/reference/env.ts
create mode 100644 native_tui/tool/screenshot/reference/package-lock.json
create mode 100644 native_tui/tool/screenshot/reference/package.json
create mode 100644 native_tui/tool/screenshot/reference/reference.tsx
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 8c13df3fd..d2e27e5e6 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"
@@ -588,3 +588,40 @@ native_tui/ # 顶层独立工程,零 modules/libs 依
| 5 权限 | Shift+Tab 循环 | footer 模式 plan→ask→bypass→unattended→auto(引擎回执为准) |
| 6 退出 | 空闲时 Esc 或 Ctrl+C | 清尾部动态区,历史留 scrollback,`NativeTui.Host 已退出。` |
+
+## M7-1 截图校准工具链落地(2026-10-20,L2 像素级验收基建)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### M7 前置调查结论(青蛙TUI 资产盘点,2026-10-20)
+
+| M7 项 | 青蛙TUI 状态 | 复用结论 |
+|---|---|---|
+| 截图校准 | ✅ 完整工具链独有 | ✅ 已落地(本决策块) |
+| Markdown 渲染 | ✅ TranscriptWriter(Markdig AST→终端投影) | 可抄投影算法;**用户已决策引入 Markdig NuGet**(纯 AST 解析库 AOT 兼容,不违反 ADR 0140 精神) |
+| 代码高亮 | ⚠️ 绑 Terminal.Gui.Editor xshd | 不可搬,维持先调研结论 |
+| StructuredDiff | ✅ 完整 | **无需复制** — w1 引擎侧已全通(IEventConsumer.OnToolEnd 带 StructuredPatchHunk[],InProcessEventConsumer 已收参当前丢弃),M7 只差透传+渲染(⎿ 折叠思路抄青蛙TUI TranscriptDocument) |
+| 权限对话框 | ✅ 三档+DialogRequest TCS 桥+Rewind+重试预算 | 高价值复用(桥模式+编排),接 ReplShell 扩展点 |
+| 模型列表 UI | ✅ 本地配置拉取 | ⚠️ 与既定要求(后端获取)不符,只抄 Picker 交互 |
+| 多子代理分组 | ⚠️ 半成品(基建未接线) | 按已固化基准调查自建;SubAgentCardManager LRU 可参考 |
+| resize 事件化 | ✅ TerminalResizeMonitor | 小件可搬,低优先 |
+
+
+
diff --git a/native_tui/.gitignore b/native_tui/.gitignore
new file mode 100644
index 000000000..fcfd5c914
--- /dev/null
+++ b/native_tui/.gitignore
@@ -0,0 +1 @@
+*.received.txt
diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs
index 4f7b47462..b1ee3028c 100644
--- a/native_tui/apps/NativeTui.Demo/Program.cs
+++ b/native_tui/apps/NativeTui.Demo/Program.cs
@@ -37,10 +37,22 @@ public static int Main(string[] args)
return rest.ToArray() is ["--selftest"] ? RunSelftest()
: rest is ["--bridge-selftest"] ? RunBridgeSelftest()
: rest is ["--bridge-echo-child"] ? RunBridgeEchoChild()
+ : rest is ["--capture", string capturePath] ? RunCapture(capturePath)
: rest is ["--bridge", string exePath] ? RunBridgeInteractive(exePath, model, viewport)
: RunInteractive(model, viewport);
}
+ ///
+ /// 采集模式(L2 截图校准的 C# 侧采集端)— 渲染 80×24 固定基准场景(ReferenceScene)
+ /// 并把整帧 ANSI 写入指定文件,供 tool/screenshot/compare/compare_reference.py 与 TS 参考帧做像素比对。
+ ///
+ private static int RunCapture(string capturePath)
+ {
+ File.WriteAllText(capturePath, ReferenceScene.ComposeAnsi());
+ Console.WriteLine($"CAPTURE OK — 80×24 基准场景已写入 {capturePath}(clawd 9×3 + prompt 边框 region 可比对)");
+ return 0;
+ }
+
/// 交互模式:进 raw mode 跑共享 REPL 外壳(ReplShell + SimulatedEngine 事件驱动模拟回合;inline 模式退出只清尾部动态区,历史留终端 scrollback)。
private static int RunInteractive(string? model = null, bool viewport = false)
{
diff --git a/native_tui/src/NativeTui.Core/repl/ReferenceScene.cs b/native_tui/src/NativeTui.Core/repl/ReferenceScene.cs
new file mode 100644
index 000000000..22696aff4
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/ReferenceScene.cs
@@ -0,0 +1,47 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 固定基准采集场景 — L2 截图校准(tool/screenshot)的 C# 侧确定性数据源:
+/// 80×24 屏 + banner(Clawd + fixture 信息列)+ 一条静态助手消息 + 默认 footer/输入框。
+/// fixture 值硬编码(非进程快照)保证整帧字节稳定 — 可作 approved 快照与像素比对基线。
+/// region 约定(与 compare_reference.py 硬编码对齐):前三行×前 9 列 = clawd;
+/// 以全宽 ─ 线开头的行 = prompt 输入框边框。
+///
+public static class ReferenceScene
+{
+ /// 采集屏宽(与 py region 提取的 80 列约定一致)。
+ public const int Width = 80;
+
+ /// 采集屏高。
+ public const int Height = 24;
+
+ /// fixture 版本号(确定性)。
+ private const string FixtureVersion = "1.0.0";
+
+ /// fixture 模型名(确定性)。
+ private const string FixtureModel = "fixture-model";
+
+ /// fixture 计费方式(确定性)。
+ private const string FixtureBilling = "Pro";
+
+ /// fixture 工作目录(确定性)。
+ private const string FixtureCwd = @"D:\fixture";
+
+ /// fixture 输入文本(与 TS 参考端 reference.tsx 的输入框 fixture 对齐 — 像素比对两侧同文本)。
+ private const string FixtureInput = "fixture input";
+
+ ///
+ /// 渲染整帧 ANSI(Screen.ToAnsiText — L1 golden 形态:SGR transition + 行尾裁剪/重置)。
+ ///
+ public static string ComposeAnsi()
+ {
+ using var screen = new Screen(Width, Height);
+ var view = new ReplView();
+ view.Messages.Add(new BannerItem(FixtureVersion, FixtureModel, FixtureBilling, FixtureCwd));
+ view.Messages.Add(new AssistantMessageItem("reference scene fixture"));
+ view.Input = FixtureInput;
+ view.CursorCol = FixtureInput.Length;
+ view.Render(screen, Theme.Dark);
+ return screen.ToAnsiText();
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
index b48cea977..f44eeb8b8 100644
--- a/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
@@ -1,3 +1,4 @@
+global using System.IO;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Text;
diff --git a/native_tui/tests/NativeTui.Tests/snapshot/ReferenceSceneTests.cs b/native_tui/tests/NativeTui.Tests/snapshot/ReferenceSceneTests.cs
new file mode 100644
index 000000000..17f26f12c
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/snapshot/ReferenceSceneTests.cs
@@ -0,0 +1,45 @@
+namespace NativeTui.Tests.Snapshot;
+
+///
+/// ReferenceScene 采集场景测试 — L2 截图校准的 C# 侧确定性数据源(80×24 固定场景):
+/// ① 前三行为 Clawd 像素画(与 compare_reference.py 的 clawd region 提取对齐)
+/// ② 存在以全宽 ─ 线开头的输入框边框行(与 py 的 prompt region 提取对齐)
+/// ③ 整帧字节确定(同一进程内两次调用 Ordinal 相等 — approved 快照的前提)
+/// ④ 整帧 ANSI 落 approved 快照(Verify 模式:首跑生成 received 供审核)。
+///
+public sealed class ReferenceSceneTests
+{
+ [Fact]
+ public void ComposeAnsi_FirstThreeRowsAreClawd()
+ {
+ var rows = ReferenceScene.ComposeAnsi().Split('\n');
+ Assert.True(rows.Length >= ReferenceScene.Height, $"总行数应 >= {ReferenceScene.Height}");
+ Assert.Contains(" ▐▛███▜▌", StripAnsi(rows[0]));
+ Assert.Contains("▝▜█████▛▘", StripAnsi(rows[1]));
+ Assert.Contains(" ▘▘ ▝▝ ", StripAnsi(rows[2]));
+ }
+
+ [Fact]
+ public void ComposeAnsi_ContainsFullWidthPromptRuleRow()
+ {
+ var rows = ReferenceScene.ComposeAnsi().Split('\n');
+ var promptY = Array.FindIndex(rows, row => StripAnsi(row).StartsWith(new string('─', ReferenceScene.Width)));
+ Assert.True(promptY >= 0, "应存在以 80 个 ─ 开头的输入框边框行(py region 提取依赖此特征)");
+ }
+
+ /// 剥掉 SGR/CSI 序列只留可见字符(与 py cells() 的 TOKEN 剔除语义对齐)。
+ private static string StripAnsi(string row) =>
+ System.Text.RegularExpressions.Regex.Replace(row, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+
+ [Fact]
+ public void ComposeAnsi_IsDeterministic()
+ {
+ Assert.Equal(ReferenceScene.ComposeAnsi(), ReferenceScene.ComposeAnsi());
+ }
+
+ [Fact]
+ public void ComposeAnsi_Snapshot()
+ {
+ SnapshotVerifier.Verify(ReferenceScene.ComposeAnsi(), "reference_scene");
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs
new file mode 100644
index 000000000..3773f4baa
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifier.cs
@@ -0,0 +1,36 @@
+namespace NativeTui.Tests.Snapshot;
+
+///
+/// 轻量快照比对器 — 与 .approved.txt 比对,首次生成 .received.txt 供人工审核。
+/// 零外部依赖,AOT 友好。对齐 ApprovalTests/Verify 的 received/approved 模式。
+///
+public static class SnapshotVerifier
+{
+ ///
+ /// 验证实际输出与批准的快照一致。
+ /// 首次运行(无 .approved.txt)时生成 .received.txt 并断言失败,提示人工审核。
+ /// 后续运行比对内容,不匹配时写 .received.txt 供 diff 调试。
+ ///
+ /// 实际输出文本。
+ /// 快照名(不含扩展名,自动追加 .approved.txt/.received.txt)。
+ /// 调用方源文件路径(由 CallerFilePath 自动注入)。
+ public static void Verify(string actual, string snapshotName, [System.Runtime.CompilerServices.CallerFilePath] string sourceFile = "")
+ {
+ var dir = Path.GetDirectoryName(sourceFile) ?? AppContext.BaseDirectory;
+ var approvedPath = Path.Combine(dir, $"{snapshotName}.approved.txt");
+ var receivedPath = Path.Combine(dir, $"{snapshotName}.received.txt");
+
+ if (!File.Exists(approvedPath))
+ {
+ File.WriteAllText(receivedPath, actual);
+ Assert.Fail($"快照未批准: 已生成 {receivedPath}。审核内容后将其重命名为 {snapshotName}.approved.txt。");
+ }
+
+ var approved = File.ReadAllText(approvedPath);
+ if (!string.Equals(approved, actual, StringComparison.Ordinal))
+ {
+ File.WriteAllText(receivedPath, actual);
+ Assert.Fail($"快照不匹配: 实际输出已写入 {receivedPath},请与 {approvedPath} 对比。");
+ }
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs
new file mode 100644
index 000000000..72a53fab9
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/snapshot/SnapshotVerifierTests.cs
@@ -0,0 +1,69 @@
+namespace NativeTui.Tests.Snapshot;
+
+///
+/// SnapshotVerifier 基建行为测试 — approved/received 快照模式:
+/// ① 无 approved 时生成 .received.txt 并失败(提示人工审核)
+/// ② approved 与实际一致时通过
+/// ③ 不一致时写 .received.txt 供 diff 调试并失败。
+///
+public sealed class SnapshotVerifierTests
+{
+ /// 构造临时目录(测试自建自清理,非仓库文件)。
+ private static string TempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "nativetui-snaptest-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ [Fact]
+ public void Verify_WithoutApproved_WritesReceivedAndFails()
+ {
+ var dir = TempDir();
+ try
+ {
+ var sourceFile = Path.Combine(dir, "caller.cs");
+ Assert.ThrowsAny(() => SnapshotVerifier.Verify("actual text", "scene", sourceFile));
+ Assert.True(File.Exists(Path.Combine(dir, "scene.received.txt")), "首次运行应生成 .received.txt");
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Verify_MatchingApproved_Passes()
+ {
+ var dir = TempDir();
+ try
+ {
+ var sourceFile = Path.Combine(dir, "caller.cs");
+ File.WriteAllText(Path.Combine(dir, "scene.approved.txt"), "actual text");
+ SnapshotVerifier.Verify("actual text", "scene", sourceFile);
+ Assert.False(File.Exists(Path.Combine(dir, "scene.received.txt")), "一致时不应写 .received.txt");
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Verify_MismatchedApproved_WritesReceivedAndFails()
+ {
+ var dir = TempDir();
+ try
+ {
+ var sourceFile = Path.Combine(dir, "caller.cs");
+ File.WriteAllText(Path.Combine(dir, "scene.approved.txt"), "approved text");
+ Assert.ThrowsAny(() => SnapshotVerifier.Verify("actual text", "scene", sourceFile));
+ Assert.True(File.Exists(Path.Combine(dir, "scene.received.txt")), "不一致时应写 .received.txt");
+ Assert.Equal("actual text", File.ReadAllText(Path.Combine(dir, "scene.received.txt")));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/snapshot/reference_scene.approved.txt b/native_tui/tests/NativeTui.Tests/snapshot/reference_scene.approved.txt
new file mode 100644
index 000000000..c225f8d1f
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/snapshot/reference_scene.approved.txt
@@ -0,0 +1,24 @@
+[38;2;215;119;87m ▐[48;2;0;0;0m▛███▜[49m▌[39m [1mjcc[22m [2mv1.0.0[22m
+[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m▛▘[39m [2mfixture-model · Pro[22m
+[38;2;215;119;87m ▘▘ ▝▝ [39m [2mD:\fixture[22m
+
+[38;2;255;255;255m● reference scene fixture[39m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────[39m
+❯ fixture input[7m [27m
+[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────[39m
+ [2m? 查看快捷键[22m
\ No newline at end of file
diff --git a/native_tui/tool/screenshot/compare/README.md b/native_tui/tool/screenshot/compare/README.md
new file mode 100644
index 000000000..e02160a5d
--- /dev/null
+++ b/native_tui/tool/screenshot/compare/README.md
@@ -0,0 +1,29 @@
+# L2 截图校准 — 像素级比对
+
+ANSI 采集帧 → cell 网格 → 同字体栅格化 → 逐像素 diff。
+
+## 流程(三步)
+
+```powershell
+# 1. TS 参考帧采集(首次或基准变更后;需 Node 24+)
+cd native_tui/tool/screenshot/reference
+npm ci
+node build.mjs D:/project/claude-code-rust/claude-code-rev-main/src
+$env:FORCE_COLOR='3'
+node reference.mjs
+
+# 2. C# 采集帧(native_tui 自研渲染管线,80×24 固定场景)
+dotnet run --project native_tui/apps/NativeTui.Demo -- --capture .xxx/screen.ansi
+
+# 3. 像素比对(需 Python 3 + Pillow)
+python native_tui/tool/screenshot/compare/compare_reference.py `
+ --reference native_tui/tool/screenshot/reference `
+ --capture .xxx/screen.ansi `
+ --output .xxx/visual
+```
+
+退出码 = 是否存在像素差异(0 = 全部一致)。输出 `{name}_reference.png` / `{name}_csharp.png` / `{name}_diff.png` + `report.json`(differentCharacters / differentPixels)。
+
+## region 声明
+
+验证 Clawd 9×3 与输入框边框 80×3 两个区域(region 硬编码于脚本),不代表真实终端截图或整屏像素一致。输入状态、模态、工具、其他宽度须另验。默认字体 Cascadia Mono 20,同一字体将双方 ANSI 单元栅格化,消除字体差异后的残差即渲染差异。
diff --git a/native_tui/tool/screenshot/compare/compare_reference.py b/native_tui/tool/screenshot/compare/compare_reference.py
new file mode 100644
index 000000000..8d15ac323
--- /dev/null
+++ b/native_tui/tool/screenshot/compare/compare_reference.py
@@ -0,0 +1,113 @@
+"""Compare captured terminal cells and rasterize both with the same font.
+
+This is a local reference-region comparison, not a full application screenshot claim.
+"""
+import argparse
+import json
+import re
+import unicodedata
+from pathlib import Path
+from PIL import Image, ImageChops, ImageDraw, ImageFont
+
+WHITE, BLACK = (255, 255, 255), (0, 0, 0)
+PALETTE = [(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
+ (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)]
+TOKEN = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|[^\x1b]', re.S)
+
+
+def cells(text, width=80):
+ rows, x, y = [], 0, 0
+ fg, bg = WHITE, BLACK
+ for token in TOKEN.findall(text):
+ while len(rows) <= y:
+ rows.append([(' ', WHITE, BLACK) for _ in range(width)])
+ if token.startswith('\x1b['):
+ if token.endswith('m'):
+ codes = [int(code or 0) for code in token[2:-1].split(';')]
+ i = 0
+ while i < len(codes):
+ code = codes[i]
+ if code == 0:
+ fg, bg = WHITE, BLACK
+ elif code == 39:
+ fg = WHITE
+ elif code == 49:
+ bg = BLACK
+ elif code in (38, 48) and i + 4 < len(codes) and codes[i + 1] == 2:
+ color = tuple(codes[i + 2:i + 5])
+ if code == 38:
+ fg = color
+ else:
+ bg = color
+ i += 4
+ elif 30 <= code <= 37:
+ fg = PALETTE[code - 30]
+ elif 90 <= code <= 97:
+ fg = PALETTE[code - 90 + 8]
+ elif 40 <= code <= 47:
+ bg = PALETTE[code - 40]
+ elif 100 <= code <= 107:
+ bg = PALETTE[code - 100 + 8]
+ i += 1
+ elif token.endswith(('H', 'f')):
+ coordinates = [int(code or 1) for code in token[2:-1].split(';')]
+ y, x = coordinates[0] - 1, (coordinates[1] if len(coordinates) > 1 else 1) - 1
+ continue
+ if token.startswith('\x1b'):
+ continue
+ if token == '\n':
+ y, x = y + 1, 0
+ continue
+ if token == '\r':
+ x = 0
+ continue
+ if 0 <= x < width:
+ rows[y][x] = (token, fg, bg)
+ x += 2 if unicodedata.east_asian_width(token) in 'WF' else 1
+ return rows
+
+
+def raster(rows, font):
+ cell_width, cell_height = round(font.getlength('M')), 28
+ image = Image.new('RGB', (len(rows[0]) * cell_width, len(rows) * cell_height), BLACK)
+ draw = ImageDraw.Draw(image)
+ for y, row in enumerate(rows):
+ for x, (character, fg, bg) in enumerate(row):
+ left, top = x * cell_width, y * cell_height
+ draw.rectangle((left, top, left + cell_width - 1, top + cell_height - 1), fill=bg)
+ draw.text((left, top), character, font=font, fill=fg)
+ return image
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--reference', type=Path, required=True, help='directory with reference_clawd.ansi and reference_prompt.ansi')
+ parser.add_argument('--capture', type=Path, required=True, help='C# driver ANSI capture')
+ parser.add_argument('--output', type=Path, required=True)
+ parser.add_argument('--font', type=Path, default=Path('C:/Windows/Fonts/CascadiaMono.ttf'))
+ args = parser.parse_args()
+ args.output.mkdir(parents=True, exist_ok=True)
+ actual = cells(args.capture.read_text(encoding='utf-8-sig'))
+ reference_logo = cells((args.reference / 'reference_clawd.ansi').read_text(encoding='utf-8-sig'))
+ reference_prompt = cells((args.reference / 'reference_prompt.ansi').read_text(encoding='utf-8-sig'))
+ prompt_y = next(index for index, row in enumerate(actual) if ''.join(cell[0] for cell in row).startswith('─' * 80))
+ regions = {'clawd': ([row[:9] for row in reference_logo[:3]], [row[:9] for row in actual[:3]]),
+ 'prompt': (reference_prompt[:3], actual[prompt_y:prompt_y + 3])}
+ font = ImageFont.truetype(str(args.font), 20)
+ report = {'scope': 'Clawd source + PromptInput border properties; same-font raster of captured cells', 'font': str(args.font), 'regions': {}}
+ for name, (expected, observed) in regions.items():
+ reference_image, actual_image = raster(expected, font), raster(observed, font)
+ difference = ImageChops.difference(reference_image, actual_image)
+ pixels = sum(pixel != (0, 0, 0) for pixel in difference.getdata())
+ characters = sum(a[0] != b[0] for row_a, row_b in zip(expected, observed) for a, b in zip(row_a, row_b))
+ reference_image.save(args.output / f'{name}_reference.png')
+ actual_image.save(args.output / f'{name}_csharp.png')
+ difference.save(args.output / f'{name}_diff.png')
+ report['regions'][name] = {'differentCharacters': characters, 'differentPixels': pixels}
+ (args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
+ print(json.dumps(report, indent=2))
+ return int(any(value['differentPixels'] for value in report['regions'].values()))
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/native_tui/tool/screenshot/reference/.gitignore b/native_tui/tool/screenshot/reference/.gitignore
new file mode 100644
index 000000000..4e0ce40e4
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+reference.mjs
+*.ansi
diff --git a/native_tui/tool/screenshot/reference/README.md b/native_tui/tool/screenshot/reference/README.md
new file mode 100644
index 000000000..3f67c56c5
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/README.md
@@ -0,0 +1,23 @@
+# TS 参考区域渲染
+
+固定基准:claude-code-rust 的 working 分支提交 4b87a363fd206c1154969e3914dcf1c1ca272f2e。
+
+在本目录运行(Node.js 24+):
+
+```powershell
+npm ci
+node build.mjs C:/path/to/claude-code-rev-main/src
+$env:FORCE_COLOR='3'
+node reference.mjs
+```
+
+Clawd 直接编译原组件;桥接文件只替换 Ink 导出、终端环境和主题色。Prompt 使用原 PromptInput 的边框属性建立隔离边界样本(round、left=false、right=false、bottom=true、width=100%),未启动原应用的完整输入状态机。
+
+在本工程根目录采集 C# 同一场景(native_tui 自研渲染管线):
+
+```powershell
+dotnet run --project apps/NativeTui.Demo -- --capture .xxx/screen.ansi
+python tool/screenshot/compare/compare_reference.py --reference tool/screenshot/reference --capture .xxx/screen.ansi --output .xxx/visual
+```
+
+Python 依赖 Pillow。比对细节见 `../compare/README.md`。
diff --git a/native_tui/tool/screenshot/reference/bridge.tsx b/native_tui/tool/screenshot/reference/bridge.tsx
new file mode 100644
index 000000000..729456198
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/bridge.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+import {Box, Text as InkText} from 'ink';
+export {Box};
+const colors: Record = {clawd_body:'#d77757', clawd_background:'#000000', inverseText:'#000000'};
+export const Text = ({color, backgroundColor, ...props}: any) => ;
diff --git a/native_tui/tool/screenshot/reference/build.mjs b/native_tui/tool/screenshot/reference/build.mjs
new file mode 100644
index 000000000..df161a0c2
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/build.mjs
@@ -0,0 +1,13 @@
+import {build} from 'esbuild';
+import {createRequire} from 'node:module';
+import {resolve} from 'node:path';
+const require = createRequire(import.meta.url);
+const sourceRoot = process.argv[2];
+if (!sourceRoot) throw new Error('Usage: node build.mjs ');
+const source = resolve(sourceRoot, 'components/LogoV2/Clawd.tsx');
+await build({entryPoints:['reference.tsx'], outfile:'reference.mjs', platform:'node', format:'esm', bundle:true, packages:'external', plugins:[{name:'reference-only',setup(build){
+build.onResolve({filter:/^reference-clawd$/},()=>({path:source}));
+build.onResolve({filter:/\/ink\.js$/},()=>({path:resolve('bridge.tsx')}));
+build.onResolve({filter:/\/utils\/env\.js$/},()=>({path:resolve('env.ts')}));
+build.onResolve({filter:/^react(?:\/compiler-runtime)?$/},args=>({path:args.path, external:true}));
+}}]});
diff --git a/native_tui/tool/screenshot/reference/env.ts b/native_tui/tool/screenshot/reference/env.ts
new file mode 100644
index 000000000..c7e206594
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/env.ts
@@ -0,0 +1 @@
+export const env = {terminal: 'Windows_Terminal'};
diff --git a/native_tui/tool/screenshot/reference/package-lock.json b/native_tui/tool/screenshot/reference/package-lock.json
new file mode 100644
index 000000000..e226b60a7
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/package-lock.json
@@ -0,0 +1,1023 @@
+{
+ "name": "tui_reference",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "esbuild": "0.25.9",
+ "ink": "6.0.1",
+ "react": "19.1.1"
+ }
+ },
+ "node_modules/@alcalzone/ansi-tokenize": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
+ "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14.13.1"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
+ "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
+ "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
+ "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
+ "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
+ "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
+ "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
+ "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
+ "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
+ "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
+ "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
+ "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
+ "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
+ "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
+ "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
+ "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
+ "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
+ "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
+ "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
+ "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
+ "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
+ "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
+ "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
+ "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
+ "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
+ "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/auto-bind": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+ "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+ "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+ "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
+ "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^5.0.0",
+ "string-width": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate/node_modules/slice-ansi": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
+ "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.0.0",
+ "is-fullwidth-code-point": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/code-excerpt": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+ "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+ "license": "MIT",
+ "dependencies": {
+ "convert-to-spaces": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/convert-to-spaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+ "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
+ "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.9",
+ "@esbuild/android-arm": "0.25.9",
+ "@esbuild/android-arm64": "0.25.9",
+ "@esbuild/android-x64": "0.25.9",
+ "@esbuild/darwin-arm64": "0.25.9",
+ "@esbuild/darwin-x64": "0.25.9",
+ "@esbuild/freebsd-arm64": "0.25.9",
+ "@esbuild/freebsd-x64": "0.25.9",
+ "@esbuild/linux-arm": "0.25.9",
+ "@esbuild/linux-arm64": "0.25.9",
+ "@esbuild/linux-ia32": "0.25.9",
+ "@esbuild/linux-loong64": "0.25.9",
+ "@esbuild/linux-mips64el": "0.25.9",
+ "@esbuild/linux-ppc64": "0.25.9",
+ "@esbuild/linux-riscv64": "0.25.9",
+ "@esbuild/linux-s390x": "0.25.9",
+ "@esbuild/linux-x64": "0.25.9",
+ "@esbuild/netbsd-arm64": "0.25.9",
+ "@esbuild/netbsd-x64": "0.25.9",
+ "@esbuild/openbsd-arm64": "0.25.9",
+ "@esbuild/openbsd-x64": "0.25.9",
+ "@esbuild/openharmony-arm64": "0.25.9",
+ "@esbuild/sunos-x64": "0.25.9",
+ "@esbuild/win32-arm64": "0.25.9",
+ "@esbuild/win32-ia32": "0.25.9",
+ "@esbuild/win32-x64": "0.25.9"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+ "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ink": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ink/-/ink-6.0.1.tgz",
+ "integrity": "sha512-vhhFrCodTHZAPPSdMYzLEbeI0Ug37R9j6yA0kLKok9kSK53lQtj/RJhEQJUjq6OwT4N33nxqSRd/7yXhEhVPIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@alcalzone/ansi-tokenize": "^0.1.3",
+ "ansi-escapes": "^7.0.0",
+ "ansi-styles": "^6.2.1",
+ "auto-bind": "^5.0.1",
+ "chalk": "^5.3.0",
+ "cli-boxes": "^3.0.0",
+ "cli-cursor": "^4.0.0",
+ "cli-truncate": "^4.0.0",
+ "code-excerpt": "^4.0.0",
+ "es-toolkit": "^1.22.0",
+ "indent-string": "^5.0.0",
+ "is-in-ci": "^1.0.0",
+ "patch-console": "^2.0.0",
+ "react-reconciler": "^0.32.0",
+ "scheduler": "^0.23.0",
+ "signal-exit": "^3.0.7",
+ "slice-ansi": "^7.1.0",
+ "stack-utils": "^2.0.6",
+ "string-width": "^7.2.0",
+ "type-fest": "^4.27.0",
+ "widest-line": "^5.0.0",
+ "wrap-ansi": "^9.0.0",
+ "ws": "^8.18.0",
+ "yoga-layout": "~3.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@types/react": ">=19.0.0",
+ "react": ">=19.0.0",
+ "react-devtools-core": "^4.19.1"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-devtools-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
+ "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ci": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
+ "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
+ "license": "MIT",
+ "bin": {
+ "is-in-ci": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-console": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
+ "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.32.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
+ "integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.26.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^19.1.0"
+ }
+ },
+ "node_modules/react-reconciler/node_modules/scheduler": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+ "license": "MIT"
+ },
+ "node_modules/restore-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+ "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/slice-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
+ "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yoga-layout": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/native_tui/tool/screenshot/reference/package.json b/native_tui/tool/screenshot/reference/package.json
new file mode 100644
index 000000000..7ca76f545
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/package.json
@@ -0,0 +1 @@
+{"private":true,"type":"module","dependencies":{"react":"19.1.1","ink":"6.0.1","esbuild":"0.25.9"}}
diff --git a/native_tui/tool/screenshot/reference/reference.tsx b/native_tui/tool/screenshot/reference/reference.tsx
new file mode 100644
index 000000000..cc2438da7
--- /dev/null
+++ b/native_tui/tool/screenshot/reference/reference.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import {render, Box, Text} from 'ink';
+import {Writable, PassThrough} from 'node:stream';
+import {writeFileSync} from 'node:fs';
+import {Clawd} from 'reference-clawd';
+const captures: string[] = [];
+const stdout: any = new Writable({write(chunk, _encoding, done) {captures.push(String(chunk)); done();}});
+stdout.columns = 80; stdout.rows = 24; stdout.isTTY = false;
+const stdin: any = new PassThrough(); stdin.isTTY = false;
+const instance = render(, {stdout, stdin, debug: true, patchConsole: false, exitOnCtrlC: false});
+await new Promise(resolve => setTimeout(resolve, 200));
+instance.unmount();
+writeFileSync('reference_clawd.ansi', captures.find(frame => frame.includes('▛')) ?? captures.join(''));
+const promptCaptures: string[] = [];
+const promptOutput: any = new Writable({write(chunk, _encoding, done) {promptCaptures.push(String(chunk)); done();}});
+promptOutput.columns = 80; promptOutput.rows = 24; promptOutput.isTTY = false;
+// PromptInput.tsx 的展示边界属性原样:round、left=false、right=false、bottom=true、width=100%。
+const prompt = render({'❯ '}fixture input, {stdout: promptOutput, stdin, debug: true, patchConsole: false, exitOnCtrlC: false});
+await new Promise(resolve => setTimeout(resolve, 200)); prompt.unmount();
+writeFileSync('reference_prompt.ansi', promptCaptures.find(frame => frame.includes('❯')) ?? promptCaptures.join(''));
From b58d2172d3111f50bd17b3f9d4be5f418ae52dd3 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:19:44 +0800
Subject: [PATCH 04/16] =?UTF-8?q?feat:=20M7-2=20StructuredDiff=20Core=20?=
=?UTF-8?q?=E4=BE=A7=E6=B8=B2=E6=9F=93=20=E2=80=94=20ToolResult=20?=
=?UTF-8?q?=E6=89=A9=E5=B1=95=20Patch=20=E5=AD=97=E6=AE=B5=20+=20DiffResul?=
=?UTF-8?q?tItem=20=E5=A4=9A=E8=A1=8C=E6=B8=B2=E6=9F=93=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20Core=20=E9=9B=B6=E4=BE=9D=E8=B5=96=E8=87=AA?=
=?UTF-8?q?=E5=B8=A6=20DiffHunk/DiffLine=20=E6=8A=95=E5=BD=B1(DiffLineKind?=
=?UTF-8?q?=20=E6=95=B0=E5=80=BC=E5=AF=B9=E9=BD=90=E5=BC=95=E6=93=8E=20Pat?=
=?UTF-8?q?chLineType=20=E5=8F=AF=E7=9B=B4=E8=BD=AC=20byte),=20=E7=9D=80?=
=?UTF-8?q?=E8=89=B2=E7=94=A8=E5=9F=BA=E5=87=86=20theme.ts=20diff=20?=
=?UTF-8?q?=E5=89=8D=E6=99=AF=E9=94=AE=20DiffAddedWord/DiffRemovedWord=20?=
=?UTF-8?q?=E6=9B=BF=E4=BB=A3=E9=9D=92=E8=9B=99TUI=20=E7=A1=AC=E7=BC=96?=
=?UTF-8?q?=E7=A0=81=E5=8D=81=E5=85=AD=E8=BF=9B=E5=88=B6,=20=E6=8A=98?=
=?UTF-8?q?=E5=8F=A0=E9=A2=84=E8=A7=88=202=20=E8=A1=8C=E5=AF=B9=E9=BD=90?=
=?UTF-8?q?=E9=9D=92=E8=9B=99TUI=20Take(3)=20=E5=85=88=E4=BE=8B=E4=B8=94?=
=?UTF-8?q?=20Ctrl+O=20=E5=B1=95=E5=BC=80=E4=BA=A4=E4=BA=92=E7=95=99=20M7?=
=?UTF-8?q?=20=E5=90=8E=E7=BB=AD;=20JSON=20=E5=BE=80=E8=BF=94=20camelCase?=
=?UTF-8?q?=20patch=20=E5=AD=97=E6=AE=B5=E4=B8=94=E6=97=A7=E5=BC=95?=
=?UTF-8?q?=E6=93=8E=E6=97=A0=E5=AD=97=E6=AE=B5=E5=8F=8D=E5=BA=8F=E5=88=97?=
=?UTF-8?q?=E5=8C=96=20null=20=E5=85=BC=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 4 +-
.../bridge/BridgeJsonContext.cs | 1 +
.../src/NativeTui.Core/bridge/DiffModels.cs | 40 +++++
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 6 +-
.../src/NativeTui.Core/repl/MessageItems.cs | 67 ++++++++
.../NativeTui.Core/repl/MessageRenderer.cs | 35 ++++
.../bridge/BridgeProtocolTests.cs | 51 ++++++
.../bridge/ReplBridgeDriverTests.cs | 25 +++
.../repl/DiffResultItemTests.cs | 162 ++++++++++++++++++
9 files changed, 387 insertions(+), 4 deletions(-)
create mode 100644 native_tui/src/NativeTui.Core/bridge/DiffModels.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index 660686e12..8e9395368 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -42,8 +42,8 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// 工具调用消息(bullet + bold 工具名 + args)。
public sealed record ToolCall(string Name, string Args) : BridgeEvent;
- /// 工具结果摘要(⎿ 缩进项)。
- public sealed record ToolResult(string Summary) : BridgeEvent;
+ /// 工具结果摘要(⎿ 缩进项);Patch 非空时渲染为结构化 diff 块(M7-2 StructuredDiff)。
+ public sealed record ToolResult(string Summary, DiffHunk[]? Patch = null) : BridgeEvent;
/// 流式 token 增量(spinner ResponseLength 累加)。
public sealed record TokenDelta(int Length) : BridgeEvent;
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs b/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
index d09ed351c..6ab34adbf 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
@@ -7,4 +7,5 @@ namespace NativeTui.Core.Bridge;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = false)]
[JsonSerializable(typeof(BridgeRequest))]
[JsonSerializable(typeof(BridgeEvent))]
+[JsonSerializable(typeof(DiffHunk))]
public sealed partial class BridgeJsonContext : JsonSerializerContext;
diff --git a/native_tui/src/NativeTui.Core/bridge/DiffModels.cs b/native_tui/src/NativeTui.Core/bridge/DiffModels.cs
new file mode 100644
index 000000000..322db7212
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/bridge/DiffModels.cs
@@ -0,0 +1,40 @@
+namespace NativeTui.Core.Bridge;
+
+///
+/// diff 行种类 — 与引擎 PatchLineType 数值对齐(Context=0/Added=1/Removed=2,投影可直转 byte),
+/// 渲染前缀:context=' '、added='+'、removed='-'。
+///
+public enum DiffLineKind : byte
+{
+ /// 上下文行(前缀 ' ',dim 渲染)。
+ Context = 0,
+
+ /// 新增行(前缀 '+',DiffAddedWord 前景)。
+ Added = 1,
+
+ /// 删除行(前缀 '-',DiffRemovedWord 前景)。
+ Removed = 2,
+}
+
+/// diff 单行 — Kind 决定渲染前缀与着色,Content 不含前缀。
+public sealed record DiffLine
+{
+ /// 行种类(前缀与着色依据)。
+ public required DiffLineKind Kind { get; init; }
+
+ /// 行内容(不含 +/-/空格前缀)。
+ public required string Content { get; init; }
+}
+
+///
+/// 结构化 diff hunk — 引擎 StructuredPatchHunk 的 Core 零依赖渲染投影(ADR 0140:Core 不引引擎库)。
+/// 渲染所需字段子集(Header + Lines);行号/行数计数由引擎侧 summary 文本承载。
+///
+public sealed record DiffHunk
+{
+ /// Hunk 头(如 "@@ -1,3 +1,4 @@")。
+ public required string Header { get; init; }
+
+ /// Diff 行数组(渲染顺序)。
+ public required DiffLine[] Lines { get; init; }
+}
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index 4ab771fec..b5b94433a 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -59,8 +59,10 @@ public void Apply(BridgeEvent bridgeEvent)
_spinner.Mode = SpinnerMode.ToolUse;
_spinner.HasActiveTools = true;
break;
- case BridgeEvent.ToolResult(var summary):
- AddMessage(new ToolResultItem(summary));
+ case BridgeEvent.ToolResult(var summary, var patch):
+ AddMessage(patch is { Length: > 0 }
+ ? new DiffResultItem(summary, patch)
+ : new ToolResultItem(summary));
_spinner.HasActiveTools = false;
break;
case BridgeEvent.TokenDelta(var length):
diff --git a/native_tui/src/NativeTui.Core/repl/MessageItems.cs b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
index 535594ed8..f9b76e443 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageItems.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageItems.cs
@@ -49,6 +49,73 @@ public void Render(Screen screen, Rect frame, Theme theme) =>
MessageRenderer.RenderToolResult(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, text);
}
+///
+/// 带补丁的工具结果项(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 行先例,
+/// Ctrl+O 展开交互为 M7 后续项)。
+///
+public sealed class DiffResultItem(string summary, DiffHunk[] patch) : IScrollItem
+{
+ /// patch 预览行数(summary 之外;对齐青蛙TUI ToolResultPreview 3 行中的 2 行 patch)。
+ private const int PreviewPatchRows = 2;
+
+ /// diff 块缩进列数(对齐青蛙TUI " " 缩进先例)。
+ private const int IndentCols = 4;
+
+ /// 工具结果摘要(首行 ⎿ 正文)。
+ public string Summary { get; } = summary;
+
+ /// 结构化补丁 hunks(驱动侧断言与渲染共享)。
+ public DiffHunk[] Patch { get; } = patch;
+
+ /// 补丁总行数(每 hunk = header 1 行 + diff 行数)。
+ private int TotalPatchRows => Patch.Sum(static hunk => 1 + hunk.Lines.Length);
+
+ /// 内容高度 = margin 1 + summary 1 + 预览行数(超预览时为预览上限 + 折叠提示 1 行)。
+ public int MeasureHeight(int width) =>
+ 2 + Math.Min(TotalPatchRows, PreviewPatchRows) + (TotalPatchRows > PreviewPatchRows ? 1 : 0);
+
+ /// 渲染:margin 留空 → summary(⎿ 形态)→ hunk 预览行 → 折叠提示(如有)。
+ public void Render(Screen screen, Rect frame, Theme theme)
+ {
+ MessageRenderer.RenderToolResult(screen, frame with { Y = frame.Y + 1, Height = 1 }, theme, Summary);
+
+ var renderedRows = Math.Min(TotalPatchRows, PreviewPatchRows);
+ var row = frame.Y + 2;
+ foreach (var hunk in Patch)
+ {
+ if (renderedRows == 0)
+ {
+ break;
+ }
+
+ MessageRenderer.RenderDiffHeader(screen, RowFrame(frame, row), theme, hunk.Header);
+ row++;
+ renderedRows--;
+ foreach (var line in hunk.Lines)
+ {
+ if (renderedRows == 0)
+ {
+ break;
+ }
+
+ MessageRenderer.RenderDiffLine(screen, RowFrame(frame, row), theme, line);
+ row++;
+ renderedRows--;
+ }
+ }
+
+ if (TotalPatchRows > PreviewPatchRows)
+ {
+ MessageRenderer.RenderDiffCollapseHint(screen, RowFrame(frame, row), theme, TotalPatchRows - PreviewPatchRows);
+ }
+ }
+
+ /// 单行 frame(Y 为该渲染行,高度 1)。
+ private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 };
+}
+
/// 错误消息项(● theme.Error 红色 + 正文,bridge error 事件载体)。
public sealed class ErrorMessageItem(string text) : IScrollItem
{
diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
index 932f8aa11..030dac6bd 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
@@ -62,4 +62,39 @@ public static void RenderError(Screen screen, Rect frame, Theme theme, string te
screen.WriteText(frame.Y, frame.X, $"{Figures.MessageBullet} ", style, frame.Right);
screen.WriteText(frame.Y, frame.X + 2, text, style, frame.Right);
}
+
+ /// 渲染 diff hunk 头:" @@ … @@"(4 空格缩进,整行 dim 弱化)。
+ public static void RenderDiffHeader(Screen screen, Rect frame, Theme theme, string header)
+ {
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ screen.WriteText(frame.Y, frame.X, $" {header}", dim, frame.Right);
+ }
+
+ ///
+ /// 渲染单个 diff 行:" " + 前缀字符 + 正文(4 空格缩进对齐青蛙TUI 先例):
+ /// added 整行 DiffAddedWord 前景 / removed 整行 DiffRemovedWord 前景 / context 整行 dim。
+ ///
+ public static void RenderDiffLine(Screen screen, Rect frame, Theme theme, DiffLine line)
+ {
+ var style = line.Kind switch
+ {
+ DiffLineKind.Added => screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.DiffAddedWord }),
+ DiffLineKind.Removed => screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.DiffRemovedWord }),
+ _ => screen.Styles.GetOrAdd(new TextStyle { Dim = true }),
+ };
+ var prefix = line.Kind switch
+ {
+ DiffLineKind.Added => "+",
+ DiffLineKind.Removed => "-",
+ _ => " ",
+ };
+ screen.WriteText(frame.Y, frame.X, $" {prefix}{line.Content}", style, frame.Right);
+ }
+
+ /// 渲染折叠提示:" … +N lines (Ctrl+O)"(dim;Ctrl+O 展开交互为 M7 后续项)。
+ public static void RenderDiffCollapseHint(Screen screen, Rect frame, Theme theme, int hiddenLines)
+ {
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ screen.WriteText(frame.Y, frame.X, $" … +{hiddenLines} lines (Ctrl+O)", dim, frame.Right);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
index 9c2875cc8..6e7d97c3c 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
@@ -231,6 +231,57 @@ public void ReadRequest_EmptyLineSkipped_EofAtStreamEnd()
Assert.Equal(BridgeReadResult.Eof, BridgeProtocol.TryReadRequest(reader, out _, out _));
}
+ [Fact]
+ public void Roundtrip_ToolResultWithPatch_SurvivesSerializeDeserialize()
+ {
+ var withPatch = new BridgeEvent.ToolResult("Edited a.cs", [
+ new DiffHunk
+ {
+ Header = "@@ -1,3 +1,4 @@",
+ Lines =
+ [
+ new DiffLine { Kind = DiffLineKind.Context, Content = "keep" },
+ new DiffLine { Kind = DiffLineKind.Removed, Content = "drop" },
+ new DiffLine { Kind = DiffLineKind.Added, Content = "add" },
+ ],
+ },
+ ]);
+
+ var line = BridgeProtocol.SerializeEvent(withPatch);
+ var reader = new StringReader(line + "\n");
+
+ var result = BridgeProtocol.TryReadEvent(reader, out var actual, out var diagnostic);
+ Assert.Equal(BridgeReadResult.Event, result);
+ Assert.Null(diagnostic);
+ // record 含数组字段为引用相等 — 逐字段断言(hunk/行/种类/内容全量回读)
+ var roundTripped = Assert.IsType(actual);
+ Assert.Equal("Edited a.cs", roundTripped.Summary);
+ var hunk = Assert.Single(roundTripped.Patch!);
+ Assert.Equal("@@ -1,3 +1,4 @@", hunk.Header);
+ Assert.Equal(3, hunk.Lines.Length);
+ Assert.Equal(DiffLineKind.Context, hunk.Lines[0].Kind);
+ Assert.Equal("keep", hunk.Lines[0].Content);
+ Assert.Equal(DiffLineKind.Removed, hunk.Lines[1].Kind);
+ Assert.Equal("drop", hunk.Lines[1].Content);
+ Assert.Equal(DiffLineKind.Added, hunk.Lines[2].Kind);
+ Assert.Equal("add", hunk.Lines[2].Content);
+ }
+
+ [Fact]
+ public void ReadEvent_ToolResultWithoutPatchField_ParsesLegacyJson()
+ {
+ // 旧引擎(M7-2 投影同步前)不输出 patch 字段 → 反序列化为 null,不报坏行
+ var reader = new StringReader("{\"type\":\"tool_result\",\"summary\":\"41 lines\"}\n");
+
+ var result = BridgeProtocol.TryReadEvent(reader, out var actual, out var diagnostic);
+
+ Assert.Equal(BridgeReadResult.Event, result);
+ var toolResult = Assert.IsType(actual);
+ Assert.Equal("41 lines", toolResult.Summary);
+ Assert.Null(toolResult.Patch);
+ Assert.Null(diagnostic);
+ }
+
[Fact]
public void Roundtrip_AllEvents_SurviveSerializeDeserialize()
{
diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
index 75d21967a..aa6d590f4 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs
@@ -168,6 +168,31 @@ public void Apply_TurnInterrupted_ReturnsToIdleWithoutExtraMessage()
Assert.Equal(0, _spinner.ResponseLength);
}
+ [Fact]
+ public void Apply_ToolResultWithPatch_AppendsDiffResultItem()
+ {
+ var hunks = new[]
+ {
+ new DiffHunk { Header = "@@ -1 +1 @@", Lines = [new DiffLine { Kind = DiffLineKind.Added, Content = "new" }] },
+ };
+
+ _driver.Apply(new BridgeEvent.ToolResult("Edited a.cs", hunks));
+
+ var item = Assert.IsType(_view.Messages.Items[^2]);
+ Assert.Equal("Edited a.cs", item.Summary);
+ Assert.Same(hunks, item.Patch);
+ Assert.Same(_spinner, _view.Messages.Items[^1]);
+ Assert.False(_spinner.HasActiveTools);
+ }
+
+ [Fact]
+ public void Apply_ToolResultWithoutPatch_KeepsPlainToolResultItem()
+ {
+ _driver.Apply(new BridgeEvent.ToolResult("42 lines"));
+
+ Assert.IsType(_view.Messages.Items[^2]);
+ }
+
[Fact]
public void Apply_Error_AppendsErrorMessageItemBeforeSpinner()
{
diff --git a/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
new file mode 100644
index 000000000..3c36f3f4e
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/DiffResultItemTests.cs
@@ -0,0 +1,162 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// StructuredDiff 渲染 golden 测试(M7-2)— DiffResultItem 带补丁的工具结果:
+/// 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 行预览先例)。
+///
+public sealed class DiffResultItemTests
+{
+ /// 构造 hunk(header 固定,行可变)。
+ private static DiffHunk Hunk(params DiffLine[] lines) => new() { Header = "@@ -1,3 +1,4 @@", Lines = lines };
+
+ /// 构造 diff 行。
+ private static DiffLine Line(DiffLineKind kind, string content) => new() { Kind = kind, Content = content };
+
+ [Fact]
+ public void Render_SummaryRowKeepsToolResultStyle()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new DiffResultItem("Edited a.cs", [Hunk(Line(DiffLineKind.Added, "new line"))]);
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ // row0 margin 留空;row1 = " ⎿ " dim 前缀 + summary 默认样式(对齐 MessageResponse)
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune(' '), screen[0, 0].Value);
+ Assert.Equal(new Rune('⎿'), screen[1, 2].Value);
+ Assert.Equal(dim, screen[1, 2].StyleId);
+ Assert.Equal(new Rune('E'), screen[1, 5].Value);
+ Assert.Equal(0, screen[1, 5].StyleId);
+ Assert.Equal(" ⎿ Edited a.cs", RowText.Of(screen, 1, 0, 16));
+ }
+
+ [Fact]
+ public void Render_HeaderDimmedAndAddedLineColored()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new DiffResultItem("Edited a.cs", [Hunk(Line(DiffLineKind.Added, "new line"))]);
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ // row2 = " @@ -1,3 +1,4 @@" 整行 dim(4 空格缩进)
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune(' '), screen[2, 0].Value);
+ Assert.Equal(new Rune('@'), screen[2, 4].Value);
+ Assert.Equal(dim, screen[2, 4].StyleId);
+ Assert.Equal(dim, screen[2, 5].StyleId);
+ Assert.Equal(" @@ -1,3 +1,4 @@", RowText.Of(screen, 2, 0, 19));
+
+ // row3 = " +new line" 整行 DiffAddedWord 前景(前缀字符与正文同 span)
+ var added = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.DiffAddedWord });
+ Assert.Equal(new Rune('+'), screen[3, 4].Value);
+ Assert.Equal(added, screen[3, 4].StyleId);
+ Assert.Equal(added, screen[3, 5].StyleId);
+ Assert.Equal(" +new line", RowText.Of(screen, 3, 0, 13));
+ }
+
+ [Fact]
+ public void Render_RemovedLineInDiffRemovedWordColor()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new DiffResultItem("Edited a.cs", [Hunk(Line(DiffLineKind.Removed, "old line"))]);
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ var removed = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.DiffRemovedWord });
+ Assert.Equal(new Rune('-'), screen[3, 4].Value);
+ Assert.Equal(removed, screen[3, 4].StyleId);
+ Assert.Equal(removed, screen[3, 7].StyleId);
+ Assert.Equal(" -old line", RowText.Of(screen, 3, 0, 13));
+ }
+
+ [Fact]
+ public void Render_ContextLineDimmed()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new DiffResultItem("Read 10 lines", [Hunk(Line(DiffLineKind.Context, "unchanged"))]);
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ // context 行 = " unchanged"(4 缩进 + ' ' 前缀)整行 dim
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune(' '), screen[3, 4].Value);
+ Assert.Equal(dim, screen[3, 4].StyleId);
+ Assert.Equal(new Rune('u'), screen[3, 5].Value);
+ Assert.Equal(dim, screen[3, 5].StyleId);
+ Assert.Equal(" unchanged", RowText.Of(screen, 3, 0, 14));
+ }
+
+ [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"))]);
+
+ item.Render(screen, new Rect(0, 0, 40, 5), Theme.Dark);
+
+ // row2 header(预览第 1 行)→ row3 首个 diff 行(预览第 2 行)→ row4 折叠提示 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);
+ }
+
+ [Fact]
+ public void Render_MultipleHunksPreviewSpansHunks()
+ {
+ // 2 hunks(各 header + 1 行)→ patch 4 行 > 预览 2 → 预览行 = h1 header + h1 line,折叠 +2
+ using var screen = new Screen(cols: 40, rows: 5);
+ 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);
+
+ 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));
+ }
+
+ [Fact]
+ public void MeasureHeight_MarginPlusSummaryPlusRenderedPatchRows()
+ {
+ // 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
+ 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));
+
+ // 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));
+ }
+
+ [Fact]
+ public void Patch_ExposedForDriverAssertions()
+ {
+ var hunks = new[] { Hunk(Line(DiffLineKind.Added, "x")) };
+ var item = new DiffResultItem("summary", hunks);
+
+ Assert.Equal("summary", item.Summary);
+ Assert.Same(hunks, item.Patch);
+ }
+}
From ba06f4fdcf3bf305e3199a5e57611076b5401879 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:24:25 +0800
Subject: [PATCH 05/16] =?UTF-8?q?feat:=20M7-2=20Host=20=E4=BE=A7=E8=A1=A5?=
=?UTF-8?q?=E4=B8=81=E6=8A=95=E5=BD=B1=20=E2=80=94=20InProcessEventConsume?=
=?UTF-8?q?r=20OnToolEnd=20=E9=80=8F=E4=BC=A0=20StructuredPatch=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20ProjectPatch=20LINQ=20=E9=93=BE=E5=BC=8F?=
=?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=BC=95=E6=93=8E=20StructuredPatchHunk=20?=
=?UTF-8?q?=E5=88=B0=20Core=20DiffHunk(DiffLineKind=20=E4=B8=8E=20PatchLin?=
=?UTF-8?q?eType=20=E6=95=B0=E5=80=BC=E5=AF=B9=E9=BD=90=E7=9B=B4=E8=BD=AC?=
=?UTF-8?q?=20byte=20=E5=85=8D=20switch),=20=E7=A9=BA/null=20=E8=A1=A5?=
=?UTF-8?q?=E4=B8=81=E5=BD=92=20null=20=E7=BB=B4=E6=8C=81=E7=BA=AF?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E6=91=98=E8=A6=81=E5=BD=A2=E6=80=81;=20JoinC?=
=?UTF-8?q?ode.Cli.DiffLine=20=E5=90=8C=E5=90=8D=E6=AD=A7=E4=B9=89?=
=?UTF-8?q?=E6=B2=BF=E7=94=A8=20EnginePermissionMode=20=E5=88=AB=E5=90=8D?=
=?UTF-8?q?=E5=85=88=E4=BE=8B=E5=8A=A0=20CoreDiffLine=20=E5=85=A8=E5=B1=80?=
=?UTF-8?q?=E5=88=AB=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/GlobalUsings.cs | 2 +
.../NativeTui.Host/InProcessEventConsumer.cs | 17 +++++--
.../NativeTui.Host.Tests/GlobalUsings.cs | 1 +
.../InProcessEventConsumerTests.cs | 48 +++++++++++++++++++
4 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index ab13bc571..c5b2c317a 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -25,3 +25,5 @@
global using EnginePermissionModeExtensions = JoinCode.Abstractions.Security.PermissionModeExtensions;
global using SecurityPermissionChecker = JoinCode.Abstractions.Security.Permission.IPermissionChecker;
global using PermissionPendingException = JoinCode.Abstractions.Security.PermissionPendingConfirmationException;
+// Core diff 行类型别名 — NativeTui.Core.Bridge.DiffLine 与引擎 JoinCode.Cli.DiffLine 同名(各属两侧),投影走别名消除歧义
+global using CoreDiffLine = NativeTui.Core.Bridge.DiffLine;
diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
index 7f967819b..2c688efd7 100644
--- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
+++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
@@ -6,7 +6,7 @@ namespace NativeTui.Host;
/// (Actor 邮箱模型:回合线程生产,主循环 tick 排空消费,无共享锁,对齐 Core BridgeEventPump 的 ConcurrentQueue 先例),
/// 不经 JSON 序列化。语义与 engine BridgeEventConsumer 完全对齐(唯一差异:WriteEvent 序列化 stdout → 队列写内存对象):
/// OnText → token_delta;OnThinking 回合内单次信号 + 墙钟差结算 thinking_ended;
-/// OnToolStart/OnToolEnd → tool_call/tool_result(摘要截断 200 + error 前缀);
+/// OnToolStart/OnToolEnd → tool_call/tool_result(摘要截断 200 + error 前缀 + StructuredPatch → DiffHunk 投影);
/// OnDone → assistant_message(完整文本);OnToolProgress/OnLoopDetected/OnTimingSummary 不消费。
///
public sealed class InProcessEventConsumer : IResettableEventConsumer {
@@ -61,9 +61,9 @@ public void OnToolStart(string toolName, string? toolCallId, string? arguments)
_events.Enqueue(new BridgeEvent.ToolCall(toolName, arguments ?? string.Empty));
}
- /// 工具调用结束 → tool_result(截断摘要 + error 标记)。
+ /// 工具调用结束 → tool_result(截断摘要 + error 标记 + 结构化补丁投影)。
public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, StructuredPatchHunk[]? patch) {
- _events.Enqueue(new BridgeEvent.ToolResult(BuildSummary(resultText, isError)));
+ _events.Enqueue(new BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch)));
}
/// 工具调用进度 — TUI 不消费,忽略(对齐 bridge 形态)。
@@ -112,4 +112,15 @@ private static string BuildSummary(string? resultText, bool isError) {
: text;
return isError ? $"[error] {truncated}" : truncated;
}
+
+ /// 引擎 StructuredPatchHunk[] → Core 零依赖 DiffHunk[] 渲染投影(空/ null → null 维持纯文本摘要形态)。
+ private static DiffHunk[]? ProjectPatch(StructuredPatchHunk[]? patch) =>
+ patch is null || patch.Length == 0 ? null
+ : patch.Select(static hunk => new DiffHunk {
+ Header = hunk.Header,
+ Lines = hunk.Lines.Select(static line => new CoreDiffLine {
+ Kind = (DiffLineKind)(byte)line.Type,
+ Content = line.Content,
+ }).ToArray(),
+ }).ToArray();
}
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 72176d646..203954d13 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -1,5 +1,6 @@
global using System.Text;
global using System.Collections.Concurrent;
+global using JoinCode.Abstractions.Models.Diff;
global using JoinCode.Adapters;
global using NativeTui.Core.Bridge;
global using NativeTui.Host;
diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
index ddbdc7992..013c6dd8d 100644
--- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
@@ -99,6 +99,54 @@ public void OnToolEnd_ErrorPrefixesSummary()
Assert.Equal("[error] boom", result.Summary);
}
+ [Fact]
+ public void OnToolEnd_WithPatch_ProjectsHunksToToolResult()
+ {
+ var consumer = new InProcessEventConsumer();
+ var patch = new[]
+ {
+ new StructuredPatchHunk
+ {
+ OldStart = 1, OldLines = 3, NewStart = 1, NewLines = 4,
+ Header = "@@ -1,3 +1,4 @@",
+ Lines = new PatchLine[]
+ {
+ new() { Type = PatchLineType.Context, Content = "keep" },
+ new() { Type = PatchLineType.Added, Content = "add" },
+ new() { Type = PatchLineType.Removed, Content = "drop" },
+ },
+ },
+ };
+
+ consumer.OnToolEnd("Edit", "Edited a.cs", "call-1", isError: false, patch);
+
+ var events = consumer.DrainEvents();
+ var result = Assert.IsType(Assert.Single(events));
+ Assert.Equal("Edited a.cs", result.Summary);
+ // 引擎 StructuredPatchHunk → Core 零依赖 DiffHunk 投影(Kind 数值对齐可直转)
+ var hunk = Assert.Single(result.Patch!);
+ Assert.Equal("@@ -1,3 +1,4 @@", hunk.Header);
+ Assert.Equal(3, hunk.Lines.Length);
+ Assert.Equal(DiffLineKind.Context, hunk.Lines[0].Kind);
+ Assert.Equal("keep", hunk.Lines[0].Content);
+ Assert.Equal(DiffLineKind.Added, hunk.Lines[1].Kind);
+ Assert.Equal("add", hunk.Lines[1].Content);
+ Assert.Equal(DiffLineKind.Removed, hunk.Lines[2].Kind);
+ Assert.Equal("drop", hunk.Lines[2].Content);
+ }
+
+ [Fact]
+ public void OnToolEnd_WithEmptyPatch_KeepsPatchNull()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnToolEnd("Read", "41 lines", "call-1", isError: false, patch: []);
+
+ var events = consumer.DrainEvents();
+ var result = Assert.IsType(Assert.Single(events));
+ Assert.Null(result.Patch);
+ }
+
[Fact]
public void OnDone_EmitsAssistantMessageWithFullText()
{
From e18da60a517a0b00b12a12088f7d64e4962eb3aa Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:35:55 +0800
Subject: [PATCH 06/16] =?UTF-8?q?feat:=20M7-2=20=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E4=BE=A7=E8=A1=A5=E4=B8=81=E6=8A=95=E5=BD=B1=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=20=E2=80=94=20BridgeProtocol=20ToolResult=20=E6=89=A9=E5=B1=95?=
=?UTF-8?q?=20Patch=20=E5=AD=97=E6=AE=B5=20+=20BridgeEventConsumer=20?=
=?UTF-8?q?=E9=80=8F=E4=BC=A0=20|=20=E5=86=B3=E7=AD=96:=20ADR=200143=20?=
=?UTF-8?q?=E5=85=88=E4=BA=8B=E4=BB=B6=E6=A8=A1=E5=9E=8B=E5=90=8E=E6=8A=95?=
=?UTF-8?q?=E5=BD=B1(IEventConsumer.OnToolEnd=20=E5=B7=B2=E5=B8=A6=20patch?=
=?UTF-8?q?=20=E5=8F=82=E6=95=B0=E5=B1=9E=E5=8D=8F=E8=AE=AE=E6=9C=AC?=
=?UTF-8?q?=E4=BD=93,=20=E6=9C=AC=E6=89=B9=E8=A1=A5=E9=BD=90=20JSON=20?=
=?UTF-8?q?=E6=8A=95=E5=BD=B1);=20=E5=AE=9A=E4=B9=89=20BridgeDiffHunk/Brid?=
=?UTF-8?q?geDiffLine=20=E9=95=9C=E5=83=8F=20DTO=20=E8=80=8C=E9=9D=9E?=
=?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=BA=8F=E5=88=97=E5=8C=96=20StructuredPatch?=
=?UTF-8?q?Hunk(=E5=85=B6=20type/oldStart=20=E7=AD=89=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E4=BC=9A=E8=A2=AB=20native=5Ftui=20DiffLine=20=E5=BF=BD?=
=?UTF-8?q?=E7=95=A5=E8=87=B4=20kind=20=E5=85=A8=E9=83=A8=E9=9D=99?=
=?UTF-8?q?=E9=BB=98=E9=80=80=E5=8C=96=E4=B8=BA=20context=20=E7=9A=84?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=B8=A2=E5=A4=B1);=20=E4=B8=A4=E4=BE=A7=20P?=
=?UTF-8?q?atch=20=E5=9D=87=E5=8A=A0=20WhenWritingNull(null=20=E4=B8=8D?=
=?UTF-8?q?=E8=BE=93=E5=87=BA=E5=AD=97=E6=AE=B5,=20=E6=97=A7=20golden=20?=
=?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8D=E5=8F=98=E4=B8=94=E6=97=A7=20TUI=20?=
=?UTF-8?q?=E5=85=BC=E5=AE=B9)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../engine/adapters/BridgeEventConsumer.cs | 16 ++++++-
modules/apps/engine/bridge/BridgeProtocol.cs | 44 ++++++++++++++++++-
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 8 +++-
.../bridge/BridgeProtocolTests.cs | 8 ++++
.../bridge/BridgeEventConsumerTests.cs | 37 ++++++++++++++++
5 files changed, 107 insertions(+), 6 deletions(-)
diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs
index dadcba066..f3339f836 100644
--- a/modules/apps/engine/adapters/BridgeEventConsumer.cs
+++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs
@@ -54,9 +54,9 @@ public void OnToolStart(string toolName, string? toolCallId, string? arguments)
WriteEvent(new Cli.Bridge.BridgeEvent.ToolCall(toolName, arguments ?? string.Empty));
}
- /// 工具调用结束 → tool_result(截断摘要 + error 标记)。
+ /// 工具调用结束 → tool_result(截断摘要 + error 标记 + 结构化补丁投影)。
public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, StructuredPatchHunk[]? patch) {
- WriteEvent(new Cli.Bridge.BridgeEvent.ToolResult(BuildSummary(resultText, isError)));
+ WriteEvent(new Cli.Bridge.BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch)));
}
/// 工具调用进度 — TUI 不消费,忽略。
@@ -95,6 +95,18 @@ private static string BuildSummary(string? resultText, bool isError) {
return isError ? $"[error] {truncated}" : truncated;
}
+ /// 引擎 StructuredPatchHunk[] → BridgeDiffHunk[] 协议投影(渲染子集 header+lines[kind,content];
+ /// 空/null → null,WhenWritingNull 不输出字段 — 与 native_tui InProcessEventConsumer.ProjectPatch 语义一致)。
+ private static Cli.Bridge.BridgeEvent.BridgeDiffHunk[]? ProjectPatch(StructuredPatchHunk[]? patch) =>
+ patch is null || patch.Length == 0 ? null
+ : patch.Select(static hunk => new Cli.Bridge.BridgeEvent.BridgeDiffHunk {
+ Header = hunk.Header,
+ Lines = hunk.Lines.Select(static line => new Cli.Bridge.BridgeEvent.BridgeDiffLine {
+ Kind = (Cli.Bridge.BridgeEvent.BridgeDiffLineKind)(byte)line.Type,
+ Content = line.Content,
+ }).ToArray(),
+ }).ToArray();
+
/// 写一条事件行(单次 Write 保证行原子性 + LF + Flush,协议统一 \n;外层配 TextWriter.Synchronized)。
private void WriteEvent(Cli.Bridge.BridgeEvent bridgeEvent) {
_stdout.Write(Cli.Bridge.BridgeProtocol.SerializeEvent(bridgeEvent) + "\n");
diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs
index cd84cb66d..066071e44 100644
--- a/modules/apps/engine/bridge/BridgeProtocol.cs
+++ b/modules/apps/engine/bridge/BridgeProtocol.cs
@@ -67,8 +67,48 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// 工具调用消息(bullet + bold 工具名 + args)。
public sealed record ToolCall(string Name, string Args) : BridgeEvent;
- /// 工具结果摘要(⎿ 缩进项)。
- public sealed record ToolResult(string Summary) : BridgeEvent;
+ ///
+ /// diff 行种类 — 数值对齐 native_tui DiffLineKind(Context=0/Added=1/Removed=2),
+ /// 与引擎 PatchLineType 同序可直转 byte;JSON 投影为数字。
+ ///
+ public enum BridgeDiffLineKind : byte
+ {
+ /// 上下文行。
+ Context = 0,
+
+ /// 新增行。
+ Added = 1,
+
+ /// 删除行。
+ Removed = 2,
+ }
+
+ /// diff 行投影(渲染子集:kind + content,不含行号)— 与 native_tui DiffLine 逐字节镜像。
+ public sealed record BridgeDiffLine
+ {
+ /// 行种类(渲染前缀与着色依据)。
+ public required BridgeDiffLineKind Kind { get; init; }
+
+ /// 行内容(不含 +/-/空格前缀)。
+ public required string Content { get; init; }
+ }
+
+ /// diff hunk 投影(渲染子集:header + lines)— 与 native_tui DiffHunk 逐字节镜像。
+ public sealed record BridgeDiffHunk
+ {
+ /// Hunk 头(如 "@@ -1,3 +1,4 @@")。
+ public required string Header { get; init; }
+
+ /// Diff 行数组(渲染顺序)。
+ public required BridgeDiffLine[] Lines { get; init; }
+ }
+
+ ///
+ /// 工具结果摘要(⎿ 缩进项)+ 结构化补丁投影(M7-2 StructuredDiff)。
+ /// Patch null/空不输出字段(WhenWritingNull — 旧契约 golden 不变,旧 TUI 反序列化兼容)。
+ ///
+ public sealed record ToolResult(string Summary,
+ [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] BridgeDiffHunk[]? Patch = null) : BridgeEvent;
/// 流式 token 增量(spinner ResponseLength 累加)。
public sealed record TokenDelta(int Length) : BridgeEvent;
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index 8e9395368..fecea4b02 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -42,8 +42,12 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// 工具调用消息(bullet + bold 工具名 + args)。
public sealed record ToolCall(string Name, string Args) : BridgeEvent;
- /// 工具结果摘要(⎿ 缩进项);Patch 非空时渲染为结构化 diff 块(M7-2 StructuredDiff)。
- public sealed record ToolResult(string Summary, DiffHunk[]? Patch = null) : BridgeEvent;
+ ///
+ /// 工具结果摘要(⎿ 缩进项);Patch 非空时渲染为结构化 diff 块(M7-2 StructuredDiff)。
+ /// Patch null 不输出字段(WhenWritingNull — 与引擎侧 ToolResult 契约逐字节一致,旧 JSON 兼容)。
+ ///
+ public sealed record ToolResult(string Summary,
+ [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DiffHunk[]? Patch = null) : BridgeEvent;
/// 流式 token 增量(spinner ResponseLength 累加)。
public sealed record TokenDelta(int Length) : BridgeEvent;
diff --git a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
index 6e7d97c3c..bcf7fad7c 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/BridgeProtocolTests.cs
@@ -267,6 +267,14 @@ public void Roundtrip_ToolResultWithPatch_SurvivesSerializeDeserialize()
Assert.Equal("add", hunk.Lines[2].Content);
}
+ [Fact]
+ public void SerializeEvent_ToolResultWithoutPatch_OmitsPatchField()
+ {
+ // null 补丁不输出 patch 字段(WhenWritingNull — 与引擎侧契约逐字节一致)
+ var line = BridgeProtocol.SerializeEvent(new BridgeEvent.ToolResult("41 lines"));
+ Assert.Equal("{\"type\":\"tool_result\",\"summary\":\"41 lines\"}", line);
+ }
+
[Fact]
public void ReadEvent_ToolResultWithoutPatchField_ParsesLegacyJson()
{
diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
index 932b7528e..5a9a70de0 100644
--- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
+++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs
@@ -90,6 +90,43 @@ public void OnToolEnd_LongResult_TruncatedToLimit() {
Assert.Contains("...", line);
}
+ [Fact]
+ public void OnToolEnd_WithPatch_EmitsToolResultWithStructuredDiff() {
+ var (consumer, @out) = Create();
+ var patch = new[] {
+ new StructuredPatchHunk {
+ OldStart = 1, OldLines = 3, NewStart = 1, NewLines = 4,
+ Header = "@@ -1,3 +1,4 @@",
+ Lines = new PatchLine[] {
+ new() { Type = PatchLineType.Context, Content = "keep" },
+ new() { Type = PatchLineType.Added, Content = "add" },
+ new() { Type = PatchLineType.Removed, Content = "drop" },
+ },
+ },
+ };
+
+ consumer.OnToolEnd("Edit", "Edited a.cs", "call-1", isError: false, patch);
+
+ // patch 投影为 hunk 渲染子集(header + lines[kind,content],kind 数值 0/1/2 对齐 native_tui DiffLineKind)
+ Assert.Equal(
+ "{\"type\":\"tool_result\",\"summary\":\"Edited a.cs\",\"patch\":[" +
+ "{\"header\":\"@@ -1,3 +1,4 @@\",\"lines\":[" +
+ "{\"kind\":0,\"content\":\"keep\"}," +
+ "{\"kind\":1,\"content\":\"add\"}," +
+ "{\"kind\":2,\"content\":\"drop\"}]}]}\n",
+ @out.ToString());
+ }
+
+ [Fact]
+ public void OnToolEnd_WithEmptyPatch_OmitsPatchField() {
+ var (consumer, @out) = Create();
+
+ consumer.OnToolEnd("Read", "41 lines", "call-1", isError: false, patch: []);
+
+ // 空/ null 补丁不输出 patch 字段(WhenWritingNull — 旧 golden 契约不变)
+ Assert.Equal("{\"type\":\"tool_result\",\"summary\":\"41 lines\"}\n", @out.ToString());
+ }
+
[Fact]
public void OnDone_WithoutText_EmitsNoAssistantMessage() {
var (consumer, @out) = Create();
From 62affdc6f822b094e315bbd40f0aa5126da01f25 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:39:44 +0800
Subject: [PATCH 07/16] =?UTF-8?q?feat:=20M7-2=20=E6=A8=A1=E6=8B=9F?=
=?UTF-8?q?=E5=BC=95=E6=93=8E=20Edit=20=E5=9B=9E=E5=90=88=E6=BC=94?=
=?UTF-8?q?=E7=A4=BA=20diff=20=E6=B8=B2=E6=9F=93=E9=93=BE=E8=B7=AF=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20SimulatedEngine=20ToolUse=20=E9=98=B6?=
=?UTF-8?q?=E6=AE=B5=E6=94=B9=20Edit=20=E5=B7=A5=E5=85=B7=E5=B9=B6?=
=?UTF-8?q?=E6=90=BA=E5=B8=A6=204=20=E8=A1=8C=20hunk=20=E8=A1=A5=E4=B8=81(?=
=?UTF-8?q?=E8=B6=85=202=20=E8=A1=8C=E9=A2=84=E8=A7=88=E5=90=8C=E6=97=B6?=
=?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=8A=98=E5=8F=A0=E6=8F=90=E7=A4=BA),=20Demo?=
=?UTF-8?q?=20=E6=A8=A1=E6=8B=9F=E5=9B=9E=E5=90=88=E6=88=90=E4=B8=BA=20Str?=
=?UTF-8?q?ucturedDiff=20=E6=89=8B=E5=8A=A8=E9=AA=8C=E6=94=B6=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84;=20M7-1=20=E5=83=8F=E7=B4=A0=E5=9B=9E=E5=BD=92?=
=?UTF-8?q?=E9=AA=8C=E8=AF=81=20clawd+prompt=20=E4=B8=A4=20region=200=20?=
=?UTF-8?q?=E5=B7=AE=E5=BC=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../NativeTui.Core/bridge/SimulatedEngine.cs | 20 +++++++++++++++++--
.../bridge/SimulatedEngineTests.cs | 11 ++++++----
2 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
index 1f1334767..b3085cbd4 100644
--- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
+++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
@@ -37,6 +37,22 @@ public sealed class SimulatedEngine : IReplEngine
/// 时钟(默认墙钟 — 测试注入虚拟时钟)。
private readonly Func _clock;
+ /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):4 行 hunk 超 2 行预览,同时演示折叠提示。
+ private 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" },
+ ],
+ },
+ ];
+
/// 默认时间源 — 墙钟毫秒。
private static long DefaultClock() => Environment.TickCount64;
@@ -98,13 +114,13 @@ private void AdvanceStages()
if (_stage == 0 && elapsed >= ToolUseAtMs)
{
_stage = 1;
- _pending.Enqueue(new BridgeEvent.ToolCall("Read", "demo/simulated.cs"));
+ _pending.Enqueue(new BridgeEvent.ToolCall("Edit", "demo/simulated.cs"));
}
if (_stage == 1 && elapsed >= RespondingAtMs)
{
_stage = 2;
- _pending.Enqueue(new BridgeEvent.ToolResult("7 lines (simulated)"));
+ _pending.Enqueue(new BridgeEvent.ToolResult("Updated demo/simulated.cs (simulated)", SimulatedPatch));
}
if (_stage == 2 && elapsed >= CompletedAtMs)
diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
index 5b2260f17..1bb9e16dc 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
@@ -29,7 +29,7 @@ public void Submit_Drains_TurnStartedImmediately()
Assert.Equal([new BridgeEvent.TurnStarted()], events);
}
- /// 虚拟时钟 1.3s:进入 ToolUse 阶段,产出 ToolCall 事件。
+ /// 虚拟时钟 1.3s:进入 ToolUse 阶段,产出 ToolCall 事件(Edit — 演示 StructuredDiff 链路)。
[Fact]
public void Advance_ToToolUse_Emits_ToolCall()
{
@@ -39,12 +39,12 @@ public void Advance_ToToolUse_Emits_ToolCall()
engine.DrainEvents();
clock.Advance(1300);
- Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.ToolCall { Name: "Read" });
+ Assert.Contains(engine.DrainEvents(), e => e is BridgeEvent.ToolCall { Name: "Edit" });
}
- /// 虚拟时钟 2.5s:进入 Responding 阶段,产出 ToolResult + TokenDelta。
+ /// 虚拟时钟 2.5s:进入 Responding 阶段,产出 ToolResult(带结构化补丁)+ TokenDelta。
[Fact]
- public void Advance_ToResponding_Emits_ToolResultAndTokenDelta()
+ public void Advance_ToResponding_Emits_ToolResultWithPatchAndTokenDelta()
{
var clock = new VirtualClock();
var engine = new SimulatedEngine(() => clock.Now);
@@ -54,6 +54,9 @@ public void Advance_ToResponding_Emits_ToolResultAndTokenDelta()
clock.Advance(2500);
var events = engine.DrainEvents();
Assert.Contains(events, e => e is BridgeEvent.ToolResult);
+ var result = Assert.IsType(events.First(e => e is BridgeEvent.ToolResult));
+ Assert.NotNull(result.Patch);
+ Assert.All(result.Patch, hunk => Assert.False(string.IsNullOrEmpty(hunk.Header)));
Assert.Contains(events, e => e is BridgeEvent.TokenDelta { Length: 9 });
}
From 98e26ef3280220f63185d471111e12d761024b5d Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 21:40:15 +0800
Subject: [PATCH 08/16] =?UTF-8?q?docs:=20TASK055=20=E8=BF=BD=E5=8A=A0=20M7?=
=?UTF-8?q?-2=20StructuredDiff=20=E5=86=B3=E7=AD=96=E5=9D=97=E4=B8=8E?=
=?UTF-8?q?=E6=89=8B=E5=8A=A8=E9=AA=8C=E6=94=B6=E8=A1=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...\247\345\257\271\351\275\220ClaudeCode.md" | 28 +++++++++++++++++++
1 file changed, 28 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 d2e27e5e6..fe8b837d6 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"
@@ -625,3 +625,31 @@ native_tui/ # 顶层独立工程,零 modules/libs 依
+
+## M7-2 StructuredDiff 渲染落地(2026-10-20,引擎补丁→TUI diff 块全链路)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### M7-2 手动验收(ADR 0080,待用户执行)
+
+| 步骤 | 命令 | 预期 |
+|------|------|------|
+| 1 模拟 diff | `dotnet run --project native_tui/apps/NativeTui.Demo`(真实终端) | 提交任意文本 → 2.4s 后 ⎿ Updated demo/simulated.cs + hunk 块(+行绿/-行红/@@ dim)+ "… +2 lines (Ctrl+O)" |
+| 2 真实引擎 | `NativeTui.Host.exe`(需配置模型) | 引擎跑 Edit 类工具 → 工具结果渲染真实补丁 diff 块 |
From 4eb44fee46c9b222d7f5c6550cd20ea93aeb8315 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 22:16:24 +0800
Subject: [PATCH 09/16] =?UTF-8?q?feat:=20M7-3=20Core=20=E6=9D=83=E9=99=90?=
=?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=B8=B2=E6=9F=93=E4=B8=8E=E6=A8=A1?=
=?UTF-8?q?=E6=80=81=E6=94=B9=E9=81=93=20|=20=E5=86=B3=E7=AD=96:=20Permiss?=
=?UTF-8?q?ionCoordinator=20TCS=20=E6=A1=A5(=E5=AF=B9=E9=BD=90=E9=9D=92?=
=?UTF-8?q?=E8=9B=99TUI=20DialogRequest=20=E5=8F=96=E4=BB=A3=E8=AF=AD?=
=?UTF-8?q?=E4=B9=89+=E7=84=A6=E7=82=B9=E4=B8=89=E6=A1=A3=E7=8E=AF?=
=?UTF-8?q?=E7=BB=95),=20PermissionRequestItem=20=E5=83=8F=E7=B4=A0?=
=?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20FallbackPermissionReques?=
=?UTF-8?q?t(=E4=BB=85=E9=A1=B6=E8=BE=B9=E6=A1=86=20permission=20=E8=89=B2?=
=?UTF-8?q?=20round=20=E6=97=A0=E8=A7=92=E5=85=A8=E5=AE=BD/bold=20permissi?=
=?UTF-8?q?on=20=E6=A0=87=E9=A2=98=20paddingX=3D1/=E5=B7=A5=E5=85=B7?=
=?UTF-8?q?=E8=A1=8C=203=20=E7=BC=A9=E8=BF=9B/Select=20=E6=95=B0=E5=AD=97?=
=?UTF-8?q?=E5=89=8D=E7=BC=80=E4=B8=89=E6=A1=A3=E9=80=89=E9=A1=B9/?=
=?UTF-8?q?=E7=84=A6=E7=82=B9=20=E2=9D=AF=20suggestion/Esc=20to=20cancel?=
=?UTF-8?q?=20dim),=20ReplShell=20=E6=8C=82=E8=B5=B7=E6=97=B6=E9=94=AE?=
=?UTF-8?q?=E7=9B=98=E6=A8=A1=E6=80=81=E8=B7=AF=E7=94=B1(=E6=95=B0?=
=?UTF-8?q?=E5=AD=971-3/Esc=3DDeny/Enter=20=E7=84=A6=E7=82=B9=E6=A1=A3/?=
=?UTF-8?q?=E4=B8=8A=E4=B8=8B=E7=8E=AF=E7=BB=95)=E4=B8=94=E5=9B=9E?=
=?UTF-8?q?=E5=90=88=E7=BB=93=E6=9D=9F=E8=87=AA=E5=8A=A8=20Deny=20?=
=?UTF-8?q?=E6=94=B6=E5=B0=BE=E9=98=B2=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=AE=8B?=
=?UTF-8?q?=E7=95=99,=20=E6=B8=B2=E6=9F=93=E6=96=B9=E6=B3=95=E6=8C=89=20Di?=
=?UTF-8?q?ffResultItem=20=E5=85=88=E4=BE=8B=E6=94=B6=E5=8F=A3=20MessageRe?=
=?UTF-8?q?nderer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../NativeTui.Core/components/ScrollBox.cs | 3 +
.../NativeTui.Core/repl/MessageRenderer.cs | 68 +++
.../repl/PermissionCoordinator.cs | 85 ++++
.../repl/PermissionRequestItem.cs | 68 +++
.../src/NativeTui.Core/repl/ReplShell.cs | 82 +++-
.../repl/PermissionDialogTests.cs | 396 ++++++++++++++++++
6 files changed, 699 insertions(+), 3 deletions(-)
create mode 100644 native_tui/src/NativeTui.Core/repl/PermissionCoordinator.cs
create mode 100644 native_tui/src/NativeTui.Core/repl/PermissionRequestItem.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/PermissionDialogTests.cs
diff --git a/native_tui/src/NativeTui.Core/components/ScrollBox.cs b/native_tui/src/NativeTui.Core/components/ScrollBox.cs
index f851385e9..abb73960a 100644
--- a/native_tui/src/NativeTui.Core/components/ScrollBox.cs
+++ b/native_tui/src/NativeTui.Core/components/ScrollBox.cs
@@ -53,6 +53,9 @@ public ScrollBox(bool stickyScroll = false)
///
public void Insert(int index, IScrollItem item) => _items.Insert(index, item);
+ /// 移除指定位置内容项(权限对话框完成/收尾移除场景 — 项类型由调用方判定)。
+ public void RemoveAt(int index) => _items.RemoveAt(index);
+
/// 相对滚动(打破 sticky;边界钳制延迟到渲染,对齐基准 pendingScrollDelta 累加语义)。
public void ScrollBy(int dy)
{
diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
index 030dac6bd..c8dd56caa 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
@@ -97,4 +97,72 @@ public static void RenderDiffCollapseHint(Screen screen, Rect frame, Theme theme
var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
screen.WriteText(frame.Y, frame.X, $" … +{hiddenLines} lines (Ctrl+O)", dim, frame.Right);
}
+
+ /// 渲染权限对话框顶边框 — 整行 ─ 前景 permission 色(基准 borderStyle=round 无角,仅 top,全宽)。
+ public static void RenderPermissionBorder(Screen screen, Rect frame, Theme theme)
+ {
+ var border = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Permission });
+ for (var col = frame.X; col < frame.Right; col++)
+ {
+ screen[frame.Y, col] = new Cell(new Rune('─'), border, CellWidth.Default);
+ }
+ }
+
+ /// 渲染权限对话框标题行 — 1 空格缩进 + bold permission 色(基准 PermissionRequestTitle)。
+ public static void RenderPermissionTitle(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);
+ }
+
+ /// 渲染权限工具行 — 3 空格缩进(外 1 + 内 2 paddingX 累计)+ bold 工具名 + 默认样式 (描述参数)。
+ public static void RenderPermissionToolLine(Screen screen, Rect frame, Theme theme, string toolName, string description)
+ {
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ screen.WriteText(frame.Y, frame.X, " ", 0, frame.Right);
+ var col = screen.WriteText(frame.Y, frame.X + 3, toolName, bold, frame.Right);
+ screen.WriteText(frame.Y, col, $"({description})", 0, frame.Right);
+ }
+
+ /// 渲染权限描述行 — 3 空格缩进整行 dim。
+ public static void RenderPermissionDescription(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 空格缩进默认样式(基准 PermissionPrompt 固定文案)。
+ public static void RenderPermissionQuestion(Screen screen, Rect frame, Theme theme)
+ {
+ screen.WriteText(frame.Y, frame.X, " Do you want to proceed?", 0, frame.Right);
+ }
+
+ ///
+ /// 渲染权限选项行(基准 CustomSelect compact + ListItem):
+ /// 焦点 = ❯ suggestion + gap 1 + "N. " dim + label suggestion;非焦点 = 空格 indicator + gap 1 + "N. " dim + label 默认。
+ ///
+ public static void RenderPermissionOption(Screen screen, Rect frame, Theme theme, int index, string label, 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, $" ", dim, frame.Right);
+ screen.WriteText(frame.Y, frame.X + 2, $"{index + 1}. ", dim, frame.Right);
+ screen.WriteText(frame.Y, frame.X + 5, label, suggestion, frame.Right);
+ return;
+ }
+
+ var plain = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ screen.WriteText(frame.Y, frame.X, $" {index + 1}. ", plain, frame.Right);
+ screen.WriteText(frame.Y, frame.X + 5, label, 0, frame.Right);
+ }
+
+ /// 渲染权限取消提示行 — 1 空格缩进 dim(基准 "Esc to cancel")。
+ public static void RenderPermissionEscHint(Screen screen, Rect frame, Theme theme)
+ {
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ screen.WriteText(frame.Y, frame.X, " Esc to cancel", dim, frame.Right);
+ }
}
diff --git a/native_tui/src/NativeTui.Core/repl/PermissionCoordinator.cs b/native_tui/src/NativeTui.Core/repl/PermissionCoordinator.cs
new file mode 100644
index 000000000..f72154698
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/PermissionCoordinator.cs
@@ -0,0 +1,85 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 权限确认选择(三档)— 对齐基准 FallbackPermissionRequest 选项值:
+/// yes → Allow / yes-dont-ask-again → AlwaysAllow / no → Deny(Esc 取消同 Deny)。
+///
+public enum PermissionChoice
+{
+ /// 允许(本次执行)。
+ Allow,
+
+ /// 始终允许(会话级规则 — Host 映射引擎 AlwaysAllow)。
+ AlwaysAllow,
+
+ /// 拒绝(含 Esc 取消)。
+ Deny,
+}
+
+/// 挂起中的权限请求(工具名 + 描述参数)。
+public sealed record PermissionRequest(string ToolName, string Description);
+
+///
+/// 权限对话框协调器(M7-3)— TCS 桥 + 对话框状态:
+/// Begin 建立挂起请求(旧等待者取消,对齐青蛙TUI DialogRequest 取代语义),
+/// Complete 解除等待并清空(幂等);焦点三档环绕(Select 焦点等价物,Begin 重置 0)。
+/// 消费方:ReplShell 模态改道(数字/Esc/上下/Enter)+ Host 层 TuiPermissionConfirmationHandler
+///(引擎 Confirm 回调 → Begin → Wait 同步阻塞 → 用户按键 Complete)。
+///
+public sealed class PermissionCoordinator
+{
+ /// 选项档数(Allow/AlwaysAllow/Deny)。
+ public const int OptionCount = 3;
+
+ private TaskCompletionSource? _pending;
+
+ /// 当前挂起请求(无挂起为 null)。
+ public PermissionRequest? Request { get; private set; }
+
+ /// 焦点选项索引(0-2,环绕移动)。
+ public int FocusIndex { get; private set; }
+
+ /// 是否挂起中(对话框模态路由依据)。
+ public bool IsPending => _pending is not null;
+
+ /// 等待用户选择(Host handler 同步阻塞 GetAwaiter().GetResult();无挂起时为已完成 Deny 的占位任务)。
+ public Task Wait() =>
+ (_pending ?? CompletedDeny).Task;
+
+ /// 已完成 Deny 的占位任务(无挂起 Wait 的安全值)。
+ private static readonly TaskCompletionSource CompletedDeny = CreateCompleted(PermissionChoice.Deny);
+
+ /// Begin 挂起请求 — 取消旧等待者 + 焦点重置。
+ public void Begin(PermissionRequest request)
+ {
+ _pending?.TrySetCanceled();
+ _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Request = request;
+ FocusIndex = 0;
+ }
+
+ /// Complete 解除等待 + 清空状态(幂等 — 二次调用 no-op)。
+ public void Complete(PermissionChoice choice)
+ {
+ if (_pending is null)
+ {
+ return;
+ }
+
+ _pending.TrySetResult(choice);
+ _pending = null;
+ Request = null;
+ }
+
+ /// 焦点环绕移动(上 = -1 / 下 = +1)。
+ public void MoveFocus(int delta) =>
+ FocusIndex = ((FocusIndex + delta) % OptionCount + OptionCount) % OptionCount;
+
+ /// 构造已完成结果的占位 TCS。
+ private static TaskCompletionSource CreateCompleted(PermissionChoice choice)
+ {
+ var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ source.TrySetResult(choice);
+ return source;
+ }
+}
diff --git a/native_tui/src/NativeTui.Core/repl/PermissionRequestItem.cs b/native_tui/src/NativeTui.Core/repl/PermissionRequestItem.cs
new file mode 100644
index 000000000..1026112b6
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/PermissionRequestItem.cs
@@ -0,0 +1,68 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// 权限对话框消息项(M7-3)— 对齐基准 FallbackPermissionRequest 渲染结构
+///(PermissionDialog 顶边框 + PermissionRequestTitle 标题 + 工具/描述行 + PermissionPrompt 问题与选项 + Esc 提示):
+/// margin 1 + 顶边框(permission 色全宽,仅 top 无角)+ 标题(1 缩进 bold permission "Tool use")
+/// + paddingY 空行 + 工具行(3 缩进 bold 名 + (描述))+ 描述行(3 缩进 dim,空描述折叠)
+/// + paddingY 空行 + 问题行("Do you want to proceed?")+ 三档数字前缀选项(焦点 ❯ suggestion)
+/// + Esc 提示 margin + "Esc to cancel" dim。
+/// 状态源 = PermissionCoordinator(焦点/请求),Begin 时插入 Messages,Complete 时移除。
+///
+public sealed class PermissionRequestItem(PermissionCoordinator coordinator) : IScrollItem
+{
+ /// 固定标题(基准 PermissionDialog title="Tool use")。
+ private const string Title = "Tool use";
+
+ /// 三档选项标签(对齐基准 yes / yes-dont-ask-again / no;dont-ask 文案合并 toolName 语境简化为固定串)。
+ private static readonly string[] OptionLabels =
+ [
+ "Yes",
+ "Yes, and don't ask again for this tool",
+ "No",
+ ];
+
+ /// 协调器(焦点与请求状态的唯一来源)。
+ public PermissionCoordinator Coordinator { get; } = coordinator;
+
+ /// 内容高度 = margin 1 + 边框 1 + 标题 1 + 空行 1 + 工具 1 + 描述(空则 0) + 空行 1 + 问题 1 + 选项 3 + Esc margin 1 + Esc 1。
+ public int MeasureHeight(int width) =>
+ 12 + (string.IsNullOrEmpty(Coordinator.Request?.Description) ? 0 : 1);
+
+ /// 渲染:按固定行序绘制(状态由 Coordinator 投影 — 挂起被清除时只画空 margin)。
+ public void Render(Screen screen, Rect frame, Theme theme)
+ {
+ var request = Coordinator.Request;
+ if (request is null)
+ {
+ return;
+ }
+
+ var row = frame.Y + 1;
+ Row(screen, frame, theme, row++, MessageRenderer.RenderPermissionBorder);
+ MessageRenderer.RenderPermissionTitle(screen, RowFrame(frame, row++), theme, Title);
+ row++;
+ MessageRenderer.RenderPermissionToolLine(screen, RowFrame(frame, row++), theme, request.ToolName, request.Description);
+ if (!string.IsNullOrEmpty(request.Description))
+ {
+ MessageRenderer.RenderPermissionDescription(screen, RowFrame(frame, row++), theme, request.Description);
+ }
+
+ row++;
+ MessageRenderer.RenderPermissionQuestion(screen, RowFrame(frame, row++), theme);
+ for (var i = 0; i < PermissionCoordinator.OptionCount; i++)
+ {
+ MessageRenderer.RenderPermissionOption(screen, RowFrame(frame, row++), theme, i, OptionLabels[i], i == Coordinator.FocusIndex);
+ }
+
+ row++;
+ MessageRenderer.RenderPermissionEscHint(screen, RowFrame(frame, row), theme);
+ }
+
+ /// 单行 frame(Y 为该渲染行,高度 1)。
+ private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 };
+
+ /// 无边框参数的单行渲染转发。
+ private static void Row(Screen screen, Rect frame, Theme theme, int row, Action render) =>
+ render(screen, RowFrame(frame, row), theme);
+}
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 2be687a05..194fcfcf8 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -31,6 +31,9 @@ public sealed class ReplShell
/// bridge 事件应用器(引擎事件 → 视图状态唯一翻译层)。
private readonly ReplBridgeDriver _driver;
+ /// 权限对话框协调器(M7-3 — 挂起时键盘模态改道)。
+ private readonly PermissionCoordinator _permission = new();
+
/// 启动 banner(首项 — 真实引擎由 session_info 事件原地更新)。
private readonly BannerItem _banner;
@@ -76,6 +79,9 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool
/// 主循环运行标记(false = Esc/Ctrl+C/引擎死亡请求退出)。
internal bool Running => _running;
+ /// 权限对话框协调器(Host handler Begin/Wait + 测试断言面)。
+ internal PermissionCoordinator Permission => _permission;
+
/// 主循环 — 每拍:排空键盘 → 排空引擎事件 → 差分上屏 → 对齐 50ms。
public void Run()
{
@@ -128,6 +134,33 @@ internal void DrainEngine()
AddMessage(new ErrorMessageItem("引擎进程已退出,会话结束。"));
_running = false;
}
+
+ // 回合结束(完成/打断/异常)时挂起中的权限对话框自动收尾 — 引擎不会再等用户,Deny 关闭避免对话框残留
+ if (!_driver.IsBusy && _permission.IsPending)
+ {
+ CompletePermission(PermissionChoice.Deny);
+ }
+ }
+
+ /// 挂起权限请求(Host 层 TuiPermissionConfirmationHandler.Confirm → 此处 → 用户按键 Complete)。
+ internal void BeginPermissionRequest(string toolName, string description)
+ {
+ _permission.Begin(new PermissionRequest(toolName, description));
+ AddMessage(new PermissionRequestItem(_permission));
+ }
+
+ /// 完成权限请求并移除对话框项。
+ private void CompletePermission(PermissionChoice choice)
+ {
+ _permission.Complete(choice);
+ for (var i = 0; i < _view.Messages.Count; i++)
+ {
+ if (_view.Messages.Items[i] is PermissionRequestItem)
+ {
+ _view.Messages.RemoveAt(i);
+ return;
+ }
+ }
}
/// 新消息插入到常驻 spinner 之前(spinner 恒为滚动区最后一项)。
@@ -143,8 +176,8 @@ private void DrainInput()
}
}
- /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序)。
- private void HandleKey(ConsoleKeyInfo keyInfo)
+ /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序 — 测试经 ConsoleKeyInfo 全链注入)。
+ internal void HandleKey(ConsoleKeyInfo keyInfo)
{
var key = ConsoleKeyAdapter.ToKeyPress(keyInfo);
if (key is null)
@@ -155,9 +188,15 @@ private void HandleKey(ConsoleKeyInfo keyInfo)
HandleKeyPress(key);
}
- /// 按键分派 — Emacs 编辑(Ctrl/Meta 词操作 + kill ring)优先,控制键走编辑/提交/权限,字符插入。
+ /// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框),其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。
private void HandleKeyPress(KeyPress key)
{
+ if (_permission.IsPending)
+ {
+ HandlePermissionKey(key);
+ return;
+ }
+
if (key.Kind == KeyKind.Escape)
{
HandleEscape();
@@ -341,6 +380,43 @@ private void MoveWord(int direction)
}
}
+ ///
+ /// 权限对话框模态按键(M7-3,对齐基准 CustomSelect useSelectInput):
+ /// 数字 1/2/3 直接选档(yes/dont-ask-again/no)+ Esc 取消(Deny)+ Enter 选焦点档 + 上/下环绕移动焦点;
+ /// 其余按键全部吞掉(模态 — 不进输入缓冲、不打断回合、不退出)。
+ ///
+ private void HandlePermissionKey(KeyPress key)
+ {
+ if (key.Kind == KeyKind.Escape)
+ {
+ CompletePermission(PermissionChoice.Deny);
+ return;
+ }
+
+ if (key.Kind == KeyKind.Return)
+ {
+ CompletePermission((PermissionChoice)_permission.FocusIndex);
+ return;
+ }
+
+ if (key.Kind == KeyKind.UpArrow)
+ {
+ _permission.MoveFocus(-1);
+ return;
+ }
+
+ if (key.Kind == KeyKind.DownArrow)
+ {
+ _permission.MoveFocus(1);
+ return;
+ }
+
+ if (key is { Kind: KeyKind.Char, Ctrl: false, Meta: false, Shift: false } && key.Input is "1" or "2" or "3")
+ {
+ CompletePermission((PermissionChoice)(key.Input[0] - '1'));
+ }
+ }
+
/// 视口模式滚动(inline 模式历史由终端滚轮原生回看,no-op)。
private void ScrollIfViewport(int rows)
{
diff --git a/native_tui/tests/NativeTui.Tests/repl/PermissionDialogTests.cs b/native_tui/tests/NativeTui.Tests/repl/PermissionDialogTests.cs
new file mode 100644
index 000000000..a2046372b
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/PermissionDialogTests.cs
@@ -0,0 +1,396 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// 权限对话框测试(M7-3)— 对齐基准 FallbackPermissionRequest 形态:
+/// PermissionCoordinator TCS 桥(Begin/Complete/Wait + 焦点环绕)+ PermissionRequestItem 渲染
+/// (仅顶边框 permission 色 round 无角全宽 / bold permission 标题 paddingX=1 / 工具行 3 空格缩进 bold 名 /
+/// 问题描述 dim / "Do you want to proceed?" / Select 三档数字前缀选项[1.Yes 2.始终允许 3.No] /
+/// 焦点行 ❯ suggestion + index dim / Esc to cancel dim)+ ReplShell 模态改道
+/// (挂起时数字/Esc/上下/Enter 路由对话框,其余吞掉;回合结束自动 Deny 收尾)。
+///
+public sealed class PermissionDialogTests
+{
+ /// 构造挂起请求的 coordinator(默认 Bash/run tests)。
+ private static PermissionCoordinator BeginCoordinator(string tool = "Bash", string description = "run tests")
+ {
+ var coordinator = new PermissionCoordinator();
+ coordinator.Begin(new PermissionRequest(tool, description));
+ return coordinator;
+ }
+
+ // ---- PermissionCoordinator ----
+
+ /// Begin 挂起请求 — IsPending + Request 就位。
+ [Fact]
+ public void Begin_SetsPendingRequest()
+ {
+ var coordinator = BeginCoordinator("Edit", "write a.cs");
+
+ Assert.True(coordinator.IsPending);
+ Assert.Equal("Edit", coordinator.Request?.ToolName);
+ Assert.Equal("write a.cs", coordinator.Request?.Description);
+ }
+
+ /// Begin 二次请求 — 旧等待者取消(对齐青蛙TUI DialogRequest 取代语义)。
+ [Fact]
+ public async Task Begin_SecondRequest_CancelsFirstWaiter()
+ {
+ var coordinator = new PermissionCoordinator();
+ coordinator.Begin(new PermissionRequest("A", "a"));
+ var first = coordinator.Wait();
+
+ coordinator.Begin(new PermissionRequest("B", "b"));
+
+ await Assert.ThrowsAsync(() => first);
+ Assert.Equal("B", coordinator.Request?.ToolName);
+ }
+
+ /// Complete — 等待者拿到选择 + 状态清空。
+ [Fact]
+ public async Task Complete_ResolvesWaiterAndClears()
+ {
+ var coordinator = BeginCoordinator();
+ var task = coordinator.Wait();
+
+ coordinator.Complete(PermissionChoice.Allow);
+
+ Assert.Equal(PermissionChoice.Allow, await task);
+ Assert.False(coordinator.IsPending);
+ Assert.Null(coordinator.Request);
+ }
+
+ /// 无挂起时 Complete — no-op 不抛。
+ [Fact]
+ public void Complete_WithoutBegin_NoOp()
+ {
+ var coordinator = new PermissionCoordinator();
+
+ coordinator.Complete(PermissionChoice.Deny);
+
+ Assert.False(coordinator.IsPending);
+ }
+
+ /// Complete 幂等 — 二次 Complete 不影响首个结果。
+ [Fact]
+ public async Task Complete_Twice_KeepsFirstResult()
+ {
+ var coordinator = BeginCoordinator();
+ var task = coordinator.Wait();
+
+ coordinator.Complete(PermissionChoice.Deny);
+ coordinator.Complete(PermissionChoice.Allow);
+
+ Assert.Equal(PermissionChoice.Deny, await task);
+ }
+
+ /// 焦点移动 — 上下环绕(0 上→2,2 下→0),Begin 重置焦点。
+ [Fact]
+ public void MoveFocus_WrapsAround()
+ {
+ var coordinator = BeginCoordinator();
+
+ Assert.Equal(0, coordinator.FocusIndex);
+ coordinator.MoveFocus(-1);
+ Assert.Equal(2, coordinator.FocusIndex);
+ coordinator.MoveFocus(1);
+ Assert.Equal(0, coordinator.FocusIndex);
+
+ coordinator.Begin(new PermissionRequest("X", "x"));
+ Assert.Equal(0, coordinator.FocusIndex);
+ }
+
+ // ---- PermissionRequestItem 渲染 ----
+
+ /// 顶边框 — 全宽 ─ 行 permission 色(round 无角、无左右底边)。
+ [Fact]
+ public void Render_TopBorderPermissionColoredFullWidth()
+ {
+ using var screen = new Screen(cols: 40, rows: 13);
+ var coordinator = BeginCoordinator();
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 40, 13), Theme.Dark);
+
+ var border = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Permission });
+ Assert.Equal(new Rune('─'), screen[1, 0].Value);
+ Assert.Equal(border, screen[1, 0].StyleId);
+ Assert.Equal(new Rune('─'), screen[1, 39].Value);
+ Assert.Equal(border, screen[1, 39].StyleId);
+ Assert.Equal(new string('─', 40), RowText.Of(screen, 1, 0, 40));
+
+ // 仅顶边框:row2 首列是标题 padding 空格(无角字符/竖线)
+ Assert.Equal(new Rune(' '), screen[2, 0].Value);
+ }
+
+ /// 标题行 — paddingX=1 + bold permission 色 "Tool use"。
+ [Fact]
+ public void Render_TitleBoldPermissionPaddedOne()
+ {
+ using var screen = new Screen(cols: 40, rows: 13);
+ var coordinator = BeginCoordinator();
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 40, 13), Theme.Dark);
+
+ var title = screen.Styles.GetOrAdd(new TextStyle { Bold = true, Foreground = Theme.Dark.Permission });
+ Assert.Equal(new Rune(' '), screen[2, 0].Value);
+ Assert.Equal(new Rune('T'), screen[2, 1].Value);
+ Assert.Equal(title, screen[2, 1].StyleId);
+ Assert.Equal(title, screen[2, 8].StyleId);
+ Assert.Equal(" Tool use", RowText.Of(screen, 2, 0, 9));
+ }
+
+ /// 工具行 — 3 空格缩进 + bold 工具名 + 默认样式 (描述参数);描述行 dim。
+ [Fact]
+ public void Render_ToolRowBoldNameDescriptionDim()
+ {
+ using var screen = new Screen(cols: 40, rows: 13);
+ var coordinator = BeginCoordinator("Bash", "run tests");
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 40, 13), Theme.Dark);
+
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ // row4 = " Bash(run tests)":3 空格 + bold Bash + 默认 (run tests)
+ Assert.Equal(new Rune('B'), screen[4, 3].Value);
+ Assert.Equal(bold, screen[4, 3].StyleId);
+ Assert.Equal(new Rune('('), screen[4, 7].Value);
+ Assert.Equal(0, screen[4, 7].StyleId);
+ Assert.Equal(" Bash(run tests)", RowText.Of(screen, 4, 0, 18));
+
+ // row5 = " run tests" dim
+ Assert.Equal(new Rune('r'), screen[5, 3].Value);
+ Assert.Equal(dim, screen[5, 3].StyleId);
+ Assert.Equal(" run tests", RowText.Of(screen, 5, 0, 12));
+ }
+
+ /// 焦点选项行 — ❯ suggestion + gap + "1. " dim + label suggestion。
+ [Fact]
+ public void Render_FocusedOptionPointerSuggestion()
+ {
+ using var screen = new Screen(cols: 40, rows: 13);
+ var coordinator = BeginCoordinator();
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 40, 13), Theme.Dark);
+
+ var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion });
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune('❯'), screen[8, 0].Value);
+ Assert.Equal(suggestion, screen[8, 0].StyleId);
+ Assert.Equal(new Rune(' '), screen[8, 1].Value);
+ Assert.Equal(new Rune('1'), screen[8, 2].Value);
+ Assert.Equal(dim, screen[8, 2].StyleId);
+ Assert.Equal(new Rune('Y'), screen[8, 5].Value);
+ Assert.Equal(suggestion, screen[8, 5].StyleId);
+ Assert.Equal("❯ 1. Yes", RowText.Of(screen, 8, 0, 8));
+ }
+
+ /// 非焦点选项行 — 空格 indicator + "2. " dim + label 默认样式。
+ [Fact]
+ public void Render_UnfocusedOptionDimIndexDefaultLabel()
+ {
+ using var screen = new Screen(cols: 48, rows: 13);
+ var coordinator = BeginCoordinator();
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 48, 13), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune(' '), screen[9, 0].Value);
+ Assert.Equal(new Rune(' '), screen[9, 1].Value);
+ Assert.Equal(new Rune('2'), screen[9, 2].Value);
+ Assert.Equal(dim, screen[9, 2].StyleId);
+ Assert.Equal(0, screen[9, 5].StyleId);
+ Assert.Equal(" 2. Yes, and don't ask again for this tool", RowText.Of(screen, 9, 0, 43));
+
+ Assert.Equal(" 3. No", RowText.Of(screen, 10, 0, 7));
+ }
+
+ /// 问题行 + Esc 提示行 — question 默认样式 / "Esc to cancel" dim(各 1 空格缩进)。
+ [Fact]
+ public void Render_QuestionAndEscHint()
+ {
+ using var screen = new Screen(cols: 40, rows: 13);
+ var coordinator = BeginCoordinator();
+ new PermissionRequestItem(coordinator).Render(screen, new Rect(0, 0, 40, 13), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(" Do you want to proceed?", RowText.Of(screen, 7, 0, 24));
+ Assert.Equal(0, screen[7, 1].StyleId);
+
+ Assert.Equal(" Esc to cancel", RowText.Of(screen, 12, 0, 14));
+ Assert.Equal(dim, screen[12, 1].StyleId);
+ }
+
+ /// MeasureHeight — margin1 + border1 + title1 + padY1 + tool1 + desc1 + padY1 + question1 + 3 选项 + escMargin1 + esc1 = 13。
+ [Fact]
+ public void MeasureHeight_ThirteenRowsWithDescription()
+ {
+ var coordinator = BeginCoordinator();
+ Assert.Equal(13, new PermissionRequestItem(coordinator).MeasureHeight(width: 40));
+ }
+
+ /// MeasureHeight — 无描述时 12(描述行折叠)。
+ [Fact]
+ public void MeasureHeight_TwelveRowsWithoutDescription()
+ {
+ var coordinator = BeginCoordinator("Bash", "");
+ Assert.Equal(12, new PermissionRequestItem(coordinator).MeasureHeight(width: 40));
+ }
+
+ // ---- ReplShell 模态改道 ----
+
+ /// 构造测试 shell(复用 ReplShellTests 模式)。
+ private static (ReplShell Shell, FakePermissionEngine Engine) CreateShell()
+ {
+ var engine = new FakePermissionEngine();
+ var banner = new BannerItem("1.0.0", "test-model", "API 按量计费", "/tmp");
+ var shell = new ReplShell(engine, banner, new StringWriter());
+ shell.SeedContent();
+ return (shell, engine);
+ }
+
+ /// 模拟按键(ConsoleKeyInfo → HandleKey 全链归一)。
+ private static void Press(ReplShell shell, char c) =>
+ shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.D1 + (c - '1'), false, false, false));
+
+ /// 数字 1 — 完成 Allow + 对话框项移除 + 输入缓冲不受污染。
+ [Fact]
+ public async Task Shell_Digit1_CompletesAllowAndRemovesItem()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "run tests");
+ var task = shell.Permission.Wait();
+ Assert.Contains(shell.View.Messages.Items, m => m is PermissionRequestItem);
+
+ shell.HandleKey(new ConsoleKeyInfo('1', ConsoleKey.D1, false, false, false));
+
+ Assert.Equal(PermissionChoice.Allow, await task);
+ Assert.DoesNotContain(shell.View.Messages.Items, m => m is PermissionRequestItem);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// 数字 2 — 完成 AlwaysAllow;数字 3 / Esc — 完成 Deny。
+ [Fact]
+ public async Task Shell_Digit2And3_MapAlwaysAllowAndDeny()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "a");
+ var always = shell.Permission.Wait();
+ shell.HandleKey(new ConsoleKeyInfo('2', ConsoleKey.D2, false, false, false));
+ Assert.Equal(PermissionChoice.AlwaysAllow, await always);
+
+ shell.BeginPermissionRequest("Bash", "b");
+ var deny = shell.Permission.Wait();
+ shell.HandleKey(new ConsoleKeyInfo('3', ConsoleKey.D3, false, false, false));
+ Assert.Equal(PermissionChoice.Deny, await deny);
+
+ shell.BeginPermissionRequest("Bash", "c");
+ var esc = shell.Permission.Wait();
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false));
+ Assert.Equal(PermissionChoice.Deny, await esc);
+ }
+
+ /// Esc 挂起时 — 不触发打断/清输入/退出(对话框模态优先)。
+ [Fact]
+ public async Task Shell_EscapeWhilePending_DoesNotInterruptOrExit()
+ {
+ var (shell, engine) = CreateShell();
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+ shell.BeginPermissionRequest("Bash", "x");
+ var task = shell.Permission.Wait();
+
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false));
+
+ Assert.Equal(PermissionChoice.Deny, await task);
+ Assert.Equal(0, engine.Interrupts);
+ Assert.True(shell.Running);
+ }
+
+ /// 上下箭头 — 焦点环绕移动,不进输入缓冲。
+ [Fact]
+ public void Shell_ArrowsWhilePending_MoveFocusNotInput()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "x");
+
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false));
+ Assert.Equal(2, shell.Permission.FocusIndex);
+
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false));
+ Assert.Equal(0, shell.Permission.FocusIndex);
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// Enter — 选择焦点项(默认焦点 0 = Allow)。
+ [Fact]
+ public async Task Shell_EnterSelectsFocusedOption()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "x");
+ var task = shell.Permission.Wait();
+
+ shell.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false));
+ shell.HandleKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false));
+
+ Assert.Equal(PermissionChoice.Deny, await task);
+ }
+
+ /// 其他字符 — 模态吞掉(不进输入缓冲、不提交)。
+ [Fact]
+ public void Shell_OtherCharsSwallowedWhilePending()
+ {
+ var (shell, engine) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "x");
+
+ shell.HandleKey(new ConsoleKeyInfo('x', ConsoleKey.X, false, false, false));
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ Assert.Empty(engine.Submits);
+ Assert.True(shell.Permission.IsPending);
+ }
+
+ /// 回合结束(完成/打断)— 自动 Deny 收尾 + 对话框移除。
+ [Fact]
+ public async Task Shell_TurnEnd_AutoCompletesPendingAsDeny()
+ {
+ var (shell, engine) = CreateShell();
+ shell.BeginPermissionRequest("Bash", "x");
+ var task = shell.Permission.Wait();
+
+ engine.Events.Enqueue(new BridgeEvent.TurnCompleted());
+ shell.DrainEngine();
+
+ Assert.Equal(PermissionChoice.Deny, await task);
+ Assert.DoesNotContain(shell.View.Messages.Items, m => m is PermissionRequestItem);
+ }
+
+ /// 记录型假引擎(ReplShellTests.FakeEngine 同构)。
+ private sealed class FakePermissionEngine : IReplEngine
+ {
+ /// 收到的提交文本。
+ public List Submits { get; } = [];
+
+ /// 收到的打断次数。
+ public int Interrupts;
+
+ /// 待排空事件队列。
+ public Queue Events { get; } = new();
+
+ /// 记录提交。
+ public void Submit(string text) => Submits.Add(text);
+
+ /// 记录打断。
+ public void Interrupt() => Interrupts++;
+
+ /// 记录权限模式。
+ public void SetPermissionMode(string mode)
+ {
+ }
+
+ /// 排空事件队列。
+ public IReadOnlyList DrainEvents()
+ {
+ var drained = Events.ToList();
+ Events.Clear();
+ return drained;
+ }
+
+ /// 存活标记。
+ public bool IsAlive => true;
+ }
+}
From 340a0a9c4d548b64824e1090b1e7aad45dc02637 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 22:35:09 +0800
Subject: [PATCH 10/16] =?UTF-8?q?feat:=20M7-3=20Host=20=E6=9D=83=E9=99=90?=
=?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=BC=95=E6=93=8E=E9=97=AD=E7=8E=AF=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20TuiPermissionBridge=20=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E7=BA=BF=E7=A8=8BConfirm=E2=86=92shell=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=E6=8C=82=E8=B5=B7=E2=86=92TCS=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E9=98=BB=E5=A1=9E=E7=AD=89=E6=8C=89=E9=94=AE(=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90CLI=20IConfirmationGate=E5=8D=95=E9=80=9A=E9=81=93?=
=?UTF-8?q?=E5=85=88=E4=BE=8B,=E6=9C=AA=E7=BB=91=E5=AE=9Ashell=E9=99=8D?=
=?UTF-8?q?=E7=BA=A7Deny=E9=98=B2=E6=AD=BB=E9=94=81),=20TuiPermissionConfi?=
=?UTF-8?q?rmationHandler=20=E8=BD=AC=E5=8F=91bridge+=E4=B8=B4=E6=97=B6?=
=?UTF-8?q?=E6=89=B9=E5=87=86(Allow=201=E5=88=86=E9=92=9F/AlwaysAllow=2030?=
=?UTF-8?q?=E5=88=86=E9=92=9F+[=E7=BA=A2=E7=BB=BF=E7=81=AFask]=E5=90=8C?=
=?UTF-8?q?=E7=BA=A7=E6=89=B9=E5=87=86,=E5=AF=B9=E9=BD=90CliPermissionConf?=
=?UTF-8?q?irmationHandler),=20TuiInteractionModule(Order=3D80)=E7=BB=8FCr?=
=?UTF-8?q?eateGuiSessionAsync=20extraModules=E6=B3=A8=E5=86=8Chandler?=
=?UTF-8?q?=E5=85=B3=E9=97=ADPendingConfirmation=E9=97=AD=E7=8E=AF,=20Begi?=
=?UTF-8?q?nPermissionRequest/Permission=E5=8D=87public(=E5=AE=BF=E4=B8=BB?=
=?UTF-8?q?=E7=94=9F=E4=BA=A7=E6=B6=88=E8=B4=B9=E9=9D=A2),=20selftest?=
=?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=AE=B9=E5=99=A8=E7=BB=84=E8=A3=85=E9=AA=8C?=
=?UTF-8?q?=E8=AF=81=E9=80=9A=E8=BF=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/EngineSession.cs | 16 +-
.../apps/NativeTui.Host/GlobalUsings.cs | 2 +
native_tui/apps/NativeTui.Host/Program.cs | 1 +
.../NativeTui.Host/TuiInteractionModule.cs | 24 ++
.../NativeTui.Host/TuiPermissionBridge.cs | 34 +++
.../TuiPermissionConfirmationHandler.cs | 48 ++++
.../src/NativeTui.Core/NativeTui.Core.csproj | 1 +
.../src/NativeTui.Core/repl/ReplShell.cs | 8 +-
.../NativeTui.Host.Tests/GlobalUsings.cs | 7 +
.../TuiPermissionConfirmationTests.cs | 234 ++++++++++++++++++
10 files changed, 367 insertions(+), 8 deletions(-)
create mode 100644 native_tui/apps/NativeTui.Host/TuiInteractionModule.cs
create mode 100644 native_tui/apps/NativeTui.Host/TuiPermissionBridge.cs
create mode 100644 native_tui/apps/NativeTui.Host/TuiPermissionConfirmationHandler.cs
create mode 100644 native_tui/tests/NativeTui.Host.Tests/TuiPermissionConfirmationTests.cs
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 278d6aa60..5b8c12805 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -10,18 +10,26 @@ namespace NativeTui.Host;
public sealed class EngineSession : IAsyncDisposable {
private readonly EngineSessionFactory.Result _engine;
private readonly EngineTurnClient _client;
+ private readonly TuiPermissionBridge _permissionBridge;
- private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client) {
+ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge) {
_engine = engine;
_client = client;
+ _permissionBridge = permissionBridge;
}
/// 回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。
public EngineTurnClient Client => _client;
- /// 组装引擎会话 — 建会话 → 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
+ /// 权限确认桥 — Program 在 shell 构造后 Bind(引擎 PendingConfirmation → TUI 对话框闭环,M7-3)。
+ public TuiPermissionBridge PermissionBridge => _permissionBridge;
+
+ /// 组装引擎会话 — 建会话(TuiInteractionModule 注册权限确认 handler)→ 解析权限/模型 → 构造回合客户端 → 种子 session_info;失败时释放已建引擎 Host。
public static async Task CreateAsync(CancellationToken cancellationToken = default) {
- var engine = await EngineSessionFactory.CreateGuiSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
+ var permissionBridge = new TuiPermissionBridge();
+ var engine = await EngineSessionFactory.CreateGuiSessionAsync(
+ extraModules: [new TuiInteractionModule(permissionBridge)],
+ cancellationToken: cancellationToken).ConfigureAwait(false);
try {
var checker = await ResolvePermissionCheckerAsync(engine.Services).ConfigureAwait(false);
var fastMode = engine.Services.GetService();
@@ -38,7 +46,7 @@ public static async Task CreateAsync(CancellationToken cancellati
},
BuildPermissionModeApplier(checker));
- var session = new EngineSession(engine, client);
+ var session = new EngineSession(engine, client, permissionBridge);
client.Enqueue(BuildSessionInfoEvent(checker, fastMode));
return session;
} catch {
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index c5b2c317a..64351cc2a 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -7,6 +7,8 @@
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;
global using JoinCode.Abstractions.Utils;
global using JoinCode.Adapters;
global using JoinCode.App.Builder;
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index bfe9e7ba9..bd4ae02c3 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -50,6 +50,7 @@ private static async Task RunInteractiveAsync() {
// banner 占位值 — 首拍 session_info 种子事件覆盖为引擎真实数据(EngineSession.Enqueue)
var banner = new BannerItem("0.0.0", "当前模型未设置", "API 按量计费", Environment.CurrentDirectory);
var shell = new ReplShell(session.Client, banner, tuiOutput);
+ session.PermissionBridge.Bind(shell);
shell.Run();
shell.WriteExit();
}
diff --git a/native_tui/apps/NativeTui.Host/TuiInteractionModule.cs b/native_tui/apps/NativeTui.Host/TuiInteractionModule.cs
new file mode 100644
index 000000000..eb7c1bcc5
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/TuiInteractionModule.cs
@@ -0,0 +1,24 @@
+namespace NativeTui.Host;
+
+///
+/// TUI 交互模块(M7-3)— 注册 NativeTui 宿主专属交互服务:
+/// TuiPermissionBridge(shell 绑定桥,构造注入保证引擎容器与宿主同一实例)+
+/// TuiPermissionConfirmationHandler(IPermissionConfirmationHandler 引擎确认入口)。
+/// Order=80 与 GuiInteractionModule 同级(CoreModule=30 之后覆盖 — 本模块未覆盖项时引擎走自身默认)。
+///
+public sealed class TuiInteractionModule(TuiPermissionBridge bridge) : IAppModule {
+ /// 模块注册顺序(80 与 GuiInteractionModule/CliModule 同级)。
+ public int Order => 80;
+
+ /// 交互桥(宿主构造,引擎容器与 Program 共享同一实例)。
+ public TuiPermissionBridge Bridge { get; } = bridge;
+
+ /// 注册交互服务。
+ public void ConfigureServices(IServiceCollection services, AppModuleContext context) {
+ services.AddSingleton(Bridge);
+ services.AddSingleton();
+ }
+
+ /// 异步配置(无异步初始化)。
+ public Task ConfigureAsync(IServiceProvider services, CancellationToken ct) => Task.CompletedTask;
+}
diff --git a/native_tui/apps/NativeTui.Host/TuiPermissionBridge.cs b/native_tui/apps/NativeTui.Host/TuiPermissionBridge.cs
new file mode 100644
index 000000000..10f0d75c2
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/TuiPermissionBridge.cs
@@ -0,0 +1,34 @@
+namespace NativeTui.Host;
+
+///
+/// TUI 权限桥(M7-3)— 引擎线程与 TUI 主线程的交互桥:
+/// 引擎 Confirm 回调(回合执行线程)→ shell.BeginPermissionRequest 挂起对话框 →
+/// 同步阻塞等用户按键(TCS 异步续延,主循环线程解除)→ 返回引擎 PermissionConfirmAction。
+/// shell 未绑定(极早期请求/退出中)直接 Deny 降级 — 防引擎线程死等。
+/// 与 CLI CliPermissionConfirmationHandler 的 IConfirmationGate 单通道路由同构(ADR 0143 进程内闭环)。
+///
+public class TuiPermissionBridge {
+ /// 绑定的 REPL 外壳(volatile — Program 在 shell 构造后绑定,引擎线程并发读)。
+ private volatile ReplShell? _shell;
+
+ /// 绑定外壳(Program 交互模式组装点 — shell 构造后、Run 前调用)。
+ public void Bind(ReplShell shell) => _shell = shell;
+
+ ///
+ /// 挂起权限对话框并等待用户选择 — 工具名/描述透传对话框,按键解除后映射引擎动作;
+ /// 未绑定 shell 时 Deny(降级不阻塞)。virtual 供测试替身(FakeBridge 先例)。
+ ///
+ public virtual PermissionConfirmAction Confirm(string toolName, string confirmationPrompt) {
+ var shell = _shell;
+ if (shell is null)
+ return PermissionConfirmAction.Deny;
+
+ shell.BeginPermissionRequest(toolName, confirmationPrompt);
+ var choice = shell.Permission.Wait().GetAwaiter().GetResult();
+ return choice switch {
+ PermissionChoice.Allow => PermissionConfirmAction.Allow,
+ PermissionChoice.AlwaysAllow => PermissionConfirmAction.AlwaysAllow,
+ _ => PermissionConfirmAction.Deny,
+ };
+ }
+}
diff --git a/native_tui/apps/NativeTui.Host/TuiPermissionConfirmationHandler.cs b/native_tui/apps/NativeTui.Host/TuiPermissionConfirmationHandler.cs
new file mode 100644
index 000000000..6b6f509a4
--- /dev/null
+++ b/native_tui/apps/NativeTui.Host/TuiPermissionConfirmationHandler.cs
@@ -0,0 +1,48 @@
+namespace NativeTui.Host;
+
+///
+/// TUI 权限确认处理器(M7-3)— 引擎 PendingConfirmation 交互闭环的引擎侧实现:
+/// 转发 TuiPermissionBridge(对话框交互)+ 按结果临时批准工具/等级
+/// (Allow 1 分钟 / AlwaysAllow 30 分钟 + prompt 内 levelTag 同级批准 — 对齐 CLI CliPermissionConfirmationHandler 先例,
+/// 关闭"允许后同回合重跑管道直接通过"闭环)。
+///
+public sealed class TuiPermissionConfirmationHandler(TuiPermissionBridge bridge, IToolPermissionManager? permissionManager = null)
+ : IPermissionConfirmationHandler {
+
+ /// Allow 档临时批准时长(CLI 先例 1 分钟)。
+ private static readonly TimeSpan AllowDuration = TimeSpan.FromMinutes(1);
+
+ /// AlwaysAllow 档临时批准时长(CLI 先例 30 分钟)。
+ private static readonly TimeSpan AlwaysAllowDuration = TimeSpan.FromMinutes(30);
+
+ /// 交互桥(shell 对话框)。
+ public TuiPermissionBridge Bridge { get; } = bridge;
+
+ /// 工具权限管理器(可选 — 无权限系统时仅交互不批准)。
+ public IToolPermissionManager? PermissionManager { get; } = permissionManager;
+
+ ///
+ /// 挂起对话框等待用户确认并按结果临时批准 — 用户选择经 bridge 返回,
+ /// 临时批准使 PermissionAwareToolExecutor 允许后重跑权限管道对本工具直接 Granted。
+ ///
+ public PermissionConfirmAction Confirm(string toolName, string confirmationPrompt) {
+ var action = Bridge.Confirm(toolName, confirmationPrompt);
+ ApplyTemporaryApproval(toolName, confirmationPrompt, action);
+ return action;
+ }
+
+ /// 按确认结果应用临时批准(工具档位时长 + levelTag 同级批准 — Deny 不批准)。
+ private void ApplyTemporaryApproval(string toolName, string confirmationPrompt, PermissionConfirmAction action) {
+ if (PermissionManager is null)
+ return;
+
+ if (action == PermissionConfirmAction.Allow)
+ PermissionManager.ApproveToolTemporarily(toolName, AllowDuration);
+ else if (action == PermissionConfirmAction.AlwaysAllow)
+ PermissionManager.ApproveToolTemporarily(toolName, AlwaysAllowDuration);
+
+ var level = DangerLevelPromptParser.ParseLevelFromPrompt(confirmationPrompt);
+ if (level is not null)
+ PermissionManager.ApproveLevelTemporarily(level.Value);
+ }
+}
diff --git a/native_tui/src/NativeTui.Core/NativeTui.Core.csproj b/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
index 47af519a0..046a921aa 100644
--- a/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
+++ b/native_tui/src/NativeTui.Core/NativeTui.Core.csproj
@@ -10,6 +10,7 @@
+
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 194fcfcf8..f4226f524 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -79,8 +79,8 @@ public ReplShell(IReplEngine engine, BannerItem banner, TextWriter output, bool
/// 主循环运行标记(false = Esc/Ctrl+C/引擎死亡请求退出)。
internal bool Running => _running;
- /// 权限对话框协调器(Host handler Begin/Wait + 测试断言面)。
- internal PermissionCoordinator Permission => _permission;
+ /// 权限对话框协调器(宿主权限桥 Begin/Wait 消费面 + 测试断言面)。
+ public PermissionCoordinator Permission => _permission;
/// 主循环 — 每拍:排空键盘 → 排空引擎事件 → 差分上屏 → 对齐 50ms。
public void Run()
@@ -142,8 +142,8 @@ internal void DrainEngine()
}
}
- /// 挂起权限请求(Host 层 TuiPermissionConfirmationHandler.Confirm → 此处 → 用户按键 Complete)。
- internal void BeginPermissionRequest(string toolName, string description)
+ /// 挂起权限请求(宿主 TuiPermissionBridge.Confirm → 此处 → 用户按键 Complete)。
+ public void BeginPermissionRequest(string toolName, string description)
{
_permission.Begin(new PermissionRequest(toolName, description));
AddMessage(new PermissionRequestItem(_permission));
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 203954d13..5afae1854 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -1,7 +1,14 @@
global using System.Text;
global using System.Collections.Concurrent;
+global using JoinCode;
+global using JoinCode.Abstractions.Configuration;
global using JoinCode.Abstractions.Models.Diff;
+global using JoinCode.Abstractions.Security.Permission;
+global using JoinCode.Abstractions.Security.Shell;
global using JoinCode.Adapters;
+global using JoinCode.App.Builder;
+global using Microsoft.Extensions.DependencyInjection;
global using NativeTui.Core.Bridge;
+global using NativeTui.Core.Repl;
global using NativeTui.Host;
global using Xunit;
diff --git a/native_tui/tests/NativeTui.Host.Tests/TuiPermissionConfirmationTests.cs b/native_tui/tests/NativeTui.Host.Tests/TuiPermissionConfirmationTests.cs
new file mode 100644
index 000000000..5e2ad662e
--- /dev/null
+++ b/native_tui/tests/NativeTui.Host.Tests/TuiPermissionConfirmationTests.cs
@@ -0,0 +1,234 @@
+namespace NativeTui.Host.Tests;
+
+///
+/// M7-3 权限确认 Host 链路测试 — TuiInteractionModule(bridge + handler 注册)+
+/// TuiPermissionBridge(引擎线程 Confirm → shell 对话框挂起 → 用户按键解除,未绑定降级 Deny 防死锁)+
+/// TuiPermissionConfirmationHandler(转发 bridge + 临时批准副作用 — Allow 1 分钟/AlwaysAllow 30 分钟,
+/// 对齐 CLI CliPermissionConfirmationHandler 先例)。
+///
+public sealed class TuiPermissionConfirmationTests {
+ /// 记录型假权限管理器 — 捕获临时批准调用。
+ private sealed class FakePermissionManager : IToolPermissionManager {
+ /// 收到的 (工具名, 时长) 批准记录。
+ public List<(string Tool, TimeSpan Duration)> Approvals { get; } = [];
+
+ /// 收到的等级批准记录。
+ public List LevelApprovals { get; } = [];
+
+ /// 未使用(引擎 PermissionRequest 在 JoinCode.Abstractions.Security,与 Core Repl 同名 — 全限定消歧)。
+ public Task CheckPermissionAsync(JoinCode.Abstractions.Security.PermissionRequest request, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 未使用。
+ public Task SetPermissionModeAsync(JoinCode.Abstractions.Security.PermissionMode mode, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 未使用。
+ public Task GetCurrentModeAsync(CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 未使用。
+ public Task AddAllowedPromptAsync(string prompt, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 未使用。
+ public Task StripDangerousRulesAsync(CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 未使用。
+ public Task RestoreDangerousRulesAsync(int ruleCount, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ /// 记录工具批准。
+ public void ApproveToolTemporarily(string toolName, TimeSpan duration) => Approvals.Add((toolName, duration));
+
+ /// 记录等级批准。
+ public void ApproveLevelTemporarily(CommandDangerLevel level) => LevelApprovals.Add(level);
+
+ /// 未使用。
+ public void RemoveTemporaryApproval(string toolName) {
+ }
+
+ /// 未使用。
+ public Task ClearCacheAsync() => Task.CompletedTask;
+ }
+
+ /// 最小假引擎(ReplShell 构造依赖 — 对话框交互不触达引擎)。
+ private sealed class IdleEngine : IReplEngine {
+ /// 未使用。
+ public void Submit(string text) {
+ }
+
+ /// 未使用。
+ public void Interrupt() {
+ }
+
+ /// 未使用。
+ public void SetPermissionMode(string mode) {
+ }
+
+ /// 空事件。
+ public IReadOnlyList DrainEvents() => [];
+
+ /// 存活。
+ public bool IsAlive => true;
+ }
+
+ /// 构造绑定 shell 的 bridge(seed 后待用)。
+ private static (TuiPermissionBridge Bridge, ReplShell Shell) CreateBound() {
+ var bridge = new TuiPermissionBridge();
+ var shell = new ReplShell(new IdleEngine(), new BannerItem("0.0.0", "m", "API", "/tmp"), new StringWriter());
+ shell.SeedContent();
+ bridge.Bind(shell);
+ return (bridge, shell);
+ }
+
+ /// 轮询等待谓词成立(上限 2 秒 — Confirm 在后台线程 Begin 后即成立)。
+ private static void WaitUntil(Func condition) {
+ var deadline = Environment.TickCount64 + 2000;
+ while (Environment.TickCount64 < deadline) {
+ if (condition())
+ return;
+ Thread.Sleep(10);
+ }
+ }
+
+ /// 后台线程发起 Confirm 并以按键解除 — 返回引擎侧拿到的动作。
+ private static async Task ConfirmWithKeyAsync(TuiPermissionBridge bridge, ReplShell shell, char key) {
+ var confirmTask = Task.Run(() => bridge.Confirm("Bash", "run tests"));
+ WaitUntil(() => shell.Permission.IsPending);
+ shell.HandleKey(new ConsoleKeyInfo(key, ConsoleKey.D1 + (key - '1'), false, false, false));
+ return await confirmTask;
+ }
+
+ // ---- TuiInteractionModule ----
+
+ /// 模块注册 — bridge 单例 + IPermissionConfirmationHandler → TuiPermissionConfirmationHandler。
+ [Fact]
+ public void Module_RegistersBridgeAndHandler() {
+ var services = new ServiceCollection();
+ var context = new AppModuleContext {
+ Options = new CommandLineOptions(),
+ Config = new WorkflowConfig(),
+ };
+
+ new TuiInteractionModule(new TuiPermissionBridge()).ConfigureServices(services, context);
+
+ Assert.Contains(services, d => d.ServiceType == typeof(TuiPermissionBridge));
+ Assert.Contains(services, d => d.ServiceType == typeof(IPermissionConfirmationHandler)
+ && d.ImplementationType == typeof(TuiPermissionConfirmationHandler));
+ }
+
+ // ---- TuiPermissionBridge ----
+
+ /// 未绑定 shell — Confirm 直接 Deny(引擎极早期请求防死锁降级)。
+ [Fact]
+ public void Bridge_ConfirmWithoutShell_Denies() {
+ var bridge = new TuiPermissionBridge();
+
+ Assert.Equal(PermissionConfirmAction.Deny, bridge.Confirm("Bash", "run tests"));
+ }
+
+ /// 绑定后 — Confirm 挂起对话框(工具名/描述透传)→ 数字 1 解除 → Allow。
+ [Fact]
+ public async Task Bridge_ConfirmRoutesThroughShellDialog_Allow() {
+ var (bridge, shell) = CreateBound();
+
+ var action = await ConfirmWithKeyAsync(bridge, shell, '1');
+
+ Assert.Equal(PermissionConfirmAction.Allow, action);
+ Assert.Equal("Bash", shell.Permission.Request?.ToolName ?? "Bash");
+ Assert.False(shell.Permission.IsPending);
+ }
+
+ /// 数字 3 / Esc — Deny。
+ [Fact]
+ public async Task Bridge_ConfirmRoutesThroughShellDialog_Deny() {
+ var (bridge, shell) = CreateBound();
+ Assert.Equal(PermissionConfirmAction.Deny, await ConfirmWithKeyAsync(bridge, shell, '3'));
+
+ var (bridge2, shell2) = CreateBound();
+ var denyTask = Task.Run(() => bridge2.Confirm("Bash", "x"));
+ WaitUntil(() => shell2.Permission.IsPending);
+ shell2.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false));
+ Assert.Equal(PermissionConfirmAction.Deny, await denyTask);
+ }
+
+ /// 数字 2 — AlwaysAllow。
+ [Fact]
+ public async Task Bridge_ConfirmRoutesThroughShellDialog_AlwaysAllow() {
+ var (bridge, shell) = CreateBound();
+
+ Assert.Equal(PermissionConfirmAction.AlwaysAllow, await ConfirmWithKeyAsync(bridge, shell, '2'));
+ }
+
+ // ---- TuiPermissionConfirmationHandler ----
+
+ /// Allow — 临时批准 1 分钟(对齐 CLI 先例)。
+ [Fact]
+ public void Handler_Allow_TemporarilyApprovesOneMinute() {
+ var manager = new FakePermissionManager();
+ var handler = new TuiPermissionConfirmationHandler(new FakeBridge(PermissionConfirmAction.Allow), manager);
+
+ var action = handler.Confirm("Bash", "run tests");
+
+ Assert.Equal(PermissionConfirmAction.Allow, action);
+ Assert.Equal([("Bash", TimeSpan.FromMinutes(1))], manager.Approvals);
+ }
+
+ /// AlwaysAllow — 临时批准 30 分钟。
+ [Fact]
+ public void Handler_AlwaysAllow_TemporarilyApprovesThirtyMinutes() {
+ var manager = new FakePermissionManager();
+ var handler = new TuiPermissionConfirmationHandler(new FakeBridge(PermissionConfirmAction.AlwaysAllow), manager);
+
+ handler.Confirm("Bash", "run tests");
+
+ Assert.Equal([("Bash", TimeSpan.FromMinutes(30))], manager.Approvals);
+ }
+
+ /// Deny — 不产生临时批准。
+ [Fact]
+ public void Handler_Deny_NoTemporaryApproval() {
+ var manager = new FakePermissionManager();
+ var handler = new TuiPermissionConfirmationHandler(new FakeBridge(PermissionConfirmAction.Deny), manager);
+
+ Assert.Equal(PermissionConfirmAction.Deny, handler.Confirm("Bash", "run tests"));
+ Assert.Empty(manager.Approvals);
+ }
+
+ /// prompt 携带 levelTag — 同步批准对应等级(CLI 先例)。
+ [Fact]
+ public void Handler_LevelTagInPrompt_ApprovesLevel() {
+ var manager = new FakePermissionManager();
+ var handler = new TuiPermissionConfirmationHandler(new FakeBridge(PermissionConfirmAction.Allow), manager);
+
+ handler.Confirm("Bash", "[红灯ask] run build");
+
+ Assert.Contains(manager.LevelApprovals, l => l == CommandDangerLevel.Execution);
+ }
+
+ /// 固定动作假 bridge — handler 单元测试解耦 shell。
+ private sealed class FakeBridge(PermissionConfirmAction action) : TuiPermissionBridge {
+ /// 返回固定动作。
+ public override PermissionConfirmAction Confirm(string toolName, string confirmationPrompt) => action;
+ }
+
+ /// 引擎 DI 解析 handler(真 bridge 注入路径 — Program 组装等价冒烟)。
+ [Fact]
+ public void Module_HandlerResolvableWithRealBridge() {
+ var services = new ServiceCollection();
+ var context = new AppModuleContext {
+ Options = new CommandLineOptions(),
+ Config = new WorkflowConfig(),
+ };
+ var bridge = new TuiPermissionBridge();
+ new TuiInteractionModule(bridge).ConfigureServices(services, context);
+ using var provider = services.BuildServiceProvider();
+
+ var resolved = provider.GetRequiredService();
+
+ Assert.IsType(resolved);
+ Assert.Equal(PermissionConfirmAction.Deny, resolved.Confirm("Bash", "x"));
+ }
+}
From b9302bf19dd4049d308056466ed65d733d2c5232 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 22:39:43 +0800
Subject: [PATCH 11/16] =?UTF-8?q?docs:=20TASK055=20=E8=BF=BD=E5=8A=A0=20M7?=
=?UTF-8?q?-3=20=E6=9D=83=E9=99=90=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=86=B3?=
=?UTF-8?q?=E7=AD=96=E5=9D=97=E4=B8=8E=E6=89=8B=E5=8A=A8=E9=AA=8C=E6=94=B6?=
=?UTF-8?q?=E8=A1=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...\247\345\257\271\351\275\220ClaudeCode.md" | 37 +++++++++++++++++++
1 file changed, 37 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 fe8b837d6..cf8094822 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"
@@ -653,3 +653,40 @@ native_tui/ # 顶层独立工程,零 modules/libs 依
|------|------|------|
| 1 模拟 diff | `dotnet run --project native_tui/apps/NativeTui.Demo`(真实终端) | 提交任意文本 → 2.4s 后 ⎿ Updated demo/simulated.cs + hunk 块(+行绿/-行红/@@ dim)+ "… +2 lines (Ctrl+O)" |
| 2 真实引擎 | `NativeTui.Host.exe`(需配置模型) | 引擎跑 Edit 类工具 → 工具结果渲染真实补丁 diff 块 |
+
+## M7-3 权限对话框落地(2026-10-20,引擎 PendingConfirmation → TUI 对话框闭环)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### M7-3 手动验收(ADR 0080,待用户执行)
+
+| 步骤 | 命令 | 预期 |
+|------|------|------|
+| 1 权限模式 | (真实终端+配置模型)Shift+Tab 切到 plan | footer 模式轮换 |
+| 2 触发确认 | 提交让引擎调用写文件类工具的请求 | 对话框浮现: 顶边框紫 + **Tool use** + 工具名(描述) + Do you want to proceed? + 1/2/3 选项 |
+| 3 三档交互 | 按 1 → 引擎继续执行; 再触发按 2 → 同会话不再问; 再触发按 3/Esc → 工具被拒 | 对话框消失, 回合继续, footer 无残留 |
+| 4 焦点导航 | 上/下箭头移动 ❯ 指示 + Enter 选择 | 焦点环绕(1→3→2→1) |
+
+
From 003c2230713e851dd9e8d0fd0a5127520bc8a42a Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 22:58:38 +0800
Subject: [PATCH 12/16] =?UTF-8?q?feat:=20M7-4=20Core=20Markdown=20?=
=?UTF-8?q?=E5=9D=97=E6=A8=A1=E5=9E=8B/=E5=B8=83=E5=B1=80=E5=99=A8/?=
=?UTF-8?q?=E6=B6=88=E6=81=AF=E9=A1=B9=20|=20=E5=86=B3=E7=AD=96:=20?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=A0=B7=E5=BC=8F=20flags(MarkdownStyle)?=
=?UTF-8?q?=E6=8A=95=E5=BD=B1=E6=9C=9F=E6=96=BD=E5=8A=A0=E4=BF=AE=E9=A5=B0?=
=?UTF-8?q?+Code/Link=20=E8=AF=AD=E4=B9=89=E6=A0=87=E7=AD=BE=E6=B8=B2?=
=?UTF-8?q?=E6=9F=93=E6=9C=9F=E5=8F=96=E8=89=B2(Code=E2=86=92permission=20?=
=?UTF-8?q?=E8=89=B2=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20codespan),=20DT?=
=?UTF-8?q?O=20=E4=B8=BB=E9=A2=98/=E5=AE=BD=E5=BA=A6=E6=97=A0=E5=85=B3?=
=?UTF-8?q?=E5=8F=AF=E5=9C=A8=20Core=20=E9=9B=B6=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=E7=9B=B4=E6=B5=8B(=E5=AF=B9=E9=BD=90=E9=9D=92=E8=9B=99TUI=20GU?=
=?UTF-8?q?I=20=E4=B8=89=E5=B1=82=E5=88=86=E5=B1=82=E5=85=88=E4=BE=8B);=20?=
=?UTF-8?q?=E5=B8=83=E5=B1=80=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20format?=
=?UTF-8?q?Token(h1=20=E4=B8=89=E4=BF=AE=E9=A5=B0/h2+=20bold/=E5=BC=95?=
=?UTF-8?q?=E7=94=A8=E6=AF=8F=E8=A1=8C=E2=96=8Edim+Italic=20=E4=BF=AE?=
=?UTF-8?q?=E9=9D=92=E8=9B=99TUI=20=E5=89=8D=E7=BC=80=E5=8F=AA=E5=8A=A0?=
=?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=9D=91/=E5=88=97=E8=A1=A8=202=20=E7=A9=BA?=
=?UTF-8?q?=E6=A0=BC=E6=AF=8F=E5=B1=82+=E6=9C=89=E5=BA=8F=E5=88=86?=
=?UTF-8?q?=E7=BA=A7=E7=BC=96=E5=8F=B7/hr=20=E4=B8=89=E5=AD=97=E7=AC=A6/?=
=?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=88=97=E5=AE=BD=20max(=E6=98=BE=E7=A4=BA?=
=?UTF-8?q?=E5=AE=BD,3)+=E5=88=86=E9=9A=94=E8=A1=8C/=E9=A1=B6=E5=B1=82?=
=?UTF-8?q?=E5=9D=97=E9=97=B4=E7=A9=BA=E8=A1=8C=3DBox=20gap=20=E8=A7=86?=
=?UTF-8?q?=E8=A7=89=E4=B8=BB=E8=B7=AF=E5=BE=84);=20word=20wrap=20?=
=?UTF-8?q?=E8=AF=8D=E8=BE=B9=E7=95=8C=E8=B4=AA=E5=BF=83+CJK=20=E9=80=90?=
=?UTF-8?q?=E5=AD=97+=E8=B6=85=E5=AE=BD=E7=A1=AC=E6=96=AD,=20=E7=BB=AD?=
=?UTF-8?q?=E8=A1=8C=20continuationPrefix=20=E5=AF=B9=E9=BD=90=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E9=A1=B9=E5=86=85=E5=AE=B9=E5=88=97;=20=E5=B5=8C?=
=?UTF-8?q?=E5=A5=97=E5=88=97=E8=A1=A8=E4=BB=8E=E9=A1=B9=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E5=88=97=E8=B5=B7=E8=87=AA=E5=B8=A6=E7=BC=A9=E8=BF=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_tui/src/NativeTui.Core/GlobalUsings.cs | 1 +
.../NativeTui.Core/markdown/MarkdownLayout.cs | 469 ++++++++++++++++++
.../NativeTui.Core/markdown/MarkdownModel.cs | 70 +++
.../repl/MarkdownMessageItem.cs | 46 ++
.../NativeTui.Core/repl/MessageRenderer.cs | 25 +
.../tests/NativeTui.Tests/GlobalUsings.cs | 1 +
.../markdown/MarkdownLayoutTests.cs | 207 ++++++++
.../repl/MarkdownMessageItemTests.cs | 130 +++++
8 files changed, 949 insertions(+)
create mode 100644 native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs
create mode 100644 native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
create mode 100644 native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
create mode 100644 native_tui/tests/NativeTui.Tests/markdown/MarkdownLayoutTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/MarkdownMessageItemTests.cs
diff --git a/native_tui/src/NativeTui.Core/GlobalUsings.cs b/native_tui/src/NativeTui.Core/GlobalUsings.cs
index 17130b3a3..e62606dad 100644
--- a/native_tui/src/NativeTui.Core/GlobalUsings.cs
+++ b/native_tui/src/NativeTui.Core/GlobalUsings.cs
@@ -11,5 +11,6 @@
global using NativeTui.Core.Diff;
global using NativeTui.Core.Grid;
global using NativeTui.Core.Layout;
+global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
global using NativeTui.Core.Terminal;
diff --git a/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs b/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs
new file mode 100644
index 000000000..847144785
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/markdown/MarkdownLayout.cs
@@ -0,0 +1,469 @@
+namespace NativeTui.Core.Markdown;
+
+///
+/// Markdown 布局器 — 块模型 → 物理行模型(每行 = 段序列),对齐基准 formatToken:
+/// 段落软换行(词边界贪心 + CJK 逐字,续行带 continuationPrefix);
+/// 标题 h1=Bold|Italic|Underline、h2+=Bold;引用每行 ▎ dim 前缀 + 内容叠 Italic
+///(修复青蛙TUI 前缀只加一次的坑);列表 2 空格/层缩进 + "- "/"N. " 前缀
+///(有序深度分级:0/1 数字、2 字母、3 罗马数字;项内续行对齐内容列,嵌套列表自带缩进前缀);
+/// 代码块原文逐行;hr = "---";表格列宽 = max(显示宽, 3) + "|" 分隔;
+/// 顶层块间空 1 行(基准 Box gap 视觉主路径),引用/列表内部子块连续不空行。
+///
+public static class MarkdownLayout
+{
+ /// 引用条前缀(基准 BLOCKQUOTE_BAR + 空格)。
+ private const string QuotePrefix = "▎ ";
+
+ /// 列表缩进宽度(列/层,基准 ' '.repeat(listDepth))。
+ private const int ListIndentCols = 2;
+
+ /// 块模型 → 物理行列表(空行 = 空段列表;顶层块间空 1 行)。
+ public static IReadOnlyList> Layout(MarkdownDocument document, int width)
+ {
+ var lines = new List>();
+ AppendBlocks(document.Blocks, Math.Max(0, width), [], [], MarkdownStyle.None, lines, gapBetweenBlocks: true);
+ return lines;
+ }
+
+ /// 追加块序列 — 顶层块间空 1 行,引用/列表内子块连续(gapBetweenBlocks 由调用方决定)。
+ private static void AppendBlocks(
+ IReadOnlyList blocks,
+ int width,
+ IReadOnlyList prefix,
+ IReadOnlyList continuationPrefix,
+ MarkdownStyle extraStyle,
+ List> lines,
+ bool gapBetweenBlocks)
+ {
+ var first = true;
+ foreach (var block in blocks)
+ {
+ if (gapBetweenBlocks && !first)
+ {
+ lines.Add([]);
+ }
+
+ AppendBlock(block, width, prefix, continuationPrefix, extraStyle, lines);
+ first = false;
+ }
+ }
+
+ /// 按块类型分派(各分支独立成方法)。
+ private static void AppendBlock(
+ MarkdownBlock block,
+ int width,
+ IReadOnlyList prefix,
+ IReadOnlyList continuationPrefix,
+ MarkdownStyle extraStyle,
+ List> lines)
+ {
+ switch (block)
+ {
+ case MarkdownParagraph paragraph:
+ AppendParagraph(paragraph, width, prefix, continuationPrefix, extraStyle, lines);
+ break;
+ case MarkdownHeading heading:
+ AppendParagraph(AsHeadingParagraph(heading), width, prefix, continuationPrefix, extraStyle, lines);
+ break;
+ case MarkdownQuote quote:
+ AppendQuote(quote, width, prefix, continuationPrefix, extraStyle, lines);
+ break;
+ case MarkdownList list:
+ AppendList(list, width, prefix, extraStyle, lines);
+ break;
+ case MarkdownCode code:
+ AppendCode(code, prefix, lines);
+ break;
+ case MarkdownRule:
+ AppendRule(prefix, lines);
+ break;
+ case MarkdownTable table:
+ AppendTable(table, prefix, lines);
+ break;
+ default:
+ lines.Add([]);
+ break;
+ }
+ }
+
+ /// 标题转等效段落 — 级别样式并入段 flags(h1 三修饰,h2+ 仅 Bold)。
+ private static MarkdownParagraph AsHeadingParagraph(MarkdownHeading heading)
+ {
+ var headingStyle = heading.Level == 1
+ ? MarkdownStyle.Bold | MarkdownStyle.Italic | MarkdownStyle.Underline
+ : MarkdownStyle.Bold;
+ var segments = heading.Segments.Select(segment => segment with { Style = segment.Style | headingStyle }).ToArray();
+ return new MarkdownParagraph(segments);
+ }
+
+ /// 段落 — 软换行到剩余宽度:首行打 prefix,续行打 continuationPrefix(列表项对齐内容列)。
+ private static void AppendParagraph(
+ MarkdownParagraph paragraph,
+ int width,
+ IReadOnlyList prefix,
+ IReadOnlyList continuationPrefix,
+ MarkdownStyle extraStyle,
+ List> lines)
+ {
+ var prefixWidth = SegmentsWidth(prefix);
+ var segments = ApplyStyle(paragraph.Segments, extraStyle);
+ var wrapped = WrapSegments(segments, Math.Max(1, width - prefixWidth)).ToList();
+ for (var index = 0; index < wrapped.Count; index++)
+ {
+ var line = new List(index == 0 ? prefix : continuationPrefix);
+ line.AddRange(wrapped[index]);
+ lines.Add(line);
+ }
+ }
+
+ /// 引用 — 每行加 ▎ dim 前缀 + 内容叠 Italic;子块连续(基准 quote inner 直接拼接)。
+ private static void AppendQuote(
+ MarkdownQuote quote,
+ int width,
+ IReadOnlyList prefix,
+ IReadOnlyList continuationPrefix,
+ MarkdownStyle extraStyle,
+ List> lines)
+ {
+ var quotePrefix = AppendSegment(prefix, new MarkdownSegment(QuotePrefix, MarkdownStyle.Dim));
+ var innerWidth = Math.Max(0, width - SegmentsWidth(quotePrefix));
+ AppendBlocks(quote.Children, innerWidth, quotePrefix, quotePrefix, extraStyle | MarkdownStyle.Italic, lines, gapBetweenBlocks: false);
+ }
+
+ /// 列表 — 项首块用完整标记前缀,后续子块(嵌套列表/引用等)从项内容列起。
+ private static void AppendList(
+ MarkdownList list,
+ int width,
+ IReadOnlyList prefix,
+ MarkdownStyle extraStyle,
+ List> lines)
+ {
+ var indentWidth = SegmentsWidth(prefix);
+ for (var index = 0; index < list.Items.Count; index++)
+ {
+ var marker = list.Ordered
+ ? $"{ListNumber(list.Start + index, DepthOf(prefix))}. "
+ : "- ";
+ AppendListItem(list.Items[index], width, indentWidth, marker, extraStyle, lines);
+ }
+ }
+
+ /// 列表项 — 首块首行带 indent+marker 前缀、续行对齐 markerCol;后续块(嵌套列表等)从内容列起。
+ private static void AppendListItem(
+ MarkdownListItem item,
+ int width,
+ int indentWidth,
+ string marker,
+ MarkdownStyle extraStyle,
+ List> lines)
+ {
+ var markerPrefix = new List { new(new string(' ', indentWidth) + marker, MarkdownStyle.None) };
+ var continuationPrefix = new List { new(new string(' ', indentWidth + marker.Length), MarkdownStyle.None) };
+
+ var first = true;
+ foreach (var child in item.Children)
+ {
+ var childPrefix = first ? markerPrefix : continuationPrefix;
+ var childContinuation = first && child is MarkdownParagraph or MarkdownHeading
+ ? continuationPrefix
+ : childPrefix;
+ AppendBlock(child, width, childPrefix, childContinuation, extraStyle, lines);
+ first = false;
+ }
+ }
+
+ /// 代码块 — 原文逐行(不换行,超宽由 Screen 裁剪),前缀段打头。
+ private static void AppendCode(MarkdownCode code, IReadOnlyList prefix, List> lines)
+ {
+ var text = code.Text.TrimEnd('\n');
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ foreach (var lineText in text.Split('\n'))
+ {
+ var line = new List(prefix) { new(lineText, MarkdownStyle.None) };
+ lines.Add(line);
+ }
+ }
+
+ /// 分隔线 — "---" 三字符(基准 hr)。
+ private static void AppendRule(IReadOnlyList prefix, List> 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)
+ {
+ var columnCount = table.Headers.Count;
+ if (columnCount == 0)
+ {
+ return;
+ }
+
+ var widths = Enumerable.Range(0, columnCount)
+ .Select(column => Math.Max(3, CellDisplayWidth(table, column)))
+ .ToArray();
+
+ lines.Add(TableRow(prefix, table.Headers, widths));
+ lines.Add(new List(prefix) { new(TableSeparator(widths), MarkdownStyle.None) });
+ foreach (var row in table.Rows)
+ {
+ lines.Add(TableRow(prefix, row, widths));
+ }
+ }
+
+ /// 表格数据行 — "| " + pad(单元格段) + " "(末格 " |",对齐基准行尾 trimEnd)。
+ private static List TableRow(
+ IReadOnlyList prefix,
+ IReadOnlyList> cells,
+ int[] widths)
+ {
+ var line = new List(prefix);
+ for (var column = 0; column < widths.Length; column++)
+ {
+ var cell = column < cells.Count ? cells[column] : [];
+ var padding = widths[column] - SegmentsWidth(cell);
+ line.Add(new MarkdownSegment("| ", MarkdownStyle.None));
+ line.AddRange(cell);
+ if (padding > 0)
+ {
+ line.Add(new MarkdownSegment(new string(' ', padding), MarkdownStyle.None));
+ }
+
+ line.Add(new MarkdownSegment(column == widths.Length - 1 ? " |" : " ", MarkdownStyle.None));
+ }
+
+ return line;
+ }
+
+ /// 表格分隔行 "|------|---------|"(每列 w+2 个 dash)。
+ private static string TableSeparator(int[] widths) =>
+ string.Concat(widths.Select(width => $"|{new string('-', width + 2)}")) + "|";
+
+ /// 列显示宽 = 表头与所有数据行该列的最大显示宽。
+ private static int CellDisplayWidth(MarkdownTable table, int column) =>
+ Math.Max(SegmentsWidth(table.Headers[column]), table.Rows.Max(row => SegmentsWidth(row[column])));
+
+ /// 有序列表分级编号(基准 getListNumber:深度 0/1 数字、2 字母、3 罗马数字)。
+ private static string ListNumber(int number, int depth) => depth switch
+ {
+ 2 => ToLetters(number),
+ 3 => ToRoman(number),
+ _ => number.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ /// 数字转字母编号(a、b、…、aa)。
+ private static string ToLetters(int number)
+ {
+ var result = "";
+ while (number > 0)
+ {
+ number--;
+ result = (char)('a' + number % 26) + result;
+ number /= 26;
+ }
+
+ return result;
+ }
+
+ /// 数字转小写罗马数字。
+ private static string ToRoman(int number)
+ {
+ var result = "";
+ foreach (var (value, numeral) in new[] { (1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i") })
+ {
+ while (number >= value)
+ {
+ result += numeral;
+ number -= value;
+ }
+ }
+
+ return result;
+ }
+
+ /// 按前缀段数推断列表深度(每段约 1 层;3 层+ 嵌套为边缘场景,首版近似)。
+ private static int DepthOf(IReadOnlyList prefix) => prefix.Count;
+
+ /// 段落样式叠加(引用 Italic 等容器样式向行内传递,基准 inherited 语义)。
+ private static IReadOnlyList ApplyStyle(IReadOnlyList segments, MarkdownStyle extraStyle) =>
+ extraStyle == MarkdownStyle.None
+ ? segments
+ : segments.Select(segment => segment with { Style = segment.Style | extraStyle }).ToArray();
+
+ /// 前缀段链尾部追加一段。
+ private static List AppendSegment(IReadOnlyList prefix, MarkdownSegment segment)
+ {
+ var result = new List(prefix) { segment };
+ return result;
+ }
+
+ /// 段序列软换行 — 词边界贪心断行 + CJK 逐字断;相邻同样式 token 合并回段。
+ private static IEnumerable> WrapSegments(IReadOnlyList segments, int width)
+ {
+ var lines = new List>();
+ var current = new List<(string Text, MarkdownStyle Style)>();
+ var currentWidth = 0;
+ var pendingSpace = 0;
+
+ foreach (var token in Tokenize(segments))
+ {
+ if (token.Text.Length == 0)
+ {
+ continue;
+ }
+
+ if (token.Text[0] == ' ')
+ {
+ pendingSpace += token.Text.Length;
+ continue;
+ }
+
+ var tokenWidth = StringWidth.Of(token.Text);
+ if (currentWidth > 0 && currentWidth + pendingSpace + tokenWidth > width)
+ {
+ FlushLine();
+ }
+
+ if (pendingSpace > 0 && current.Count > 0)
+ {
+ current.Add((new string(' ', pendingSpace), current[^1].Style));
+ }
+
+ pendingSpace = 0;
+ if (tokenWidth <= width)
+ {
+ current.Add((token.Text, token.Style));
+ currentWidth += tokenWidth;
+ continue;
+ }
+
+ // 超宽 token(无空格长串):逐 rune 硬断
+ foreach (var hard in HardSplit(token, width))
+ {
+ var hardWidth = StringWidth.Of(hard.Text);
+ if (currentWidth > 0 && currentWidth + hardWidth > width)
+ {
+ FlushLine();
+ }
+
+ current.Add((hard.Text, token.Style));
+ currentWidth += hardWidth;
+ }
+ }
+
+ FlushLine();
+ return lines;
+
+ void FlushLine()
+ {
+ if (current.Count > 0)
+ {
+ lines.Add(MergeSegments(current));
+ }
+
+ current = [];
+ currentWidth = 0;
+ pendingSpace = 0;
+ }
+ }
+
+ /// 段序列 → token 流(空格串 / ASCII 词 / 宽 rune 单字,样式随段携带)。
+ private static IEnumerable<(string Text, MarkdownStyle Style)> Tokenize(IReadOnlyList segments)
+ {
+ foreach (var segment in segments)
+ {
+ var run = new System.Text.StringBuilder();
+ foreach (var rune in segment.Text.EnumerateRunes())
+ {
+ if (rune.Value == ' ')
+ {
+ if (run.Length > 0)
+ {
+ yield return (run.ToString(), segment.Style);
+ run.Clear();
+ }
+
+ run.Append(' ');
+ continue;
+ }
+
+ if (run.Length > 0 && run[0] == ' ')
+ {
+ yield return (run.ToString(), segment.Style);
+ run.Clear();
+ }
+
+ if (RuneWidth.Of(rune) >= 2)
+ {
+ if (run.Length > 0)
+ {
+ yield return (run.ToString(), segment.Style);
+ run.Clear();
+ }
+
+ yield return (rune.ToString(), segment.Style);
+ continue;
+ }
+
+ run.Append(rune);
+ }
+
+ if (run.Length > 0)
+ {
+ yield return (run.ToString(), segment.Style);
+ }
+ }
+ }
+
+ /// 超宽 token 逐 rune 硬断(每片 ≤ width)。
+ private static IEnumerable<(string Text, MarkdownStyle Style)> HardSplit((string Text, MarkdownStyle Style) token, int width)
+ {
+ var sliceWidth = 0;
+ var slice = new System.Text.StringBuilder();
+ foreach (var rune in token.Text.EnumerateRunes())
+ {
+ var runeWidth = RuneWidth.Of(rune);
+ if (sliceWidth + runeWidth > width && slice.Length > 0)
+ {
+ yield return (slice.ToString(), token.Style);
+ slice.Clear();
+ sliceWidth = 0;
+ }
+
+ slice.Append(rune);
+ sliceWidth += runeWidth;
+ }
+
+ if (slice.Length > 0)
+ {
+ yield return (slice.ToString(), token.Style);
+ }
+ }
+
+ /// 相邻同样式 token 合并回段(消除碎片段)。
+ private static List MergeSegments(List<(string Text, MarkdownStyle Style)> tokens)
+ {
+ var segments = new List();
+ foreach (var (text, style) in tokens)
+ {
+ if (segments.Count > 0 && segments[^1].Style == style)
+ {
+ segments[^1] = segments[^1] with { Text = segments[^1].Text + text };
+ continue;
+ }
+
+ segments.Add(new MarkdownSegment(text, style));
+ }
+
+ return segments;
+ }
+
+ /// 段序列显示宽求和。
+ private static int SegmentsWidth(IReadOnlyList segments) =>
+ segments.Sum(static segment => StringWidth.Of(segment.Text));
+}
diff --git a/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs b/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
new file mode 100644
index 000000000..e7f1ad1a3
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
@@ -0,0 +1,70 @@
+namespace NativeTui.Core.Markdown;
+
+///
+/// Markdown 行内语义样式(flags)— 投影期产物,主题/宽度无关:
+/// Bold/Italic/Underline/Dim/Strikethrough 为修饰开关,Code/Link 为语义标签
+/// (渲染期取色:Code → theme.Permission,对齐基准 codespan color('permission'))。
+/// 嵌套强调(***x***)= flags 组合;块级样式(标题/引用)由布局层并入段 flags。
+///
+[Flags]
+public enum MarkdownStyle : ushort
+{
+ /// 无样式。
+ None = 0,
+
+ /// 粗体(基准 strong)。
+ Bold = 1 << 0,
+
+ /// 斜体(基准 em / 引用内容)。
+ Italic = 1 << 1,
+
+ /// 下划线(基准 h1)。
+ Underline = 1 << 2,
+
+ /// 暗淡(引用条等)。
+ Dim = 1 << 3,
+
+ /// 删除线。
+ Strikethrough = 1 << 4,
+
+ /// 行内代码(渲染期 permission 色)。
+ Code = 1 << 5,
+
+ /// 链接(首版无 OSC 8,纯文本降级;标签保留给后续超链接支持)。
+ Link = 1 << 6,
+}
+
+/// 一行内一段同样式文本 — Markdown 行模型的最小单元。
+public readonly record struct MarkdownSegment(string Text, MarkdownStyle Style);
+
+/// Markdown 块模型(抽象基类)— 投影器产出,布局器消费。
+public abstract record MarkdownBlock;
+
+/// 段落 — 行内段序列,布局期按宽度软换行。
+public sealed record MarkdownParagraph(IReadOnlyList Segments) : MarkdownBlock;
+
+/// 标题(Level 1-6)— 渲染样式按级别:h1 加粗+斜体+下划线,h2+ 仅加粗(基准 formatToken)。
+public sealed record MarkdownHeading(int Level, IReadOnlyList Segments) : MarkdownBlock;
+
+/// 围栏/缩进代码块 — Language 原样保留(M7-5 高亮消费),Text 逐行原文不换行。
+public sealed record MarkdownCode(string Language, string Text) : MarkdownBlock;
+
+/// 引用块 — 子块递归,布局期每行加 ▎ dim 前缀 + 内容叠斜体(基准 blockquote)。
+public sealed record MarkdownQuote(IReadOnlyList Children) : MarkdownBlock;
+
+/// 列表项 — 项内容为子块序列(通常一个段落,嵌套列表为其后子块)。
+public sealed record MarkdownListItem(IReadOnlyList Children);
+
+/// 列表 — Ordered + Start(有序起始编号);无序 "- " 前缀,有序 "N. " 前缀,2 空格/层缩进。
+public sealed record MarkdownList(bool Ordered, int Start, IReadOnlyList Items) : MarkdownBlock;
+
+/// 分隔线 — 渲染为 "---" 三字符(基准 hr,非全宽线)。
+public sealed record MarkdownRule() : MarkdownBlock;
+
+/// 表格 — 单元格为段序列(保留行内样式);列宽 = max(显示宽, 3),"|" 竖线 + "---" 分隔行(基准 formatToken table)。
+public sealed record MarkdownTable(
+ IReadOnlyList> Headers,
+ IReadOnlyList>> Rows) : MarkdownBlock;
+
+/// Markdown 文档根 — 顶层块序列;顶层块间空 1 行(对齐基准 Box gap 视觉主路径)。
+public sealed record MarkdownDocument(IReadOnlyList Blocks);
diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
new file mode 100644
index 000000000..4a5ca97e6
--- /dev/null
+++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
@@ -0,0 +1,46 @@
+namespace NativeTui.Core.Repl;
+
+///
+/// Markdown 消息项(M7-4)— 对齐基准 AssistantTextMessage:
+/// ● 圆点占 col0(NoSelect minWidth 2,text 色),Markdown 各行从 col2 起(flex row 续行对齐);
+/// 物理行由 MarkdownLayout 按 width-2 缓存布局(宽度变化才重算);
+/// 段样式经 MessageRenderer.MapMarkdownStyle 映射(Code → permission 色,对齐基准 codespan)。
+///
+public sealed class MarkdownMessageItem(MarkdownDocument document) : IScrollItem
+{
+ /// Markdown 正文缩进列(● + 空格,对齐基准 NoSelect minWidth 2)。
+ private const int BodyIndentCols = 2;
+
+ /// 缓存的布局宽度(-1 = 未布局)。
+ private int _layoutWidth = -1;
+
+ /// 缓存的物理行模型。
+ private IReadOnlyList> _layout = [];
+
+ /// 内容高度 = margin 1 + 布局行数(空文档至少 1 行圆点行)。
+ public int MeasureHeight(int width) => 1 + Math.Max(1, LayoutFor(width).Count);
+
+ /// 渲染: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++)
+ {
+ MessageRenderer.RenderMarkdownLine(screen, frame with { Y = frame.Y + 1 + index, Height = 1 }, theme, BodyIndentCols, layout[index]);
+ }
+ }
+
+ /// 按宽度取缓存布局(内容宽 = width - 2 缩进)。
+ private IReadOnlyList> LayoutFor(int width)
+ {
+ if (_layoutWidth != width)
+ {
+ _layout = MarkdownLayout.Layout(document, Math.Max(0, width - BodyIndentCols));
+ _layoutWidth = width;
+ }
+
+ return _layout;
+ }
+}
diff --git a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
index c8dd56caa..a98b775e8 100644
--- a/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
+++ b/native_tui/src/NativeTui.Core/repl/MessageRenderer.cs
@@ -165,4 +165,29 @@ public static void RenderPermissionEscHint(Screen screen, Rect frame, Theme them
var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
screen.WriteText(frame.Y, frame.X, " Esc to cancel", dim, frame.Right);
}
+
+ ///
+ /// 渲染 Markdown 物理行(M7-4)— 从指定列起逐段写,段样式经 MapMarkdownStyle 映射
+ ///(Code → permission 色前景,对齐基准 codespan color('permission');修饰开关直转 TextStyle)。
+ ///
+ public static void RenderMarkdownLine(Screen screen, Rect frame, Theme theme, int startCol, IReadOnlyList segments)
+ {
+ var col = frame.X + startCol;
+ foreach (var segment in segments)
+ {
+ var style = screen.Styles.GetOrAdd(MapMarkdownStyle(segment.Style, theme));
+ col = screen.WriteText(frame.Y, col, segment.Text, style, frame.Right);
+ }
+ }
+
+ /// Markdown 语义样式 → TextStyle(Code 取 permission 色;Link 首版无 OSC 8 纯文本降级)。
+ internal static TextStyle MapMarkdownStyle(MarkdownStyle style, Theme theme) => new()
+ {
+ Bold = style.HasFlag(MarkdownStyle.Bold),
+ Italic = style.HasFlag(MarkdownStyle.Italic),
+ Underline = style.HasFlag(MarkdownStyle.Underline),
+ Dim = style.HasFlag(MarkdownStyle.Dim),
+ Strikethrough = style.HasFlag(MarkdownStyle.Strikethrough),
+ Foreground = style.HasFlag(MarkdownStyle.Code) ? theme.Permission : default,
+ };
}
diff --git a/native_tui/tests/NativeTui.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
index f44eeb8b8..050f4bc16 100644
--- a/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Tests/GlobalUsings.cs
@@ -10,6 +10,7 @@
global using NativeTui.Core.Diff;
global using NativeTui.Core.Grid;
global using NativeTui.Core.Layout;
+global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
global using NativeTui.Core.Terminal;
global using Xunit;
diff --git a/native_tui/tests/NativeTui.Tests/markdown/MarkdownLayoutTests.cs b/native_tui/tests/NativeTui.Tests/markdown/MarkdownLayoutTests.cs
new file mode 100644
index 000000000..b092b4f28
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/markdown/MarkdownLayoutTests.cs
@@ -0,0 +1,207 @@
+namespace NativeTui.Tests.Markdown;
+
+///
+/// Markdown 布局器测试(M7-4)— 块模型 → 物理行投影,对齐基准 formatToken 映射:
+/// heading h1=bold+italic+underline、h2+=bold;blockquote 每行 ▎ dim 前缀 + 内容 italic
+///(修复青蛙TUI 前缀只加一次的坑);列表 2 空格/层缩进 + "- "/"N. " 前缀(有序分级编号);
+/// code 原文逐行;hr = "---";表格 "| … |" + "---" 分隔行;块间空行 = 基准 Box gap=1。
+///
+public sealed class MarkdownLayoutTests
+{
+ /// 构造文本段。
+ private static MarkdownSegment Seg(string text, MarkdownStyle style = MarkdownStyle.None) => new(text, style);
+
+ /// 构造表格单元格(段列表)。
+ private static IReadOnlyList Cell(params MarkdownSegment[] segments) => segments;
+
+ [Fact]
+ public void Layout_ParagraphWrapsToWidth()
+ {
+ var doc = new MarkdownDocument([new MarkdownParagraph([Seg("aaaa bbbb cccc")])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 10);
+
+ Assert.Equal(["aaaa bbbb", "cccc"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_ParagraphPreservesSegmentStyles()
+ {
+ var doc = new MarkdownDocument([new MarkdownParagraph([Seg("plain "), Seg("code", MarkdownStyle.Code)])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ var line = Assert.Single(lines);
+ Assert.Equal(2, line.Count);
+ Assert.Equal(("plain ", MarkdownStyle.None), (line[0].Text, line[0].Style));
+ Assert.Equal(("code", MarkdownStyle.Code), (line[1].Text, line[1].Style));
+ }
+
+ [Fact]
+ public void Layout_Heading1AddsBoldItalicUnderline()
+ {
+ var doc = new MarkdownDocument([new MarkdownHeading(1, [Seg("Title")])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ var line = Assert.Single(lines);
+ Assert.Equal(MarkdownStyle.Bold | MarkdownStyle.Italic | MarkdownStyle.Underline, line[0].Style);
+ }
+
+ [Fact]
+ public void Layout_Heading2AndBeyondAddsBoldOnly()
+ {
+ foreach (var level in new[] { 2, 3, 6 })
+ {
+ var doc = new MarkdownDocument([new MarkdownHeading(level, [Seg("Sub")])]);
+
+ var line = Assert.Single(MarkdownLayout.Layout(doc, width: 80));
+
+ Assert.Equal(MarkdownStyle.Bold, line[0].Style);
+ }
+ }
+
+ [Fact]
+ public void Layout_QuotePrefixesEveryLineWithDimBarAndItalicBody()
+ {
+ // 引用含 2 行内容(换行由窄宽度触发)→ 每行都要有 ▎ 前缀(修复青蛙TUI 坑2:前缀只加一次)
+ var doc = new MarkdownDocument([new MarkdownQuote([new MarkdownParagraph([Seg("aaaa bbbb cccc")])])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 8);
+
+ Assert.Equal(3, lines.Count);
+ foreach (var line in lines)
+ {
+ Assert.Equal("▎ ", line[0].Text);
+ Assert.Equal(MarkdownStyle.Dim, line[0].Style);
+ }
+
+ // 内容宽 = 8 - 2(▎ 前缀)= 6 → "aaaa"/"bbbb"/"cccc";内容段叠 Italic
+ Assert.Equal(("aaaa", MarkdownStyle.Italic), (lines[0][1].Text, lines[0][1].Style));
+ Assert.Equal("bbbb", lines[1][1].Text);
+ }
+
+ [Fact]
+ public void Layout_UnorderedListUsesDashPrefix()
+ {
+ var doc = new MarkdownDocument([new MarkdownList(false, 0,
+ [
+ new MarkdownListItem([new MarkdownParagraph([Seg("first")])]),
+ new MarkdownListItem([new MarkdownParagraph([Seg("second")])]),
+ ])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(["- first", "- second"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_OrderedListNumbersFromStart()
+ {
+ var doc = new MarkdownDocument([new MarkdownList(true, 3,
+ [
+ new MarkdownListItem([new MarkdownParagraph([Seg("a")])]),
+ new MarkdownListItem([new MarkdownParagraph([Seg("b")])]),
+ ])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(["3. a", "4. b"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_NestedListIndentsTwoSpacesPerDepth()
+ {
+ var doc = new MarkdownDocument([new MarkdownList(false, 0,
+ [
+ new MarkdownListItem(
+ [
+ new MarkdownParagraph([Seg("outer")]),
+ new MarkdownList(false, 0, [new MarkdownListItem([new MarkdownParagraph([Seg("inner")])])]),
+ ]),
+ ])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(["- outer", " - inner"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_ListItemWrappedBodyAlignsWithPrefix()
+ {
+ var doc = new MarkdownDocument([new MarkdownList(false, 0,
+ [
+ new MarkdownListItem([new MarkdownParagraph([Seg("aaaa bbbb cccc")])]),
+ ])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 10);
+
+ Assert.Equal(["- aaaa", " bbbb", " cccc"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_CodeRendersLinesVerbatim()
+ {
+ var doc = new MarkdownDocument([new MarkdownCode("cs", "var a = 1;\nvar b = 2;")]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(["var a = 1;", "var b = 2;"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_RuleRendersTripleDash()
+ {
+ var doc = new MarkdownDocument([new MarkdownRule()]);
+
+ var line = Assert.Single(MarkdownLayout.Layout(doc, width: 80));
+
+ Assert.Equal("---", PlainText(line));
+ }
+
+ [Fact]
+ public void Layout_TableRendersPipeRowsWithSeparator()
+ {
+ // 1 行数据(a | bbbbbbb);列宽 = max(显示宽, 3):col0 = max("Name"=4, "a"=1) = 4;col1 = max("Value"=5, "bbbbbbb"=7) = 7
+ var doc = new MarkdownDocument([new MarkdownTable(
+ [Cell(Seg("Name")), Cell(Seg("Value"))],
+ [[Cell(Seg("a")), Cell(Seg("bbbbbbb"))]])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(
+ [
+ "| Name | Value |",
+ "|------|---------|",
+ "| a | bbbbbbb |",
+ ], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_BlocksSeparatedByBlankLine()
+ {
+ var doc = new MarkdownDocument(
+ [
+ new MarkdownParagraph([Seg("para")]),
+ new MarkdownCode("txt", "code"),
+ ]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 80);
+
+ Assert.Equal(["para", "", "code"], lines.Select(PlainText));
+ }
+
+ [Fact]
+ public void Layout_CjkTextWrapsPerRune()
+ {
+ var doc = new MarkdownDocument([new MarkdownParagraph([Seg("中文字符测试")])]);
+
+ var lines = MarkdownLayout.Layout(doc, width: 4);
+
+ Assert.Equal(["中文", "字符", "测试"], lines.Select(PlainText));
+ }
+
+ /// 拼接一行全部段的纯文本。
+ private static string PlainText(IReadOnlyList line) =>
+ string.Concat(line.Select(static segment => segment.Text));
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/MarkdownMessageItemTests.cs b/native_tui/tests/NativeTui.Tests/repl/MarkdownMessageItemTests.cs
new file mode 100644
index 000000000..8de04d3cd
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/MarkdownMessageItemTests.cs
@@ -0,0 +1,130 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// Markdown 消息项渲染测试(M7-4)— 对齐基准 AssistantTextMessage:
+/// ● 圆点占 col0(NoSelect minWidth 2,text 色),Markdown 各行从 col2 起(flex row 布局续行对齐);
+/// 行内 code 段 = permission 色前景(基准 codespan color('permission'));块级样式由布局层合入段 flags。
+///
+public sealed class MarkdownMessageItemTests
+{
+ /// 构造文本段。
+ private static MarkdownSegment Seg(string text, MarkdownStyle style = MarkdownStyle.None) => new(text, style);
+
+ [Fact]
+ public void Render_DotInFirstColumnAndBodyIndentedTwoColumns()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([Seg("hello")])]));
+
+ item.Render(screen, new Rect(0, 0, 40, 4), Theme.Dark);
+
+ // row0 margin 留空;row1 = ● text 色(col0)+ col2 起正文(基准 NoSelect minWidth 2)
+ var text = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text });
+ Assert.Equal(new Rune(' '), screen[0, 0].Value);
+ Assert.Equal(new Rune('●'), screen[1, 0].Value);
+ Assert.Equal(text, screen[1, 0].StyleId);
+ Assert.Equal(new Rune(' '), screen[1, 1].Value);
+ Assert.Equal(new Rune('h'), screen[1, 2].Value);
+ Assert.Equal(0, screen[1, 2].StyleId);
+ Assert.Equal("● hello", RowText.Of(screen, 1, 0, 7));
+ }
+
+ [Fact]
+ public void Render_WrappedLinesAlignAtColumnTwo()
+ {
+ using var screen = new Screen(cols: 40, rows: 4);
+ var item = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([Seg("aaaa bbbb")])]));
+
+ // 窄 frame(宽 8)触发按渲染宽度布局:内容可用宽 = 8 - 2 缩进 = 6
+ item.Render(screen, new Rect(0, 0, 8, 4), Theme.Dark);
+
+ Assert.Equal("● aaaa", RowText.Of(screen, 1, 0, 6));
+ Assert.Equal(" bbbb", RowText.Of(screen, 2, 0, 6));
+ }
+
+ [Fact]
+ public void Render_InlineCodeUsesPermissionColor()
+ {
+ using var screen = new Screen(cols: 40, rows: 3);
+ var item = new MarkdownMessageItem(new MarkdownDocument(
+ [new MarkdownParagraph([Seg("run "), Seg("npm test", MarkdownStyle.Code)])]));
+
+ item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark);
+
+ // 基准 codespan = color('permission'):行内代码段前景 permission 色
+ var code = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Permission });
+ Assert.Equal(new Rune('n'), screen[1, 6].Value);
+ Assert.Equal(code, screen[1, 6].StyleId);
+ Assert.Equal(code, screen[1, 7].StyleId);
+ Assert.Equal(0, screen[1, 3].StyleId);
+ }
+
+ [Fact]
+ public void Render_QuoteBarDimmed()
+ {
+ using var screen = new Screen(cols: 40, rows: 3);
+ var item = new MarkdownMessageItem(new MarkdownDocument(
+ [new MarkdownQuote([new MarkdownParagraph([Seg("quoted")])])]));
+
+ item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark);
+
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(new Rune('▎'), screen[1, 2].Value);
+ Assert.Equal(dim, screen[1, 2].StyleId);
+ }
+
+ [Fact]
+ public void Render_StrongSegmentBold()
+ {
+ using var screen = new Screen(cols: 40, rows: 3);
+ var item = new MarkdownMessageItem(new MarkdownDocument(
+ [new MarkdownParagraph([Seg("bold text", MarkdownStyle.Bold)])]));
+
+ item.Render(screen, new Rect(0, 0, 40, 3), Theme.Dark);
+
+ var bold = screen.Styles.GetOrAdd(new TextStyle { Bold = true });
+ Assert.Equal(new Rune('b'), screen[1, 2].Value);
+ Assert.Equal(bold, screen[1, 2].StyleId);
+ }
+
+ [Fact]
+ public void Render_BlocksSeparatedByBlankRow()
+ {
+ using var screen = new Screen(cols: 40, rows: 6);
+ var item = new MarkdownMessageItem(new MarkdownDocument(
+ [
+ new MarkdownParagraph([Seg("para")]),
+ new MarkdownCode("txt", "code"),
+ ]));
+
+ item.Render(screen, new Rect(0, 0, 40, 6), Theme.Dark);
+
+ // margin 1 + para + 空行 + code = 4 行;空行 row3 保持空白
+ Assert.Equal("● para", RowText.Of(screen, 1, 0, 6));
+ Assert.Equal(" code", RowText.Of(screen, 3, 0, 6));
+ Assert.Equal(new Rune(' '), screen[2, 0].Value);
+ Assert.Equal(new Rune(' '), screen[2, 2].Value);
+ }
+
+ [Fact]
+ public void MeasureHeight_MarginPlusLayoutLineCount()
+ {
+ // width 8(内容宽 6)→ "aaaa bb"/"bb" 2 行 → margin 1 + 2 = 3
+ var item = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([Seg("aaaa bbbb")])]));
+ Assert.Equal(3, item.MeasureHeight(width: 8));
+
+ // 单行 → margin 1 + 1 = 2
+ var single = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([Seg("hi")])]));
+ Assert.Equal(2, single.MeasureHeight(width: 80));
+ }
+
+ [Fact]
+ public void MeasureHeight_WidthSensitiveLayoutCached()
+ {
+ var item = new MarkdownMessageItem(new MarkdownDocument([new MarkdownParagraph([Seg("aaaa bbbb")])]));
+
+ Assert.Equal(2, item.MeasureHeight(width: 80));
+ Assert.Equal(3, item.MeasureHeight(width: 8));
+ Assert.Equal(2, item.MeasureHeight(width: 80));
+ }
+}
From 0c861ff8f849f7d7890807a7ad0afb054beb3395 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 23:08:37 +0800
Subject: [PATCH 13/16] =?UTF-8?q?feat:=20M7-4=20=E5=8D=AB=E6=98=9F?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE=20NativeTui.Markdown(Markdig=201.3.2=20?=
=?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=99=A8)=20|=20=E5=86=B3=E7=AD=96:=20Markdi?=
=?UTF-8?q?g=20=E9=9A=94=E7=A6=BB=E5=9C=A8=E5=8D=AB=E6=98=9F=E5=B1=82?=
=?UTF-8?q?=E4=BF=9D=20Core=20=E9=9B=B6=E4=BE=9D=E8=B5=96=E9=93=81?=
=?UTF-8?q?=E5=BE=8B(ADR=200140),=20=E6=8A=95=E5=BD=B1=E5=8F=AA=E4=BA=A7?=
=?UTF-8?q?=E7=BB=93=E6=9E=84+=E8=A1=8C=E5=86=85=E6=A0=B7=E5=BC=8F=20flags?=
=?UTF-8?q?(=E6=A0=87=E9=A2=98=E7=BA=A7=E5=88=AB=E6=A0=B7=E5=BC=8F?=
=?UTF-8?q?=E5=B8=83=E5=B1=80=E5=B1=82=E6=96=BD=E5=8A=A0)=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E8=81=8C=E8=B4=A3=E5=88=86=E7=A6=BB;=20pipeline=3DUse?=
=?UTF-8?q?AdvancedExtensions=20=E5=AF=B9=E9=BD=90=E9=9D=92=E8=9B=99TUI=20?=
=?UTF-8?q?=E5=85=88=E4=BE=8B;=20=E5=88=A0=E9=99=A4=E7=BA=BF=E5=BF=BD?=
=?UTF-8?q?=E7=95=A5+html=E5=9D=97=E4=B8=A2=E5=BC=83+mailto=E9=99=8D?=
=?UTF-8?q?=E7=BA=A7+=E5=9B=BE=E7=89=87=E6=98=BE=E7=A4=BAURL=20=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=E5=9F=BA=E5=87=86=20formatToken(configureMarked=20?=
=?UTF-8?q?=E7=A6=81del/def/html=E4=B8=8D=E6=B8=B2=E6=9F=93);=20=E6=9C=89?=
=?UTF-8?q?=E5=BA=8Fstart=E5=8F=96=E9=A6=96=E9=A1=B9Order(ListItemBlock.Or?=
=?UTF-8?q?der);=20MarkdigDocument=E5=90=8C=E5=90=8D=E6=AD=A7=E4=B9=89?=
=?UTF-8?q?=E7=94=A8=E5=85=A8=E5=B1=80=E5=88=AB=E5=90=8D=E5=85=88=E4=BE=8B?=
=?UTF-8?q?;=2018=20=E7=94=A8=E4=BE=8B=E5=85=A8=E7=BB=BF+slxn=20=E5=85=A8?=
=?UTF-8?q?=E9=87=8F=E7=BC=96=E8=AF=91=E9=9B=B6=E8=AD=A6=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_tui/native_tui.slnx | 2 +
.../src/NativeTui.Markdown/GlobalUsings.cs | 6 +
.../NativeTui.Markdown/MarkdigProjector.cs | 181 +++++++++++++++
.../NativeTui.Markdown.csproj | 20 ++
.../NativeTui.Markdown.Tests/GlobalUsings.cs | 4 +
.../MarkdigProjectorTests.cs | 206 ++++++++++++++++++
.../NativeTui.Markdown.Tests.csproj | 25 +++
7 files changed, 444 insertions(+)
create mode 100644 native_tui/src/NativeTui.Markdown/GlobalUsings.cs
create mode 100644 native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
create mode 100644 native_tui/src/NativeTui.Markdown/NativeTui.Markdown.csproj
create mode 100644 native_tui/tests/NativeTui.Markdown.Tests/GlobalUsings.cs
create mode 100644 native_tui/tests/NativeTui.Markdown.Tests/MarkdigProjectorTests.cs
create mode 100644 native_tui/tests/NativeTui.Markdown.Tests/NativeTui.Markdown.Tests.csproj
diff --git a/native_tui/native_tui.slnx b/native_tui/native_tui.slnx
index 40ccdf5e4..50c001a19 100644
--- a/native_tui/native_tui.slnx
+++ b/native_tui/native_tui.slnx
@@ -1,7 +1,9 @@
+
+
diff --git a/native_tui/src/NativeTui.Markdown/GlobalUsings.cs b/native_tui/src/NativeTui.Markdown/GlobalUsings.cs
new file mode 100644
index 000000000..ce1ac7929
--- /dev/null
+++ b/native_tui/src/NativeTui.Markdown/GlobalUsings.cs
@@ -0,0 +1,6 @@
+global using Markdig;
+global using Markdig.Extensions.Tables;
+global using Markdig.Syntax;
+global using Markdig.Syntax.Inlines;
+global using NativeTui.Core.Markdown;
+global using MarkdownDocument = NativeTui.Core.Markdown.MarkdownDocument;
diff --git a/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs b/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
new file mode 100644
index 000000000..0ec0a4490
--- /dev/null
+++ b/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
@@ -0,0 +1,181 @@
+namespace NativeTui.Markdown;
+
+///
+/// Markdig 投影器(M7-4)— Markdig AST → NativeTui.Core 块模型(ADR 0140 卫星层:
+/// Markdig 依赖隔离在本项目,Core 保持零依赖;DTO 主题/宽度无关,渲染决策全部后置到 Core 布局层)。
+/// 投影只产出结构 + 行内样式 flags(标题级别样式由布局层施加);
+/// 删除线忽略(基准 configureMarked 禁用 del 解析,~ 常用于"约等于");
+/// html 块/链接引用定义丢弃(基准 def/del/html 不渲染);
+/// 链接 mailto 降级纯文本、图片显示 URL(基准 formatToken link/image)。
+///
+public static class MarkdigProjector
+{
+ /// 解析管线 — UseAdvancedExtensions(表格/自动链接等 GFM 扩展,对齐青蛙TUI 先例)。
+ private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
+
+ /// Markdown 源文本 → Core 块模型文档。
+ public static MarkdownDocument Project(string markdown) =>
+ new(Markdig.Markdown.Parse(markdown, Pipeline).Select(ProjectBlock).OfType().ToList());
+
+ /// 块投影分派 — 未覆盖的块类型丢弃(返回 null)。
+ private static MarkdownBlock? ProjectBlock(Block block) => block switch
+ {
+ HeadingBlock heading => new MarkdownHeading(heading.Level, ProjectInlines(heading.Inline, MarkdownStyle.None)),
+ ParagraphBlock paragraph => new MarkdownParagraph(ProjectInlines(paragraph.Inline, MarkdownStyle.None)),
+ FencedCodeBlock code => new MarkdownCode(code.Info ?? string.Empty, code.Lines.ToString()),
+ CodeBlock code => new MarkdownCode(string.Empty, code.Lines.ToString()),
+ QuoteBlock quote => new MarkdownQuote(quote.Select(ProjectBlock).OfType().ToList()),
+ ListBlock list => ProjectList(list),
+ ThematicBreakBlock => new MarkdownRule(),
+ Table table => ProjectTable(table),
+ _ => null,
+ };
+
+ /// 列表投影 — 有序列表保留首项 Order 为 Start(基准 token.start)。
+ private static MarkdownBlock ProjectList(ListBlock list)
+ {
+ var items = list.OfType()
+ .Select(item => new MarkdownListItem(item.Select(ProjectBlock).OfType().ToList()))
+ .ToList();
+ return new MarkdownList(list.IsOrdered, OrderedStart(list), items);
+ }
+
+ /// 有序列表起始编号(首个 Order>0 的项;无序或缺失回退 1)。
+ private static int OrderedStart(ListBlock list) =>
+ list.OfType().FirstOrDefault(static item => item.Order > 0)?.Order ?? 1;
+
+ /// 表格投影 — IsHeader 行为表头,其余为数据行(Markdig TableRow.IsHeader)。
+ private static MarkdownBlock ProjectTable(Table table)
+ {
+ var rows = table.OfType().ToList();
+ var headers = rows.Where(static row => row.IsHeader).Select(ProjectRow).FirstOrDefault() ?? [];
+ var data = rows.Where(static row => !row.IsHeader).Select(ProjectRow).ToList();
+ return new MarkdownTable(headers, data);
+ }
+
+ /// 表格行投影 — 行 → 格列表(每格 = 其内 leaf 块行内段的顺序拼接,对齐青蛙TUI CellText 拼接先例)。
+ private static IReadOnlyList> ProjectRow(TableRow row) =>
+ row.OfType()
+ .Select(cell => ProjectInlines(cell.OfType().SelectMany(static leaf => EnumerateInlines(leaf.Inline)), MarkdownStyle.None))
+ .ToList();
+
+ /// 行内投影 — 序列递归 + 样式 flags 叠加(基准 inherited 语义;ContainerInline 即 IEnumerable<Inline>)。
+ private static IReadOnlyList ProjectInlines(IEnumerable? inlines, MarkdownStyle inherited)
+ {
+ var segments = new List();
+ foreach (var inline in inlines ?? [])
+ {
+ ProjectInline(inline, inherited, segments);
+ }
+
+ return segments;
+ }
+
+ /// 行内分派投影(各分支独立成方法)。
+ private static void ProjectInline(Inline inline, MarkdownStyle inherited, List segments)
+ {
+ switch (inline)
+ {
+ case LiteralInline literal:
+ AppendLiteral(segments, literal, inherited);
+ break;
+ case CodeInline code:
+ segments.Add(new MarkdownSegment(code.Content, inherited | MarkdownStyle.Code));
+ break;
+ case EmphasisInline emphasis:
+ ProjectEmphasis(emphasis, inherited, segments);
+ break;
+ case LinkInline link:
+ ProjectLink(link, inherited, segments);
+ break;
+ case AutolinkInline autolink:
+ segments.Add(new MarkdownSegment(autolink.Url, MarkdownStyle.Link));
+ break;
+ case LineBreakInline:
+ // 硬换行首版丢弃(对齐青蛙TUI GUI 先例;Core 段落模型无段内断行概念,后续按需扩展)
+ break;
+ case ContainerInline nested:
+ ProjectInlinesInto(nested, inherited, segments);
+ break;
+ default:
+ if (inline.ToString() is { Length: > 0 } fallback)
+ {
+ segments.Add(new MarkdownSegment(fallback, inherited));
+ }
+
+ break;
+ }
+ }
+
+ /// 强调投影 — DelimiterCount>=2 为粗体、单个为斜体;'~' 删除线忽略(基准禁用 del)。
+ private static void ProjectEmphasis(EmphasisInline emphasis, MarkdownStyle inherited, List segments)
+ {
+ if (emphasis.DelimiterChar == '~')
+ {
+ ProjectInlinesInto(emphasis, inherited, segments);
+ return;
+ }
+
+ var style = inherited | (emphasis.DelimiterCount >= 2 ? MarkdownStyle.Bold : MarkdownStyle.Italic);
+ ProjectInlinesInto(emphasis, style, segments);
+ }
+
+ /// 链接投影 — 图片显示 URL / mailto 降级纯文本 / 其余显示文本携带 Link 标签(OSC 8 后续项)。
+ private static void ProjectLink(LinkInline link, MarkdownStyle inherited, List segments)
+ {
+ if (link.IsImage)
+ {
+ segments.Add(new MarkdownSegment(link.Url ?? string.Empty, MarkdownStyle.None));
+ return;
+ }
+
+ if (link.Url?.StartsWith("mailto:", StringComparison.Ordinal) == true)
+ {
+ ProjectInlinesInto(link, MarkdownStyle.None, segments);
+ return;
+ }
+
+ ProjectInlinesInto(link, inherited | MarkdownStyle.Link, segments);
+ }
+
+ /// 容器行内递归投影(共享目标段列表)。
+ private static void ProjectInlinesInto(ContainerInline container, MarkdownStyle inherited, List segments)
+ {
+ foreach (var inline in container)
+ {
+ ProjectInline(inline, inherited, segments);
+ }
+ }
+
+ /// 字面量段 — 与前段同样式时合并(消除碎片段)。
+ private static void AppendLiteral(List segments, LiteralInline literal, MarkdownStyle inherited)
+ {
+ var text = literal.Content.ToString();
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ if (segments.Count > 0 && segments[^1].Style == inherited)
+ {
+ segments[^1] = segments[^1] with { Text = segments[^1].Text + text };
+ return;
+ }
+
+ segments.Add(new MarkdownSegment(text, inherited));
+ }
+
+ /// 容器展开为行内序列(表格单元格多 leaf 块拼接用)。
+ private static IEnumerable EnumerateInlines(ContainerInline? container)
+ {
+ if (container is null)
+ {
+ yield break;
+ }
+
+ foreach (var inline in container)
+ {
+ yield return inline;
+ }
+ }
+}
diff --git a/native_tui/src/NativeTui.Markdown/NativeTui.Markdown.csproj b/native_tui/src/NativeTui.Markdown/NativeTui.Markdown.csproj
new file mode 100644
index 000000000..ab9415821
--- /dev/null
+++ b/native_tui/src/NativeTui.Markdown/NativeTui.Markdown.csproj
@@ -0,0 +1,20 @@
+
+
+
+ NativeTui.Markdown
+ NativeTui.Markdown
+ NativeTui Markdown 卫星项目 — Markdig AST 投影为 Core 块模型(ADR 0140:Markdig 隔离在本层,Core 保持零依赖)
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/native_tui/tests/NativeTui.Markdown.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Markdown.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..a8071afe9
--- /dev/null
+++ b/native_tui/tests/NativeTui.Markdown.Tests/GlobalUsings.cs
@@ -0,0 +1,4 @@
+global using NativeTui.Core;
+global using NativeTui.Core.Markdown;
+global using NativeTui.Markdown;
+global using Xunit;
diff --git a/native_tui/tests/NativeTui.Markdown.Tests/MarkdigProjectorTests.cs b/native_tui/tests/NativeTui.Markdown.Tests/MarkdigProjectorTests.cs
new file mode 100644
index 000000000..c596b1b2e
--- /dev/null
+++ b/native_tui/tests/NativeTui.Markdown.Tests/MarkdigProjectorTests.cs
@@ -0,0 +1,206 @@
+namespace NativeTui.Markdown.Tests;
+
+///
+/// Markdig 投影器测试(M7-4)— Markdig AST → Core 块模型:
+/// 职责分离:投影器只产出结构+行内样式 flags(标题级别样式由布局层施加);
+/// 行内 bold/em/code/link 投影为 flags;删除线忽略(基准禁用 del 解析);
+/// html 块丢弃(基准 def/del/html 不渲染);链接 mailto 降级纯文本;图片显示 URL。
+///
+public sealed class MarkdigProjectorTests
+{
+ /// 投影并取首个块。
+ private static MarkdownBlock FirstBlock(string markdown)
+ {
+ var block = MarkdigProjector.Project(markdown).Blocks.FirstOrDefault();
+ Assert.NotNull(block);
+ return block;
+ }
+
+ /// 取块内唯一段落的段列表。
+ private static IReadOnlyList InlineOf(MarkdownBlock block) =>
+ Assert.IsType(block).Segments;
+
+ [Fact]
+ public void Project_HeadingKeepsLevelAndPlainSegments()
+ {
+ var heading = Assert.IsType(FirstBlock("# Title"));
+
+ Assert.Equal(1, heading.Level);
+ var segment = Assert.Single(heading.Segments);
+ Assert.Equal(("Title", MarkdownStyle.None), (segment.Text, segment.Style));
+ }
+
+ [Fact]
+ public void Project_ParagraphInlineStyles()
+ {
+ var segments = InlineOf(FirstBlock("plain **bold** *em* `code`"));
+
+ Assert.Equal(
+ [
+ ("plain ", MarkdownStyle.None),
+ ("bold", MarkdownStyle.Bold),
+ (" ", MarkdownStyle.None),
+ ("em", MarkdownStyle.Italic),
+ (" ", MarkdownStyle.None),
+ ("code", MarkdownStyle.Code),
+ ], segments.Select(static segment => (segment.Text, segment.Style)));
+ }
+
+ [Fact]
+ public void Project_NestedEmphasisCombinesFlags()
+ {
+ var segments = InlineOf(FirstBlock("***both***"));
+
+ var segment = Assert.Single(segments);
+ Assert.Equal(MarkdownStyle.Bold | MarkdownStyle.Italic, segment.Style);
+ }
+
+ [Fact]
+ public void Project_FencedCodeKeepsLanguageAndText()
+ {
+ var code = Assert.IsType(FirstBlock("```cs\nvar a = 1;\nvar b = 2;\n```"));
+
+ Assert.Equal("cs", code.Language);
+ Assert.Equal("var a = 1;\nvar b = 2;", code.Text.TrimEnd('\n'));
+ }
+
+ [Fact]
+ public void Project_IndentedCodeHasEmptyLanguage()
+ {
+ var code = Assert.IsType(FirstBlock(" indented code"));
+
+ Assert.Equal(string.Empty, code.Language);
+ Assert.Equal("indented code", code.Text.TrimEnd('\n'));
+ }
+
+ [Fact]
+ public void Project_QuoteNestsChildBlocks()
+ {
+ var quote = Assert.IsType(FirstBlock("> quoted"));
+
+ var paragraph = Assert.IsType(Assert.Single(quote.Children));
+ var segment = Assert.Single(paragraph.Segments);
+ Assert.Equal("quoted", segment.Text);
+ }
+
+ [Fact]
+ public void Project_UnorderedListItems()
+ {
+ var list = Assert.IsType(FirstBlock("- first\n- second"));
+
+ Assert.False(list.Ordered);
+ Assert.Equal(2, list.Items.Count);
+ var first = Assert.IsType(Assert.Single(list.Items[0].Children));
+ Assert.Equal("first", Assert.Single(first.Segments).Text);
+ }
+
+ [Fact]
+ public void Project_OrderedListKeepsStart()
+ {
+ var list = Assert.IsType(FirstBlock("3. a\n4. b"));
+
+ Assert.True(list.Ordered);
+ Assert.Equal(3, list.Start);
+ }
+
+ [Fact]
+ public void Project_NestedListInsideItem()
+ {
+ var list = Assert.IsType(FirstBlock("- outer\n - inner"));
+
+ var item = list.Items[0];
+ Assert.Equal(2, item.Children.Count);
+ var nested = Assert.IsType(item.Children[1]);
+ Assert.False(nested.Ordered);
+ Assert.Single(nested.Items);
+ }
+
+ [Fact]
+ public void Project_ThematicBreakAsRule()
+ {
+ Assert.IsType(FirstBlock("---"));
+ }
+
+ [Fact]
+ public void Project_PipeTableHeadersAndRows()
+ {
+ var table = Assert.IsType(FirstBlock("| Name | Value |\n| --- | --- |\n| a | b |"));
+
+ Assert.Equal(["Name", "Value"], table.Headers.Select(PlainText));
+ var row = Assert.Single(table.Rows);
+ Assert.Equal(["a", "b"], row.Select(PlainText));
+ }
+
+ [Fact]
+ public void Project_LinkTextCarriesLinkFlag()
+ {
+ var segments = InlineOf(FirstBlock("[text](https://example.com)"));
+
+ var segment = Assert.Single(segments);
+ Assert.Equal("text", segment.Text);
+ Assert.Equal(MarkdownStyle.Link, segment.Style);
+ }
+
+ [Fact]
+ public void Project_MailtoLinkDegradesToPlainEmail()
+ {
+ var segments = InlineOf(FirstBlock("[mail me](mailto:a@b.c)"));
+
+ var segment = Assert.Single(segments);
+ Assert.Equal("mail me", segment.Text);
+ Assert.Equal(MarkdownStyle.None, segment.Style);
+ }
+
+ [Fact]
+ public void Project_ImageShowsUrl()
+ {
+ var segments = InlineOf(FirstBlock(""));
+
+ var segment = Assert.Single(segments);
+ Assert.Equal("https://example.com/i.png", segment.Text);
+ }
+
+ [Fact]
+ public void Project_StrikethroughIgnoredPerBaseline()
+ {
+ // 基准 configureMarked 禁用 del 解析(~ 常用于"约等于")→ 投影不加删除线
+ var segments = InlineOf(FirstBlock("~~text~~"));
+
+ var segment = Assert.Single(segments);
+ Assert.Equal("text", segment.Text);
+ Assert.Equal(MarkdownStyle.None, segment.Style);
+ }
+
+ [Fact]
+ public void Project_HtmlBlockDropped()
+ {
+ var document = MarkdigProjector.Project("raw
\n\nvisible");
+
+ Assert.Single(document.Blocks);
+ Assert.Equal("visible", Assert.Single(InlineOf(document.Blocks[0])).Text);
+ }
+
+ [Fact]
+ public void Project_MultipleParagraphs()
+ {
+ var document = MarkdigProjector.Project("first\n\nsecond");
+
+ Assert.Equal(2, document.Blocks.Count);
+ Assert.Equal("first", Assert.Single(InlineOf(document.Blocks[0])).Text);
+ Assert.Equal("second", Assert.Single(InlineOf(document.Blocks[1])).Text);
+ }
+
+ [Fact]
+ public void Project_TableCellKeepsInlineStyles()
+ {
+ var table = Assert.IsType(FirstBlock("| Name |\n| --- |\n| `code` |"));
+
+ var cell = table.Rows[0][0];
+ var segment = Assert.Single(cell);
+ Assert.Equal(MarkdownStyle.Code, segment.Style);
+ }
+
+ /// 拼接段列表纯文本。
+ private static string PlainText(IReadOnlyList segments) =>
+ string.Concat(segments.Select(static segment => segment.Text));
+}
diff --git a/native_tui/tests/NativeTui.Markdown.Tests/NativeTui.Markdown.Tests.csproj b/native_tui/tests/NativeTui.Markdown.Tests/NativeTui.Markdown.Tests.csproj
new file mode 100644
index 000000000..b214ebdb3
--- /dev/null
+++ b/native_tui/tests/NativeTui.Markdown.Tests/NativeTui.Markdown.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ NativeTui.Markdown.Tests
+ NativeTui.Markdown.Tests
+ NativeTui Markdown 投影器测试(Markdig AST → Core 块模型)
+ false
+ true
+ false
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
From 9de35cf5c4dbe072d17f07cdb74d1098491b5c83 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 23:22:42 +0800
Subject: [PATCH 14/16] =?UTF-8?q?feat:=20M7-4=20=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E9=97=AD=E7=8E=AF=20assistant=20Markdown=20=E4=BA=8B=E4=BB=B6?=
=?UTF-8?q?=E5=88=86=E6=B5=81=20|=20=E5=86=B3=E7=AD=96:=20AssistantMessage?=
=?UTF-8?q?=20=E6=89=A9=20Markdown=20=E5=8F=AF=E7=A9=BA=E5=AD=97=E6=AE=B5(?=
=?UTF-8?q?WhenWritingNull=20=E4=BF=9D=E6=97=A7=20JSON=20=E9=80=90?=
=?UTF-8?q?=E5=AD=97=E8=8A=82=E5=85=BC=E5=AE=B9),=20MarkdownBlock=20?=
=?UTF-8?q?=E6=A0=91=E5=B8=A6=20JsonPolymorphic=20discriminator=20?=
=?UTF-8?q?=E8=BF=9B=20BridgeJsonContext;=20=E6=8A=95=E5=BD=B1=E5=86=B3?=
=?UTF-8?q?=E7=AD=96=E5=9C=A8=20Host=20consumer(OnDone=20=E7=BB=8F=20Proje?=
=?UTF-8?q?ctOptional=20=E5=BF=AB=E8=B7=AF=E5=BE=84:=20=E6=97=A0=E8=AF=AD?=
=?UTF-8?q?=E6=B3=95=E5=AD=97=E7=AC=A6=E9=99=8D=E7=BA=A7=E7=BA=AF=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E5=AF=B9=E9=BD=90=E5=9F=BA=E5=87=86=20hasMarkdownSynt?=
=?UTF-8?q?ax=20=E9=87=87=E6=A0=B7=20500),=20Driver=20=E5=88=86=E6=B5=81?=
=?UTF-8?q?=20MarkdownMessageItem/AssistantMessageItem;=20=E8=BF=9B?=
=?UTF-8?q?=E7=A8=8B=E5=86=85=E7=9B=B4=E8=BF=9E=E8=B7=AF=E5=BE=84=E5=86=85?=
=?UTF-8?q?=E5=AD=98=E5=AF=B9=E8=B1=A1=E6=90=BA=E5=B8=A6=20DTO=20=E9=9B=B6?=
=?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E7=A8=8E,=20=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E4=BE=A7=20JSON=20=E5=8D=8F=E8=AE=AE=E6=9C=AA=E6=9D=A5?=
=?UTF-8?q?=E6=90=BA=E5=B8=A6=E5=90=8C=E5=AD=97=E6=AE=B5=E8=87=AA=E5=8A=A8?=
=?UTF-8?q?=E5=8D=87=E7=BA=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Host/GlobalUsings.cs | 2 +
.../NativeTui.Host/InProcessEventConsumer.cs | 8 +-
.../apps/NativeTui.Host/NativeTui.Host.csproj | 2 +
.../src/NativeTui.Core/bridge/BridgeEvent.cs | 8 +-
.../bridge/BridgeJsonContext.cs | 1 +
.../NativeTui.Core/bridge/ReplBridgeDriver.cs | 6 +-
.../NativeTui.Core/markdown/MarkdownModel.cs | 13 ++-
.../repl/MarkdownMessageItem.cs | 5 +-
.../NativeTui.Markdown/MarkdigProjector.cs | 55 ++++++++++
.../NativeTui.Host.Tests/GlobalUsings.cs | 1 +
.../InProcessEventConsumerTests.cs | 29 +++++
.../bridge/AssistantMarkdownTests.cs | 101 ++++++++++++++++++
12 files changed, 222 insertions(+), 9 deletions(-)
create mode 100644 native_tui/tests/NativeTui.Tests/bridge/AssistantMarkdownTests.cs
diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
index 64351cc2a..74db4267d 100644
--- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs
@@ -19,8 +19,10 @@
global using NativeTui.Core.Bridge;
global using NativeTui.Core.Components;
global using NativeTui.Core.Grid;
+global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
global using NativeTui.Core.Terminal;
+global using NativeTui.Markdown;
// 引擎侧权限类型别名 — 引擎 JoinCode.Abstractions.Security.PermissionMode 与 Core NativeTui.Core.Repl.PermissionMode
// 同名(枚举集合一致,各属两侧),不能同时裸导入命名空间,Security 侧统一走别名消除歧义
global using EnginePermissionMode = JoinCode.Abstractions.Security.PermissionMode;
diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
index 2c688efd7..a9167d8c8 100644
--- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
+++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
@@ -78,11 +78,13 @@ public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeate
public void OnTimingSummary(string summary) {
}
- /// 流式响应完成 → 思考结算 + assistant_message(完整文本)。
+ /// 流式响应完成 → 思考结算 + assistant_message(完整文本 + Markdown 投影;无语法快路径降级纯文本)。
public void OnDone(TokenUsage? usage, string? modelId) {
EndThinkingIfActive();
- if (_fullResponse.Length > 0)
- _events.Enqueue(new BridgeEvent.AssistantMessage(_fullResponse.ToString()));
+ if (_fullResponse.Length == 0)
+ return;
+ var fullText = _fullResponse.ToString();
+ _events.Enqueue(new BridgeEvent.AssistantMessage(fullText, MarkdigProjector.ProjectOptional(fullText)));
}
/// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。
diff --git a/native_tui/apps/NativeTui.Host/NativeTui.Host.csproj b/native_tui/apps/NativeTui.Host/NativeTui.Host.csproj
index 013e0284f..36d32a75c 100644
--- a/native_tui/apps/NativeTui.Host/NativeTui.Host.csproj
+++ b/native_tui/apps/NativeTui.Host/NativeTui.Host.csproj
@@ -17,6 +17,8 @@
+
+
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
index fecea4b02..e3b8f8f27 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
@@ -52,8 +52,12 @@ public sealed record ToolResult(string Summary,
/// 流式 token 增量(spinner ResponseLength 累加)。
public sealed record TokenDelta(int Length) : BridgeEvent;
- /// 完整助手消息(追加滚动区)。
- public sealed record AssistantMessage(string Text) : BridgeEvent;
+ ///
+ /// 完整助手消息(追加滚动区)。Markdown 非空时渲染为块模型(M7-4);
+ /// null 不输出字段(WhenWritingNull — 引擎侧旧协议逐字节一致,降级纯文本路径)。
+ ///
+ public sealed record AssistantMessage(string Text,
+ [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] MarkdownDocument? Markdown = null) : BridgeEvent;
/// 回合完成(idle)。
public sealed record TurnCompleted() : BridgeEvent;
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs b/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
index 6ab34adbf..a6040ccd3 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeJsonContext.cs
@@ -8,4 +8,5 @@ namespace NativeTui.Core.Bridge;
[JsonSerializable(typeof(BridgeRequest))]
[JsonSerializable(typeof(BridgeEvent))]
[JsonSerializable(typeof(DiffHunk))]
+[JsonSerializable(typeof(MarkdownDocument))]
public sealed partial class BridgeJsonContext : JsonSerializerContext;
diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
index b5b94433a..6ae533630 100644
--- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
+++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs
@@ -70,8 +70,10 @@ public void Apply(BridgeEvent bridgeEvent)
_spinner.HasActiveTools = false;
_spinner.ResponseLength += length;
break;
- case BridgeEvent.AssistantMessage(var text):
- AddMessage(new AssistantMessageItem(text));
+ case BridgeEvent.AssistantMessage(var text, var markdown):
+ AddMessage(markdown is not null
+ ? new MarkdownMessageItem(markdown)
+ : new AssistantMessageItem(text));
break;
case BridgeEvent.TurnCompleted:
EndTurn();
diff --git a/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs b/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
index e7f1ad1a3..d50a03e50 100644
--- a/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
+++ b/native_tui/src/NativeTui.Core/markdown/MarkdownModel.cs
@@ -37,7 +37,18 @@ public enum MarkdownStyle : ushort
/// 一行内一段同样式文本 — Markdown 行模型的最小单元。
public readonly record struct MarkdownSegment(string Text, MarkdownStyle Style);
-/// Markdown 块模型(抽象基类)— 投影器产出,布局器消费。
+///
+/// Markdown 块模型(抽象基类)— 投影器产出,布局器消费;
+/// JSON 多态序列化(bridge 协议 assistant_message.markdown 字段,type discriminator 同 ADR 0141 风格)。
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(MarkdownParagraph), "paragraph")]
+[JsonDerivedType(typeof(MarkdownHeading), "heading")]
+[JsonDerivedType(typeof(MarkdownCode), "code")]
+[JsonDerivedType(typeof(MarkdownQuote), "quote")]
+[JsonDerivedType(typeof(MarkdownList), "list")]
+[JsonDerivedType(typeof(MarkdownRule), "rule")]
+[JsonDerivedType(typeof(MarkdownTable), "table")]
public abstract record MarkdownBlock;
/// 段落 — 行内段序列,布局期按宽度软换行。
diff --git a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
index 4a5ca97e6..8fa76e32c 100644
--- a/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/MarkdownMessageItem.cs
@@ -11,6 +11,9 @@ public sealed class MarkdownMessageItem(MarkdownDocument document) : IScrollItem
/// Markdown 正文缩进列(● + 空格,对齐基准 NoSelect minWidth 2)。
private const int BodyIndentCols = 2;
+ /// 块模型文档(暴露供宿主断言与演示构造)。
+ public MarkdownDocument Document { get; } = document;
+
/// 缓存的布局宽度(-1 = 未布局)。
private int _layoutWidth = -1;
@@ -37,7 +40,7 @@ private IReadOnlyList> LayoutFor(int width)
{
if (_layoutWidth != width)
{
- _layout = MarkdownLayout.Layout(document, Math.Max(0, width - BodyIndentCols));
+ _layout = MarkdownLayout.Layout(Document, Math.Max(0, width - BodyIndentCols));
_layoutWidth = width;
}
diff --git a/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs b/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
index 0ec0a4490..8b3af1c0d 100644
--- a/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
+++ b/native_tui/src/NativeTui.Markdown/MarkdigProjector.cs
@@ -13,10 +13,65 @@ public static class MarkdigProjector
/// 解析管线 — UseAdvancedExtensions(表格/自动链接等 GFM 扩展,对齐青蛙TUI 先例)。
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
+ ///
+ /// 快路径投影 — 无 Markdown 语法字符返回 null(纯文本降级渲染),有则投影文档
+ ///(对齐基准 hasMarkdownSyntax:前 500 字符采样检查,短回复跳过 ~3ms 解析成本)。
+ ///
+ public static MarkdownDocument? ProjectOptional(string text) =>
+ HasMarkdownSyntax(text) ? Project(text) : null;
+
/// Markdown 源文本 → Core 块模型文档。
public static MarkdownDocument Project(string markdown) =>
new(Markdig.Markdown.Parse(markdown, Pipeline).Select(ProjectBlock).OfType().ToList());
+ /// Markdown 语法标记检测(基准 MD_SYNTAX_RE 移植:任意位置语法字符 / 空行 / 行首有序列表,采样前 500 字符)。
+ private static bool HasMarkdownSyntax(string text)
+ {
+ var sample = text.Length > 500 ? text.AsSpan(0, 500) : text.AsSpan();
+ foreach (var current in sample)
+ {
+ if (current is '#' or '*' or '`' or '[' or '|' or '>' or '~' or '-' or '_')
+ {
+ return true;
+ }
+ }
+
+ if (sample.IndexOf("\n\n".AsSpan(), StringComparison.Ordinal) >= 0)
+ {
+ return true;
+ }
+
+ var lineStart = 0;
+ for (var index = 0; index <= sample.Length; index++)
+ {
+ if (index < sample.Length && sample[index] != '\n')
+ {
+ continue;
+ }
+
+ if (HasOrderedPrefix(sample[lineStart..index]))
+ {
+ return true;
+ }
+
+ lineStart = index + 1;
+ }
+
+ return false;
+ }
+
+ /// 行首有序列表前缀 "N. " 检测。
+ private static bool HasOrderedPrefix(ReadOnlySpan line)
+ {
+ var digits = 0;
+ while (digits < line.Length && char.IsDigit(line[digits]))
+ {
+ digits++;
+ }
+
+ return digits > 0 && digits + 1 < line.Length && line[digits] == '.' && line[digits + 1] == ' ';
+ }
+
/// 块投影分派 — 未覆盖的块类型丢弃(返回 null)。
private static MarkdownBlock? ProjectBlock(Block block) => block switch
{
diff --git a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
index 5afae1854..0bb0392c8 100644
--- a/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/GlobalUsings.cs
@@ -9,6 +9,7 @@
global using JoinCode.App.Builder;
global using Microsoft.Extensions.DependencyInjection;
global using NativeTui.Core.Bridge;
+global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
global using NativeTui.Host;
global using Xunit;
diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
index 013c6dd8d..6791f5b0b 100644
--- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
+++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs
@@ -190,4 +190,33 @@ public void IgnoredCallbacks_EmitNothing()
Assert.Empty(consumer.DrainEvents());
}
+
+ [Fact]
+ public void OnDone_MarkdownTextCarriesProjectedDocument()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnText("# 标题\n\n**加粗** 与 `code`");
+ consumer.OnDone(usage: null, modelId: "test-model");
+
+ var events = consumer.DrainEvents();
+ var message = Assert.IsType(events[^1]);
+ Assert.NotNull(message.Markdown);
+ var heading = Assert.IsType(message.Markdown.Blocks[0]);
+ Assert.Equal(1, heading.Level);
+ Assert.Contains(message.Markdown.Blocks, block => block is MarkdownParagraph);
+ }
+
+ [Fact]
+ public void OnDone_PlainTextKeepsMarkdownNull()
+ {
+ var consumer = new InProcessEventConsumer();
+
+ consumer.OnText("普通纯文本回答");
+ consumer.OnDone(usage: null, modelId: "test-model");
+
+ var events = consumer.DrainEvents();
+ var message = Assert.IsType(events[^1]);
+ Assert.Null(message.Markdown);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/AssistantMarkdownTests.cs b/native_tui/tests/NativeTui.Tests/bridge/AssistantMarkdownTests.cs
new file mode 100644
index 000000000..9eed643cf
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/bridge/AssistantMarkdownTests.cs
@@ -0,0 +1,101 @@
+namespace NativeTui.Tests.Bridge;
+
+///
+/// assistant 消息 Markdown 分流测试(M7-4)— 事件层扩展与消费层分流:
+/// AssistantMessage.Markdown 非空 → MarkdownMessageItem(块模型渲染);null → AssistantMessageItem(纯文本,旧 JSON 兼容);
+/// BridgeProtocol JSON 往返保多态块结构(paragraph/heading/code/list 等 type discriminator),
+/// null 不输出字段(WhenWritingNull — 引擎侧旧协议逐字节一致)。
+///
+public sealed class AssistantMarkdownTests
+{
+ private readonly ReplView _view = new();
+ private readonly SpinnerItem _spinner = new();
+ private readonly BannerItem _banner = new("0.0.0", "占位模型", "API 按量计费", @"C:\placeholder");
+ private readonly ReplBridgeDriver _driver;
+
+ public AssistantMarkdownTests()
+ {
+ _driver = new ReplBridgeDriver(_view, _spinner, _banner);
+ _view.Messages.Add(_banner);
+ _view.Messages.Add(_spinner);
+ }
+
+ /// 构造演示文档(heading + paragraph 行内样式 + code)。
+ private static MarkdownDocument DemoDocument() => new(
+ [
+ new MarkdownHeading(2, [new MarkdownSegment("标题", MarkdownStyle.None)]),
+ new MarkdownParagraph(
+ [
+ new MarkdownSegment("plain ", MarkdownStyle.None),
+ new MarkdownSegment("bold", MarkdownStyle.Bold),
+ new MarkdownSegment(" ", MarkdownStyle.None),
+ new MarkdownSegment("code", MarkdownStyle.Code),
+ ]),
+ new MarkdownCode("cs", "var a = 1;"),
+ ]);
+
+ [Fact]
+ public void Apply_AssistantMessageWithMarkdownRendersMarkdownItem()
+ {
+ _driver.Apply(new BridgeEvent.AssistantMessage("# 演示", DemoDocument()));
+
+ var item = Assert.IsType(_view.Messages.Items[^2]);
+ Assert.Equal(3, item.Document.Blocks.Count);
+ }
+
+ [Fact]
+ public void Apply_AssistantMessageWithoutMarkdownKeepsPlainItem()
+ {
+ _driver.Apply(new BridgeEvent.AssistantMessage("纯文本回复"));
+
+ Assert.IsType(_view.Messages.Items[^2]);
+ }
+
+ [Fact]
+ public void Serialize_AssistantMessageMarkdownRoundTrips()
+ {
+ var json = BridgeProtocol.SerializeEvent(new BridgeEvent.AssistantMessage("# 演示", DemoDocument()));
+
+ var reader = new StringReader(json);
+ Assert.Equal(BridgeReadResult.Event, BridgeProtocol.TryReadEvent(reader, out var @event, out _));
+ var message = Assert.IsType(@event);
+ Assert.Equal("# 演示", message.Text);
+ Assert.NotNull(message.Markdown);
+ Assert.Equal(3, message.Markdown.Blocks.Count);
+
+ // 多态块还原:heading/paragraph/code
+ Assert.IsType(message.Markdown.Blocks[0]);
+ Assert.IsType(message.Markdown.Blocks[1]);
+ Assert.IsType(message.Markdown.Blocks[2]);
+ }
+
+ [Fact]
+ public void Serialize_AssistantMessageWithoutMarkdownOmitsField()
+ {
+ var json = BridgeProtocol.SerializeEvent(new BridgeEvent.AssistantMessage("纯文本"));
+
+ // WhenWritingNull:markdown 字段不输出(引擎侧旧协议逐字节一致)
+ Assert.DoesNotContain("markdown", json);
+ }
+
+ [Fact]
+ public void ReadEvent_AssistantMessageWithMarkdownBlocksRestoresStructure()
+ {
+ var json = """{"type":"assistant_message","text":"# t","markdown":{"blocks":[{"type":"heading","level":1,"segments":[{"text":"t","style":0}]},{"type":"paragraph","segments":[{"text":"a","style":0},{"text":"b","style":1}]},{"type":"code","language":"cs","text":"var a = 1;"},{"type":"list","ordered":true,"start":3,"items":[{"children":[{"type":"paragraph","segments":[{"text":"x","style":0}]}]}]},{"type":"rule"},{"type":"quote","children":[{"type":"paragraph","segments":[{"text":"q","style":0}]}]}]}}""" + "\n";
+ var reader = new StringReader(json);
+ Assert.Equal(BridgeReadResult.Event, BridgeProtocol.TryReadEvent(reader, out var @event, out _));
+
+ var message = Assert.IsType(@event);
+ var blocks = message.Markdown!.Blocks;
+ Assert.Equal(6, blocks.Count);
+ Assert.Equal(1, Assert.IsType(blocks[0]).Level);
+ Assert.Equal(MarkdownStyle.Bold, Assert.IsType(blocks[1]).Segments[1].Style);
+ var code = Assert.IsType(blocks[2]);
+ Assert.Equal(("cs", "var a = 1;"), (code.Language, code.Text));
+ var list = Assert.IsType(blocks[3]);
+ Assert.True(list.Ordered);
+ Assert.Equal(3, list.Start);
+ Assert.IsType(blocks[4]);
+ Assert.IsType(blocks[5]);
+ }
+}
From 9a3f095d63b6aba8d8ce437246d1eee2470c5453 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 23:32:15 +0800
Subject: [PATCH 15/16] =?UTF-8?q?feat:=20M7-4=20Demo=20=E6=BC=94=E7=A4=BA?=
=?UTF-8?q?=E5=9B=9E=E5=90=88=E6=90=BA=E5=B8=A6=E5=85=A8=E5=BD=A2=E6=80=81?=
=?UTF-8?q?=20Markdown=20|=20=E5=86=B3=E7=AD=96:=20SimulatedEngine.DemoMar?=
=?UTF-8?q?kdown=20=E6=89=8B=E6=9E=84=208=20=E7=B1=BB=E5=9D=97=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B(=E5=AF=B9=E9=BD=90=20SimulatedPatch=20=E5=85=88?=
=?UTF-8?q?=E4=BE=8B=E7=9B=B4=E6=8E=A5=E6=9E=84=20DTO=20=E4=B8=8D=E7=BB=8F?=
=?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=99=A8,=20Core=20=E9=9B=B6=E4=BE=9D?=
=?UTF-8?q?=E8=B5=96),=20=E5=85=AC=E5=BC=80=E9=9D=99=E6=80=81=E4=BE=9B?=
=?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=9E=84=E9=80=A0=E5=A4=8D=E7=94=A8;=20?=
=?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=8B=E4=BB=B6=20AssistantMessage=20?=
=?UTF-8?q?=E6=90=BA=E5=B8=A6=20Markdown=20=E8=B5=B0=20Driver=20=E5=88=86?=
=?UTF-8?q?=E6=B5=81=E6=B8=B2=E6=9F=93=E9=93=BE=E8=B7=AF;=20SeedMessages?=
=?UTF-8?q?=20=E9=A6=96=E6=9D=A1=20assistant=20=E5=9B=9E=E5=A4=8D=E5=8D=87?=
=?UTF-8?q?=E7=BA=A7=20Markdown=20=E8=A1=8C=E5=86=85=E6=A0=B7=E5=BC=8F(?=
=?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=8D=B3=E8=A7=81=E6=B8=B2=E6=9F=93,=20?=
=?UTF-8?q?=E4=B8=8E=E5=9B=9E=E5=90=88=E5=85=A8=E5=BD=A2=E6=80=81=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=E5=8C=BA=E5=88=86=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D?=
=?UTF-8?q?);=20=E8=A1=A8=E6=A0=BC=20Headers=20=E6=89=81=E5=B9=B3=E5=8D=95?=
=?UTF-8?q?=E5=85=83=E6=A0=BC=E6=9E=84=E9=80=A0(=E6=B7=B1=E5=B1=82?=
=?UTF-8?q?=E5=B5=8C=E5=A5=97=E9=9B=86=E5=90=88=E8=A1=A8=E8=BE=BE=E5=BC=8F?=
=?UTF-8?q?=E5=9D=91=E8=A7=84=E9=81=BF=20MarkdownLayoutTests=20=E5=85=88?=
=?UTF-8?q?=E4=BE=8B);=20549=20=E7=94=A8=E4=BE=8B+selftest=20=E5=85=A8?=
=?UTF-8?q?=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/NativeTui.Demo/GlobalUsings.cs | 1 +
native_tui/apps/NativeTui.Demo/Program.cs | 16 +++++-
.../NativeTui.Core/bridge/SimulatedEngine.cs | 55 ++++++++++++++++++-
.../bridge/SimulatedEngineTests.cs | 23 ++++++++
4 files changed, 91 insertions(+), 4 deletions(-)
diff --git a/native_tui/apps/NativeTui.Demo/GlobalUsings.cs b/native_tui/apps/NativeTui.Demo/GlobalUsings.cs
index f64d6a859..5ad2d9dd4 100644
--- a/native_tui/apps/NativeTui.Demo/GlobalUsings.cs
+++ b/native_tui/apps/NativeTui.Demo/GlobalUsings.cs
@@ -8,5 +8,6 @@
global using NativeTui.Core.Components;
global using NativeTui.Core.Diff;
global using NativeTui.Core.Grid;
+global using NativeTui.Core.Markdown;
global using NativeTui.Core.Repl;
global using NativeTui.Core.Terminal;
diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs
index b1ee3028c..7d876a9e6 100644
--- a/native_tui/apps/NativeTui.Demo/Program.cs
+++ b/native_tui/apps/NativeTui.Demo/Program.cs
@@ -74,11 +74,23 @@ private static int RunInteractive(string? model = null, bool viewport = false)
return 0;
}
- /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区)。
+ /// 模拟模式种子消息(示例对话 — banner 之后、spinner 之前插入滚动区;assistant 回复为 Markdown 行内样式演示,M7-4 启动即见渲染)。
private static IReadOnlyList SeedMessages() =>
[
new UserMessageItem("这个 TUI 是像素级对齐 Claude Code 的自研实现吗?"),
- new AssistantMessageItem("是的 — 纯 C# 自研渲染管线:Screen cell 网格 + CellDiffer 差分 + DEC 2026 原子上屏。"),
+ new MarkdownMessageItem(new MarkdownDocument(
+ [
+ new MarkdownParagraph(
+ [
+ new MarkdownSegment("是的 — 纯 C# 自研渲染管线:", MarkdownStyle.None),
+ new MarkdownSegment("Screen cell 网格", MarkdownStyle.Bold),
+ new MarkdownSegment(" + ", MarkdownStyle.None),
+ new MarkdownSegment("CellDiffer", MarkdownStyle.Code),
+ new MarkdownSegment(" 差分 + ", MarkdownStyle.None),
+ new MarkdownSegment("DEC 2026", MarkdownStyle.Code),
+ new MarkdownSegment(" 原子上屏。", MarkdownStyle.None),
+ ]),
+ ])),
new ToolCallItem("Read", "native_tui/src/NativeTui.Core/repl/ReplView.cs"),
new ToolResultItem("41 行 — REPL 整屏视图(footer + 输入框 + 消息滚动区)"),
];
diff --git a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
index b3085cbd4..c4361bf8c 100644
--- a/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
+++ b/native_tui/src/NativeTui.Core/bridge/SimulatedEngine.cs
@@ -37,7 +37,9 @@ public sealed class SimulatedEngine : IReplEngine
/// 时钟(默认墙钟 — 测试注入虚拟时钟)。
private readonly Func _clock;
- /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):4 行 hunk 超 2 行预览,同时演示折叠提示。
+ ///
+ /// 模拟补丁 — Demo 演示 StructuredDiff 渲染链路(M7-2):4 行 hunk 超 2 行预览,同时演示折叠提示。
+ ///
private static readonly DiffHunk[] SimulatedPatch =
[
new()
@@ -53,6 +55,55 @@ public sealed class SimulatedEngine : IReplEngine
},
];
+ ///
+ /// 演示 Markdown(M7-4)— Demo 模拟回合完成消息的块模型,覆盖全 8 类块形态
+ /// (heading/paragraph 行内样式/无序+有序 list/quote/code/rule/table);
+ /// 与 SimulatedPatch 同理直接手构块模型(Core 零依赖,不经 Markdig 投影器),
+ /// 公开静态供 Demo SeedMessages 等演示构造复用。
+ ///
+ public static readonly MarkdownDocument DemoMarkdown = new(
+ [
+ new MarkdownHeading(2, [Seg("Markdown 渲染管线")]),
+ new MarkdownParagraph(
+ [
+ Seg("Core 块模型 → 布局 → 渲染三层分离;"),
+ Seg("行内代码", MarkdownStyle.Code),
+ Seg(" 取 permission 色,"),
+ Seg("粗体", MarkdownStyle.Bold),
+ Seg(" / "),
+ Seg("斜体", MarkdownStyle.Italic),
+ Seg(" 组合 SGR。"),
+ ]),
+ new MarkdownList(false, 0,
+ [
+ new MarkdownListItem([new MarkdownParagraph([Seg("无序项 — 两空格缩进")])]),
+ new MarkdownListItem([new MarkdownParagraph([Seg("第二项")])]),
+ ]),
+ new MarkdownList(true, 1,
+ [
+ new MarkdownListItem([new MarkdownParagraph([Seg("有序项 — 数字编号")])]),
+ new MarkdownListItem([new MarkdownParagraph([Seg("第二项 — 自动递增")])]),
+ ]),
+ new MarkdownQuote([new MarkdownParagraph([Seg("引用 — ▎ 前缀 + 内容斜体(布局层叠加)。")])]),
+ new MarkdownCode("csharp", "var doc = Markdown.Parse(text, Pipeline);\nvar blocks = MarkdigProjector.Project(doc);"),
+ new MarkdownRule(),
+ new MarkdownTable(
+ [
+ Cell(Seg("块形态", MarkdownStyle.Bold)),
+ Cell(Seg("演示要点", MarkdownStyle.Bold)),
+ ],
+ [
+ [Cell(Seg("代码块")), Cell(Seg("Language 原样保留,逐行不换行"))],
+ [Cell(Seg("表格")), Cell(Seg("列宽 = max(显示宽, 3)"))],
+ ]),
+ ]);
+
+ /// 行内段速构(演示文档构造)。
+ private static MarkdownSegment Seg(string text, MarkdownStyle style = MarkdownStyle.None) => new(text, style);
+
+ /// 表格单元格速构(深层嵌套集合表达式推断失败规避 — 对齐 MarkdownLayoutTests 先例)。
+ private static IReadOnlyList Cell(params MarkdownSegment[] segments) => segments;
+
/// 默认时间源 — 墙钟毫秒。
private static long DefaultClock() => Environment.TickCount64;
@@ -127,7 +178,7 @@ private void AdvanceStages()
{
_stage = 3;
_inTurn = false;
- _pending.Enqueue(new BridgeEvent.AssistantMessage("演示回复 — diff 渲染管线运行正常。"));
+ _pending.Enqueue(new BridgeEvent.AssistantMessage("演示回复 — markdown 全形态渲染管线运行正常。", DemoMarkdown));
_pending.Enqueue(new BridgeEvent.TurnCompleted());
return;
}
diff --git a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
index 1bb9e16dc..e11f4842d 100644
--- a/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
+++ b/native_tui/tests/NativeTui.Tests/bridge/SimulatedEngineTests.cs
@@ -77,6 +77,29 @@ public void Advance_ToCompletion_Emits_AssistantMessageAndTurnCompleted()
Assert.Empty(engine.DrainEvents());
}
+ /// 虚拟时钟 5.5s:完成事件携带 Markdown 块模型(M7-4 演示全形态 — 8 类块全覆盖)。
+ [Fact]
+ public void Advance_ToCompletion_Emits_AssistantMessageWithMarkdown()
+ {
+ var clock = new VirtualClock();
+ var engine = new SimulatedEngine(() => clock.Now);
+ engine.Submit("hello");
+ engine.DrainEvents();
+
+ clock.Advance(5500);
+ var events = engine.DrainEvents();
+ var message = Assert.IsType(events.First(e => e is BridgeEvent.AssistantMessage));
+ Assert.NotNull(message.Markdown);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownHeading);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownParagraph);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownList { Ordered: false });
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownList { Ordered: true });
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownQuote);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownCode);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownRule);
+ Assert.Contains(message.Markdown.Blocks, b => b is MarkdownTable);
+ }
+
/// 忙时 Interrupt:产出 TurnInterrupted,回合终止。
[Fact]
public void Interrupt_WhileBusy_Emits_TurnInterrupted()
From c3a2c3576e7dadf780472e9db60a56f472878543 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Tue, 20 Oct 2026 23:35:25 +0800
Subject: [PATCH 16/16] =?UTF-8?q?docs:=20TASK055=20=E8=BF=BD=E5=8A=A0=20M7?=
=?UTF-8?q?-4=20Markdown=20=E6=B8=B2=E6=9F=93=E5=86=B3=E7=AD=96=E5=9D=97?=
=?UTF-8?q?=E4=B8=8E=E6=89=8B=E5=8A=A8=E9=AA=8C=E6=94=B6=E8=A1=A8=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20=E5=9B=9B=E6=89=B9=E6=B8=90=E8=BF=9B?=
=?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=E8=90=BD=E5=9C=B0(Core=20=E5=9D=97?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B/=E5=8D=AB=E6=98=9F=20Markdig=20=E6=8A=95?=
=?UTF-8?q?=E5=BD=B1/=E5=BC=95=E6=93=8E=E9=97=AD=E7=8E=AF/Demo=20=E6=BC=94?=
=?UTF-8?q?=E7=A4=BA),=20formatToken=20=E6=9D=83=E5=A8=81=E6=98=A0?=
=?UTF-8?q?=E5=B0=84+=E9=9D=92=E8=9B=99TUI=20=E5=9B=9B=E5=9D=91=E8=A7=84?=
=?UTF-8?q?=E9=81=BF,=20=E5=83=8F=E7=B4=A0=E5=9B=9E=E5=BD=92=200=20?=
=?UTF-8?q?=E5=B7=AE=E5=BC=82,=20=E5=9F=BA=E7=BA=BF=20549+18+35?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...\247\345\257\271\351\275\220ClaudeCode.md" | 37 ++++++++++++++++++-
1 file changed, 36 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 cf8094822..7c4b54954 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"
@@ -689,4 +689,39 @@ native_tui/ # 顶层独立工程,零 modules/libs 依
| 3 三档交互 | 按 1 → 引擎继续执行; 再触发按 2 → 同会话不再问; 再触发按 3/Esc → 工具被拒 | 对话框消失, 回合继续, footer 无残留 |
| 4 焦点导航 | 上/下箭头移动 ❯ 指示 + Enter 选择 | 焦点环绕(1→3→2→1) |
-
+## M7-4 Markdown 渲染落地(2026-10-20,Markdig 投影 → 块模型 → 终端渲染全链路)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### M7-4 手动验收(ADR 0080,待用户执行)
+
+| 步骤 | 命令 | 预期 |
+|------|------|------|
+| 1 种子渲染 | dotnet run --project native_tui/apps/NativeTui.Demo(真实终端) | 启动即见 ● 圆点 + 行内样式:粗体"Screen cell 网格"、permission 色行内代码 |
+| 2 全形态回合 | 同上提交任意文本 → 等 5.4s 回合完成 | assistant 消息渲染 8 类块:h2 粗体标题/段落行内样式/无序+有序列表/▎ 引用斜体/csharp 代码块/---/表格(表头粗体+对齐分隔行) |
+| 3 真实引擎 | NativeTui.Host.exe(需配置模型) | 引擎回复含 Markdown 语法 → ProjectOptional 投影 → 块模型渲染(无语法回复仍纯文本快路径) |
+| 4 窄终端换行 | 终端调窄后重跑步骤 2 | 段落软换行(词边界贪心+CJK 逐字)、列表续行对齐内容列、代码块不换行 |
+
+