Skip to content
100 changes: 100 additions & 0 deletions docs/proposed/design/DSG042-tui-raw-stdin-input-pump-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 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<KeyPress>)
```

主循环 `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 通道复活成本)

## 执行状态(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 均由它定位,留为常驻设施。
2 changes: 1 addition & 1 deletion docs/proposed/task/TASK065-S11统一接线收尾.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 零接线成本

Expand Down
79 changes: 76 additions & 3 deletions native_tui/apps/NativeTui.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ public static async Task<int> 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 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](无参数进入交互模式,需真实终端)");
Console.Error.WriteLine($"未知参数: {string.Join(' ', args)}。用法: NativeTui.Host [--selftest | --selftest-turn | --selftest-commands | --raw-input](无参数进入交互模式,需真实终端;--raw-input 启用 stdin 原始流输入泵灰度通道)");
return 1;
}

Expand Down Expand Up @@ -255,7 +259,7 @@ private static async Task WaitTurnCompleteAsync(EngineSession session, TimeSpan
}

/// <summary>交互模式 — TTY 检查 → UTF-8 → raw mode → 引擎组装(Console.Out 重定向 stderr 防引擎污染渲染)→ REPL 主循环。</summary>
private static async Task<int> RunInteractiveAsync() {
private static async Task<int> RunInteractiveAsync(bool useRawStdinPump = false) {
if (Console.IsInputRedirected || Console.IsOutputRedirected) {
Console.Error.WriteLine("交互模式需要真实终端(stdin/stdout 被重定向)。请直接在终端运行 NativeTui.Host,或用 --selftest 跑非交互冒烟。");
return 1;
Expand Down Expand Up @@ -315,6 +319,7 @@ private static async Task<int> 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,
Expand All @@ -332,7 +337,11 @@ private static async Task<int> 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);
pickerEngine.Attach(shell);
await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false);
session.PermissionBridge.Bind(shell);
Expand All @@ -353,6 +362,9 @@ private static async Task<int> RunInteractiveAsync() {
readPassword: shell.ReadPasswordCommand);
shell.Run();
shell.WriteExit();
// DSG042 P2 — 原始流泵收尾(通道完成 + 读循环取消;阻塞中的 stdin Read 由
// IsBackground 线程随进程退出回收 — Dispose 不等待线程终结防挂)
rawInputPump?.Dispose();
}
} finally {
Console.SetOut(originalOut);
Expand Down Expand Up @@ -401,6 +413,67 @@ private static async Task SaveThemeAsync(IServiceProvider services, string theme
}
}

/// <summary>输入泵探针(DSG042 P2 — 真实 stdin 路径实测):管道喂 VT 字节流
///(printf '|' 形态 — 终端即往 stdin 写这些字节,管道字节流与终端形态一致)→
/// RawStdinPump 读线程全链(UTF-8 增量解码/前缀攒齐/paste 整块/修饰组合)→ 键位全表逐位断言。
/// stdin 经管道输入(探针通道 — 不经交互模式重定向守卫);EOF 后读线程退出收敛。</summary>
private static async Task<int> RunSelftestInputAsync() {
using var pump = new RawStdinPump();
var keys = new List<KeyPress>();
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<string>();
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;
}

/// <summary>单键断言辅助(越界/不匹配记失败带索引诊断)。</summary>
private static void AssertInputKey(List<KeyPress> keys, int index, Func<KeyPress, bool> predicate, string label, List<string> 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)}");
}
}

/// <summary>非交互冒烟(ADR 0080 真实组装非 mock)— 真实引擎会话组装 + session_info 握手断言 +
/// 权限模式真实回执(PermissionChecker 安全闸链路)+ 无效模式 Error 诱导;不提交回合(不调 LLM)。</summary>
private static async Task<int> RunSelftestAsync() {
Expand Down
1 change: 1 addition & 0 deletions native_tui/src/NativeTui.Core/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading