From 65c16012375d5f20ab1bc281b745c4825d0a4301 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Fri, 23 Oct 2026 00:15:14 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85gh=E6=98=BE?=
=?UTF-8?q?=E7=A4=BAPR=E7=9A=84=E5=88=86=E6=94=AF=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mcp/git_hub/api/GitHubApiResponseDtos.cs | 13 ++++++++++++
.../git_hub/handlers/GitHubToolHandlers.cs | 20 ++++++++++++++++---
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/modules/mcp/mcp/git_hub/api/GitHubApiResponseDtos.cs b/modules/mcp/mcp/git_hub/api/GitHubApiResponseDtos.cs
index 8df35f320..7f0a0dcfe 100644
--- a/modules/mcp/mcp/git_hub/api/GitHubApiResponseDtos.cs
+++ b/modules/mcp/mcp/git_hub/api/GitHubApiResponseDtos.cs
@@ -441,6 +441,19 @@ internal sealed class PrListItemResponse {
/// 作者
[JsonPropertyName("user")]
public UserRefResponse? User { get; init; }
+ /// 源分支引用(head.ref — gh CLI BRANCH 列数据源;search API 精简响应可能缺省 null)
+ [JsonPropertyName("head")]
+ public PrHeadRefResponse? Head { get; init; }
+ /// 目标分支引用(base.ref — 与源分支合成 head→base 列)
+ [JsonPropertyName("base")]
+ public PrHeadRefResponse? Base { get; init; }
+}
+
+/// PR 分支引用(head/base 共用 — pulls API {ref: "..."} 形态)
+internal sealed class PrHeadRefResponse {
+ /// 分支名
+ [JsonPropertyName("ref")]
+ public string Ref { get; init; } = "";
}
/// PR 列表 items 包装 — search API {items:[...]} 格式
diff --git a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.cs b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.cs
index 9a048f7d7..6a91d7860 100644
--- a/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.cs
+++ b/modules/mcp/mcp/git_hub/handlers/GitHubToolHandlers.cs
@@ -548,7 +548,7 @@ private static string SummarizeRepo(string json) {
}
///
- /// 精简 PR 列表 JSON — 表格格式(number, state, title, author),兼容 pulls API 数组和 search API {items} 格式
+ /// 精简 PR 列表 JSON — 表格格式(number, state, title, author, branch head→base),兼容 pulls API 数组和 search API {items} 格式
///
private static string SummarizePrList(string json) {
try {
@@ -561,14 +561,28 @@ private static string SummarizePrList(string json) {
}
if (prs is null) return json;
var sb = new StringBuilder(512);
- sb.AppendLine("PR#\t状态\t标题\t作者");
- foreach (var pr in prs) sb.AppendLine($"{pr.Number}\t{pr.State}\t{pr.Title}\t{pr.User?.Login ?? ""}");
+ sb.AppendLine("PR#\t状态\t标题\t作者\t分支");
+ foreach (var pr in prs) {
+ var branch = FormatPrBranch(pr);
+ sb.AppendLine($"{pr.Number}\t{pr.State}\t{pr.Title}\t{pr.User?.Login ?? ""}\t{branch}");
+ }
return sb.ToString();
} catch {
return json;
}
}
+ /// PR 分支列文案 — head→base(同分支省箭头;引用缺失回退空串——search API 精简响应无 head/base)。
+ private static string FormatPrBranch(PrListItemResponse pr) {
+ var head = pr.Head?.Ref ?? string.Empty;
+ var @base = pr.Base?.Ref ?? string.Empty;
+ if (head.Length == 0 && @base.Length == 0) {
+ return string.Empty;
+ }
+
+ return head == @base || @base.Length == 0 ? head : $"{head}→{@base}";
+ }
+
///
/// 精简 Issue 列表 JSON — 表格格式(number, state, title, author)
///
From 22532fccfa09526b8f83e42035b325f5106d4e4b Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Fri, 23 Oct 2026 01:00:29 +0800
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E9=9D=A2=E6=9D=BF=E9=80=89?=
=?UTF-8?q?=E4=B8=AD=E5=AF=BC=E8=88=AA=20+=20BridgeClient=20=E5=81=9C?=
=?UTF-8?q?=E6=AD=A2=E6=8E=A5=E7=BA=BF=20+=20ReadPassword=20=E5=AF=86?=
=?UTF-8?q?=E6=96=87=E6=A1=86=EF=BC=88D/E=20=E5=8F=8C=E5=AD=90=E4=BB=A3?=
=?UTF-8?q?=E7=90=86=E6=89=B9=E6=AC=A1=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:?=
=?UTF-8?q?=20D=3DShift+Down=20=E8=BF=9B=E5=85=A5=E9=9D=A2=E6=9D=BF?=
=?UTF-8?q?=E9=80=89=E4=B8=AD=E6=80=81(=E5=88=86=E6=B4=BE=E9=93=BE=20curso?=
=?UTF-8?q?rNav=20=E5=90=8E=20suggestion=20=E5=89=8D=E2=80=94=E2=80=94?=
=?UTF-8?q?=E8=BD=BB=E9=87=8F=E6=A8=A1=E6=80=81=E5=AF=B9=E9=BD=90=20=5Fcur?=
=?UTF-8?q?sorNav=20=E5=BD=A2=E6=80=81,=E4=B8=8E=20Shift+Up=20=E5=AF=B9?=
=?UTF-8?q?=E7=A7=B0;=E9=80=89=E4=B8=AD=E8=A1=8C=20=E2=9D=AF=20=E5=89=8D?=
=?UTF-8?q?=E7=BC=80+suggestion=20=E8=89=B2=E5=AF=B9=E9=BD=90=20RenderMode?=
=?UTF-8?q?lListRow=20=E5=85=88=E4=BE=8B;Enter=20=E6=9F=A5=E7=9C=8B?=
=?UTF-8?q?=E5=9B=9B=E5=85=83=E7=BB=84=E6=B6=88=E6=81=AF=E9=A6=96=E7=89=88?=
=?UTF-8?q?=E9=9D=9E=E5=85=A8=E5=B1=8F=E8=AF=A6=E6=83=85;MoveSelection=20?=
=?UTF-8?q?=E5=88=B0=E5=A4=B4=E5=81=9C=E5=AF=B9=E9=BD=90=20ModelPicker;?=
=?UTF-8?q?=E7=AA=97=E5=8F=A3=E8=B7=9F=E9=9A=8F=20FollowWindowToSelection+?=
=?UTF-8?q?TickEvict=20=E9=92=B3=E5=88=B6);=20E=3DIReplEngine.StopBackgrou?=
=?UTF-8?q?ndTask=20DIM=20=E9=BB=98=E8=AE=A4=E5=AE=9E=E7=8E=B0(SimulatedEn?=
=?UTF-8?q?gine+=E5=85=A8=E9=83=A8=E6=B5=8B=E8=AF=95=20fake=20=E9=9B=B6?=
=?UTF-8?q?=E6=94=B9=E5=8A=A8)+BridgeClient=20=E5=8F=91=20stop=5Ftask(?=
=?UTF-8?q?=E5=AF=B9=E9=BD=90=20Interrupt=20=E5=8F=91=E9=80=81=E5=BD=A2?=
=?UTF-8?q?=E6=80=81)+BridgeEngine/CommandPickerEngine=20=E9=80=8F?=
=?UTF-8?q?=E4=BC=A0+Demo=20--bridge=20=E6=8E=A5=E7=BA=BF(=E6=9E=B6?=
=?UTF-8?q?=E6=9E=84=E4=BF=AE=E6=AD=A3=E5=8F=91=E7=8E=B0:BridgeClient=20?=
=?UTF-8?q?=E9=9D=9E=20IReplEngine,BridgeEngine=20=E6=89=8D=E6=98=AF?=
=?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8,Host=20=E8=B5=B0=20session.Client?=
=?UTF-8?q?=20=E5=A4=9A=E6=80=81)+ReadPassword=20=E5=A4=8D=E7=94=A8=20Comm?=
=?UTF-8?q?andDialog=20prompt=20=E5=85=A8=E9=83=A8=E4=BB=B6=E4=BB=85?=
=?UTF-8?q?=E5=8A=A0=20IsPassword=20=E5=BD=A2=E6=80=81=E7=BB=B4=E5=BA=A6(I?=
=?UTF-8?q?nputRequestKind=20=E5=94=AF=E4=B8=80=E6=95=B0=E6=8D=AE=E6=BA=90?=
=?UTF-8?q?,=E6=8E=A9=E7=A0=81=E6=B8=B2=E6=9F=93=20PasswordMask.Of,?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=98=BB=E5=A1=9E=E6=A1=A5=E5=AF=B9=E9=BD=90?=
=?UTF-8?q?=20Confirm=20=E5=85=88=E4=BE=8B);=20=E4=B8=BB=E4=BB=A3=E7=90=86?=
=?UTF-8?q?=20debug=203=20=E5=A4=84:BridgeClient=20=E4=B8=89=E5=A4=84=20?=
=?UTF-8?q?=5Fprocess.StandardInput=20=E6=BC=8F=E6=94=B9=20=5Fstdin(?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9E=84=E9=80=A0=20null=20=E8=BF=9B?=
=?UTF-8?q?=E7=A8=8B=E8=A7=A3=E5=BC=95=E7=94=A8)+IsEngineAlive=20null=20?=
=?UTF-8?q?=E5=AE=89=E5=85=A8(=E6=B5=8B=E8=AF=95=E6=9E=84=E9=80=A0?=
=?UTF-8?q?=E6=81=92=E6=B4=BB)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_tui/apps/NativeTui.Demo/Program.cs | 6 +-
.../apps/NativeTui.Host/EngineSession.cs | 17 +-
native_tui/apps/NativeTui.Host/Program.cs | 12 +-
.../src/NativeTui.Core/bridge/BridgeClient.cs | 44 +++--
.../src/NativeTui.Core/bridge/BridgeEngine.cs | 4 +
.../src/NativeTui.Core/bridge/IReplEngine.cs | 7 +
.../repl/BackgroundTaskPanel.cs | 70 ++++++--
.../repl/CommandDialogCoordinator.cs | 21 ++-
.../NativeTui.Core/repl/CommandDialogItem.cs | 13 +-
.../repl/CommandPickerEngine.cs | 3 +
.../src/NativeTui.Core/repl/ReplShell.cs | 116 +++++++++++-
.../permissions/InputRequestCoordinator.cs | 2 +
.../repl/permissions/PasswordMask.cs | 3 +-
.../bridge/BridgeClientTests.cs | 59 +++++++
.../repl/BackgroundTaskPanelTests.cs | 75 ++++++++
.../repl/CommandDialogTests.cs | 103 +++++++++++
.../repl/ReplShellPanelNavigationTests.cs | 165 ++++++++++++++++++
17 files changed, 663 insertions(+), 57 deletions(-)
create mode 100644 native_tui/tests/NativeTui.Tests/bridge/BridgeClientTests.cs
create mode 100644 native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs
diff --git a/native_tui/apps/NativeTui.Demo/Program.cs b/native_tui/apps/NativeTui.Demo/Program.cs
index 4bc82d714..f851461bf 100644
--- a/native_tui/apps/NativeTui.Demo/Program.cs
+++ b/native_tui/apps/NativeTui.Demo/Program.cs
@@ -166,7 +166,11 @@ private static int RunBridgeInteractive(string exePath, string? model = null, bo
var version = typeof(Program).Assembly.GetName().Version is { } v ? v.ToString(3) : "0.0.0";
var info = SessionInfo.Create(version, model);
var banner = new BannerItem(info.Version, info.Model, info.Billing, info.WorkingDirectory);
- var shell = new ReplShell(engine, banner, Console.Out, inline: !viewport, codeHighlighter: new TextMateCodeHighlighter(dark: true));
+ // M8-8 后续 — x 停止后台任务:bridge 模式经 IReplEngine 接口写子进程 stdin stop_task 请求
+ //(引擎侧分派 CancelFork/StopTeammate 后 cancelled 终态事件泵回推面板)
+ var shell = new ReplShell(engine, banner, Console.Out, inline: !viewport,
+ onStopTask: engine.StopBackgroundTask,
+ codeHighlighter: new TextMateCodeHighlighter(dark: true));
shell.Run();
shell.WriteExit();
}
diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs
index 29bcc46dc..a23588f99 100644
--- a/native_tui/apps/NativeTui.Host/EngineSession.cs
+++ b/native_tui/apps/NativeTui.Host/EngineSession.cs
@@ -44,12 +44,6 @@ private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient clien
/// 重发 session_info 事件 — 模型/供应商切换等运行时状态变化后刷新 banner(ReplBridgeDriver.ApplySessionInfo 差分上屏)。
public void RefreshSessionInfo() => _client.Enqueue(BuildSessionInfoEvent(_checker, _fastMode, _engine.Config));
- /// 停止后台任务(M8-8 后续 — Program 在 shell 构造处 onStopTask 委托接线,
- /// 转发回合客户端 → 后台任务泵 CancelFork/StopTeammate 分派)。
- /// 任务类别(fork|teammate)
- /// 任务标识
- public void StopBackgroundTask(string kind, string taskId) => _client.StopBackgroundTask(kind, taskId);
-
/// TUI 静默日志 provider(internal — 测试断言非 Console:Console 直写 stderr 与 inline 渲染交错致光标错乱/横向线残影)。
/// 错误反馈走 EngineTurnClient Error 事件链(● 回合执行失败),不依赖引擎日志。
internal static ILoggerProvider LoggerProvider => SilentLoggerProvider.Instance;
@@ -74,8 +68,9 @@ public static async Task CreateAsync(CancellationToken cancellati
// S8 confirm 闭环 — 命令确认回调走 TuiPermissionBridge TCS 桥(复用权限对话框模态,
// 两档映射 Allow/AlwaysAllow → true;shell 未绑定时 bridge 自动 Deny → false 安全降级)。
- // prompt/readPassword 数据层(InputRequestCoordinator/PasswordMask)已就绪,
- // ReplShell 输入模态入口属 S7 域 — 接线未决,期间保持 null(命令侧默认空输入降级)。
+ // prompt/readPassword 组装期 null(无 shell 可挂模态)— 交互模式 Program 在 shell 构造后
+ // BindInteractions 重绑为 ReplShell 命令对话框同步桥(prompt 明文 / readPassword 密文遮蔽,
+ // TASK065 F2b 已接;组装期 null = 命令侧默认空输入降级)。
commandCallbacks.BindInteractions(
confirm: message => MapConfirmResult(permissionBridge.Confirm(CommandConfirmToolName, message)),
prompt: null,
@@ -183,7 +178,8 @@ private static async Task RunTurnAsync(SessionController controller, string inpu
/// S8/TASK065 F2 交互面透传:confirm 组装期闭环走权限对话框 TCS 桥(selftest 等非交互宿主
/// 也有 confirm 能力);交互模式 Program 在 shell 构造后 BindInteractions 重绑 confirm/prompt
/// 为 ReplShell 命令对话框同步桥(语义更准;解锁 /commit /login /resume 等交互命令);
- /// readPassword 双方均未接(密文输入框复杂度高 — 空输入降级,遗留项登记)。
+ /// readPassword 密文模态(TASK065 F2b — 回显 PasswordMask 等宽遮蔽、Enter 提交原文/Esc 取消 null,
+ /// 数据层原文仅存协调器,渲染层不落明文)。
/// TASK065 F6 — 命令回合标记 consumer 纯文本模式(OnDone 跳过 Markdig 投影,防 /help 类
/// 缩进对齐文本被解释为代码块/表格错位;对话回合 Markdown 不受影响)。
/// internal static — 回调透传链测试面(fake DI 驱动真实命令注册链)。
@@ -288,7 +284,8 @@ public void BindInteractions(Func? confirm, Func?
/// TASK065 F2,/login /resume 等。null — 命令侧默认空输入)。
public Func? Prompt => _prompt;
- /// 密码输入回调(命令 readPassword → TUI 遮蔽输入模态;null — 命令侧默认空串。双方均未接线,遗留项)。
+ /// 密码输入回调(交互模式经 BindInteractions 重绑为 ReplShell 命令密文输入对话框同步桥 —
+ /// TASK065 F2b,回显 PasswordMask 遮蔽 + Enter 提交原文/Esc null。null — 命令侧默认空串降级)。
public Func? ReadPassword => _readPassword;
/// 已绑定任一回调(internal — Program 接线测试断言面)。
diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs
index 8cd4b7898..e278ee574 100644
--- a/native_tui/apps/NativeTui.Host/Program.cs
+++ b/native_tui/apps/NativeTui.Host/Program.cs
@@ -299,9 +299,10 @@ private static async Task RunInteractiveAsync() {
themeName: themeName,
commandUsage: commandUsage,
workspaceRoot: Environment.CurrentDirectory,
- // M8-8 后续 — x 停止后台任务委托:面板运行中条目 → 引擎泵 CancelFork/StopTeammate 分派
- //(cancelled 终态 upsert 由泵推回面板即时反馈)
- onStopTask: (kind, taskId) => session.StopBackgroundTask(kind, taskId),
+ // M8-8 后续 — x 停止后台任务委托:面板运行中条目 → 回合客户端停止入口(多态统一面 —
+ // 进程内 EngineTurnClient 泵分派 CancelFork/StopTeammate,bridge 形态走同接口的
+ // BridgeEngine 写 stop_task 请求;cancelled 终态 upsert 由泵推回面板即时反馈)
+ onStopTask: (kind, taskId) => session.Client.StopBackgroundTask(kind, taskId),
// 方案 B — 流式期 Markdown 块级增量投影(Markdig 卫星注入:稳定前缀 memoize +
// 增长尾纯文本,对齐基准 StreamingMarkdown;落定替换 MarkdownMessageItem 全量渲染)
streamingProjector: new StreamingMarkdownProjector());
@@ -317,11 +318,12 @@ private static async Task RunInteractiveAsync() {
// TASK065 F2/S8 融合 — 交互面重绑:confirm/prompt 从组装期权限对话框初值升级为
// ReplShell 命令对话框同步桥(语义更准:命令确认非工具权限;/commit /login /resume 等
// 交互命令解锁 — 回合线程挂起等模态按键,对齐 TuiPermissionBridge 先例);
- // readPassword 未接(密文输入框复杂度高 — /login 交互流程降级,遗留项登记)
+ // readPassword 密文模态(TASK065 F2b — 回显 PasswordMask 遮蔽 + Enter 提交原文/Esc null,
+ // /login 等密文输入命令解锁)
session.CommandCallbacks.BindInteractions(
confirm: shell.ConfirmCommand,
prompt: shell.PromptCommand,
- readPassword: null);
+ readPassword: shell.ReadPasswordCommand);
shell.Run();
shell.WriteExit();
}
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeClient.cs b/native_tui/src/NativeTui.Core/bridge/BridgeClient.cs
index 3533179af..35d587723 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeClient.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeClient.cs
@@ -2,23 +2,35 @@ namespace NativeTui.Core.Bridge;
///
/// bridge 引擎客户端 — TUI 父进程封装(ADR 0141):拉起引擎子进程(stdio 管道),
-/// 请求写 stdin(Submit/Interrupt),事件由 BridgeEventPump 后台排空(DrainEvents)。
-/// Dispose:发 quit → 关 stdin → 等引擎退出(无超时 — 关闭即释放)→ 等泵收尾。
+/// 请求写 stdin(Submit/Interrupt/SetPermissionMode/StopBackgroundTask),事件由 BridgeEventPump
+/// 后台排空(DrainEvents)。Dispose:发 quit → 关 stdin → 等引擎退出(无超时 — 关闭即释放)→ 等泵收尾。
/// 引擎 stderr 直通终端(不重定向):诊断可见且无管道缓冲填满死锁风险。
///
public sealed class BridgeClient : IDisposable
{
- private readonly Process _process;
+ private readonly TextWriter _stdin;
+ private readonly Process? _process;
private readonly BridgeEventPump _pump;
private BridgeClient(Process process, BridgeEventPump pump)
{
_process = process;
+ _stdin = process.StandardInput;
_pump = pump;
}
- /// 引擎进程是否仍存活。
- public bool IsEngineAlive => !_process.HasExited;
+ /// 测试构造 — 注入 stdin 写面与 stdout 读面(internal — BridgeClientTests 单测协议行;
+ /// 无子进程,Dispose 只收事件泵;对齐生产构造的泵启动形态)。
+ internal BridgeClient(TextWriter stdin, TextReader stdout)
+ {
+ _process = null;
+ _stdin = stdin;
+ _pump = new BridgeEventPump(stdout);
+ _pump.Start();
+ }
+
+ /// 引擎进程是否仍存活(测试构造无子进程恒活 — 状态由事件泵 EOF 感知)。
+ public bool IsEngineAlive => _process is null || !_process.HasExited;
/// 拉起引擎子进程并启动事件泵(exe 不存在 / 启动失败抛异常)。
public static BridgeClient Start(string exePath, string arguments = "")
@@ -39,15 +51,23 @@ public static BridgeClient Start(string exePath, string arguments = "")
/// 提交用户输入(开始回合)。
public void Submit(string text) =>
- BridgeProtocol.WriteRequest(_process.StandardInput, new BridgeRequest.Submit(text));
+ BridgeProtocol.WriteRequest(_stdin, new BridgeRequest.Submit(text));
/// 打断当前回合。
public void Interrupt() =>
- BridgeProtocol.WriteRequest(_process.StandardInput, new BridgeRequest.Interrupt());
+ BridgeProtocol.WriteRequest(_stdin, new BridgeRequest.Interrupt());
/// 切换权限模式(mode 为协议值 plan/auto/ask/bypass/unattended;生效值以引擎回执事件为准)。
public void SetPermissionMode(string mode) =>
- BridgeProtocol.WriteRequest(_process.StandardInput, new BridgeRequest.SetPermissionMode(mode));
+ BridgeProtocol.WriteRequest(_stdin, new BridgeRequest.SetPermissionMode(mode));
+
+ /// 停止后台任务(M8-8 — kind: fork|teammate,写 stdin stop_task 请求;
+ /// 即发即返不等回执 — 引擎侧 ServeLoop 分派 CancelFork/StopTeammate 后回推 cancelled 终态
+ /// background_task 事件,面板即时反馈走事件链,对齐 Interrupt 的发送形态)。
+ /// 任务类别(fork|teammate)
+ /// 任务标识
+ public void StopBackgroundTask(string kind, string taskId) =>
+ BridgeProtocol.WriteRequest(_stdin, new BridgeRequest.StopTask(kind, taskId));
/// 排空事件队列(返回本次取出的全部事件)。
public IReadOnlyList DrainEvents() => _pump.DrainEvents();
@@ -59,9 +79,9 @@ public void SetPermissionMode(string mode) =>
public void Dispose()
{
CloseEngineGracefully();
- _process.WaitForExit();
+ _process?.WaitForExit();
_pump.Dispose();
- _process.Dispose();
+ _process?.Dispose();
}
/// 优雅关闭:发 quit + 关 stdin(引擎已退出/管道断开时跳过 — 尽力而为)。
@@ -69,8 +89,8 @@ private void CloseEngineGracefully()
{
try
{
- BridgeProtocol.WriteRequest(_process.StandardInput, new BridgeRequest.Quit());
- _process.StandardInput.Close();
+ BridgeProtocol.WriteRequest(_stdin, new BridgeRequest.Quit());
+ _stdin.Close();
}
catch (IOException)
{
diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
index 23f2d9826..ab7e5edb0 100644
--- a/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
+++ b/native_tui/src/NativeTui.Core/bridge/BridgeEngine.cs
@@ -25,6 +25,10 @@ public sealed class BridgeEngine : IReplEngine, IDisposable
/// 请求权限模式切换 — 写子进程 stdin(回执事件为准)。
public void SetPermissionMode(string mode) => _client.SetPermissionMode(mode);
+ /// 停止后台任务(M8-8 — 写子进程 stdin stop_task 请求;cancelled 终态 background_task
+ /// 事件由泵回推面板,即发即返不等回执)。
+ public void StopBackgroundTask(string kind, string taskId) => _client.StopBackgroundTask(kind, taskId);
+
/// 排空事件泵待应用事件。
public IReadOnlyList DrainEvents() => _client.DrainEvents();
diff --git a/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs b/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
index f2dfb2c5b..c38012240 100644
--- a/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
+++ b/native_tui/src/NativeTui.Core/bridge/IReplEngine.cs
@@ -23,6 +23,13 @@ public interface IReplEngine
/// 排空诊断旁路(shell 写 stderr;无诊断实现返回空 — 默认空)。
IReadOnlyList DrainDiagnostics() => Array.Empty();
+ /// 停止后台任务(M8-8 — kind: fork|teammate;默认空实现 — 无后台任务能力的实现
+ /// (模拟引擎/测试桩)零成本,对齐 DrainDiagnostics 默认成员先例;进程内 EngineTurnClient
+ /// 泵分派 / 子进程 BridgeEngine 写 stop_task 请求为显式实现)。
+ /// 任务类别(fork|teammate)
+ /// 任务标识
+ void StopBackgroundTask(string kind, string taskId) { }
+
/// 引擎存活(进程内恒 true;子进程退出后 false — shell 空闲时追加退出消息并结束)。
bool IsAlive { get; }
}
diff --git a/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs
index 6fe9799b2..e70607756 100644
--- a/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs
+++ b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs
@@ -21,10 +21,12 @@ public sealed record BackgroundTaskEntry(string Kind, string TaskId, string Desc
/// 区别:不落滚动区、终态驻留 5s 后过期驱逐)、上限窗口 min(10, n) + "+N more" 溢出行
/// (基准全量渲染靠 React 虚拟化扛 300 规模,本项目无虚拟化 — 窗口+提示为对等机制,
/// 对齐 MaxQueuedInputs=10 / SubagentGroupCoordinator.MaxVisible 先例)。
-/// 选中态(M8-8 后续):仅 running 条目可选(终态不可选 — 驱逐倒计时中无操作意义),
-/// 选中导航键位留后续(首版 x 停止走"选中或首个 running"回退)。
+/// 选中态(M8-8 登记遗留 — Shift+Down 进入 / ↑↓kj 移动 / Enter 查看 / x 停止 / Esc 退出,
+/// 键位接线在 ReplShell):仅 running 条目可选(终态不可选 — 驱逐倒计时中无操作意义),
+/// 选中导航窗口跟随(WindowStart — 对齐 ModelPicker.MoveFocus 先例,选中行恒可见)。
/// 消费方:ReplBridgeDriver 路由(BackgroundTask 事件)+ ReplView 渲染(排队面板之上,
-/// Render 内每帧 TickEvict 驱逐)+ ReplShell x 停止键(SelectedEntry/HasRunning 门槛)。
+/// Render 内每帧 TickEvict 驱逐)+ ReplShell 选中导航模态(IsSelectionActive/MoveSelection/
+/// SelectedEntry/HasRunning)。
///
public sealed class BackgroundTaskPanel
{
@@ -67,6 +69,13 @@ public sealed class BackgroundTaskPanel
/// 驱逐/Upsert 状态翻转后不主动校正,SelectedEntry 守卫兜底)。
public int SelectedIndex { get; private set; } = -1;
+ /// 选中态激活(M8-8 登记遗留 — SelectedIndex >= 0 语义糖,ReplShell 选中导航模态分派依据)。
+ public bool IsSelectionActive => SelectedIndex >= 0;
+
+ /// 滚动窗口起点(0 起 — 选中导航窗口跟随,对齐 ModelPicker.WindowStart 先例:
+ /// MoveSelection 保证选中行可见的最小窗口位移;常态恒 0,ClearSelection 归零回默认视图)。
+ public int WindowStart { get; private set; }
+
/// 选中条目(M8-8 后续 — 返回 (Kind,TaskId) 元组;仅 running 态可选:无选中或选中条目
/// 已翻终态返回 null — 停止键消费方回退首个 running 条目)。
public (string Kind, string TaskId)? SelectedEntry =>
@@ -99,7 +108,9 @@ public void Upsert(string kind, string taskId, string description, string state)
}
/// 移动选中(M8-8 后续 — delta ±1 沿方向找最近 running 条目,跳过终态条目;
- /// 无选中时按 delta 方向取首个 running(向前从条目 0、向后从末条);无 running 或到头保持不变(不环绕))。
+ /// 无选中时按 delta 方向取首个 running(向前从条目 0、向后从末条);无 running 或到头保持不变
+ /// (不环绕 — 对齐 ModelPicker.MoveFocus clamp 列表语义);命中后窗口跟随保证选中行可见)。
+ /// 消费方:ReplShell 选中导航模态 ↑↓/kj 键 + Shift+Down 进入(无选中 → 首个 running)。
/// 移动方向与步数符号(+1 向后 / -1 向前)
public void MoveSelection(int delta)
{
@@ -118,15 +129,39 @@ public void MoveSelection(int delta)
if (_tasks[i].State == RunningState)
{
SelectedIndex = i;
+ FollowWindowToSelection();
return;
}
}
}
+ /// 退出选中态(M8-8 登记遗留 — ReplShell 选中导航模态 Esc 消费):
+ /// SelectedIndex 归 -1 + 窗口归 0(回默认视图 — 对齐 ModelPicker.Complete 归零先例,
+ /// 常驻面板常态展示插入序前 N 条)。
+ public void ClearSelection()
+ {
+ SelectedIndex = -1;
+ WindowStart = 0;
+ }
+
+ /// 窗口跟随(对齐 ModelPicker.MoveFocus 先例)— 保证选中索引可见的最小窗口位移。
+ private void FollowWindowToSelection()
+ {
+ if (SelectedIndex < WindowStart)
+ {
+ WindowStart = SelectedIndex;
+ }
+ else if (SelectedIndex >= WindowStart + VisibleCount)
+ {
+ WindowStart = SelectedIndex - VisibleCount + 1;
+ }
+ }
+
/// 终态驱逐(M8-8 后续 evictAfter — 基准 CoordinatorAgentStatus.tsx:51-63):
- /// 终态条目定格超 移除(仅删终态 — running 永不驱逐;
+ /// 终态条目定格超 秒移除(仅删终态 — running 永不驱逐;
/// FinalizedAtMs=0 非终态不受影响);选中索引随移除位平移/钳制(选中位被删置 -1、
- /// 后移条目索引前移、超界钳到末位;-1 不动)。全终态+全驱逐后面板回到零渲染。
+ /// 后移条目索引前移、超界钳到末位;-1 不动)+ 滚动窗口钳制(防删除后悬空)。
+ /// 全终态+全驱逐后面板回到零渲染。
/// 调用方:ReplView.Render 每帧拍(墙钟直取,对齐 spinner WallNowMs 先例);测试传可控时间。
/// 当前墙钟(Environment.TickCount64 — 测试可传偏移值控制过期)
/// 终态驻留时长(默认 )
@@ -155,20 +190,33 @@ public void TickEvict(long nowMs, int evictAfterMs = EvictAfterMs)
{
SelectedIndex = _tasks.Count - 1;
}
+
+ var maxWindowStart = Math.Max(0, _tasks.Count - VisibleCount);
+ if (WindowStart > maxWindowStart)
+ {
+ WindowStart = maxWindowStart;
+ }
}
/// 渲染 — 每任务一行 "● kind: 描述 · 状态":● 状态色(running text / completed success /
- /// failed、cancelled error)、kind+描述 normal、状态 dim;超窗末行 "+N more" dim;空面板零输出。
+ /// failed、cancelled error)、kind+描述 normal、状态 dim;超窗末行 "+N more" dim;空面板零输出。
+ /// 选中行(M8-8 登记遗留)— 对齐 ModelPicker.RenderModelListRow 焦点形态:❯ 前缀(suggestion 色)
+ /// 替换 ● 状态点 + 正文段 suggestion 色(状态文字信息由行尾状态段保留);渲染循环按 WindowStart
+ /// 偏移取条目(选中导航窗口跟随)。
public void Render(Screen screen, Rect frame, Theme theme)
{
var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ var selection = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Suggestion });
for (var i = 0; i < VisibleCount; i++)
{
- var task = _tasks[i];
+ var itemIndex = WindowStart + i;
+ var task = _tasks[itemIndex];
var row = frame.Y + i;
- var dotStyle = screen.Styles.GetOrAdd(new TextStyle { Foreground = DotColor(task.State, theme) });
- var col = screen.WriteText(row, frame.X, $"{Figures.BlackCircle} ", dotStyle, frame.Right);
- col = screen.WriteText(row, col, $"{task.Kind}: {task.Description}", styleId: 0, rightBound: frame.Right);
+ var selected = itemIndex == SelectedIndex;
+ var col = selected
+ ? screen.WriteText(row, frame.X, $"{Figures.Pointer} ", selection, frame.Right)
+ : screen.WriteText(row, frame.X, $"{Figures.BlackCircle} ", screen.Styles.GetOrAdd(new TextStyle { Foreground = DotColor(task.State, theme) }), frame.Right);
+ col = screen.WriteText(row, col, $"{task.Kind}: {task.Description}", styleId: selected ? selection : 0, rightBound: frame.Right);
screen.WriteText(row, col, $"{Figures.MiddotSeparator}{StatusText(task.State)}", dim, frame.Right);
}
diff --git a/native_tui/src/NativeTui.Core/repl/CommandDialogCoordinator.cs b/native_tui/src/NativeTui.Core/repl/CommandDialogCoordinator.cs
index 69a86f29a..0f43267a1 100644
--- a/native_tui/src/NativeTui.Core/repl/CommandDialogCoordinator.cs
+++ b/native_tui/src/NativeTui.Core/repl/CommandDialogCoordinator.cs
@@ -1,11 +1,13 @@
namespace NativeTui.Core.Repl;
///
-/// 命令交互对话框协调器(TASK065 F2)— 斜杠命令 confirm/prompt 回调的 TCS 桥 + 对话框状态:
+/// 命令交互对话框协调器(TASK065 F2)— 斜杠命令 confirm/prompt/readPassword 回调的 TCS 桥 + 对话框状态:
/// 引擎回合线程(SlashCommandRunner 命令侧同步回调)→ ReplShell.BeginCommandConfirm/BeginCommandPrompt
/// 挂起对话框 → 主循环模态按键路由 → Complete 解除等待(对齐 PermissionCoordinator 形态:
/// Begin 取代旧等待者 / Complete 幂等 / 无挂起 Wait 返回已完成降级占位防调用线程死等)。
-/// confirm 两档焦点(Yes/No)环绕;prompt 输入缓冲尾追加/尾退格(光标移动属对话框增强遗留项)。
+/// confirm 两档焦点(Yes/No)环绕;prompt 输入缓冲尾追加/尾退格(光标移动属对话框增强遗留项);
+/// 输入形态分 Text 明文回显 / Password 密文遮蔽回显(TASK065 F2b — InputRequestKind 唯一数据源,
+/// 渲染层走 PasswordMask)。
///
public sealed class CommandDialogCoordinator
{
@@ -24,18 +26,25 @@ public sealed class CommandDialogCoordinator
/// 当前对话框消息(确认问题/输入提示;无挂起为 null)。
public string? Message { get; private set; }
- /// prompt 输入缓冲(挂起期间的编辑文本,Enter 提交)。
+ /// prompt 输入缓冲(挂起期间的编辑文本,Enter 提交 — 密文形态明文仅存本层,渲染走 PasswordMask)。
public string PromptInput { get; private set; } = string.Empty;
/// confirm 焦点选项索引(0=Yes / 1=No,环绕移动,Begin 重置 0)。
public int ConfirmFocusIndex { get; private set; }
+ /// 当前输入形态(S8 InputRequestKind — Text 明文回显 / Password 密文遮蔽回显;
+ /// 非输入挂起态为 Text 默认值)。
+ public InputRequestKind PromptKind { get; private set; }
+
/// 是否挂起中(对话框模态路由依据)。
public bool IsPending => _confirmPending is not null || _promptPending is not null;
/// 挂起的是输入对话框(false = 确认对话框)。
public bool IsPrompt => _promptPending is not null;
+ /// 挂起的是密文输入框(readPassword — 渲染层走 PasswordMask 遮蔽,明文仅存数据层)。
+ public bool IsPassword => IsPrompt && PromptKind == InputRequestKind.Password;
+
/// 等待确认结果(命令回调线程同步阻塞 GetAwaiter().GetResult();无挂起时为已完成 false 占位任务)。
public Task WaitConfirm() =>
(_confirmPending ?? CompletedFalse).Task;
@@ -53,13 +62,15 @@ public void BeginConfirm(string message)
ConfirmFocusIndex = 0;
}
- /// Begin 输入请求 — 取消旧等待者 + 输入缓冲清空。
- public void BeginPrompt(string message)
+ /// Begin 输入请求 — 取消旧等待者 + 输入缓冲清空 + 形态记录
+ /// (kind — Text 明文回显 / Password 密文遮蔽回显,默认 Text 兼容既有调用)。
+ public void BeginPrompt(string message, InputRequestKind kind = InputRequestKind.Text)
{
CancelPending();
_promptPending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Message = message;
PromptInput = string.Empty;
+ PromptKind = kind;
}
/// Complete 确认 — 解除等待 + 清空状态(幂等 — 二次调用 no-op)。
diff --git a/native_tui/src/NativeTui.Core/repl/CommandDialogItem.cs b/native_tui/src/NativeTui.Core/repl/CommandDialogItem.cs
index 36d5c208e..60e620517 100644
--- a/native_tui/src/NativeTui.Core/repl/CommandDialogItem.cs
+++ b/native_tui/src/NativeTui.Core/repl/CommandDialogItem.cs
@@ -1,11 +1,12 @@
namespace NativeTui.Core.Repl;
///
-/// 命令交互对话框消息项(TASK065 F2)— 斜杠命令 confirm/prompt 挂起时插入滚动区:
+/// 命令交互对话框消息项(TASK065 F2)— 斜杠命令 confirm/prompt/readPassword 挂起时插入滚动区:
/// 视觉对齐 PermissionRequestItem(顶边框 permission 色 + bold 标题 + 3 缩进消息行 +
/// Select 数字前缀选项 + Esc 取消提示),confirm 两档(1.Yes / 2.No 焦点 ❯ suggestion);
-/// prompt 形态为输入行(❯ suggestion + 输入缓冲 + 行尾反色光标块)。状态源 =
-/// CommandDialogCoordinator(消息/焦点/输入缓冲),Begin 时插入 Messages,Complete 时移除。
+/// prompt 形态为输入行(❯ suggestion + 输入缓冲 + 行尾反色光标块);password 形态回显
+/// PasswordMask 等宽遮蔽(TASK065 F2b — 明文仅存协调器数据层,渲染层不落明文)。
+/// 状态源 = CommandDialogCoordinator(消息/焦点/输入缓冲/形态),Begin 时插入 Messages,Complete 时移除。
///
public sealed class CommandDialogItem(CommandDialogCoordinator coordinator) : IScrollItem
{
@@ -54,12 +55,14 @@ public void Render(Screen screen, Rect frame, Theme theme)
MessageRenderer.RenderPermissionEscHint(screen, RowFrame(frame, row), theme);
}
- /// prompt 输入行 — 3 缩进 ❯ suggestion + 输入缓冲 + 行尾反色光标块(输入光标语义)。
+ /// prompt 输入行 — 3 缩进 ❯ suggestion + 输入缓冲 + 行尾反色光标块(输入光标语义);
+ /// 密文形态(TASK065 F2b)回显 PasswordMask 等宽遮蔽 — 长度 1:1 保持,光标块/退格语义不变。
private void RenderPromptInputRow(Screen screen, Rect frame, Theme theme)
{
var suggestion = screen.Styles.GetOrAdd(new TextStyle { Foreground = theme.Suggestion });
var col = screen.WriteText(frame.Y, frame.X, " ❯ ", suggestion, frame.Right);
- col = screen.WriteText(frame.Y, col, Coordinator.PromptInput, 0, frame.Right);
+ var echo = Coordinator.IsPassword ? PasswordMask.Of(Coordinator.PromptInput) : Coordinator.PromptInput;
+ col = screen.WriteText(frame.Y, col, echo, 0, frame.Right);
if (col < frame.Right)
{
var inverse = screen.Styles.GetOrAdd(new TextStyle { Inverse = true });
diff --git a/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs b/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
index 6de7ac65c..8009a2832 100644
--- a/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
+++ b/native_tui/src/NativeTui.Core/repl/CommandPickerEngine.cs
@@ -51,6 +51,9 @@ public void Submit(string text)
/// 权限模式 — 透传内层。
public void SetPermissionMode(string mode) => inner.SetPermissionMode(mode);
+ /// 停止后台任务(M8-8 — 透传内层;装饰器完整转发防吞停止请求)。
+ public void StopBackgroundTask(string kind, string taskId) => inner.StopBackgroundTask(kind, taskId);
+
/// 排空事件 — 透传内层。
public IReadOnlyList DrainEvents() => inner.DrainEvents();
diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
index 8eb4cf6f8..49ab98ed0 100644
--- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs
+++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs
@@ -183,8 +183,8 @@ private void HandleExitCtrlD()
private readonly Action? _copyToClipboard;
/// 后台任务停止委托(M8-8 后续 — 空闲态 x 停止面板运行中任务,参数 = (kind, taskId);
- /// 宿主注入引擎泵转发(进程内直连 EngineSession.StopBackgroundTask),Core 零依赖不持泵
- /// (对齐 clipboard 注入先例);null = 停止能力降级,x 走正常字符插入)。
+ /// 宿主注入引擎停止转发(进程内 EngineTurnClient / bridge BridgeEngine — IReplEngine 多态统一面),
+ /// Core 零依赖不持接口(对齐 clipboard 注入先例);null = 停止能力降级,x 走正常字符插入)。
private readonly Action? _onStopTask;
/// 主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。
@@ -470,7 +470,8 @@ public bool ConfirmCommand(string message)
}
///
- /// 命令输入同步桥(同上 — prompt 形态;Esc 取消返回 null 对齐命令侧可空语义)。
+ /// 命令输入同步桥(同上 — prompt 形态;Esc 取消返回 null 对齐命令侧可空语义;
+ /// kind 透传 — Password 密文形态回显 PasswordMask 遮蔽)。
///
public string? PromptCommand(string message)
{
@@ -478,6 +479,17 @@ public bool ConfirmCommand(string message)
return _commandDialog.WaitPrompt().GetAwaiter().GetResult();
}
+ ///
+ /// 命令密文输入同步桥(TASK065 F2b — readPassword 形态:Enter 提交原文/Esc 取消 null,
+ /// 回显 PasswordMask 等宽遮蔽、明文仅存协调器数据层;签名同步对齐
+ /// TuiCommandCallbacks.ReadPassword 的 Func<string,string?>,阻塞桥先例同 ConfirmCommand)。
+ ///
+ public string? ReadPasswordCommand(string message)
+ {
+ BeginCommandPrompt(message, InputRequestKind.Password);
+ return _commandDialog.WaitPrompt().GetAwaiter().GetResult();
+ }
+
/// 挂起命令确认对话框(插入滚动区,模态路由生效)。
public void BeginCommandConfirm(string message)
{
@@ -485,10 +497,11 @@ public void BeginCommandConfirm(string message)
AddMessage(new CommandDialogItem(_commandDialog));
}
- /// 挂起命令输入对话框(插入滚动区,模态路由生效)。
- public void BeginCommandPrompt(string message)
+ /// 挂起命令输入对话框(插入滚动区,模态路由生效;kind — Text 明文回显 /
+ /// Password 密文遮蔽回显,默认 Text 兼容既有 prompt 调用)。
+ public void BeginCommandPrompt(string message, InputRequestKind kind = InputRequestKind.Text)
{
- _commandDialog.BeginPrompt(message);
+ _commandDialog.BeginPrompt(message, kind);
AddMessage(new CommandDialogItem(_commandDialog));
}
@@ -710,7 +723,9 @@ internal void HandleRawKeyPress(KeyPress key)
/// 按键分派 — 权限对话框模态优先(数字/Esc/上下/Enter 路由对话框)→ 模型选择器模态
/// (M7-6:Esc/上下/Enter 路由选择器)→ 搜索模态(Ctrl+R 反向搜索)→ 消息光标导航模态
- /// (交互层 Step1:光标激活时 ↑↓/kj/Shift 跳转/Enter 展开/Esc 两级退出)→ 补全激活专属键
+ /// (交互层 Step1:光标激活时 ↑↓/kj/Shift 跳转/Enter 展开/Esc 两级退出)→ 后台任务面板
+ /// 选中导航模态(M8-8 登记遗留:选中态激活时 ↑↓/kj 移动/Enter 查看/x 停止/Esc 退出,
+ /// 其余键吞掉 — 与光标导航互斥且后于其分派)→ 补全激活专属键
/// (Up/Down 导航/Tab 接受/Enter 执行/Esc 关闭,基准 Autocomplete 上下文优先于 Chat)
/// → transcript 全屏模态(S6 收敛:Controller.IsActive 时整屏独占 — 激活时光标态不可达
/// (进入被建议后分派拦截),j/k 归 transcript less 键位)
@@ -748,6 +763,16 @@ private void HandleKeyPress(KeyPress key)
return;
}
+ // M8-8 登记遗留 — 后台任务面板选中导航模态(对齐 _cursorNav 轻量模态形态 — 纯状态机
+ // 不新增对话框项):置于光标导航之后(两模态互斥,光标导航优先 — 任务选中态激活时
+ // Shift+Up 等键在本分支被吞,不会误入光标导航)、补全之前(选中态吞全部字符键 —
+ // 模态语义,字符不进输入框不触发补全)
+ if (_view.BackgroundTasks.IsSelectionActive)
+ {
+ HandleBackgroundTaskSelectionKey(key);
+ return;
+ }
+
if (HandleSuggestionKey(key))
{
return;
@@ -779,6 +804,19 @@ private void HandleKeyPress(KeyPress key)
return;
}
+ // M8-8 登记遗留 — Shift+Down 进入后台任务面板选中态(基准 useBackgroundTaskNavigation.ts:181-189:
+ // 有后台任务时进入导航;与下方 Shift+Up 进消息光标导航对称)。门槛 = 空闲空输入 + 面板有
+ // running 条目(忙时/打字中不进入 — 回合中面板仍渲染但不可交互;无 running 落入下方
+ // 历史导航;不需停止委托 — 导航与查看无外部依赖,x 停止在选中态内降级无操作)
+ if (key is { Kind: KeyKind.DownArrow, Shift: true, Ctrl: false, Meta: false }
+ && !IsBusy
+ && _input.Text.Length == 0
+ && _view.BackgroundTasks.HasRunning)
+ {
+ BeginBackgroundTaskSelection();
+ return;
+ }
+
if (key is { Kind: KeyKind.UpArrow, Shift: true, Ctrl: false, Meta: false })
{
BeginCursorNavigation();
@@ -1255,6 +1293,70 @@ private void StopRunningBackgroundTask()
return null;
}
+ ///
+ /// 后台任务面板选中导航模态按键(M8-8 登记遗留 — 基准 BackgroundTasksDialog.tsx 导航态 +
+ /// useBackgroundTaskNavigation.ts:181-189,对齐 HandleCursorNavKey 轻量模态形态):
+ /// ↑/↓/k/j 相邻 running 条目移动(MoveSelection 跳终态 + 窗口跟随)、Enter 查看选中任务
+ /// (首版 = ToolResultItem 消息四元组 — 全屏详情对话框留后续)、x 停止选中(复用
+ /// StopRunningBackgroundTask — SelectedEntry 终态守卫回退首个 running;选中态为用户
+ /// 主动保持的模态,忙时不强制退出,后台任务与前台回合独立可停)、Esc 退出选中态
+ /// (ClearSelection 归 -1 + 窗口归 0);其余按键全部吞掉(模态 — 字符不进输入缓冲)。
+ ///
+ private void HandleBackgroundTaskSelectionKey(KeyPress key)
+ {
+ if (key.Kind == KeyKind.Escape)
+ {
+ _view.BackgroundTasks.ClearSelection();
+ return;
+ }
+
+ if (key.Kind == KeyKind.Return)
+ {
+ ShowSelectedBackgroundTask();
+ return;
+ }
+
+ switch (key.Kind)
+ {
+ case KeyKind.UpArrow:
+ _view.BackgroundTasks.MoveSelection(-1);
+ return;
+ case KeyKind.DownArrow:
+ _view.BackgroundTasks.MoveSelection(1);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "k":
+ _view.BackgroundTasks.MoveSelection(-1);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "j":
+ _view.BackgroundTasks.MoveSelection(1);
+ return;
+ case KeyKind.Char when !key.Ctrl && !key.Meta && key.Input is "x":
+ StopRunningBackgroundTask();
+ return;
+ default:
+ // 模态:其余键吞掉(不进输入缓冲 — 选中态激活期间打字暂停)
+ return;
+ }
+ }
+
+ /// 进入后台任务面板选中态(Shift+Down — MoveSelection(+1) 无选中取首个 running 条目;
+ /// 键位门槛已由分派链进入点保证 HasRunning,此处必命中)。
+ private void BeginBackgroundTaskSelection() => _view.BackgroundTasks.MoveSelection(1);
+
+ /// Enter 查看选中任务详情(首版 = ToolResultItem 消息显示 kind/taskId/description/state
+ /// 四元组,协议 state 原串直显 — 与面板汉化文案对照无歧义;查看后保持选中态支持连续操作;
+ /// 全屏详情对话框留后续登记)。无选中/索引越界防御 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}"));
+ }
+ }
+
/// 帮助面板模态按键 — Esc 或 '?' 关闭(对齐基准 help menu 双关闭键);
/// 其余键吞掉(模态 — 不进输入缓冲,面板期间输入暂停)。
private void HandleHelpKey(KeyPress key)
diff --git a/native_tui/src/NativeTui.Core/repl/permissions/InputRequestCoordinator.cs b/native_tui/src/NativeTui.Core/repl/permissions/InputRequestCoordinator.cs
index a98e1c1dc..d64f80f5b 100644
--- a/native_tui/src/NativeTui.Core/repl/permissions/InputRequestCoordinator.cs
+++ b/native_tui/src/NativeTui.Core/repl/permissions/InputRequestCoordinator.cs
@@ -2,6 +2,8 @@ namespace NativeTui.Core.Repl;
///
/// 输入请求种类(S8)— 命令交互回调(prompt/readPassword)的模态输入语义。
+/// kind 唯一数据源(TASK065 F2b — CommandDialogCoordinator.BeginPrompt 消费,
+/// Password 形态渲染走 PasswordMask 遮蔽)。
///
public enum InputRequestKind
{
diff --git a/native_tui/src/NativeTui.Core/repl/permissions/PasswordMask.cs b/native_tui/src/NativeTui.Core/repl/permissions/PasswordMask.cs
index a730730d9..4913c12c6 100644
--- a/native_tui/src/NativeTui.Core/repl/permissions/PasswordMask.cs
+++ b/native_tui/src/NativeTui.Core/repl/permissions/PasswordMask.cs
@@ -2,7 +2,8 @@ namespace NativeTui.Core.Repl;
///
/// 密码遮蔽(S8)— readPassword 输入的等宽 * 遮蔽纯函数(数据层):
-/// 渲染层消费方为输入框(PromptInputBox S7 域 — 接线未决,届时输入回显走本函数替换原文)。
+/// 渲染层消费方为命令密文输入对话框(CommandDialogItem — TASK065 F2b 已接线,
+/// 密文形态回显走本函数替换原文)。
/// 长度 1:1 保持(光标定位/退格语义与普通输入一致 — 渲染层无需特判宽度)。
///
public static class PasswordMask
diff --git a/native_tui/tests/NativeTui.Tests/bridge/BridgeClientTests.cs b/native_tui/tests/NativeTui.Tests/bridge/BridgeClientTests.cs
new file mode 100644
index 000000000..230f27994
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/bridge/BridgeClientTests.cs
@@ -0,0 +1,59 @@
+namespace NativeTui.Tests.Bridge;
+
+///
+/// bridge 客户端停止接线测试(M8-8 后续)— BridgeClient.StopBackgroundTask 写 stdin
+/// stop_task 请求(对齐 Submit/Interrupt 的 BridgeProtocol.WriteRequest 发送形态;
+/// 即发即返不等回执 — cancelled 终态事件由泵回推)+ BridgeEngine 适配器 IReplEngine
+/// 接口多态转发(bridge 模式 x 停止的 ReplShell 消费面)。
+/// internal 构造注入 StringWriter/StringReader(无子进程 — 协议行单测,真进程级走 Demo --bridge-selftest)。
+///
+public sealed class BridgeClientTests
+{
+ /// 构造测试客户端(stdin 捕获到 StringWriter,stdout 空流 — 泵立即 Eof 收尾)。
+ private static (BridgeClient Client, StringWriter Stdin) CreateClient()
+ {
+ var stdin = new StringWriter();
+ var client = new BridgeClient(stdin, new StringReader(string.Empty));
+ return (client, stdin);
+ }
+
+ /// StopBackgroundTask — 写一行 stop_task JSON(kind/taskId camelCase 逐字段)。
+ [Fact]
+ public void StopBackgroundTask_WritesStopTaskRequestLine()
+ {
+ var (client, stdin) = CreateClient();
+
+ client.StopBackgroundTask("fork", "fork-1");
+
+ Assert.Equal("{\"type\":\"stop_task\",\"kind\":\"fork\",\"taskId\":\"fork-1\"}\n", stdin.ToString());
+ }
+
+ /// StopBackgroundTask 第二发 — teammate 形态独立成行(×2 kind/taskId 序列化正确性 +
+ /// 多次停止请求不互相覆盖,newline 分格协议语义)。
+ [Fact]
+ public void StopBackgroundTask_SecondRequest_AppendsSeparateLine()
+ {
+ var (client, stdin) = CreateClient();
+
+ client.StopBackgroundTask("fork", "fork-1");
+ client.StopBackgroundTask("teammate", "teammate-2");
+
+ Assert.Equal(
+ "{\"type\":\"stop_task\",\"kind\":\"fork\",\"taskId\":\"fork-1\"}\n{\"type\":\"stop_task\",\"kind\":\"teammate\",\"taskId\":\"teammate-2\"}\n",
+ stdin.ToString());
+ }
+
+ /// BridgeEngine 适配器 — IReplEngine.StopBackgroundTask 接口多态调用经转发写子进程 stdin
+ /// (bridge 模式 ReplShell 的 x 停止委托接线面:engine.StopBackgroundTask → client → 协议行)。
+ [Fact]
+ public void BridgeEngine_StopBackgroundTask_ForwardsToClientStdin()
+ {
+ var stdin = new StringWriter();
+ using var engine = new BridgeEngine(new BridgeClient(stdin, new StringReader(string.Empty)));
+ var replEngine = (IReplEngine)engine;
+
+ replEngine.StopBackgroundTask("teammate", "tm-9");
+
+ Assert.Equal("{\"type\":\"stop_task\",\"kind\":\"teammate\",\"taskId\":\"tm-9\"}\n", stdin.ToString());
+ }
+}
diff --git a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs
index 60189b44c..bcd657225 100644
--- a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs
@@ -401,4 +401,79 @@ public void ReplView_MeasureContentHeight_IncludesBackgroundPanelHeight()
// 消息 2 + 间隔 1 + 输入区 3 + 后台面板 1 + footer 1 = 8
Assert.Equal(8, view.MeasureContentHeight(cols: 40));
}
+
+ /// 选中行 ❯ 前缀(suggestion 色)替换 ● 状态点 + 正文 suggestion 色 + 状态段 dim 保持
+ /// (对齐 ModelPicker.RenderModelListRow 焦点形态);未选中行保持 ● 状态色点 + 默认正文。
+ [Fact]
+ public void Render_SelectedEntry_PointerPrefixAndSuggestionColor()
+ {
+ using var screen = new Screen(cols: 40, rows: 2);
+ var panel = new BackgroundTaskPanel();
+ panel.Upsert("fork", "f-1", "任务1", "completed");
+ panel.Upsert("fork", "f-2", "任务2", "running");
+ panel.MoveSelection(1);
+
+ panel.Render(screen, new Rect(0, 0, 40, 2), Theme.Dark);
+
+ // 未选中行(f-1 终态)保持 ● 状态色点前缀
+ Assert.Equal("● fork: 任务1 · 已完成", RowText.Of(screen, 0, 0, 40).TrimEnd());
+ // 选中行(f-2)❯ 前缀替换 ● 状态点 — 状态信息由行尾状态段保留
+ Assert.Equal("❯ fork: 任务2 · 运行中", RowText.Of(screen, 1, 0, 40).TrimEnd());
+ var selection = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Suggestion });
+ Assert.Equal(selection, screen[1, 0].StyleId);
+ // 选中行状态段(" · 运行中")dim 保持:❯ 1 列 + 空格 1 + "fork: " 6 + "任务2" 5 → col 13 起
+ var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true });
+ Assert.Equal(dim, screen[1, 13].StyleId);
+ }
+
+ /// 选中行超出窗口 — 滚动窗口跟随保证选中行可见(对齐 ModelPicker.MoveFocus 窗口跟随先例)。
+ [Fact]
+ public void Render_SelectionBeyondWindow_ScrollsWindowToKeepSelectionVisible()
+ {
+ using var screen = new Screen(cols: 40, rows: 11);
+ var panel = new BackgroundTaskPanel();
+ for (var i = 1; i <= 12; i++)
+ {
+ panel.Upsert("fork", $"f-{i}", $"任务{i}", "running");
+ }
+
+ for (var i = 0; i < 12; i++)
+ {
+ panel.MoveSelection(1);
+ }
+
+ Assert.Equal(11, panel.SelectedIndex);
+ panel.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark);
+
+ // 窗口跟随:选中第 12 条(索引 11)→ WindowStart=2,首行任务3、末可见行任务12(选中 ❯)
+ Assert.Equal(2, panel.WindowStart);
+ Assert.Equal("● fork: 任务3 · 运行中", RowText.Of(screen, 0, 0, 40).TrimEnd());
+ Assert.Equal("❯ fork: 任务12 · 运行中", RowText.Of(screen, 9, 0, 40).TrimEnd());
+ Assert.Equal("+2 more", RowText.Of(screen, 10, 0, 40).TrimEnd());
+ }
+
+ /// ClearSelection — 选中归 -1(IsSelectionActive=false)+ 滚动窗口归 0(回默认视图)。
+ [Fact]
+ public void ClearSelection_ResetsIndexAndWindow()
+ {
+ var panel = new BackgroundTaskPanel();
+ for (var i = 1; i <= 12; i++)
+ {
+ panel.Upsert("fork", $"f-{i}", $"任务{i}", "running");
+ }
+
+ for (var i = 0; i < 12; i++)
+ {
+ panel.MoveSelection(1);
+ }
+
+ Assert.True(panel.IsSelectionActive);
+ Assert.True(panel.WindowStart > 0);
+
+ panel.ClearSelection();
+
+ Assert.False(panel.IsSelectionActive);
+ Assert.Equal(-1, panel.SelectedIndex);
+ Assert.Equal(0, panel.WindowStart);
+ }
}
diff --git a/native_tui/tests/NativeTui.Tests/repl/CommandDialogTests.cs b/native_tui/tests/NativeTui.Tests/repl/CommandDialogTests.cs
index 852f4f24d..6f4c40447 100644
--- a/native_tui/tests/NativeTui.Tests/repl/CommandDialogTests.cs
+++ b/native_tui/tests/NativeTui.Tests/repl/CommandDialogTests.cs
@@ -337,6 +337,109 @@ public async Task ConfirmCommand_BackgroundThreadBridge_UserAccepts_ReturnsTrue(
Assert.True(await bridge);
}
+ // ---- TASK065 F2b readPassword 密文模态 ----
+
+ /// 密文挂起 — IsPassword 置位(InputRequestKind.Password)+ 渲染回显等宽遮蔽(***),
+ /// 数据层保持原文(明文仅存协调器)。
+ [Fact]
+ public void PasswordDialog_Pending_RendersMaskedEcho()
+ {
+ var coordinator = new CommandDialogCoordinator();
+ coordinator.BeginPrompt("请输入密码:", InputRequestKind.Password);
+ coordinator.AppendPromptInput('a');
+ coordinator.AppendPromptInput('b');
+ coordinator.AppendPromptInput('c');
+
+ Assert.True(coordinator.IsPassword);
+ Assert.Equal("abc", coordinator.PromptInput);
+ using var screen = new Screen(cols: 40, rows: 9);
+ new CommandDialogItem(coordinator).Render(screen, new Rect(0, 0, 40, 9), Theme.Dark);
+
+ // row6 输入行 — 回显遮蔽(3 缩进 + ❯ + 空格 + ***)
+ Assert.Equal(" ❯ ***", RowText.Of(screen, 6, 0, 8));
+ }
+
+ /// 明文形态回归 — kind 默认 Text 回显原文(既有 prompt 行为零变化)。
+ [Fact]
+ public void PromptDialog_TextKind_RendersPlainEcho()
+ {
+ var coordinator = new CommandDialogCoordinator();
+ coordinator.BeginPrompt("输入");
+ coordinator.AppendPromptInput('o');
+ coordinator.AppendPromptInput('k');
+
+ Assert.False(coordinator.IsPassword);
+ using var screen = new Screen(cols: 40, rows: 9);
+ new CommandDialogItem(coordinator).Render(screen, new Rect(0, 0, 40, 9), Theme.Dark);
+
+ Assert.Equal(" ❯ ok", RowText.Of(screen, 6, 0, 7));
+ }
+
+ /// 密文退格 — 尾字符删除(掩码长度随原文同步缩短后提交)。
+ [Fact]
+ public async Task PasswordDialog_Backspace_EditsSecretBeforeSubmit()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginCommandPrompt("密码", InputRequestKind.Password);
+ var task = shell.CommandDialog.WaitPrompt();
+
+ Press(shell, ConsoleKey.A, 'a');
+ Press(shell, ConsoleKey.B, 'b');
+ Press(shell, ConsoleKey.Backspace);
+ Press(shell, ConsoleKey.Enter);
+
+ Assert.Equal("a", await task);
+ }
+
+ /// 密文 Enter — 提交原文(非掩码串,掩码仅渲染层)。
+ [Fact]
+ public async Task PasswordDialog_Enter_SubmitsPlainTextNotMask()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginCommandPrompt("密码", InputRequestKind.Password);
+ var task = shell.CommandDialog.WaitPrompt();
+
+ Press(shell, ConsoleKey.S, 's');
+ Press(shell, ConsoleKey.E, 'e');
+ Press(shell, ConsoleKey.C, 'c');
+ Press(shell, ConsoleKey.Enter);
+
+ Assert.Equal("sec", await task);
+ }
+
+ /// 密文 Esc — 取消完成为 null(对齐 prompt 可空取消语义)。
+ [Fact]
+ public async Task PasswordDialog_Escape_CompletesNull()
+ {
+ var (shell, _) = CreateShell();
+ shell.BeginCommandPrompt("密码", InputRequestKind.Password);
+ var task = shell.CommandDialog.WaitPrompt();
+
+ Press(shell, ConsoleKey.A, 'a');
+ Press(shell, ConsoleKey.Escape);
+
+ Assert.True(task.IsCompleted);
+ Assert.Null(await task);
+ }
+
+ /// 同步桥接线契约(TASK065 F2b)— ReadPasswordCommand 后台线程阻塞等密文模态,
+ /// 用户输入 + Enter 后释放返回原文(对齐 ConfirmCommand 同步桥先例 — 宿主 BindInteractions
+ /// readPassword 接线形态)。
+ [Fact]
+ public async Task ReadPasswordCommand_BackgroundThreadBridge_ReturnsPlainText()
+ {
+ var (shell, _) = CreateShell();
+ var bridge = Task.Run(() => shell.ReadPasswordCommand("请输入 API key:"));
+
+ Assert.True(SpinWaitUntil(() => shell.CommandDialog.IsPending && shell.CommandDialog.IsPassword), "桥线程应挂起密文对话框");
+ Press(shell, ConsoleKey.K, 'k');
+ Press(shell, ConsoleKey.Enter);
+
+ var winner = await Task.WhenAny(bridge, Task.Delay(TimeSpan.FromSeconds(5)));
+ Assert.Same(bridge, winner);
+ Assert.Equal("k", await bridge);
+ }
+
/// 自旋等待谓词成立(2s 预算 — 单进程内存操作,确定性足够)。
private static bool SpinWaitUntil(Func condition)
{
diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs
new file mode 100644
index 000000000..ba836061e
--- /dev/null
+++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellPanelNavigationTests.cs
@@ -0,0 +1,165 @@
+namespace NativeTui.Tests.Repl;
+
+///
+/// ReplShell 后台任务面板选中导航集成测试(M8-8 登记遗留 — 基准 useBackgroundTaskNavigation.ts:181-189 +
+/// BackgroundTasksDialog.tsx 导航态):Shift+Down 进入选中态(空闲空输入 + 有 running 门槛 — 无 running /
+/// 忙时不进入)、选中态 ↑↓ 移动(跳终态 + 窗口跟随)、Enter 查看选中任务详情(ToolResultItem 四元组消息)、
+/// x 停止选中任务(注入停止委托捕获)、Esc 退出选中态、模态吞键(字符不进输入框)。
+/// 构造形态对齐 ReplShellIntegrationStopTaskTests(面板条目由测试直接 Upsert 种入)。
+///
+public sealed class ReplShellPanelNavigationTests
+{
+ /// 构造测试 shell(注入记录型停止委托 — 面板条目由测试直接 Upsert 种入)。
+ private static (ReplShell Shell, FakeReplEngine Engine, List<(string Kind, string TaskId)> Stops) CreateShell()
+ {
+ var engine = new FakeReplEngine();
+ var stops = new List<(string Kind, string TaskId)>();
+ var shell = new ReplShell(
+ engine,
+ new BannerItem("1.0.0", "m", "b", "/tmp"),
+ new StringWriter(),
+ sizeSource: new FakeTerminalSizeSource(80, 24),
+ onStopTask: (kind, taskId) => stops.Add((kind, taskId)));
+ shell.SeedContent();
+ return (shell, engine, stops);
+ }
+
+ /// 功能键注入(箭头/Enter/Esc — keyChar 形态对齐 MessageCursorTests 先例)。
+ private static void Press(ReplShell shell, ConsoleKey key, bool shift = false) =>
+ shell.HandleKey(new ConsoleKeyInfo(key is ConsoleKey.Enter ? '\r' : '\0', key, shift, alt: false, control: false));
+
+ /// 字符键注入('a'/'x' 等 — 形态对齐 ReplShellIntegrationStopTaskTests.Char)。
+ private static void Char(ReplShell shell, char c) =>
+ shell.HandleKey(new ConsoleKeyInfo(c, ConsoleKey.A, shift: false, alt: false, control: false));
+
+ /// 空闲空输入 + 面板有 running 条目 — Shift+Down 进入选中态(首个 running 条目,跳过终态)。
+ [Fact]
+ public void Enter_IdleWithRunningTasks_ShiftDownSelectsFirstRunning()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("fork", "f-1", "任务1", "completed");
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅", "running");
+ shell.View.BackgroundTasks.Upsert("fork", "f-2", "任务2", "running");
+
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+
+ Assert.True(shell.View.BackgroundTasks.IsSelectionActive);
+ // 跳过终态 f-1 → 选中首个 running tm-1
+ Assert.Equal(1, shell.View.BackgroundTasks.SelectedIndex);
+ Assert.Equal(("teammate", "tm-1"), shell.View.BackgroundTasks.SelectedEntry);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// 面板无 running 条目 — Shift+Down 不进入选中态(落入历史导航 — 无历史 no-op)。
+ [Fact]
+ public void Enter_NoRunningTasks_ShiftDownDoesNotEnterSelection()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("fork", "f-1", "任务1", "completed");
+
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+
+ Assert.False(shell.View.BackgroundTasks.IsSelectionActive);
+ Assert.Equal(-1, shell.View.BackgroundTasks.SelectedIndex);
+ }
+
+ /// 忙时 — 空闲态门槛拦截(回合中面板仍渲染但不可交互,Shift+Down 不进入选中态)。
+ [Fact]
+ public void Enter_WhileBusy_ShiftDownDoesNotEnterSelection()
+ {
+ var (shell, engine, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅", "running");
+ engine.Events.Enqueue(new BridgeEvent.TurnStarted());
+ shell.DrainEngine();
+
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+
+ Assert.False(shell.View.BackgroundTasks.IsSelectionActive);
+ }
+
+ /// 选中态 ↑/↓ 移动选中(MoveSelection 跳终态 + 到头不动不环绕)。
+ [Fact]
+ public void Selection_ArrowKeys_MoveSelection()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("fork", "f-1", "任务1", "running");
+ shell.View.BackgroundTasks.Upsert("fork", "f-2", "任务2", "completed");
+ shell.View.BackgroundTasks.Upsert("fork", "f-3", "任务3", "running");
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+ Assert.Equal(0, shell.View.BackgroundTasks.SelectedIndex);
+
+ Press(shell, ConsoleKey.DownArrow);
+ // f-2 终态跳过 → f-3
+ Assert.Equal(2, shell.View.BackgroundTasks.SelectedIndex);
+ Press(shell, ConsoleKey.UpArrow);
+ Assert.Equal(0, shell.View.BackgroundTasks.SelectedIndex);
+ Press(shell, ConsoleKey.UpArrow);
+ // 到头不动(不环绕)
+ Assert.Equal(0, shell.View.BackgroundTasks.SelectedIndex);
+ }
+
+ /// 选中态 Enter — 查看选中任务详情(ToolResultItem 四元组消息上屏 + 保持选中态)。
+ [Fact]
+ public void Selection_EnterKey_ShowsTaskDetailMessage()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅PR", "running");
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+
+ 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);
+ // 查看后保持选中态(连续查看/停止支持)
+ Assert.True(shell.View.BackgroundTasks.IsSelectionActive);
+ }
+
+ /// 选中态 x — 停止选中任务(注入停止委托捕获 (kind,taskId) + 字符不进输入框)。
+ [Fact]
+ public void Selection_XKey_StopsSelectedTask()
+ {
+ var (shell, _, stops) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("fork", "f-1", "任务1", "completed");
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅", "running");
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+ Assert.Equal(("teammate", "tm-1"), shell.View.BackgroundTasks.SelectedEntry);
+
+ Char(shell, 'x');
+
+ Assert.Equal([("teammate", "tm-1")], stops);
+ Assert.Equal(string.Empty, shell.Input.Text);
+ }
+
+ /// 选中态 Esc — 退出选中态(SelectedIndex 归 -1)。
+ [Fact]
+ public void Selection_EscapeKey_ExitsSelection()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅", "running");
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+ Assert.True(shell.View.BackgroundTasks.IsSelectionActive);
+
+ Press(shell, ConsoleKey.Escape);
+
+ Assert.False(shell.View.BackgroundTasks.IsSelectionActive);
+ Assert.Equal(-1, shell.View.BackgroundTasks.SelectedIndex);
+ }
+
+ /// 选中态模态吞键 — 字符不进输入框(基准全屏导航态 PromptInput 卸载语义)。
+ [Fact]
+ public void Selection_SwallowsCharacterKeys()
+ {
+ var (shell, _, _) = CreateShell();
+ shell.View.BackgroundTasks.Upsert("teammate", "tm-1", "审阅", "running");
+ Press(shell, ConsoleKey.DownArrow, shift: true);
+
+ Char(shell, 'a');
+ Char(shell, 'b');
+
+ Assert.Equal(string.Empty, shell.Input.Text);
+ Assert.True(shell.View.BackgroundTasks.IsSelectionActive);
+ }
+}