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