From e0801805dc00cf15a42fe1a537f8cd35f1870ec0 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 02:33:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=AF=B9=E8=AF=9D=E6=A1=86+left=20=E9=94=AE?= =?UTF-8?q?=20+=20ctrl+g=20=E5=A4=96=E9=83=A8=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=EF=BC=88I/K=20=E5=8F=8C=E5=AD=90=E4=BB=A3=E7=90=86=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=EF=BC=8CJ=20@=20=E8=A1=A5=E5=85=A8=E9=98=B2=E9=87=8D?= =?UTF-8?q?=E5=81=9A=E9=9B=B6=E6=94=B9=E5=8A=A8=EF=BC=89=20|=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96:=20I=3DBackgroundTaskDetailDialog=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E6=9E=84=E9=80=A0(=E5=8F=AA=E8=AF=BB=E6=97=A0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E9=9B=B6=E5=8D=8F=E8=B0=83=E5=99=A8=E5=A2=9E?= =?UTF-8?q?=E7=9B=8A,=E4=B8=80=E6=AC=A1=E5=AE=9A=E6=A0=BC=E5=85=8D?= =?UTF-8?q?=E7=96=AB=E6=8C=82=E8=BD=BD=E6=9C=9F=20upsert/=E9=A9=B1?= =?UTF-8?q?=E9=80=90=E6=89=B0=E5=8A=A8)+Enter=20=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86(=E6=A8=A1=E6=80=81=E5=88=86?= =?UTF-8?q?=E6=B4=BE=E9=93=BE=20cursorNav=20=E5=90=8E=20selection=20?= =?UTF-8?q?=E5=89=8D,Esc/left=20=E5=85=B3=E9=97=AD=E5=9B=9E=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E6=80=81)+left=20=E5=8F=8C=E8=AF=AD=E4=B9=89=E4=BA=92?= =?UTF-8?q?=E6=96=A5(=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=80=81=E5=85=B3?= =?UTF-8?q?=E7=AA=97/=E9=80=89=E4=B8=AD=E6=80=81=E9=80=80=E5=87=BA)+Status?= =?UTF-8?q?Text=20internal=20=E5=8C=96=E5=8D=95=E4=B8=80=E6=B1=89=E5=8C=96?= =?UTF-8?q?=E6=BA=90;=20K=3Dctrl+g=20=E5=A4=96=E9=83=A8=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8(fire-and-forget+ConcurrentQueue=20=E8=B7=A8=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E7=BC=93=E5=86=B2+DrainEngine=20=E4=B8=BB=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=B6=88=E8=B4=B9=E5=AF=B9=E9=BD=90=20bash=20?= =?UTF-8?q?=E7=9B=B4=E9=80=9A=E5=85=88=E4=BE=8B=E2=80=94=E2=80=94FillInput?= =?UTF-8?q?=20=E5=9B=9E=E4=B8=BB=E5=BE=AA=E7=8E=AF=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E9=98=B2=E6=B8=B2=E6=9F=93=E7=AB=9E=E4=BA=89;=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E8=BE=93=E5=85=A5=E6=9C=AA=E5=8F=98=E6=89=8D?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E5=B7=B2=E5=8F=98=E4=B8=A2=E5=BC=83;External?= =?UTF-8?q?EditorLauncher=20$EDITOR=20=E5=B8=A6=E5=8F=82=E6=95=B0=E6=8B=86?= =?UTF-8?q?=E5=88=86+notepad=20=E5=85=9C=E5=BA=95+WaitForExitAsync=20?= =?UTF-8?q?=E6=97=A0=E8=B6=85=E6=97=B6=E4=BA=A4=E4=BA=92=E7=AD=89=E5=BE=85?= =?UTF-8?q?+temp=20try-finally=20=E8=B5=84=E6=BA=90=E6=B8=85=E7=90=86)+?= =?UTF-8?q?=E9=98=B2=E9=87=8D=E5=85=A5+=E7=A9=BA=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=97=A0=E6=93=8D=E4=BD=9C;=20J=3D@=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E7=BB=8F=E6=A0=B8=E5=AE=9E=20S10=20=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E5=B7=B2=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0(AtFileSu?= =?UTF-8?q?ggester=20BFS+TTL+CurrentAtToken=20=E4=B8=AD=E9=83=A8=E5=85=89?= =?UTF-8?q?=E6=A0=87=E6=94=AF=E6=8C=81)=E2=80=94=E2=80=94=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E5=81=9A=E9=9B=B6=E6=94=B9=E5=8A=A8;=20=E4=B8=BB?= =?UTF-8?q?=E4=BB=A3=E7=90=86=20debug=202=20=E5=A4=84:=E6=8A=98=E8=A1=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=AE=BD=E5=AD=97=E7=AC=A6=E6=9C=9F=E6=9C=9B?= =?UTF-8?q?(PermissionTextWrap=20ASCII=20=E5=AD=97=E7=AC=A6=E6=95=B0?= =?UTF-8?q?=E8=AF=AD=E4=B9=89,=E6=B1=89=E5=AD=97=202cell=20=E8=B6=85?= =?UTF-8?q?=E5=80=BC=E5=8C=BA=E6=88=AA=E6=96=AD=E2=80=94=E2=80=94=E6=94=B9?= =?UTF-8?q?=20ASCII=20=E8=AF=8D=E7=BB=84=E6=B5=8B=E6=8A=98=E8=A1=8C?= =?UTF-8?q?=E8=AF=AD=E4=B9=89+=E5=AE=BD=E5=AD=97=E7=AC=A6=E6=8A=98?= =?UTF-8?q?=E8=A1=8C=E7=99=BB=E8=AE=B0=E9=81=97=E7=95=99)/lambda=20?= =?UTF-8?q?=E6=97=A0,=E8=A7=81=E5=89=8D=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NativeTui.Host/ExternalEditorLauncher.cs | 89 +++++++++ native_tui/apps/NativeTui.Host/Program.cs | 5 +- .../repl/BackgroundTaskDetailDialog.cs | 103 ++++++++++ .../repl/BackgroundTaskPanel.cs | 5 +- .../src/NativeTui.Core/repl/ReplShell.cs | 137 +++++++++++++- .../repl/BackgroundTaskDetailDialogTests.cs | 124 ++++++++++++ .../repl/BackgroundTaskPanelTests.cs | 32 ++++ .../repl/ExternalEditorTests.cs | 177 ++++++++++++++++++ .../repl/ReplShellPanelNavigationTests.cs | 102 +++++++++- 9 files changed, 751 insertions(+), 23 deletions(-) create mode 100644 native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs create mode 100644 native_tui/src/NativeTui.Core/repl/BackgroundTaskDetailDialog.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs diff --git a/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs b/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs new file mode 100644 index 000000000..99c692f1a --- /dev/null +++ b/native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs @@ -0,0 +1,89 @@ +namespace NativeTui.Host; + +/// +/// 外部编辑器启动器(TASK064 P3 — 基准 defaultBindings.ts:83-84 ctrl+g/ctrl+x ctrl+e → +/// chat:externalEditor → $EDITOR):输入文本写 temp 文件 → 启动 $EDITOR(未设置时 +/// Windows 兜底 notepad.exe、其余兜底 vi)→ 无超时等待退出(交互等待非资源释放 — +/// 用户编辑时长不限,对齐 AGENTS 释放禁超时的反向面:等待是功能本体)→ 读回编辑后文本。 +/// 编辑器启动失败/结果读取失败返回 null(ReplShell 侧降级丢弃不打扰输入框)。 +/// +public static class ExternalEditorLauncher +{ + /// 编辑输入文本 — 返回编辑后文本(含空串 = 用户清空的合法编辑形态);null = 启动/读取失败。 + /// 当前输入文本(写入 temp 文件供编辑器打开)。 + public static async Task EditAsync(string text) + { + var path = Path.Combine(Path.GetTempPath(), $"nativetui-editor-{Guid.NewGuid():N}.md"); + await File.WriteAllTextAsync(path, text).ConfigureAwait(false); + try + { + using var process = StartEditor(path); + if (process is null) + { + return null; + } + + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + return await File.ReadAllTextAsync(path).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + // 编辑器通道容错 — 启动失败/等待中断/读回失败统一 null 降级(调用方静默丢弃) + return null; + } + finally + { + // temp 文件资源清理(非仓库文件 — AGENTS 禁删铁律针对仓库文件;temp 归位清理 + // 是进程资源卫生,删除失败静默:文件留在系统 temp 由 OS 清理策略兜底) + try + { + File.Delete(path); + } + catch (IOException) + { + } + } + } + + /// 解析并启动编辑器进程 — $EDITOR 环境变量(支持带参数形态如 "code -w", + /// 按空白拆分首段为可执行名 + 余段前置参数 + temp 文件路径尾参);未设置时平台兜底 + ///(Windows notepad.exe / 其余 vi)。启动失败返回 null。 + private static Process? StartEditor(string filePath) + { + var editor = Environment.GetEnvironmentVariable("EDITOR"); + var fileName = string.IsNullOrWhiteSpace(editor) + ? (OperatingSystem.IsWindows() ? "notepad.exe" : "vi") + : editor.Trim().Split(' ', 2)[0]; + var prefixArguments = string.IsNullOrWhiteSpace(editor) || !editor.Contains(' ') + ? string.Empty + : editor[(editor.IndexOf(' ') + 1)..].Trim() + " "; + + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = prefixArguments + filePath, + UseShellExecute = false, + }, + }; + if (process.Start()) + { + return process; + } + + process.Dispose(); + return null; + } + catch (System.ComponentModel.Win32Exception) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } +} diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index dbc9850a6..b57517af8 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -329,7 +329,10 @@ private static async Task RunInteractiveAsync() { streamingProjector: new StreamingMarkdownProjector(), // S11 遗留#1 — /theme 持久化:合法切换写回引擎 settings.json theme 键 //(IConfigurationService.SetAsync;写盘失败静默降级会话级 — ReplShell 侧容错) - saveThemeAsync: name => SaveThemeAsync(session.Services, name)); + saveThemeAsync: name => SaveThemeAsync(session.Services, name), + // TASK064 P3 — ctrl+g 外部编辑器委托:输入文本 → temp 文件 → $EDITOR + //(未设置时 Windows notepad.exe / 其余 vi 兜底)→ 读回编辑后文本;失败 null 降级丢弃 + launchExternalEditor: text => ExternalEditorLauncher.EditAsync(text)); pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); diff --git a/native_tui/src/NativeTui.Core/repl/BackgroundTaskDetailDialog.cs b/native_tui/src/NativeTui.Core/repl/BackgroundTaskDetailDialog.cs new file mode 100644 index 000000000..433c4ad0d --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/BackgroundTaskDetailDialog.cs @@ -0,0 +1,103 @@ +namespace NativeTui.Core.Repl; + +/// +/// 后台任务详情对话框消息项(M8-8 登记遗留收口 — 基准 BackgroundTasksDialog.tsx:230-348 详情视图): +/// 选中态 Enter 挂载的只读详情视图。数据形态 = 快照构造(BackgroundTaskEntry + nowMs 一次定格, +/// 不持协调器状态机 — 对比 PermissionRequestItem/ModelPickerDialog 的协调器消费形态:详情无焦点导航/ +/// 无选项/无同步阻塞桥,协调器零增益;快照构造更简且天然免疫挂载期间面板 upsert/驱逐扰动)。 +/// 渲染结构(对齐权限/模型对话框骨架):margin 1 + 顶边框(permission 色全宽,复用 +/// MessageRenderer.RenderPermissionBorder)+ 标题 "后台任务详情"(1 缩进 bold permission,复用 +/// RenderModelTitle)+ 空行 + 字段行组(1 缩进 dim 标签 + 默认样式值 — 任务类别/任务标识/任务描述/ +/// 任务状态/终态时间,标签恒 4 汉字 = 显示恒 8 列 + 2 间隔 → 值列恒 col 11,全中文等宽免宽度计算) +/// + 空行 + 底部提示行 dim "esc 返回 · left 关闭"。 +/// 任务描述全文折行(复用 PermissionTextWrap.Wrap 词边界折行 — 值区宽 = 帧宽 - 值列起点, +/// 续行缩进对齐值列;空描述折叠为 1 行 "—" 占位);任务状态汉化复用 BackgroundTaskPanel.StatusText +/// 单一映射源(面板状态段与详情状态行同源不双维护);终态时间 = FinalizedAtMs>0 时相对秒数 +/// "N 秒前"(nowMs 构造注入参数化 — 测试可控防墙钟不确定断言),未终态显示 "—"。 +/// 消费方:ReplShell.ShowSelectedBackgroundTask(Enter 挂载 AddMessage)+ +/// HandleBackgroundTaskDetailKey(Esc/left 关闭 RemoveDialogItem 移除)。 +/// +public sealed class BackgroundTaskDetailDialog(BackgroundTaskEntry entry, long nowMs) : IScrollItem +{ + /// 标题文案(详情视图标题 — 基准 detail 标题)。 + private const string TitleText = "后台任务详情"; + + /// 底部提示行(esc/left 双关闭键引导 — 基准 Byline 形态对齐 RenderModelShortcuts)。 + private const string HintText = $"esc 返回{Figures.MiddotSeparator}left 关闭"; + + /// 字段标签(恒 4 汉字 = 显示恒 8 列 — 值列对齐无需宽度计算)。 + private const string KindLabel = "任务类别"; + private const string TaskIdLabel = "任务标识"; + private const string DescriptionLabel = "任务描述"; + private const string StateLabel = "任务状态"; + private const string FinalizedLabel = "终态时间"; + + /// 值列起点(1 缩进 + 8 标签列 + 2 间隔 — 字段值与描述续行共用缩进)。 + private const int ValueIndent = 11; + + /// 详情条目快照(构造定格 — 挂载期间面板 upsert/驱逐不影响已展示内容)。 + public BackgroundTaskEntry Entry { get; } = entry; + + /// 构造时刻墙钟(终态相对秒数基准 — 测试参数化注入防不确定断言)。 + public long NowMs { get; } = nowMs; + + /// 内容高度 = margin 1 + 边框 1 + 标题 1 + 空行 1 + 固定字段 4(类别/标识/状态/终态时间) + /// + 描述行 max(1, N)(空描述折叠 1 行 "—" 占位 — 对齐权限对话框空描述折叠先例)+ 空行 1 + 提示 1。 + public int MeasureHeight(int width) => 10 + DescriptionLines(width); + + /// 渲染:按固定行序绘制(快照数据 — 无状态流转,与 MeasureHeight 同参数防行数漂移)。 + public void Render(Screen screen, Rect frame, Theme theme) + { + var row = frame.Y + 1; + MessageRenderer.RenderPermissionBorder(screen, RowFrame(frame, row++), theme); + MessageRenderer.RenderModelTitle(screen, RowFrame(frame, row++), theme, TitleText); + row++; + row = RenderField(screen, frame, row, KindLabel, Entry.Kind); + row = RenderField(screen, frame, row, TaskIdLabel, Entry.TaskId); + row = RenderDescription(screen, frame, row); + row = RenderField(screen, frame, row, StateLabel, BackgroundTaskPanel.StatusText(Entry.State)); + row = RenderField(screen, frame, row, FinalizedLabel, FinalizedAtText); + row++; + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(row, frame.X, $" {HintText}", dim, frame.Right); + } + + /// 终态时间文案 — FinalizedAtMs>0 时相对秒数 "N 秒前"(TickCount64 差值, + /// 负差防御钳 0);未终态 "—"。 + private string FinalizedAtText => Entry.FinalizedAtMs > 0 + ? $"{Math.Max(0L, (NowMs - Entry.FinalizedAtMs) / 1000)} 秒前" + : "—"; + + /// 描述区渲染 — 首行带标签 + 值首行,续行缩进对齐值列(默认样式 — 字段值区样式统一)。 + private int RenderDescription(Screen screen, Rect frame, int row) + { + var lines = WrapDescription(frame.Width); + row = RenderField(screen, frame, row, DescriptionLabel, lines.Length > 0 ? lines[0] : "—"); + for (var i = 1; i < lines.Length; i++) + { + screen.WriteText(row, frame.X + ValueIndent, lines[i], 0, frame.Right); + row++; + } + + return row; + } + + /// 字段行渲染 — 1 缩进 dim 标签(4 汉字恒 8 列)+ 2 间隔 + 默认样式值(值列 col 11)。 + private static int RenderField(Screen screen, Rect frame, int row, string label, string value) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + screen.WriteText(row, frame.X, $" {label} ", dim, frame.Right); + screen.WriteText(row, frame.X + ValueIndent, value, 0, frame.Right); + return row + 1; + } + + /// 描述折行(值区宽 = 帧宽 - 值列起点;宽度防御钳 1 — 契约破坏不丢内容)。 + private string[] WrapDescription(int width) => + PermissionTextWrap.Wrap(Entry.Description, Math.Max(1, width - ValueIndent)); + + /// 描述行数(MeasureHeight/Render 同参数一致性锚点 — 空描述 1 行占位)。 + private int DescriptionLines(int width) => Math.Max(1, WrapDescription(width).Length); + + /// 单行 frame(Y 为该渲染行,高度 1)。 + private static Rect RowFrame(Rect frame, int row) => frame with { Y = row, Height = 1 }; +} diff --git a/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs index e70607756..c24cf5e44 100644 --- a/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs +++ b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs @@ -230,8 +230,9 @@ public void Render(Screen screen, Rect frame, Theme theme) internal static bool IsFinal(string state) => state is CompletedState or FailedState or CancelledState; - /// 状态文案汉化(running 运行中 / completed 已完成 / failed 失败 / cancelled 已取消;未知值原样显示 — 协议演进宽容)。 - private static string StatusText(string state) => state switch + /// 状态文案汉化(running 运行中 / completed 已完成 / failed 失败 / cancelled 已取消;未知值原样显示 — 协议演进宽容)。 + /// 消费方:本面板 Render 状态段 + BackgroundTaskDetailDialog 详情状态行(internal — 单一映射源不双维护)。 + internal static string StatusText(string state) => state switch { RunningState => "运行中", CompletedState => "已完成", diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 0f718fbbb..2c24c4563 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -132,6 +132,12 @@ private void HandleExitCtrlD() /// 暂存单槽内容(internal — 测试断言面;空串 = 无暂存)。 internal string StashedInput => _stashedInput; + /// 外部编辑器会话进行中(internal — 测试断言面)。 + internal bool ExternalEditorActive => _externalEditorActive; + + /// 外部编辑器进行中任务句柄(internal — 测试 await 完成面;null = 无进行中会话)。 + internal Task? ExternalEditorTask => _externalEditorTask; + /// 帮助面板开关(S6 任务 5 — 空闲态 '?' 弹出;分派链插在 suggestion/transcript 之后)。 private bool _helpOpen; @@ -199,6 +205,30 @@ private void HandleExitCtrlD() /// Core 零依赖不持接口(对齐 clipboard 注入先例);null = 停止能力降级,x 走正常字符插入)。 private readonly Action? _onStopTask; + /// 后台任务详情对话框开(M8-8 登记遗留收口 — 选中态 Enter 挂载,Esc/left 关闭; + /// 对齐 _helpOpen 轻量模态先例 — 对话框快照构造无协调器,挂载态由本标记承载分派路由; + /// 激活期间分派链插入选中态之前吞全部按键,关闭后回选中态继续导航)。 + private bool _backgroundTaskDetailOpen; + + /// 后台任务详情对话框态(internal — 测试断言面)。 + internal bool IsBackgroundTaskDetailOpen => _backgroundTaskDetailOpen; + + /// 外部编辑器启动委托(TASK064 P3 — 基准 defaultBindings.ts:83-84 ctrl+g/ctrl+x ctrl+e → + /// chat:externalEditor → $EDITOR):输入文本 → 编辑后文本;null = 编辑器失败/取消降级丢弃。 + /// Core 零依赖不启动进程 — 宿主注入(对齐 clipboard/onStopTask 注入先例)。 + private readonly Func>? _launchExternalEditor; + + /// 外部编辑器会话进行中(防重入 — 编辑器未收尾前再按 ctrl+g 无操作,不并发开两个编辑器)。 + private bool _externalEditorActive; + + /// 外部编辑器进行中任务句柄(fire-and-forget 保留引用 — internal 测试 await 面)。 + private Task? _externalEditorTask; + + /// 外部编辑器完成结果缓冲(TASK064 P3 — 完成于线程池线程,FillInput 必须回主循环线程 + ///(与渲染循环竞争 _input),复用 bash 直通跨线程缓冲 + DrainEngine 主循环消费先例): + /// 元组 = (发起时原文, 编辑后文本 — null = 编辑器失败/取消丢弃信号)。 + private readonly ConcurrentQueue<(string Original, string? Edited)> _externalEditorResults = new(); + /// 主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。 internal bool IsRunning => _running; @@ -247,7 +277,9 @@ private void HandleExitCtrlD() /// workspaceRoot=工作区根目录(S10 接线 — @ 文件补全索引根;null = @ 补全禁用降级); /// onStopTask=后台任务停止委托(M8-8 后续 — 空闲态 x 停止面板运行中任务,参数 = (kind, taskId); /// null = 停止能力降级,x 走正常字符插入); - /// streamingProjector=流式 Markdown 块级增量投影器(方案 B 卫星注入;null = 纯文本降级 M8-1 行为)。 + /// streamingProjector=流式 Markdown 块级增量投影器(方案 B 卫星注入;null = 纯文本降级 M8-1 行为); + /// launchExternalEditor=外部编辑器启动委托(TASK064 P3 — ctrl+g 编辑当前输入; + /// 输入文本 → 编辑后文本,null = 失败/取消降级丢弃;null 委托 = 能力降级 ctrl+g 无操作)。 public ReplShell( IReplEngine engine, BannerItem banner, @@ -263,7 +295,8 @@ public ReplShell( string? workspaceRoot = null, Action? onStopTask = null, IStreamingMarkdownProjector? streamingProjector = null, - Func? saveThemeAsync = null) + Func? saveThemeAsync = null, + Func>? launchExternalEditor = null) { _engine = engine; _banner = banner; @@ -277,6 +310,7 @@ public ReplShell( _copyToClipboard = clipboard; _saveThemeAsync = saveThemeAsync; _onStopTask = onStopTask; + _launchExternalEditor = launchExternalEditor; _theme = ThemeSelection.Resolve(themeName); _atFileSuggester = workspaceRoot is null ? null : new AtFileSuggester(workspaceRoot); // S9 接线 — spinner 终端字符集探测注入一次(会话期终端类型不变;Windows Terminal/默认 `*` 集) @@ -430,6 +464,9 @@ internal void DrainEngine() // S10 接线 — bash 直通结果上屏(主循环线程消费并发队列) DrainBashResults(); + + // TASK064 P3 — 外部编辑器结果回填(主循环线程消费并发队列 — FillInput 与渲染循环同线程) + DrainExternalEditorResults(); } /// 打断恢复(M8-2)— 该回合用户消息之后无实质响应项(assistant/工具/流式/错误/子代理) @@ -777,6 +814,16 @@ private void HandleKeyPress(KeyPress key) return; } + // M8-8 登记遗留收口 — 后台任务详情对话框模态(选中态 Enter 挂载,Esc/left 关闭 — + // 对齐 permission/modelPicker 模态优先先例):插入选中态之前(对话框激活期间全部按键 + // 路由到对话框分支吞掉;选中态保持激活,关闭后无缝回选中导航继续操作 — Enter 挂载 + // 不可重入:本分支拦截 Enter 不再触发 ShowSelectedBackgroundTask) + if (_backgroundTaskDetailOpen) + { + HandleBackgroundTaskDetailKey(key); + return; + } + // M8-8 登记遗留 — 后台任务面板选中导航模态(对齐 _cursorNav 轻量模态形态 — 纯状态机 // 不新增对话框项):置于光标导航之后(两模态互斥,光标导航优先 — 任务选中态激活时 // Shift+Up 等键在本分支被吞,不会误入光标导航)、补全之前(选中态吞全部字符键 — @@ -977,6 +1024,10 @@ private void HandleCtrlKey(string name) case "f": _input.Right(); break; + case "g": + // TASK064 P3 — 基准 defaultBindings.ts:83-84 ctrl+g/ctrl+x ctrl+e → chat:externalEditor → $EDITOR + LaunchExternalEditor(); + break; case "h": _input.Backspace(); break; @@ -1315,10 +1366,12 @@ private void StopRunningBackgroundTask() /// 后台任务面板选中导航模态按键(M8-8 登记遗留 — 基准 BackgroundTasksDialog.tsx 导航态 + /// useBackgroundTaskNavigation.ts:181-189,对齐 HandleCursorNavKey 轻量模态形态): /// ↑/↓/k/j 相邻 running 条目移动(MoveSelection 跳终态 + 窗口跟随)、Enter 查看选中任务 - /// (首版 = ToolResultItem 消息四元组 — 全屏详情对话框留后续)、x 停止选中(复用 + /// (详情对话框挂载 — ShowSelectedBackgroundTask,M8-8 登记遗留收口升级)、x 停止选中(复用 /// StopRunningBackgroundTask — SelectedEntry 终态守卫回退首个 running;选中态为用户 /// 主动保持的模态,忙时不强制退出,后台任务与前台回合独立可停)、Esc 退出选中态 - /// (ClearSelection 归 -1 + 窗口归 0);其余按键全部吞掉(模态 — 字符不进输入缓冲)。 + /// (ClearSelection 归 -1 + 窗口归 0)、left 退出选中态(D 批次遗留 left 退出键 — 基准 + /// BackgroundTasksDialog left 关闭语义;对话框态的 left 由 HandleBackgroundTaskDetailKey + /// 消费关闭对话框,两态互斥不冲突);其余按键全部吞掉(模态 — 字符不进输入缓冲)。 /// private void HandleBackgroundTaskSelectionKey(KeyPress key) { @@ -1342,6 +1395,10 @@ private void HandleBackgroundTaskSelectionKey(KeyPress key) case KeyKind.DownArrow: _view.BackgroundTasks.MoveSelection(1); return; + case KeyKind.LeftArrow: + // D 批次遗留 left 退出键(基准 BackgroundTasksDialog left 关闭)— 退出选中态回常态 + _view.BackgroundTasks.ClearSelection(); + return; case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "k": _view.BackgroundTasks.MoveSelection(-1); return; @@ -1361,17 +1418,30 @@ private void HandleBackgroundTaskSelectionKey(KeyPress key) /// 键位门槛已由分派链进入点保证 HasRunning,此处必命中)。 private void BeginBackgroundTaskSelection() => _view.BackgroundTasks.MoveSelection(1); - /// Enter 查看选中任务详情(首版 = ToolResultItem 消息显示 kind/taskId/description/state - /// 四元组,协议 state 原串直显 — 与面板汉化文案对照无歧义;查看后保持选中态支持连续操作; - /// 全屏详情对话框留后续登记)。无选中/索引越界防御 no-op。 + /// Enter 查看选中任务详情(M8-8 登记遗留收口 — 升级为详情对话框挂载滚动区: + /// BackgroundTaskDetailDialog 快照构造(Entry + 墙钟),对齐 ModelPicker 挂载模式 + /// AddMessage + 关闭时 RemoveDialogItem 移除;查看后保持选中态(对话框关闭回选中态 + /// 可继续导航 — 对话框激活期间按键路由见 HandleBackgroundTaskDetailKey;挂载不可重入 — + /// 分派链对话框分支拦截 Enter)。无选中/索引越界防御 no-op。 private void ShowSelectedBackgroundTask() { var tasks = _view.BackgroundTasks.Tasks; if (_view.BackgroundTasks.SelectedIndex is >= 0 and var index && index < tasks.Count) { - var task = tasks[index]; - AddMessage(new ToolResultItem( - $"后台任务详情 — kind: {task.Kind} · id: {task.TaskId} · 描述: {task.Description} · 状态: {task.State}")); + _backgroundTaskDetailOpen = true; + AddMessage(new BackgroundTaskDetailDialog(tasks[index], Environment.TickCount64)); + } + } + + /// 后台任务详情对话框模态按键(对齐 HandleHelpKey 轻量模态形态 — 详情只读无交互, + /// 数据快照构造无协调器):Esc / left 关闭对话框(置 false + RemoveDialogItem 移除滚动区项 — + /// 回选中态继续导航,选中态全程保持激活);其余按键全部吞掉(模态 — 字符不进输入缓冲)。 + private void HandleBackgroundTaskDetailKey(KeyPress key) + { + if (key.Kind is KeyKind.Escape or KeyKind.LeftArrow) + { + _backgroundTaskDetailOpen = false; + RemoveDialogItem(); } } @@ -2465,4 +2535,51 @@ private void RestoreStashedInput(string stashed) FillInput(stashed, cursorToStart: false); } } + + /// ctrl+g 打开外部编辑器编辑当前输入(TASK064 P3 — 基准 defaultBindings.ts:83-84 + /// ctrl+g/ctrl+x ctrl+e → $EDITOR):空输入/委托未注入/编辑会话进行中均无操作; + /// fire-and-forget 异步等待(编辑器期间主循环继续渲染不冻屏 — 输入框可继续输入, + /// 首版简化:发起时记录原文,编辑器完成后输入框未变才回填编辑结果,已变丢弃防覆盖用户编辑)。 + private void LaunchExternalEditor() + { + var text = _input.Text; + if (text.Length == 0 || _launchExternalEditor is null || _externalEditorActive) + { + return; + } + + _externalEditorActive = true; + _externalEditorTask = EditInExternalEditorAsync(text); + } + + /// 外部编辑器异步等待体 — 线程池完成不触 UI 状态(结果入并发队列, + /// DrainExternalEditorResults 主循环消费);委托异常/失败结果静默降级丢弃(对齐 bash 直通容错先例)。 + private async Task EditInExternalEditorAsync(string originalText) + { + try + { + var edited = await _launchExternalEditor!(originalText).ConfigureAwait(false); + _externalEditorResults.Enqueue((originalText, edited)); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + // 编辑器通道容错 — 失败入队 null 信号(仅复位会话标记,输入框不受打扰) + _externalEditorResults.Enqueue((originalText, null)); + } + } + + /// 外部编辑器结果消费(DrainEngine 每拍 — 主循环线程): + /// null = 失败/取消丢弃;输入框与发起时原文一致才回填编辑结果(已变丢弃 — 用户编辑优先, + /// 编辑结果含空串 = 用户清空文本的合法编辑形态)。 + private void DrainExternalEditorResults() + { + while (_externalEditorResults.TryDequeue(out var result)) + { + _externalEditorActive = false; + if (result.Edited is { } edited && _input.Text == result.Original) + { + FillInput(edited, cursorToStart: false); + } + } + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs new file mode 100644 index 000000000..5eb7b1f10 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskDetailDialogTests.cs @@ -0,0 +1,124 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 后台任务详情对话框测试(M8-8 登记遗留收口 — 基准 BackgroundTasksDialog.tsx:230-348 详情视图): +/// 快照构造(Entry + nowMs 参数化注入防墙钟不确定断言)、字段行组渲染(dim 标签 + 值, +/// 标签恒 4 汉字 8 显示列 → 值列恒 col 11)、任务状态汉化复用面板单一映射源、 +/// 终态时间相对秒数(FinalizedAtMs>0)/未终态 —、描述超宽折行(PermissionTextWrap 复用, +/// 续行缩进对齐值列)、底部提示行 "esc 返回 · left 关闭" dim。 +/// +public sealed class BackgroundTaskDetailDialogTests +{ + /// 字段行组渲染 — 固定行序(margin/边框/标题/空行/类别/标识/描述/状态/终态时间/空行/提示) + /// + 快照 Entry 字段直通 + 值列恒 col 11 对齐 + MeasureHeight 单行描述 = 11。 + [Fact] + public void Render_FieldRows_KindTaskIdDescriptionState() + { + using var screen = new Screen(cols: 40, rows: 11); + var dialog = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("teammate", "tm-1", "审阅PR", "running"), nowMs: 10_000); + + Assert.Equal(11, dialog.MeasureHeight(40)); + dialog.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark); + + // 行序:margin0 / 边框1 / 标题2 / 空3 / 类别4 / 标识5 / 描述6 / 状态7 / 终态8 / 空9 / 提示10 + Assert.Equal(new string('─', 40), RowText.Of(screen, 1, 0, 40)); + Assert.Equal(" 后台任务详情", RowText.Of(screen, 2, 0, 40).TrimEnd()); + Assert.Equal(" 任务类别 teammate", RowText.Of(screen, 4, 0, 40).TrimEnd()); + Assert.Equal(" 任务标识 tm-1", RowText.Of(screen, 5, 0, 40).TrimEnd()); + Assert.Equal(" 任务描述 审阅PR", RowText.Of(screen, 6, 0, 40).TrimEnd()); + Assert.Equal(" 任务状态 运行中", RowText.Of(screen, 7, 0, 40).TrimEnd()); + Assert.Equal(" 终态时间 —", RowText.Of(screen, 8, 0, 40).TrimEnd()); + + // 标签恒 4 汉字 = 显示恒 8 列 + 2 间隔 — 值列恒 col 11 起(快照字段直通) + Assert.Equal("teammate", RowText.Of(screen, 4, 11, 8).TrimEnd()); + Assert.Equal("tm-1", RowText.Of(screen, 5, 11, 4).TrimEnd()); + } + + /// 任务状态汉化复用面板映射源 — completed 已完成;未知值宽容原样(协议演进)。 + [Fact] + public void Render_State_UsesPanelChineseMapping() + { + using var screen = new Screen(cols: 40, rows: 11); + var dialog = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("fork", "f-1", "构建", "completed"), nowMs: 10_000); + + dialog.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark); + + Assert.Equal(" 任务状态 已完成", RowText.Of(screen, 7, 0, 40).TrimEnd()); + + // 未知状态原样显示 — 对齐面板 StatusText 宽容语义(单一映射源不双维护) + using var unknownScreen = new Screen(cols: 40, rows: 11); + var unknown = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("fork", "f-2", "探测", "paused"), nowMs: 10_000); + + unknown.Render(unknownScreen, new Rect(0, 0, 40, 11), Theme.Dark); + + Assert.Equal(" 任务状态 paused", RowText.Of(unknownScreen, 7, 0, 40).TrimEnd()); + } + + /// 终态时间 — FinalizedAtMs>0 相对秒数 "N 秒前"(nowMs 构造注入可控防墙钟不确定); + /// 未终态(FinalizedAtMs=0)显示 —。 + [Fact] + public void Render_FinalizedAt_RelativeSeconds_OrDash() + { + using var screen = new Screen(cols: 40, rows: 11); + var dialog = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("fork", "f-1", "构建", "failed", FinalizedAtMs: 8_000), nowMs: 10_000); + + dialog.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark); + + // (10_000 - 8_000) / 1000 = 2 秒前 — 构造时刻快照不随墙钟漂移 + Assert.Equal(" 终态时间 2 秒前", RowText.Of(screen, 8, 0, 40).TrimEnd()); + + using var runningScreen = new Screen(cols: 40, rows: 11); + var running = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("fork", "f-2", "测试", "running"), nowMs: 10_000); + + running.Render(runningScreen, new Rect(0, 0, 40, 11), Theme.Dark); + + Assert.Equal(" 终态时间 —", RowText.Of(runningScreen, 8, 0, 40).TrimEnd()); + } + + /// 描述超宽折行 — 词边界折行(PermissionTextWrap 复用)续行缩进对齐值列 col 11 + + /// MeasureHeight 随折行数增长(MeasureHeight/Render 同参数防行数漂移)。 + /// 描述用 ASCII 词组 — PermissionTextWrap 按字符数折行(ASCII 语义),汉字 2 cell + /// 显示宽会超值区被 Screen 截断(既有工具局限,宽字符折行为登记遗留非本对话框回归)。 + [Fact] + public void Render_LongDescription_WrapsAtValueColumn() + { + using var screen = new Screen(cols: 24, rows: 12); + var dialog = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("fork", "f-1", "aaaa bbbb cccc dddd", "running"), nowMs: 10_000); + + // 值区宽 = 24 - 11 = 13 字符:第一行收 2 词(9 字符),"cccc dddd" 换行 → 描述 2 行 + Assert.Equal(12, dialog.MeasureHeight(24)); + dialog.Render(screen, new Rect(0, 0, 24, 12), Theme.Dark); + + Assert.Equal(" 任务描述 aaaa bbbb", RowText.Of(screen, 6, 0, 24).TrimEnd()); + // 续行缩进对齐值列(col 11 起 — 与字段值共用起点) + Assert.Equal("cccc dddd", RowText.Of(screen, 7, 11, 13).TrimEnd()); + // 折行后状态/终态/提示行随之下移一行(row 8/9/11) + Assert.Equal(" 任务状态 运行中", RowText.Of(screen, 8, 0, 24).TrimEnd()); + Assert.Equal(" esc 返回 · left 关闭", RowText.Of(screen, 11, 0, 24).TrimEnd()); + } + + /// 底部提示行 — "esc 返回 · left 关闭" dim(Byline 形态对齐 RenderModelShortcuts); + /// 字段行标签 dim / 值默认样式分色断言。 + [Fact] + public void Render_HintRow_EscReturnLeftClose_Dim() + { + using var screen = new Screen(cols: 40, rows: 11); + var dialog = new BackgroundTaskDetailDialog( + new BackgroundTaskEntry("teammate", "tm-1", "审阅", "running"), nowMs: 1_000); + + dialog.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark); + + Assert.Equal(" esc 返回 · left 关闭", RowText.Of(screen, 10, 0, 40).TrimEnd()); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[10, 1].StyleId); + // 字段行:标签 dim / 值默认样式(screen[4, 1] = "任" 字位置,screen[4, 11] = 值首字符) + Assert.Equal(dim, screen[4, 1].StyleId); + Assert.Equal(0, screen[4, 11].StyleId); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs index bcd657225..4ba4efc2d 100644 --- a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs @@ -476,4 +476,36 @@ public void ClearSelection_ResetsIndexAndWindow() Assert.Equal(-1, panel.SelectedIndex); Assert.Equal(0, panel.WindowStart); } + + /// Entry 快照字段形态(详情对话框构造数据源 — M8-8 登记遗留收口): + /// Upsert 投影五字段直通保真 + record with 替换式更新(非原地突变 — 面板/对话框快照隔离依据)。 + [Fact] + public void BackgroundTaskEntry_SnapshotFields_PreserveUpsertedValues() + { + var panel = new BackgroundTaskPanel(); + panel.Upsert("teammate", "tm-7", "审阅架构文档", "running"); + + var snapshot = panel.Tasks[0]; + Assert.Equal(new BackgroundTaskEntry("teammate", "tm-7", "审阅架构文档", "running"), snapshot); + + // record with 替换式更新(Upsert 终态翻转实现形态)— 原快照不可变 + var finalized = snapshot with { State = "completed", FinalizedAtMs = 1000 }; + Assert.Equal("completed", finalized.State); + Assert.Equal(1000, finalized.FinalizedAtMs); + Assert.Equal("running", snapshot.State); + Assert.Equal(0, snapshot.FinalizedAtMs); + } + + /// StatusText 汉化映射(internal 化 — BackgroundTaskDetailDialog 详情状态行复用同一映射源, + /// 面板状态段与详情状态行不双维护):已知四态汉化 + 未知值宽容原样。 + [Fact] + public void StatusText_MapsKnownAndUnknownStates_DetailDialogReusesPanelMapping() + { + Assert.Equal("运行中", BackgroundTaskPanel.StatusText("running")); + Assert.Equal("已完成", BackgroundTaskPanel.StatusText("completed")); + Assert.Equal("失败", BackgroundTaskPanel.StatusText("failed")); + Assert.Equal("已取消", BackgroundTaskPanel.StatusText("cancelled")); + // 协议演进宽容 — 未知值原样显示 + Assert.Equal("paused", BackgroundTaskPanel.StatusText("paused")); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs b/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs new file mode 100644 index 000000000..63ccb1348 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/ExternalEditorTests.cs @@ -0,0 +1,177 @@ +namespace NativeTui.Tests.Repl; + +/// +/// ReplShell ctrl+g 外部编辑器测试(TASK064 P3 — 基准 defaultBindings.ts:83-84 +/// ctrl+g/ctrl+x ctrl+e → chat:externalEditor → $EDITOR):委托调用触发(原文透传 + +/// 会话标记置位)、空输入无操作、编辑结果回填(输入未变)、输入已变丢弃(用户编辑优先)、 +/// null 失败降级丢弃、会话进行中防重入、未注入委托降级无操作。 +/// 假编辑器 = TaskCompletionSource 可控委托(Host 侧 ExternalEditorLauncher 进程依赖 +/// 不在 Core 测试覆盖 — 登记遗留见任务报告)。构造形态对齐 StashInputTests。 +/// +public sealed class ExternalEditorTests +{ + /// 可控假编辑器 — 记录调用原文,TCS 控制编辑完成时机与结果。 + private sealed class FakeEditor + { + public List Received { get; } = []; + + public TaskCompletionSource Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task EditAsync(string text) + { + Received.Add(text); + return Completion.Task; + } + } + + /// 构造测试 shell(可选注入假编辑器委托)。 + private static (ReplShell Shell, FakeEditor Editor) CreateShell(bool injectEditor = true) + { + var editor = new FakeEditor(); + var shell = new ReplShell( + new FakeReplEngine(), + new BannerItem("1.0.0", "m", "b", "/tmp"), + new StringWriter(), + sizeSource: new FakeTerminalSizeSource(80, 24), + launchExternalEditor: injectEditor ? editor.EditAsync : null); + shell.SeedContent(); + return (shell, editor); + } + + /// ctrl+g 注入(ConsoleKeyInfo 形态 — ConsoleKeyAdapter 归一 Ctrl+字母)。 + private static void CtrlG(ReplShell shell) => + shell.HandleKey(new ConsoleKeyInfo('g', ConsoleKey.G, shift: false, alt: false, control: true)); + + /// 逐字符打字注入(形态对齐 StashInputTests.Type)。 + private static void Type(ReplShell shell, string text) + { + foreach (var c in text) + { + shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.A, shift: false, alt: false, control: false)); + } + } + + /// 有输入 ctrl+g — 委托收到当前输入原文 + 会话标记置位 + 任务句柄就绪。 + [Fact] + public void CtrlG_WithText_InvokesEditorWithCurrentInput() + { + var (shell, editor) = CreateShell(); + Type(shell, "hello draft"); + + CtrlG(shell); + + Assert.Equal(["hello draft"], editor.Received); + Assert.True(shell.ExternalEditorActive); + Assert.NotNull(shell.ExternalEditorTask); + } + + /// 空输入 ctrl+g — 无操作(委托不调用、无会话标记)。 + [Fact] + public void CtrlG_EmptyInput_IsNoOp() + { + var (shell, editor) = CreateShell(); + + CtrlG(shell); + + Assert.Empty(editor.Received); + Assert.False(shell.ExternalEditorActive); + Assert.Null(shell.ExternalEditorTask); + } + + /// 编辑完成且输入框未变 — 编辑结果回填输入框 + 会话标记复位。 + [Fact] + public async Task CtrlG_EditorResult_FillsInput_WhenUnchanged() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlG(shell); + + editor.Completion.SetResult("edited by external editor"); + await shell.ExternalEditorTask!; + shell.DrainEngine(); + + Assert.Equal("edited by external editor", shell.Input.Text); + Assert.False(shell.ExternalEditorActive); + } + + /// 编辑完成但输入框已变(编辑期间用户继续打字)— 结果丢弃防覆盖用户编辑。 + [Fact] + public async Task CtrlG_EditorResult_Discarded_WhenInputChanged() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlG(shell); + + // 编辑器会话期间用户继续输入(fire-and-forget 不冻屏 — 输入框仍可编辑) + Type(shell, " more"); + + editor.Completion.SetResult("edited result"); + await shell.ExternalEditorTask!; + shell.DrainEngine(); + + Assert.Equal("draft more", shell.Input.Text); + Assert.False(shell.ExternalEditorActive); + } + + /// 编辑器失败/取消(null 结果)— 丢弃不打扰输入框 + 会话标记复位。 + [Fact] + public async Task CtrlG_NullResult_DiscardedAndClearsActive() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlG(shell); + + editor.Completion.SetResult(null); + await shell.ExternalEditorTask!; + shell.DrainEngine(); + + Assert.Equal("draft", shell.Input.Text); + Assert.False(shell.ExternalEditorActive); + } + + /// 编辑会话进行中再按 ctrl+g — 防重入(委托只调用一次,不并发开两个编辑器)。 + [Fact] + public void CtrlG_WhileActive_IgnoresSecondInvocation() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlG(shell); + Type(shell, "2"); + + CtrlG(shell); + + Assert.Single(editor.Received, "draft"); + Assert.True(shell.ExternalEditorActive); + } + + /// 未注入编辑器委托 — ctrl+g 降级无操作(不崩溃、不修改输入)。 + [Fact] + public void CtrlG_WithoutDelegate_IsNoOp() + { + var (shell, _) = CreateShell(injectEditor: false); + Type(shell, "draft"); + + CtrlG(shell); + + Assert.Equal("draft", shell.Input.Text); + Assert.False(shell.ExternalEditorActive); + Assert.Null(shell.ExternalEditorTask); + } + + /// 编辑结果为空串(用户在编辑器里清空文本)— 合法编辑形态,回填清空输入框。 + [Fact] + public async Task CtrlG_EmptyEditedText_ClearsInput() + { + var (shell, editor) = CreateShell(); + Type(shell, "draft"); + CtrlG(shell); + + editor.Completion.SetResult(string.Empty); + await shell.ExternalEditorTask!; + shell.DrainEngine(); + + Assert.Equal(string.Empty, shell.Input.Text); + Assert.False(shell.ExternalEditorActive); + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs index ba836061e..4952a02b1 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs @@ -3,8 +3,10 @@ namespace NativeTui.Tests.Repl; /// /// ReplShell 后台任务面板选中导航集成测试(M8-8 登记遗留 — 基准 useBackgroundTaskNavigation.ts:181-189 + /// BackgroundTasksDialog.tsx 导航态):Shift+Down 进入选中态(空闲空输入 + 有 running 门槛 — 无 running / -/// 忙时不进入)、选中态 ↑↓ 移动(跳终态 + 窗口跟随)、Enter 查看选中任务详情(ToolResultItem 四元组消息)、 -/// x 停止选中任务(注入停止委托捕获)、Esc 退出选中态、模态吞键(字符不进输入框)。 +/// 忙时不进入)、选中态 ↑↓ 移动(跳终态 + 窗口跟随)、Enter 查看选中任务详情(M8-8 登记遗留收口 — +/// 详情对话框挂载 BackgroundTaskDetailDialog 快照构造)、x 停止选中任务(注入停止委托捕获)、 +/// Esc 退出选中态、left 退出选中态(D 批次遗留 left 退出键)、对话框态 Esc/left 关闭回选中态、 +/// 模态吞键(选中态与对话框期字符均不进输入框)。 /// 构造形态对齐 ReplShellIntegrationStopTaskTests(面板条目由测试直接 Upsert 种入)。 /// public sealed class ReplShellPanelNavigationTests @@ -98,9 +100,11 @@ public void Selection_ArrowKeys_MoveSelection() Assert.Equal(0, shell.View.BackgroundTasks.SelectedIndex); } - /// 选中态 Enter — 查看选中任务详情(ToolResultItem 四元组消息上屏 + 保持选中态)。 + /// 选中态 Enter — 挂载后台任务详情对话框(M8-8 登记遗留收口:BackgroundTaskDetailDialog + /// 快照构造入滚动区 + IsBackgroundTaskDetailOpen 激活 + 保持选中态;首版 ToolResultItem 四元组消息 + /// 形态已被对话框取代 — 不再产生消息项)。 [Fact] - public void Selection_EnterKey_ShowsTaskDetailMessage() + public void Selection_EnterKey_MountsDetailDialog() { var (shell, _, _) = CreateShell(); shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running"); @@ -108,12 +112,90 @@ public void Selection_EnterKey_ShowsTaskDetailMessage() Press(shell, ConsoleKey.Enter); - var detail = shell.View.Messages.Items.OfType().Single(); - Assert.Contains("kind: teammate", detail.Text); - Assert.Contains("id: tm-1", detail.Text); - Assert.Contains("描述: 审阅PR", detail.Text); - Assert.Contains("状态: running", detail.Text); - // 查看后保持选中态(连续查看/停止支持) + var dialog = shell.View.Messages.Items.OfType().Single(); + Assert.True(shell.IsBackgroundTaskDetailOpen); + // 快照构造 — 对话框内嵌选中条目全文(kind/taskId/description 直通) + Assert.Equal("teammate", dialog.Entry.Kind); + Assert.Equal("tm-1", dialog.Entry.TaskId); + Assert.Equal("审阅PR", dialog.Entry.Description); + // 首版四元组消息形态已被对话框取代 — 不再发 ToolResultItem + Assert.DoesNotContain(shell.View.Messages.Items, static item => item is ToolResultItem); + // 查看后保持选中态(对话框关闭后回选中态可继续导航) + Assert.True(shell.View.BackgroundTasks.IsSelectionActive); + } + + /// 对话框态 Esc — 关闭对话框(对话框项移除 + 态复位)回选中态继续导航。 + [Fact] + public void DetailDialog_EscapeKey_ClosesDialog_ReturnsToSelection() + { + var (shell, _, _) = CreateShell(); + shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running"); + Press(shell, ConsoleKey.DownArrow, shift: true); + Press(shell, ConsoleKey.Enter); + Assert.True(shell.IsBackgroundTaskDetailOpen); + + Press(shell, ConsoleKey.Escape); + + Assert.False(shell.IsBackgroundTaskDetailOpen); + Assert.Empty(shell.View.Messages.Items.OfType()); + // 回选中态 — 可继续导航/再次 Enter 查看 + Assert.True(shell.View.BackgroundTasks.IsSelectionActive); + Assert.Equal(0, shell.View.BackgroundTasks.SelectedIndex); + } + + /// 对话框态 left — 关闭对话框回选中态(提示行 "left 关闭" 承诺;与选中态 left 退出互斥 — + /// 对话框激活期间分派链对话框分支在前拦截)。 + [Fact] + public void DetailDialog_LeftArrow_ClosesDialog_ReturnsToSelection() + { + var (shell, _, _) = CreateShell(); + shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running"); + Press(shell, ConsoleKey.DownArrow, shift: true); + Press(shell, ConsoleKey.Enter); + + Press(shell, ConsoleKey.LeftArrow); + + Assert.False(shell.IsBackgroundTaskDetailOpen); + Assert.Empty(shell.View.Messages.Items.OfType()); + // left 只关对话框 — 选中态保持(区别于选中态 left 退出选中态) + Assert.True(shell.View.BackgroundTasks.IsSelectionActive); + } + + /// 选中态 left(非对话框期)— 退出选中态(D 批次遗留 left 退出键,基准 + /// BackgroundTasksDialog left 关闭语义;对齐 Esc 退出行为)。 + [Fact] + public void Selection_LeftArrow_ExitsSelection() + { + var (shell, _, _) = CreateShell(); + shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running"); + Press(shell, ConsoleKey.DownArrow, shift: true); + Assert.True(shell.View.BackgroundTasks.IsSelectionActive); + + Press(shell, ConsoleKey.LeftArrow); + + Assert.False(shell.View.BackgroundTasks.IsSelectionActive); + Assert.Equal(-1, shell.View.BackgroundTasks.SelectedIndex); + } + + /// 对话框态模态吞键 — 字符不进输入框、对话框保持挂载(对齐选中态吞键语义; + /// 对话框激活期间 Enter 亦被吞 — 挂载不可重入)。 + [Fact] + public void DetailDialog_SwallowsCharacterKeys() + { + var (shell, _, _) = CreateShell(); + shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running"); + Press(shell, ConsoleKey.DownArrow, shift: true); + Press(shell, ConsoleKey.Enter); + Assert.True(shell.IsBackgroundTaskDetailOpen); + + Char(shell, 'a'); + Char(shell, 'b'); + // Enter 被吞 — 不重复挂载第二个对话框 + Press(shell, ConsoleKey.Enter); + + Assert.Equal(string.Empty, shell.Input.Text); + Assert.True(shell.IsBackgroundTaskDetailOpen); + Assert.Single(shell.View.Messages.Items.OfType()); Assert.True(shell.View.BackgroundTasks.IsSelectionActive); }