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 @@ + ▐▛███▜▌ jcc v1.0.0 +▝▜█████▛▘ fixture-model · Pro + ▘▘ ▝▝  D:\fixture + +● reference scene fixture + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── +❯ fixture input  +──────────────────────────────────────────────────────────────────────────────── + ? 查看快捷键 \ 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("![alt](https://example.com/i.png)")); + + 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 逐字)、列表续行对齐内容列、代码块不换行 | + +