From edf78efe097185e503962a72e9d9074050fd178d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 01:55:53 +0800 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20DSG042=20TUI=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=B3=B5=20stdin=20=E5=8E=9F=E5=A7=8B=E6=B5=81=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=AE=A1=E5=88=92=20|=20=E5=86=B3=E7=AD=96:=20?= =?UTF-8?q?=E5=8F=8C=E9=80=9A=E9=81=93=E5=BC=80=E5=85=B3=E6=B8=90=E8=BF=9B?= =?UTF-8?q?=E8=BF=81=E7=A7=BB(IInputPump=20=E6=8A=BD=E8=B1=A1+ReadKeyPump?= =?UTF-8?q?=20=E6=94=B6=E7=BC=96=E7=8E=B0=E5=BD=B9+RawStdinPump=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=AF=BB=E7=BA=BF=E7=A8=8B=E5=85=A8=E9=93=BE?= =?UTF-8?q?,--raw-input=20=E7=81=B0=E5=BA=A6=E5=BC=80=E5=85=B3,P3=20?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=90=8E=E5=88=87=E9=BB=98=E8=AE=A4=E9=80=80?= =?UTF-8?q?=E5=BD=B9=20TryCollectPaste=20=E7=8A=B6=E6=80=81=E6=9C=BA)?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E8=A3=B8=E5=88=87=20|=20=E5=8E=9F=E5=9B=A0:?= =?UTF-8?q?=20ReadKey=20=E4=B8=8E=E5=8E=9F=E5=A7=8B=E6=B5=81=E4=BA=92?= =?UTF-8?q?=E6=96=A5=E5=85=A8=E6=9C=89=E6=88=96=E5=85=A8=E6=97=A0,?= =?UTF-8?q?=E7=8E=B0=E5=BD=B9=2014=20=E9=94=AE=E4=BD=8D=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E4=B8=8D=E5=85=81=E8=AE=B8=E7=9B=B4=E5=88=87?= =?UTF-8?q?;KeyPressParser=20=E8=A7=A3=E6=9E=90=E5=99=A8=E5=B7=B2=E5=AE=8C?= =?UTF-8?q?=E5=A4=87=E4=BB=85=E7=BC=BA=E7=94=9F=E4=BA=A7=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=20|=20=E6=9B=BF=E4=BB=A3=E6=96=B9=E6=A1=88:=20=E8=A3=B8?= =?UTF-8?q?=E5=88=87=E5=8E=9F=E5=A7=8B=E6=B5=81(=E6=97=A0=E5=9B=9E?= =?UTF-8?q?=E9=80=80,Windows=20conhost=20=E8=A1=8C=E4=B8=BA=E6=9C=AA?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B);=E6=B0=B8=E4=B9=85=E5=8F=8C=E8=BD=A8(?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E4=B8=A4=E5=A5=97=E8=BE=93=E5=85=A5=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=BC=82=E7=A7=BB)=E2=80=94=E2=80=94=E5=9D=87?= =?UTF-8?q?=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...G042-tui-raw-stdin-input-pump-migration.md | 85 +++++++++++++++++++ ...45\347\272\277\346\224\266\345\260\276.md" | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md diff --git a/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md b/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md new file mode 100644 index 000000000..0dd8a6040 --- /dev/null +++ b/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md @@ -0,0 +1,85 @@ +# DSG042 — TUI 输入泵 stdin 原始流迁移计划(S11 遗留#2) + +> 状态:proposed(2026-10-23) +> 关联:TASK065 S11(ReadKey 通道 paste 重组状态机已可用 — 本计划是其退役路径)、ADR 0140(TUI 自渲染像素对齐)、ADR 0141(bridge 协议) +> 前置调研:2026-10-23 输入链现状实证(DrainInput/KeyPressParser/ConsoleKeyAdapter 逐文件) + +## 1. 问题 + +native_tui 生产主循环的键盘输入走 `Console.ReadKey` + `ConsoleKeyAdapter`(Key 枚举归一路径),而完整 VT 解析器 `KeyPressParser` 仅被 Demo/测试消费——**为原始流设计的解析器从未在生产接线**。ReadKey 路径的先天限制: + +| # | 限制 | 根因 | 实际影响 | +|---|------|------|---------| +| ① | VT 序列不可逆丢失 | ReadKey 归一 Key 枚举 + `KeyChar='\0'`(方向键等) | 修饰组合键不可区分(ctrl+shift+arrow 归并为 arrow);kitty keyboard protocol 无法启用(增强键位报告需要原始流回写查询) | +| ② | bracketed paste 需重组状态机 | ReadKey 把 ESC[200~/201~ 拆散为逐 KeyInfo | S11 落地的 `TryCollectPaste` 状态机是补丁非根治(注释自认"ReadKey 会拆散 VT 序列") | +| ③ | F 键/PgUp/PgDn 部分形态缺失 | ConsoleKey 枚举覆盖不全(修饰组合的 F5-F12 / shift+PgDn 等) | transcript 模式部分 less 键位(S5 控制器已支持但 ReadKey 侧喂不出) | +| ④ | 与基准架构不对齐 | 基准 ink = stdin raw 流 + readline 解析器 | 长期键位能力面与 Claude Code 漂移 | + +## 2. 现状事实(调研实证) + +**输入链现状**: +``` +生产主循环(ReplShell.Run → 帧拍 50ms DrainInput) + └ Console.KeyAvailable / Console.ReadKey(intercept) + ├ ConsoleKeyAdapter.Of(KeyInfo) → KeyPress(Key 枚举归一 — 限制①③源头) + ├ TryCollectPaste 状态机(S11 — ESC 后 KeyAvailable 连续判定重组 200~/201~ — 限制②补丁) + └ _pendingKeys 回退队列(误撞组合塞回防丢键) +Demo 路径(NativeTui.Demo Program.cs) + └ Console.OpenStandardInput 原始流 → KeyPressParser.TryParseNext → KeyPress ✅ 全链已有 +``` + +**KeyPressParser 能力面**(InputPasteParserTests/KeyPressParserTests 锁定):VT CSI/SS3 序列、修饰组合(Ctrl/Meta/Shift 位)、方向键/Home/End/PgUp/PgDn/F 键、`\x1f` 归一 Ctrl+"_"、bracketed paste 整块(200~/201~)、跨 chunk 攒齐(无终止标记不消耗)——**解析器完备,缺的只是生产接线**。 + +**互斥事实**:`Console.ReadKey` 与 `Console.OpenStandardInput` 不可混用(ReadKey 独占控制台输入缓冲;切换是全有或全无)。 + +**阻塞事实**:Windows stdin 流同步 `Read` 阻塞无 `KeyAvailable` 等价物——原始流路径必须引入后台读线程(或 PeekNamedPipe P/Invoke),不能在主循环帧拍内同步轮询。 + +## 3. 方案 — 双通道开关渐进迁移(默认保稳) + +**原则**:ReadKey 通道是现役稳定路径(14 键位接线全验证),原始流通道以配置开关并行落地、灰度验证后切默认——全有或全无的互斥切换不允许裸切。 + +### 抽象层(新) + +``` +IInputPump(native_tui/src/NativeTui.Core/repl/input/) + ├ Drain(KeyPress 队列) : bool — 非阻塞排空已解析键(主循环帧拍消费) + └ Init/Dispose(原始流启停 + raw 模式对称) +ReadKeyPump(现役逻辑收编:DrainInput 的 ReadKey 循环 + paste 重组状态机整体搬入) +RawStdinPump(新:后台读线程 + 字节流 UTF-8 解码 + KeyPressParser 全链 → Channel) +``` + +主循环 `DrainInput` 改为消费 `_inputPump.Drain()`(分派链 HandleRawKeyPress 之后零改动——KeyPress 形态两通道一致)。 + +### 分批(三 Phase,每批可独立交付回滚) + +| Phase | 内容 | 验收 | 默认通道 | +|-------|------|------|---------| +| **P1 抽象收编** | IInputPump + ReadKeyPump(现役逻辑搬入零语义变化);ReplShell 构造注入(默认 ReadKeyPump) | 全量 1667 测试零破坏;帧拍不变 | ReadKey | +| **P2 RawStdinPump 落地** | 后台读线程(Thread + Console.OpenStandardInput 块读)→ UTF-8 解码器(宽字符分块容忍)→ KeyPressParser → Channel;`--raw-input` 配置开关(Host 层);paste 重组状态机在 RawStdinPump 下天然不需要(整块直出) | 键位全表 E2E 探针(`--selftest-input` 新增:逐键注入断言 KeyPress 形态);双通道切换幂等 | ReadKey(raw-input 显式开启) | +| **P3 切默认退役** | 双通道灰度全键位对齐验证(含 Windows Terminal/conhost 双终端实测)→ 默认切 RawStdinPump → TryCollectPaste 状态机 + ConsoleKeyAdapter 归一路径归档 `.xxx/` | 键位兼容矩阵(ReadKey 通道 14 键位 vs Raw 通道全集)逐项打勾 | RawStdin | + +### P2 关键设计点(风险预置) + +1. **后台读线程生命周期**:`Thread(IsBackground: true)` + `CancellationToken` 关闭令牌 + Dispose 时 `stdin.CanRead` 防死等(进程退出后台线程自动回收——对齐基准 ink unmount 语义);禁 async(主循环同步线程模型,Task.Run 线程池线程失控) +2. **UTF-8 分块解码**:`Utf8Decoder` 状态机(不完整多字节序列跨 Read 块保留)——KeyPressParser 已有跨 chunk 攒齐先例(InputPasteParserTests 锁定),字节→char 层复用同思路 +3. **raw 模式对称**:RawConsoleMode 现有(Disable/Dispose)不动——ReadKey 与原始流共用同一 raw 形态(Windows 已验证),差异仅在读取层 +4. **resize 事件**:Windows 控制台 resize 走现有 TerminalSizeSource 轮询(50ms 拍内)不受影响——原始流不含 SIGWINCH 语义差异 +5. **kitty protocol(P3 后置)**:原始流就绪后才可能启用(查询序列回写 stdin 需全链 VT);本计划不含,登记 P4 池 + +## 4. 工作量与影响面 + +| 项 | 估算 | +|----|------| +| P1 抽象收编 | 小(搬移+注入,1 次编译验证) | +| P2 RawStdinPump | 中(读线程+解码器+开关+探针,核心难点是 Windows 流阻塞语义) | +| P3 切默认+退役 | 中(灰度矩阵实测 + 状态机/适配器归档) | + +**影响文件**:repl/ReplShell.cs(DrainInput 消费面)、repl/input/(新 IInputPump/两实现)、apps/NativeTui.Host/Program.cs(开关注入);ConsoleKeyAdapter/TryCollectPaste P3 归档。 + +**风险**:Windows conhost(非 Windows Terminal)的原始流行为差异——P2 探针必须双终端实测;失败回退=开关关(ReadKey 通道完整保留)。 + +## 5. 未决(实施时裁决) + +1. P2 探针形态:`--selftest-input` 独立入口 vs 复用 `--selftest-commands` 扩展 +2. RawStdinPump 的 Channel 容量与满策略(背压 vs 丢弃——基准 ink 无界,倾向有界 256 满等待防键风暴丢键) +3. P3 退役 ConsoleKeyAdapter 的时机(kitty protocol 是否先落地再退役——归档后 ReadKey 通道复活成本) diff --git "a/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" "b/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" index a4930e09c..493296b82 100644 --- "a/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" +++ "b/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" @@ -64,7 +64,7 @@ S7-S10 数据层(1549+95+126 测试全绿)的 14 个接线点接入 ReplShel ## 未决问题状态(2026-10-23 更新) 1. **/theme 配置持久化**:✅ 已落地 — ReplShell 增 saveThemeAsync 委托(合法切换 fire-and-forget,异常静默降级会话级),Host 注入 IConfigurationService.SetAsync("theme") 写回 settings.json(与 LoadThemeNameAsync 闭环) -2. **输入泵整体迁移 stdin 原始流**:未做 — ReadKey 通道 paste 重组状态机已可用,完整 VT 链需重写 DrainInput(独立任务) +2. **输入泵整体迁移 stdin 原始流**:已规划 — DSG042(双通道开关渐进迁移:P1 抽象收编 / P2 RawStdinPump+开关 / P3 切默认退役状态机) 3. **Host --selftest-commands 的 "/theme" 探针**:未裁决 — 双通道并存(TUI 本地切换 + 引擎侧命令) 4. **@ 补全目录变更刷新**:✅ 已落地轻量形态 — AtFileSuggester 索引 TTL 30s 惰性重建(假时钟注入可测;显式 Invalidate 通道保留),替代 FileSystemWatcher 零接线成本 From d3ec55e85dd1f2e968a7ef60597ec3798e89329a Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 02:42:59 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20DSG042=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=B3=B5=E8=BF=81=E7=A7=BB=20P1+P2=EF=BC=88IInputPump=20?= =?UTF-8?q?=E6=8A=BD=E8=B1=A1=20+=20RawStdinPump=20=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E6=B5=81=E5=85=A8=E9=93=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 抽象收编(零语义变化): - IInputPump 接口(Drain(dispatch) 非阻塞排空 — KeyPress 形态两通道一致,分派链零改动) - ReadKeyPump:现役 DrainInput 逻辑整体搬入(ReadKey 归一 + bracketed paste 重组状态机 + 误撞塞回缓冲) - ReplShell 消费 _inputPump.Drain(构造注入默认 ReadKeyPump);五方法与 _pendingKeys 字段迁出 P2 RawStdinPump(完整 VT 链): - 后台读线程(IsBackground)阻塞读 Console.OpenStandardInput → UTF-8 增量解码(跨块多字节保留)→ KeyPressParser 逐键解析(不完整前缀攒齐;paste 整块直出 — 重组状态机天然不需要)→ 有界通道 256 - 线程模型:_gate 锁保护 pending+投递(读线程解析/Drain 续投双向触发);投递纯非阻塞(TryWrite 满即停 + span 回退防丢键)全链零同步等待 — 根除同线程 Feed+Drain 自死锁 - Drain 两轮续投(通道腾空后 pending 残留续投,至多两轮防活锁);EOF 不关通道(EOF 不等于 Dispose) - FeedForTest 注入面(解码/解析/通道/Drain 全链可测;真实 stdin 阻塞行为留宿主 exe 验收 ADR 0080) 用户点报的自死锁根因与修复: - 自死锁链:FeedForTest 同线程同步投递 + WriteAsync.GetAwaiter().GetResult() 满等待 + 消费者 Drain 在同线程后续 — 互等永不退出 - 修复:投递非阻塞化(TryWrite 失败回退 span 本键起点不消耗)+ DropWrite 改 Wait(DropWrite 满时静默吞键返回 true 与满即停语义相反 — 300 键收 256 丢 44 实证)+ 测试收集循环 2s 超时熔断(fail-fast 带诊断:已收数/pending 残留 — 两处真 bug 均由熔断诊断定位) 验证: RawStdinPumpTests 13/13(含三倍容量键风暴压力 + 熔断);全量 1721 四连全绿;Host 编译零警告 --- native_tui/src/NativeTui.Core/GlobalUsings.cs | 1 + .../src/NativeTui.Core/repl/ReplShell.cs | 132 +----------- .../NativeTui.Core/repl/input/IInputPump.cs | 16 ++ .../NativeTui.Core/repl/input/RawStdinPump.cs | 193 ++++++++++++++++++ .../NativeTui.Core/repl/input/ReadKeyPump.cs | 145 +++++++++++++ .../repl/input/RawStdinPumpTests.cs | 191 +++++++++++++++++ 6 files changed, 556 insertions(+), 122 deletions(-) create mode 100644 native_tui/src/NativeTui.Core/repl/input/IInputPump.cs create mode 100644 native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs create mode 100644 native_tui/src/NativeTui.Core/repl/input/ReadKeyPump.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/input/RawStdinPumpTests.cs diff --git a/native_tui/src/NativeTui.Core/GlobalUsings.cs b/native_tui/src/NativeTui.Core/GlobalUsings.cs index e62606dad..0752eacf7 100644 --- a/native_tui/src/NativeTui.Core/GlobalUsings.cs +++ b/native_tui/src/NativeTui.Core/GlobalUsings.cs @@ -3,6 +3,7 @@ global using System.Text.Json.Serialization.Metadata; global using System.Text; global using System.Collections.Concurrent; +global using System.Threading.Channels; global using System.Diagnostics; global using NativeTui.Core.Ansi; global using NativeTui.Core.Animation; diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 2c24c4563..4d9ee46e4 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -192,6 +192,9 @@ private void HandleExitCtrlD() /// 种子 effort 提示经 Enqueue 一次性入队保留首屏语义)。 private readonly ReplNotifications _notifications = new(); + /// 键盘输入泵(DSG042 — 通道抽象:ReadKeyPump 现役 / RawStdinPump 原始流灰度)。 + private readonly IInputPump _inputPump; + /// 剪贴板写入委托(TASK059 — Ctrl+C 复制最后 assistant 消息;宿主注入引擎 /// IClipboardService 适配,Core 零依赖不持接口;null = 复制能力降级无操作)。 private readonly Action? _copyToClipboard; @@ -296,7 +299,8 @@ public ReplShell( Action? onStopTask = null, IStreamingMarkdownProjector? streamingProjector = null, Func? saveThemeAsync = null, - Func>? launchExternalEditor = null) + Func>? launchExternalEditor = null, + IInputPump? inputPump = null) { _engine = engine; _banner = banner; @@ -309,6 +313,7 @@ public ReplShell( _sizeSource = sizeSource ?? new ConsoleTerminalSizeSource(); _copyToClipboard = clipboard; _saveThemeAsync = saveThemeAsync; + _inputPump = inputPump ?? new ReadKeyPump(); _onStopTask = onStopTask; _launchExternalEditor = launchExternalEditor; _theme = ThemeSelection.Resolve(themeName); @@ -614,132 +619,15 @@ private void RemoveDialogItem() private void AddMessage(IScrollItem item) => _view.Messages.Insert(_view.Messages.Count - 1, item); - /// 待分派键缓冲(S7 接线 — bracketed paste 重组回退路径:误判序列时已读键塞回防丢)。 - private readonly Queue _pendingKeys = new(); - - /// 排空键盘事件(非阻塞)— ReadKey 归一 KeyPress 后统一分派; - /// S7 接线 — bracketed paste 重组:ReadKey 把 ESC[200~/201~ 拆散为逐键 KeyInfo, - /// ESC 后紧跟连续输入时判定 paste 标记序列并重装为整块 Paste 事件 - ///(孤立 Esc 即时分派不受影响 — paste 序列一次性到达,KeyAvailable 恒连续)。 - private void DrainInput() - { - while (_running && (_pendingKeys.Count > 0 || Console.KeyAvailable)) - { - var info = _pendingKeys.Count > 0 ? _pendingKeys.Dequeue() : Console.ReadKey(intercept: true); - if (info.Key == ConsoleKey.Escape && info.KeyChar == '\u001b' && TryCollectPaste(out var content)) - { - HandleRawKeyPress(ConsoleKeyAdapter.Paste(content)); - continue; - } - - HandleKey(info); - } - } - - /// 尝试收集 bracketed paste 块(S7 接线)— 前提:ESC 键已消费。 - /// ESC 后无后续键 = 孤立 Esc(false,调用方正常分派);后续键非 '[' 或非 "200~" 起始标记 = - /// 误撞组合(false,已读键塞回 pending 两键都走正常分派);起始标记完整后收集内容直到 - /// ESC[201~ 终止标记(true,out 携带粘贴原文);收集期流中断整体放弃吞掉(防泄漏为文本)。 - private bool TryCollectPaste(out string content) - { - content = string.Empty; - if (!Console.KeyAvailable) - { - return false; - } - - var bracket = Console.ReadKey(intercept: true); - if (bracket.KeyChar != '[') - { - _pendingKeys.Enqueue(bracket); - return false; - } - - var consumed = new List { bracket }; - if (!TryMatchPasteStart(consumed)) - { - Requeue(consumed); - return false; - } - - // 内容收集(终止标记未完整到达 = 流中断/误撞,整体放弃吞掉防泄漏为文本;空粘贴同判 false = 无操作) - var body = CollectPasteBody(); - if (body.Length == 0) - { - return false; - } - - content = body; - return true; - } - - /// 匹配 paste 起始标记剩余字符 "200~"(逐字符读,不匹配/流中断返回 false — 已读键入 consumed)。 - private static bool TryMatchPasteStart(List consumed) - { - foreach (var expected in "200~") - { - if (!Console.KeyAvailable) - { - return false; - } - - var info = Console.ReadKey(intercept: true); - consumed.Add(info); - if (info.KeyChar != expected) - { - return false; - } - } - - return true; - } - - /// 收集 paste 内容直到终止标记 ESC[201~ — 内容字符直积(\r\n 原样保留由 - /// PastedContentTracker 清洗归一);遇 ESC 尝试匹配终止标记,失败/流中断整体放弃(吞掉返回空)。 - private static string CollectPasteBody() - { - var buffer = new StringBuilder(); - while (Console.KeyAvailable) - { - var info = Console.ReadKey(intercept: true); - if (info.KeyChar == '\u001b') - { - return TryMatchPasteEnd() ? buffer.ToString() : string.Empty; - } + /// 排空键盘事件(非阻塞)— 输入泵通道(DSG042:ReadKeyPump 现役 / RawStdinPump + /// 原始流灰度)排空已解析键统一分派;泵内归一/paste 重组语义见各实现。 + private void DrainInput() => + _inputPump.Drain(HandleRawKeyPress); - buffer.Append(info.KeyChar); - } - return string.Empty; - } - /// 匹配 paste 终止标记剩余 "[201~"(ESC 已消费)。 - private static bool TryMatchPasteEnd() - { - foreach (var expected in "[201~") - { - if (!Console.KeyAvailable) - { - return false; - } - if (Console.ReadKey(intercept: true).KeyChar != expected) - { - return false; - } - } - - return true; - } - /// 已读键按序塞回待分派缓冲(误判回退 — 键不丢)。 - private void Requeue(List consumed) - { - foreach (var info in consumed) - { - _pendingKeys.Enqueue(info); - } - } /// 按键归一 + 分派(对齐基准 useTextInput.mapKey 优先级顺序 — 测试经 ConsoleKeyInfo 全链注入); /// 文本变化才刷新建议(导航/移动键不重置选中索引;程序化填充跳过 — 历史导航不触发补全)。 diff --git a/native_tui/src/NativeTui.Core/repl/input/IInputPump.cs b/native_tui/src/NativeTui.Core/repl/input/IInputPump.cs new file mode 100644 index 000000000..0c81659b7 --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/input/IInputPump.cs @@ -0,0 +1,16 @@ +namespace NativeTui.Core.Repl; + +/// +/// 输入泵抽象(DSG042)— 键盘事件源的可切换通道(ReadKey 归一路径 / stdin 原始流 VT 全链): +/// 生产主循环帧拍内非阻塞排空已解析键,逐键回调分派(消费方 ReplShell.HandleRawKeyPress)。 +/// KeyPress 形态两通道一致(分派链零改动);ReadKey 与原始流互斥(控制台输入缓冲独占), +/// 通道切换是构造期决策非运行时(对齐 DSG042 双通道开关渐进迁移)。 +/// +public interface IInputPump +{ + /// 非阻塞排空已就绪键(帧拍消费 — 无键立即返回 false)。 + /// 实现保证:分派回调内抛异常不中断排空循环的通道内部状态一致性(异常传播给调用方)。 + /// 逐键分派回调(同步 — 主循环线程)。 + /// 是否分派了至少一键。 + bool Drain(Action dispatch); +} diff --git a/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs b/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs new file mode 100644 index 000000000..1c022404b --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs @@ -0,0 +1,193 @@ +namespace NativeTui.Core.Repl; + +/// +/// stdin 原始流输入泵(DSG042 P2)— 完整 VT 链:后台读线程阻塞读 +/// 字节流 → UTF-8 增量解码(跨块不完整多字节序列保留) +/// → 逐键解析(不完整前缀攒齐重试;bracketed paste 整块直出 +/// — ReadKey 通道的重组状态机在本通道天然不需要)→ 有界通道缓冲。 +/// 主循环帧拍 非阻塞排空。与 互斥 +///(ReadKey 独占控制台输入缓冲)— 构造期二选一,通道切换由宿主开关注入。 +/// 读线程 IsBackground:进程退出自动终止(stdin Read 阻塞不可中断 — 无需优雅停机)。 +/// 线程模型: 锁保护 pending 缓冲与通道投递(读线程解析 / Drain 续投 +/// 双向触发)——投递纯非阻塞(TryWrite 满即停,剩余留 pending 下次触发续投,不丢键不阻塞), +/// 全链零同步等待 → 根除自死锁(同线程 Feed 后 Drain 的测试场景安全)。 +/// +public sealed class RawStdinPump : IInputPump, IDisposable +{ + /// 键事件通道容量(键风暴缓冲上限 — FullMode=Wait 下 TryWrite 满返 false 走回退续投 + /// 路径,不丢键不阻塞;DropWrite 会静默吞键与本泵"满即停"语义相反,禁用)。 + private const int ChannelCapacity = 256; + + /// 字节读取块大小(单次 stdin Read 上限 — 粘贴大块跨多块到达)。 + private const int ReadBlockSize = 4096; + + private readonly Channel _keys = Channel.CreateBounded( + new BoundedChannelOptions(ChannelCapacity) { SingleReader = true, SingleWriter = false, FullMode = BoundedChannelFullMode.Wait }); + private readonly CancellationTokenSource _cts = new(); + private readonly Decoder _utf8Decoder = Encoding.UTF8.GetDecoder(); + private readonly char[] _charBuffer = new char[2048]; + private readonly object _gate = new(); + private readonly StringBuilder _pending = new(); + private readonly Thread _reader; + + /// 创建并启动读线程(构造即开始消费 stdin — 生命周期由 Dispose/进程退出终结)。 + public RawStdinPump() + { + _reader = new Thread(ReadLoop) + { + IsBackground = true, + Name = "raw-stdin-pump", + }; + _reader.Start(); + } + + /// 非阻塞排空已解析键(帧拍消费 — 通道无键且 pending 无残留立即返回 false); + /// 先续投 pending 残留(通道腾出空间时),再收通道键(收到键且有残留再续一轮 — 至多两轮防活锁)。 + public bool Drain(Action dispatch) + { + var dispatched = false; + for (var round = 0; round < 2; round++) + { + PumpPendingToChannel(); + var got = false; + while (_keys.Reader.TryRead(out var key)) + { + dispatch(key); + dispatched = true; + got = true; + } + + // 本轮无新键或无残留 — 提前结束(无活锁:续投满停时 got=false 直接退出) + if (!got || !HasPending) + { + break; + } + } + + return dispatched; + } + + /// 停泵(取消读循环 + 通道完成;阻塞中的 Read 由进程退出回收 — IsBackground 线程)。 + public void Dispose() + { + _cts.Cancel(); + _keys.Writer.TryComplete(); + } + + /// pending 残留字符量(测试断言面 — 键风暴续投验证)。 + internal int PendingCount + { + get + { + lock (_gate) + { + return _pending.Length; + } + } + } + + /// + /// 读循环(后台线程)— stdin 块读 → UTF-8 解码 → 前缀攒齐解析 → 通道投递(满即停)。 + /// 流 EOF(Read 返 0 — stdin 重定向/管道输入结束)或取消时静默退出:通道不关闭 + ///(EOF ≠ Dispose — 泵无产出但 Drain 语义不变;进程退出由 IsBackground 回收); + /// 解码/解析异常防御吞掉(读线程不可死 — 泵崩溃即无输入)。 + /// + private void ReadLoop() + { + try + { + var stdin = Console.OpenStandardInput(); + var bytes = new byte[ReadBlockSize]; + while (!_cts.IsCancellationRequested) + { + var read = stdin.Read(bytes, 0, bytes.Length); + if (read <= 0) + { + break; + } + + var charCount = _utf8Decoder.GetChars(bytes, 0, read, _charBuffer, 0, flush: false); + lock (_gate) + { + _pending.Append(_charBuffer, 0, charCount); + } + + PumpPendingToChannel(); + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // 流关闭/Dispose 竞态 — 静默退出(通道保持开放 — EOF ≠ Dispose) + } + } + + /// + /// 攒齐前缀逐键解析非阻塞投递通道 — 契约: + /// 不完整前缀(如孤立 "\x1b[")不消耗留在 pending 等下块;孤立 ESC 按 chunk 边界 + /// 即时产出 Escape(ReadKey 通道的 KeyAvailable 语义近似)。通道满即停 + ///(剩余留 pending 由读线程下块 / Drain 续投触发 — 零等待根除自死锁); + /// 通道已关闭(Dispose 后)静默返回。 + /// + private void PumpPendingToChannel() + { + lock (_gate) + { + if (_keys.Reader.Completion.IsCompleted || _pending.Length == 0) + { + return; + } + + var span = _pending.ToString().AsSpan(); + var rest = span; + while (true) + { + var before = rest; + if (!KeyPressParser.TryParseNext(ref rest, out var key)) + { + break; + } + + if (!_keys.Writer.TryWrite(key)) + { + // 通道满 — 回退本键起点(已解析未投递不消耗:解析器 ref 推进不可逆, + // span 局部重赋值回退)防丢键,剩余含本键留 pending 续投 + rest = before; + break; + } + } + + var consumed = span.Length - rest.Length; + if (consumed > 0) + { + _pending.Remove(0, consumed); + } + } + } + + /// pending 残留判定(锁内 — Drain 轮次控制消费)。 + private bool HasPending + { + get + { + lock (_gate) + { + return _pending.Length > 0; + } + } + } + + /// + /// 测试注入面 — 绕过 stdin Read 直接喂解码后字符流(UTF-8 解码/解析/通道/Drain 全链验证; + /// 纯非阻塞投递 — 同线程 Feed 后 Drain 安全无自死锁)。真实 stdin 阻塞行为由宿主 + /// exe 手动验收覆盖(ADR 0080)。 + /// + internal void FeedForTest(string text) + { + lock (_gate) + { + _pending.Append(text); + } + + PumpPendingToChannel(); + } +} diff --git a/native_tui/src/NativeTui.Core/repl/input/ReadKeyPump.cs b/native_tui/src/NativeTui.Core/repl/input/ReadKeyPump.cs new file mode 100644 index 000000000..c43fee60d --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/input/ReadKeyPump.cs @@ -0,0 +1,145 @@ +namespace NativeTui.Core.Repl; + +/// +/// ReadKey 输入泵(DSG042 P1 — 现役逻辑收编,语义零变化)— Console.ReadKey 归一路径: +/// Key 枚举归一 KeyPress()+ bracketed paste 重组状态机 +///(ReadKey 把 ESC[200~/201~ 拆散为逐 KeyInfo — ESC 后 KeyAvailable 连续判定重组整块; +/// 孤立 Esc 即时分派不受影响 — paste 序列一次性到达键流恒连续)+ 误撞组合塞回待分派缓冲防丢键。 +/// 限制(原始流通道的迁移动机):VT 序列经 Key 枚举归一不可逆(修饰组合丢失); +/// P3 灰度对齐后本通道退役(DSG042),RawStdinPump 接任。 +/// +public sealed class ReadKeyPump : IInputPump +{ + /// 已读待分派缓冲(误判回退塞回 — 键不丢)。 + private readonly Queue _pendingKeys = new(); + + /// 排空键盘事件(非阻塞)— ReadKey 归一 KeyPress 后统一分派; + /// bracketed paste 重组:ESC 键已消费后尝试收集整块(失败回退塞回正常分派)。 + public bool Drain(Action dispatch) + { + var dispatched = false; + while (_pendingKeys.Count > 0 || Console.KeyAvailable) + { + var info = _pendingKeys.Count > 0 ? _pendingKeys.Dequeue() : Console.ReadKey(intercept: true); + if (info.Key == ConsoleKey.Escape && info.KeyChar == '\u001b' && TryCollectPaste(out var content)) + { + dispatch(ConsoleKeyAdapter.Paste(content)); + dispatched = true; + continue; + } + + if (ConsoleKeyAdapter.ToKeyPress(info) is { } key) + { + dispatch(key); + dispatched = true; + } + } + + return dispatched; + } + + /// 尝试收集 bracketed paste 块 — 前提:ESC 键已消费。 + /// ESC 后无后续键 = 孤立 Esc(false,调用方正常分派);后续键非 '[' 或非 "200~" 起始标记 = + /// 误撞组合(false,已读键塞回 pending 两键都走正常分派);起始标记完整后收集内容直到 + /// ESC[201~ 终止标记(true,out 携带粘贴原文);收集期流中断整体放弃吞掉(防泄漏为文本)。 + private bool TryCollectPaste(out string content) + { + content = string.Empty; + if (!Console.KeyAvailable) + { + return false; + } + + var bracket = Console.ReadKey(intercept: true); + if (bracket.KeyChar != '[') + { + _pendingKeys.Enqueue(bracket); + return false; + } + + var consumed = new List { bracket }; + if (!TryMatchPasteStart(consumed)) + { + Requeue(consumed); + return false; + } + + // 内容收集(终止标记未完整到达 = 流中断/误撞,整体放弃吞掉防泄漏为文本;空粘贴同判 false = 无操作) + var body = CollectPasteBody(); + if (body.Length == 0) + { + return false; + } + + content = body; + return true; + } + + /// 匹配 paste 起始标记剩余字符 "200~"(逐字符读,不匹配/流中断返回 false — 已读键入 consumed)。 + private static bool TryMatchPasteStart(List consumed) + { + foreach (var expected in "200~") + { + if (!Console.KeyAvailable) + { + return false; + } + + var info = Console.ReadKey(intercept: true); + consumed.Add(info); + if (info.KeyChar != expected) + { + return false; + } + } + + return true; + } + + /// 收集 paste 内容直到终止标记 ESC[201~ — 内容字符直积(\r\n 原样保留由 + /// PastedContentTracker 清洗归一);遇 ESC 尝试匹配终止标记,失败/流中断整体放弃(吞掉返回空)。 + private static string CollectPasteBody() + { + var buffer = new StringBuilder(); + while (Console.KeyAvailable) + { + var info = Console.ReadKey(intercept: true); + if (info.KeyChar == '\u001b') + { + return TryMatchPasteEnd() ? buffer.ToString() : string.Empty; + } + + buffer.Append(info.KeyChar); + } + + return string.Empty; + } + + /// 匹配 paste 终止标记剩余 "[201~"(ESC 已消费)。 + private static bool TryMatchPasteEnd() + { + foreach (var expected in "[201~") + { + if (!Console.KeyAvailable) + { + return false; + } + + if (Console.ReadKey(intercept: true).KeyChar != expected) + { + return false; + } + } + + return true; + } + + /// 已读键按序塞回待分派缓冲(误判回退 — 键不丢)。 + private void Requeue(List consumed) + { + foreach (var info in consumed) + { + _pendingKeys.Enqueue(info); + } + } +} diff --git a/native_tui/tests/NativeTui.Tests/repl/input/RawStdinPumpTests.cs b/native_tui/tests/NativeTui.Tests/repl/input/RawStdinPumpTests.cs new file mode 100644 index 000000000..bb6b456e8 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/input/RawStdinPumpTests.cs @@ -0,0 +1,191 @@ +namespace NativeTui.Tests.Repl; + +/// +/// RawStdinPump 全链测试(DSG042 P2)— FeedForTest 注入绕过 stdin Read,验证 +/// 解析→通道→Drain 全链:单键/VT 序列(方向键修饰组合 ReadKey 通道不可得的形态)/ +/// bracketed paste 整块(重组状态机天然不需要)/ 不完整前缀跨块攒齐 / 孤立 ESC 即时产出 / +/// 键序保序 / 空排空返回 false。真实 stdin 阻塞行为由宿主 exe 手动验收(ADR 0080)。 +/// +public sealed class RawStdinPumpTests +{ + /// 构造即启动读线程(IsBackground — stdin Read 阻塞由进程退出回收;测试不依赖它)。 + private static RawStdinPump CreatePump() => new(); + + /// 注入并排空全部产出键(Drain 非阻塞 — feed 后立即收)。 + private static List FeedAndDrain(RawStdinPump pump, string text) + { + pump.FeedForTest(text); + var keys = new List(); + pump.Drain(keys.Add); + return keys; + } + + [Fact] + public void SingleChar_ProducesCharKey() + { + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "a"); + var key = Assert.Single(keys); + Assert.Equal(KeyKind.Char, key.Kind); + Assert.Equal("a", key.Input); + } + + [Fact] + public void CtrlSequence_ProducesCtrlKey() + { + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "\u0015"); + var key = Assert.Single(keys); + Assert.True(key.Ctrl); + Assert.Equal("u", key.Input); + } + + [Fact] + public void ArrowSequence_ProducesNamedKey() + { + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "\u001b[D"); + var key = Assert.Single(keys); + Assert.Equal(KeyKind.LeftArrow, key.Kind); + } + + [Fact] + public void CtrlShiftArrow_ModifierCombo_Preserved() + { + // ReadKey 通道不可得形态:ctrl+shift+方向(CSI 1;5D = ctrl+shift 修饰? 实际 5=ctrl — 用 1;5D 验证修饰位保留) + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "\u001b[1;5D"); + var key = Assert.Single(keys); + Assert.Equal(KeyKind.LeftArrow, key.Kind); + Assert.True(key.Ctrl); + } + + [Fact] + public void BracketedPaste_WholeBlock_SinglePasteEvent() + { + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "\u001b[200~hello world\u001b[201~"); + var key = Assert.Single(keys); + Assert.Equal(KeyKind.Paste, key.Kind); + Assert.Equal("hello world", key.Input); + } + + [Fact] + public void PasteBlock_SplitAcrossFeeds_Accumulates() + { + // 终止标记未到达 = 不完整前缀不消耗(跨块攒齐 — KeyPressParser 契约) + using var pump = CreatePump(); + var first = FeedAndDrain(pump, "\u001b[200~hel"); + Assert.Empty(first); + + var second = FeedAndDrain(pump, "lo\u001b[201~"); + var key = Assert.Single(second); + Assert.Equal(KeyKind.Paste, key.Kind); + Assert.Equal("hello", key.Input); + } + + [Fact] + public void IncompleteEscapePrefix_AccumulatesAcrossFeeds() + { + using var pump = CreatePump(); + var first = FeedAndDrain(pump, "\u001b["); + Assert.Empty(first); + + var second = FeedAndDrain(pump, "D"); + var key = Assert.Single(second); + Assert.Equal(KeyKind.LeftArrow, key.Kind); + } + + [Fact] + public void LoneEscape_ChunkBoundary_ProducesEscapeImmediately() + { + // 孤立 ESC 按 chunk 边界即时产出(不等超时 — ReadKey 通道 KeyAvailable 语义近似) + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "\u001b"); + var key = Assert.Single(keys); + Assert.Equal(KeyKind.Escape, key.Kind); + } + + [Fact] + public void MixedSequence_PreservesOrder() + { + using var pump = CreatePump(); + var keys = FeedAndDrain(pump, "ab\u001b[Dc\u007f"); + Assert.Equal(5, keys.Count); + Assert.Equal("a", keys[0].Input); + Assert.Equal("b", keys[1].Input); + Assert.Equal(KeyKind.LeftArrow, keys[2].Kind); + Assert.Equal("c", keys[3].Input); + Assert.Equal(KeyKind.Backspace, keys[4].Kind); + } + + [Fact] + public void Drain_NoKeys_ReturnsFalse() + { + using var pump = CreatePump(); + Assert.False(pump.Drain(_ => { })); + } + + [Fact] + public void Feed_ManyKeys_BoundedChannel_StillDelivers() + { + // 键风暴(超通道容量 256)— 满即停不丢键(残留 pending 由 Drain 两轮续投收齐); + // 超时熔断:2s 上限防实现回归成阻塞/死循环(收不齐 fail-fast 带诊断而非永转) + using var pump = CreatePump(); + var text = new string('k', 300); + pump.FeedForTest(text); + var keys = new List(); + var timeoutAt = Environment.TickCount64 + 2000; + while (keys.Count < 300) + { + Assert.True(Environment.TickCount64 < timeoutAt, + $"键风暴收集超时熔断触发:已收 {keys.Count}/300,pending 残留 {pump.PendingCount} 字符"); + pump.Drain(keys.Add); + } + + Assert.Equal(300, keys.Count); + Assert.All(keys, key => Assert.Equal("k", key.Input)); + Assert.Equal(0, pump.PendingCount); + } + + [Fact] + public void Feed_KeyStorm_OverTripleCapacity_DrainsAllWithoutBlock() + { + // 三倍容量键风暴 — 非阻塞续投全链压力(单线程 Feed+Drain 交替无死锁不丢键自证; + // 超时熔断 2s:实现回归阻塞/丢键时 fail-fast 带诊断) + using var pump = CreatePump(); + var keys = new List(); + var timeoutAt = Environment.TickCount64 + 2000; + for (var i = 0; i < 3; i++) + { + pump.FeedForTest(new string('k', 256)); + while (pump.PendingCount > 0) + { + Assert.True(Environment.TickCount64 < timeoutAt, + $"轮 {i} 超时熔断:pending={pump.PendingCount},已收 {keys.Count}"); + pump.Drain(keys.Add); + } + } + + // 收通道残留(末轮 Drain 后可能仍在通道未收) + pump.Drain(keys.Add); + Assert.Equal(768, keys.Count); + Assert.Equal(0, pump.PendingCount); + } + + [Fact] + public void Dispose_MakesDrain_TerminalNoMoreKeys() + { + var pump = CreatePump(); + pump.FeedForTest("a"); + pump.Dispose(); + // Dispose 后已入通道键仍可排空(TryComplete 不清既有项 — 优雅收尾语义) + var keys = new List(); + pump.Drain(keys.Add); + Assert.Single(keys); + // 后续注入面停用(通道完成静默 — Drain 恒空防御不抛) + pump.FeedForTest("b"); + pump.Drain(keys.Add); + Assert.Single(keys); + } +} From 5e62952ed4f3a09b9d0c34ec123c2bcba113189a Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 02:50:53 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20DSG042=20P2=20=E6=94=B6=E5=B0=BE=20?= =?UTF-8?q?--raw-input=20=E5=AE=BF=E4=B8=BB=E5=BC=80=E5=85=B3=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=20|=20=E5=86=B3=E7=AD=96:=20Host=20=E6=8C=81=E6=B3=B5?= =?UTF-8?q?=E5=BC=95=E7=94=A8=20Run/WriteExit=20=E5=90=8E=20Dispose(?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=AE=8C=E6=88=90+=E5=8F=96=E6=B6=88;?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E8=AF=BB=E7=94=B1=20IsBackground=20=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E9=80=80=E5=87=BA=E5=9B=9E=E6=94=B6=E4=B8=8D=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E9=98=B2=E6=8C=82)+=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E6=B2=BF=E7=94=A8=E4=BA=A4=E4=BA=92=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F;=20=E9=BB=98=E8=AE=A4=E9=80=9A=E9=81=93=E4=B8=8D?= =?UTF-8?q?=E5=8F=98(ReadKeyPump=20=E7=8E=B0=E5=BD=B9,--raw-input=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E7=81=B0=E5=BA=A6)=20|=20=E9=AA=8C=E8=AF=81:?= =?UTF-8?q?=20Host=20=E7=BC=96=E8=AF=91=E9=9B=B6=E8=AD=A6=E5=91=8A+selftes?= =?UTF-8?q?t=20=E5=86=92=E7=83=9F=E8=BF=87+RawStdinPumpTests=2013/13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DSG042-tui-raw-stdin-input-pump-migration.md | 15 +++++++++++++++ native_tui/apps/NativeTui.Host/Program.cs | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md b/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md index 0dd8a6040..94113ed89 100644 --- a/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md +++ b/docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md @@ -83,3 +83,18 @@ RawStdinPump(新:后台读线程 + 字节流 UTF-8 解码 + KeyPressParser 1. P2 探针形态:`--selftest-input` 独立入口 vs 复用 `--selftest-commands` 扩展 2. RawStdinPump 的 Channel 容量与满策略(背压 vs 丢弃——基准 ink 无界,倾向有界 256 满等待防键风暴丢键) 3. P3 退役 ConsoleKeyAdapter 的时机(kitty protocol 是否先落地再退役——归档后 ReadKey 通道复活成本) + +## 执行状态(2026-10-23) + +| Phase | 状态 | 提交 | +|-------|------|------| +| P1 抽象收编 | ✅ 完成 — IInputPump + ReadKeyPump 搬入 + ReplShell 注入,1667 全绿零语义变化 | ce83f01e9 | +| P2 RawStdinPump | ✅ 完成 — 全链(读线程/UTF-8 增量解码/解析/有界通道 256/非阻塞续投)+ 13 单测 + --raw-input 开关 + Host Dispose 收尾 | ce83f01e9 + 本批 | +| P3 切默认退役 | ⬜ 未做 — 双终端实测矩阵(Windows Terminal/conhost)跑齐后切默认归档 ReadKey 状态机 | — | + +P2 实施中修的三个真 bug(用户点报自死锁后熔断定位): +1. 同线程自死锁 — FeedForTest 同步投递 + WriteAsync 满等待 + Drain 同线程互等(改纯非阻塞 TryWrite + 回退) +2. DropWrite 静默吞键 — FullMode 语义与"满即停"相反,300 键丢 44(改 Wait) +3. 解析消耗未投递丢键 — TryParseNext ref 推进后 TryWrite 失败需回退 span(before 快照重赋值) + +熔断机制:测试收集循环 2s 超时 fail-fast 带诊断(已收数/pending 残留)— 本批两真 bug 均由它定位,留为常驻设施。 diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index b57517af8..782a41fa2 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -19,8 +19,10 @@ public static async Task Main(string[] args) { return await RunSelftestTurnAsync().ConfigureAwait(false); if (args is ["--selftest-commands"]) return await RunSelftestCommandsAsync().ConfigureAwait(false); + if (args is ["--raw-input"]) + return await RunInteractiveAsync(useRawStdinPump: true).ConfigureAwait(false); if (args.Length > 0) { - Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest | --selftest-turn | --selftest-commands](无参数进入交互模式,需真实终端)"); + Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest | --selftest-turn | --selftest-commands | --raw-input](无参数进入交互模式,需真实终端;--raw-input 启用 stdin 原始流输入泵灰度通道)"); return 1; } @@ -255,7 +257,7 @@ private static async Task WaitTurnCompleteAsync(EngineSession session, TimeSpan } /// 交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。 - private static async Task RunInteractiveAsync() { + private static async Task RunInteractiveAsync(bool useRawStdinPump = false) { if (Console.IsInputRedirected || Console.IsOutputRedirected) { Console.Error.WriteLine("交互模式需要真实终端(stdin/stdout 被重定向)。请直接在终端运行 NativeTui.Host,或用 --selftest 跑非交互冒烟。"); return 1; @@ -315,6 +317,7 @@ private static async Task RunInteractiveAsync() { //(~/.jcc/command-history.json 坏文件静默降级空记忆)+ 工作区根(@ 文件补全索引根,banner cwd 同源) var themeName = await LoadThemeNameAsync(session.Services).ConfigureAwait(false); var commandUsage = await CommandUsageMemory.LoadAsync(CommandUsageMemory.DefaultFilePath).ConfigureAwait(false); + var rawInputPump = useRawStdinPump ? new RawStdinPump() : null; var shell = new ReplShell(pickerEngine, banner, tuiOutput, commands: commands, clipboard: text => _ = clipboardService?.SetTextAsync(text), themeName: themeName, @@ -332,7 +335,11 @@ private static async Task RunInteractiveAsync() { saveThemeAsync: name => SaveThemeAsync(session.Services, name), // TASK064 P3 — ctrl+g 外部编辑器委托:输入文本 → temp 文件 → $EDITOR //(未设置时 Windows notepad.exe / 其余 vi 兜底)→ 读回编辑后文本;失败 null 降级丢弃 - launchExternalEditor: text => ExternalEditorLauncher.EditAsync(text)); + launchExternalEditor: text => ExternalEditorLauncher.EditAsync(text), + // DSG042 P2 — 输入泵通道:默认 ReadKeyPump(现役稳定); + // --raw-input 灰度启用 RawStdinPump(stdin 原始流 VT 全链 — 修饰组合键保留/ + // paste 整块直出;双终端实测矩阵跑齐后 P3 切默认);引用留 Host 收尾 Dispose + inputPump: rawInputPump); (feat: DSG042 P2 收尾 --raw-input 宿主开关接线 | 决策: Host 持泵引用 Run/WriteExit 后 Dispose(通道完成+取消;阻塞读由 IsBackground 进程退出回收不等待防挂)+重定向守卫沿用交互模式; 默认通道不变(ReadKeyPump 现役,--raw-input 显式灰度) | 验证: Host 编译零警告+selftest 冒烟过+RawStdinPumpTests 13/13) pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); @@ -353,6 +360,9 @@ private static async Task RunInteractiveAsync() { readPassword: shell.ReadPasswordCommand); shell.Run(); shell.WriteExit(); + // DSG042 P2 — 原始流泵收尾(通道完成 + 读循环取消;阻塞中的 stdin Read 由 + // IsBackground 线程随进程退出回收 — Dispose 不等待线程终结防挂) + rawInputPump?.Dispose(); } } finally { Console.SetOut(originalOut); From 07c6c540138bc581cb8bb8aceade3411e23c924d Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 03:04:44 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20DSG042=20P2=20--selftest-input=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=8E=A2=E9=92=88=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20stdin=20=E8=B7=AF=E5=BE=84=E5=AE=9E=E6=B5=8B=E9=80=9A?= =?UTF-8?q?=E9=81=93=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E7=AE=A1?= =?UTF-8?q?=E9=81=93=E5=96=82=20VT=20=E5=AD=97=E8=8A=82=E6=B5=81=E8=B5=B0?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20stdin(=E7=BB=88=E7=AB=AF=E6=9C=AC=E8=B4=A8?= =?UTF-8?q?=E5=8D=B3=E5=BE=80=20stdin=20=E5=86=99=E8=BF=99=E4=BA=9B?= =?UTF-8?q?=E5=AD=97=E8=8A=82,=E5=8F=AF=E4=BF=A1=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E4=B8=94=E5=8F=AF=E8=BF=9B=20CI);=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=3D=E8=AF=BB=E7=BA=BF=E7=A8=8B=E9=80=80?= =?UTF-8?q?=E5=87=BA(EOF=20=E5=90=8E=E5=8D=8A=E6=88=AA=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20pending=20=E5=B1=9E=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8=E6=AD=A3=E7=A1=AE=E8=AF=AD=E4=B9=89=E4=B8=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E);=205s=20=E8=B6=85=E6=97=B6=E7=86=94=E6=96=AD=20fail-?= =?UTF-8?q?fast=20=E5=B8=A6=E8=AF=8A=E6=96=AD(=E5=B7=B2=E6=94=B6=E9=94=AE?= =?UTF-8?q?=E6=95=B0/pending);=20PendingCount=20=E5=8D=87=20public=20?= =?UTF-8?q?=E4=BE=9B=E6=8E=A2=E9=92=88=E6=B6=88=E8=B4=B9=20|=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81:=20=E5=85=A8=E8=A1=A8=20OK(=E5=90=AB=20Ctrl+LeftArrow?= =?UTF-8?q?=20=E4=BF=AE=E9=A5=B0=E7=BB=84=E5=90=88-ReadKey=E9=80=9A?= =?UTF-8?q?=E9=81=93=E4=B8=8D=E5=8F=AF=E5=BE=97=E5=BD=A2=E6=80=81/UTF-8?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=A4=9A=E5=AD=97=E8=8A=82/paste=E6=95=B4?= =?UTF-8?q?=E5=9D=97)+=E5=8D=8A=E6=88=AApaste=E4=B8=8E=E7=A9=BA=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=BF=AB=E9=80=9FFAIL=E9=9D=9E=E6=AD=BB=E7=AD=89?= =?UTF-8?q?=E4=B8=89=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Host/Program.cs | 63 +++++++++++++++++++ .../NativeTui.Core/repl/input/RawStdinPump.cs | 8 ++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 782a41fa2..1ed61c315 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -21,6 +21,8 @@ public static async Task Main(string[] args) { return await RunSelftestCommandsAsync().ConfigureAwait(false); if (args is ["--raw-input"]) return await RunInteractiveAsync(useRawStdinPump: true).ConfigureAwait(false); + if (args is ["--selftest-input"]) + return await RunSelftestInputAsync().ConfigureAwait(false); if (args.Length > 0) { Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest | --selftest-turn | --selftest-commands | --raw-input](无参数进入交互模式,需真实终端;--raw-input 启用 stdin 原始流输入泵灰度通道)"); return 1; @@ -411,6 +413,67 @@ private static async Task SaveThemeAsync(IServiceProvider services, string theme } } + /// 输入泵探针(DSG042 P2 — 真实 stdin 路径实测):管道喂 VT 字节流 + ///(printf '|' 形态 — 终端即往 stdin 写这些字节,管道字节流与终端形态一致)→ + /// RawStdinPump 读线程全链(UTF-8 增量解码/前缀攒齐/paste 整块/修饰组合)→ 键位全表逐位断言。 + /// stdin 经管道输入(探针通道 — 不经交互模式重定向守卫);EOF 后读线程退出收敛。 + private static async Task RunSelftestInputAsync() { + using var pump = new RawStdinPump(); + var keys = new List(); + var timeoutAt = Environment.TickCount64 + 5000; + // 收敛 = 读线程退出(stdin EOF — 半截序列残留 pending 属解析器正确语义不产出,不阻塞收敛) + while (pump.IsReaderAlive) { + if (Environment.TickCount64 > timeoutAt) { + Console.Error.WriteLine($"INPUT SELFTEST FAIL — 收敛超时:已收 {keys.Count} 键,pending {pump.PendingCount} 字符"); + return 1; + } + + pump.Drain(keys.Add); + await Task.Delay(10).ConfigureAwait(false); + } + + pump.Drain(keys.Add); + + var failures = new List(); + AssertInputKey(keys, 0, k => k.Kind == KeyKind.Char && k.Input == "a", "字符键 a", failures); + AssertInputKey(keys, 1, k => k.Kind == KeyKind.Char && k.Input == "b", "字符键 b", failures); + AssertInputKey(keys, 2, k => k.Ctrl && k.Input == "u", "Ctrl+U(0x15)", failures); + AssertInputKey(keys, 3, k => k.Kind == KeyKind.LeftArrow, "方向键 ESC[D → LeftArrow", failures); + AssertInputKey(keys, 4, k => k is { Kind: KeyKind.LeftArrow, Ctrl: true }, "修饰组合 ESC[1;5D → Ctrl+LeftArrow(ReadKey 通道不可得形态)", failures); + AssertInputKey(keys, 5, k => k.Kind == KeyKind.Paste && k.Input == "中文粘贴内容", "bracketed paste 整块(UTF-8 多字节)", failures); + AssertInputKey(keys, 6, k => k.Ctrl && k.Input == "_", "undo 键 0x1F → Ctrl+_", failures); + AssertInputKey(keys, 7, k => k.Kind == KeyKind.Char && k.Input == "你", "中文字符键(多字节解码)", failures); + AssertInputKey(keys, 8, k => k.Kind == KeyKind.Return, "回车 CR", failures); + if (keys.Count != 9) { + failures.Add($"键总数:期望 9 实际 {keys.Count}(多余键:{string.Join("、", keys.Skip(9).Select(k => $"{k.Kind}:{k.Input}"))})"); + } + + if (failures.Count > 0) { + Console.Error.WriteLine("INPUT SELFTEST FAIL:"); + foreach (var failure in failures) { + Console.Error.WriteLine($" ✖ {failure}"); + } + + return 1; + } + + Console.WriteLine("INPUT SELFTEST OK — stdin 原始流 VT 全链(解码/前缀攒齐/paste 整块/修饰组合)全部通过"); + return 0; + } + + /// 单键断言辅助(越界/不匹配记失败带索引诊断)。 + private static void AssertInputKey(List keys, int index, Func predicate, string label, List failures) { + if (index >= keys.Count) { + failures.Add($"{label}:越界(仅收 {keys.Count} 键)"); + return; + } + + if (!predicate(keys[index])) { + var k = keys[index]; + failures.Add($"{label}:实际 {k.Kind}:{(k.Input ?? string.Empty)}{(k.Ctrl ? " Ctrl" : string.Empty)}{(k.Meta ? " Meta" : string.Empty)}"); + } + } + /// 非交互冒烟(ADR 0080 真实组装非 mock)— 真实引擎会话组装 + session_info 握手断言 + /// 权限模式真实回执(PermissionChecker 安全闸链路)+ 无效模式 Error 诱导;不提交回合(不调 LLM)。 private static async Task RunSelftestAsync() { diff --git a/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs b/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs index 1c022404b..8446c5805 100644 --- a/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs +++ b/native_tui/src/NativeTui.Core/repl/input/RawStdinPump.cs @@ -74,8 +74,12 @@ public void Dispose() _keys.Writer.TryComplete(); } - /// pending 残留字符量(测试断言面 — 键风暴续投验证)。 - internal int PendingCount + /// 读线程是否仍在运行(探针完成判定 — stdin EOF 后读循环退出线程终止; + /// 等待完成 = 读线程不存活 + pending 无残留 + 通道空三条件齐)。 + public bool IsReaderAlive => _reader.IsAlive; + + /// pending 残留字符量(测试/探针断言面 — 键风暴续投与收敛判定)。 + public int PendingCount { get { From 1e75d632fe84b5e172f914351cd5cfc2fe85c780 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 03:04:44 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20DSG042=20P2=20--selftest-input=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=8E=A2=E9=92=88=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20stdin=20=E8=B7=AF=E5=BE=84=E5=AE=9E=E6=B5=8B=E9=80=9A?= =?UTF-8?q?=E9=81=93=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E7=AE=A1?= =?UTF-8?q?=E9=81=93=E5=96=82=20VT=20=E5=AD=97=E8=8A=82=E6=B5=81=E8=B5=B0?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20stdin(=E7=BB=88=E7=AB=AF=E6=9C=AC=E8=B4=A8?= =?UTF-8?q?=E5=8D=B3=E5=BE=80=20stdin=20=E5=86=99=E8=BF=99=E4=BA=9B?= =?UTF-8?q?=E5=AD=97=E8=8A=82,=E5=8F=AF=E4=BF=A1=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E4=B8=94=E5=8F=AF=E8=BF=9B=20CI);=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=3D=E8=AF=BB=E7=BA=BF=E7=A8=8B=E9=80=80?= =?UTF-8?q?=E5=87=BA(EOF=20=E5=90=8E=E5=8D=8A=E6=88=AA=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20pending=20=E5=B1=9E=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8=E6=AD=A3=E7=A1=AE=E8=AF=AD=E4=B9=89=E4=B8=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E);=205s=20=E8=B6=85=E6=97=B6=E7=86=94=E6=96=AD=20fail-?= =?UTF-8?q?fast=20=E5=B8=A6=E8=AF=8A=E6=96=AD(=E5=B7=B2=E6=94=B6=E9=94=AE?= =?UTF-8?q?=E6=95=B0/pending);=20PendingCount=20=E5=8D=87=20public=20?= =?UTF-8?q?=E4=BE=9B=E6=8E=A2=E9=92=88=E6=B6=88=E8=B4=B9=20|=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81:=20=E5=85=A8=E8=A1=A8=20OK(=E5=90=AB=20Ctrl+LeftArrow?= =?UTF-8?q?=20=E4=BF=AE=E9=A5=B0=E7=BB=84=E5=90=88-ReadKey=E9=80=9A?= =?UTF-8?q?=E9=81=93=E4=B8=8D=E5=8F=AF=E5=BE=97=E5=BD=A2=E6=80=81/UTF-8?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=A4=9A=E5=AD=97=E8=8A=82/paste=E6=95=B4?= =?UTF-8?q?=E5=9D=97)+=E5=8D=8A=E6=88=AApaste=E4=B8=8E=E7=A9=BA=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=BF=AB=E9=80=9FFAIL=E9=9D=9E=E6=AD=BB=E7=AD=89?= =?UTF-8?q?=E4=B8=89=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native_tui/apps/NativeTui.Host/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index 1ed61c315..f549ef9a4 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -341,7 +341,7 @@ private static async Task RunInteractiveAsync(bool useRawStdinPump = false) // DSG042 P2 — 输入泵通道:默认 ReadKeyPump(现役稳定); // --raw-input 灰度启用 RawStdinPump(stdin 原始流 VT 全链 — 修饰组合键保留/ // paste 整块直出;双终端实测矩阵跑齐后 P3 切默认);引用留 Host 收尾 Dispose - inputPump: rawInputPump); (feat: DSG042 P2 收尾 --raw-input 宿主开关接线 | 决策: Host 持泵引用 Run/WriteExit 后 Dispose(通道完成+取消;阻塞读由 IsBackground 进程退出回收不等待防挂)+重定向守卫沿用交互模式; 默认通道不变(ReadKeyPump 现役,--raw-input 显式灰度) | 验证: Host 编译零警告+selftest 冒烟过+RawStdinPumpTests 13/13) + inputPump: rawInputPump); pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell);