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);
}