<!-- 文档分类: OPS-MAN-031 | 阶段: 运维 | 原路径: docs/指南/终端-tui-有框输入区重构方案-2026-05-31.md -->
# 终端 TUI 有框输入区重构方案

> ⚠️ **已归档 / 被取代**：本方案设计的自研 TUI 运行时（`runtime/alternateScreen.js` / `runtime/terminalInput.js` / `runtime/screenRenderer.js` / `store/replStore.js` 等）未按此方案实施；2026-06-05 起 TUI 已直接迁移至官方 Ink 运行时（`backend/src/cli/tui/ink-components/`），上述自研模块均已删除。本文仅作历史方案保留，最新 TUI 实现请参阅 [tui-inquirer闪退修复-2026-06-05.md](../04_IMPL_实现/%5BIMPL-RPT-003%5D%20tui-inquirer闪退修复-2026-06-05.md)。

> 日期: 2026-05-31
> 状态: 方案已确认，待实施
> 目标范围: `backend/src/cli/repl.js` 所代表的 full REPL 交互层
> 关联背景: `docs/04_IMPL_实现/[IMPL-RPT-021] 网关超时与帧修复.md` · `docs/04_IMPL_实现/[IMPL-RPT-019] 终端提示符泄漏与交付空行修复-2026-05-31.md`

---

## 一、问题定义

当前 KHY 的 full REPL 希望同时满足四个目标：

1. 保留带边框的活动输入区
2. 支持持续滚动的终端输出
3. 允许流式正文、工具日志、状态行混合打印
4. 在终端 resize 时不变形、不残影、不污染 scrollback

这四个目标在当前 `readline + ANSI 相对补画` 架构下彼此冲突。

用户已经多次在真实终端中复现以下问题：

1. 拉伸后输入框边框变形
2. 上下边线残留到 scrollback
3. `❯` 提示符泄漏到正式输出前
4. 为止血而做的 resize 降级，会让边框直接消失

结论很明确：

- 继续补 `repl.js` 里的 ANSI 光标移动细节，只会反复修局部症状
- 如果产品目标仍然是“稳定保留有框输入区”，就必须切换到正式 TUI 架构

---

## 二、为什么现架构注定不稳

当前 full REPL 的关键路径是：

1. `readline.createInterface()` 接管输入
2. `rl.prompt()` / `rl._refreshLine()` 驱动输入重绘
3. `process.stdout.write()` 和 `console.log()` 在主屏 scrollback 上持续追加内容
4. 活动输入框边框通过相对 ANSI 光标移动临时补画

这类架构的问题不是“有一个 escape sequence 写错了”，而是**终端所有权冲突**：

1. `readline` 认为自己拥有当前输入行
2. 业务输出认为自己可以随时往 stdout 追加日志
3. 边框系统认为自己可以在输入区上下插入临时装饰
4. resize 会让输入换行行数、光标绝对位置、已渲染边框宽度同时失效

于是任何一个环节先动，另外两个环节就会被动失真。

这也是为什么前面即使修掉了：

- busy prompt 泄漏
- 执行过程与正文之间的空行
- resize 时边框残影

最终还是会在真实终端里继续看到“有框输入区”本身不稳定。

---

## 三、从 Claude Code 学到的关键结论

对 `/home/kodehu03/Downloads/Claude-Code-main.zip` 的检查结果表明：

1. Claude Code 使用 `React + Ink`
2. 终端尺寸变化通过 `useTerminalSize()` 触发布局重排
3. 输入区和边框属于组件树的一部分，不靠 `readline` 上方的相对 ANSI 补画
4. 整个交互期的布局由 fullscreen / TUI shell 统一管理

这说明 Claude 稳定的根因不是“它更会写 ANSI”，而是：

**它把输入区、正文区、状态区都收进了一个统一的保留态布局系统里。**

对 KHY 的启示不是“抄几个 escape sequence”，而是要改下面这件事：

**从即时 stdout 输出模型，切换到状态驱动的 retained-mode TUI。**

---

## 四、总体方案

### 4.1 总体方向

保留“有框输入区”，但不再允许它依附在主屏 scrollback 上通过 ANSI 临时补画。

full REPL 迁移到新的 TUI Shell：

1. 启动 full REPL 时进入 alternate screen
2. 输入区固定在底部布局槽内
3. 正文区变成应用内 transcript viewport
4. 所有输出先写入状态树，再由 renderer 统一刷屏
5. resize 时基于新终端尺寸整屏重排，而不是局部修补

### 4.2 范围控制

第一阶段只改 full REPL：

- 改: `backend/src/cli/repl.js`
- 不改: `liteRepl.js`
- 不改: one-shot 非 TTY 路径
- 不改: 现有 AI/工具业务逻辑

原因：

1. full REPL 是当前“有框输入区”问题的主战场
2. `liteRepl.js` 仍可作为保守 fallback
3. 先控制爆炸半径，避免一次性重写全部 CLI

---

## 五、为什么必须使用 alternate screen

如果继续留在主屏 scrollback：

1. 应用内固定输入区与终端自然滚动会互相争抢底部空间
2. 长输出后回看依赖宿主终端，而非应用自身布局
3. 输入框边框一旦需要“贴底固定”，就仍然要使用相对 ANSI 补画

这会把问题重新带回来。

因此本方案明确要求：

1. full REPL 进入 alternate screen 运行
2. 主屏 scrollback 在进入前完整保留
3. 交互期间的回看由 transcript viewport 负责
4. 退出时恢复主屏，不污染宿主 shell 历史

这和项目规则不冲突：

- 主屏 scrollback 不会被破坏
- 也不需要使用 scroll region
- 输入区稳定性由应用布局保证，不由终端自然滚动“碰运气”

---

## 六、目标架构

建议新增目录：

```text
backend/src/cli/tui/
├── runtime/
│   ├── alternateScreen.js
│   ├── terminalInput.js
│   ├── screenRenderer.js
│   └── terminalCapabilities.js
├── store/
│   ├── replStore.js
│   └── actions.js
├── layout/
│   └── AppShell.js
├── components/
│   ├── TranscriptViewport.js
│   ├── PromptFrame.js
│   ├── FooterBar.js
│   ├── StatusOverlay.js
│   └── OverlayHost.js
└── adapters/
    └── replController.js
```

### 6.1 `runtime/alternateScreen.js`

职责：

1. 进入 / 退出 alternate screen
2. 统一管理光标显示状态
3. 管理 bracketed paste
4. 处理退出时的终端恢复

要求：

1. 任何异常退出都必须恢复主屏和光标
2. 不允许把终端残留在 raw mode
3. 不允许残留 alternate screen

### 6.2 `runtime/terminalInput.js`

职责：

1. 放弃 `readline`
2. 改为 raw stdin + keypress 解析
3. 自己维护文本缓冲、光标、历史、选择、粘贴事件

必须支持：

1. 左右移动
2. 多行输入与自动换行
3. 历史上下导航
4. bracketed paste
5. 中文输入法共存
6. `/s`、`/i`、普通输入排队

### 6.3 `runtime/screenRenderer.js`

职责：

1. 持有上一帧 virtual screen
2. 根据布局结果生成下一帧
3. 只输出最小 diff，避免整屏闪烁

要求：

1. resize 时允许整屏重绘
2. 普通输入时只刷新受影响区域
3. 输出策略必须和业务状态解耦

### 6.4 `store/replStore.js`

这是整套 TUI 的单一状态源。

至少包含以下状态：

1. `terminal`: `rows` / `columns` / color depth / capability flags
2. `prompt`: `value` / `cursor` / `mode` / `historyIndex`
3. `transcript`: 消息块、工具块、状态块
4. `footer`: 模型、tokens、git、permission mode
5. `overlay`: slash picker、@ 文件选择、权限确认、计划面板
6. `activity`: busy / streaming / tool running / interrupt requested
7. `viewport`: transcript scrollTop / stickyBottom / unseen divider

原则：

1. 业务逻辑只能 dispatch action
2. 组件不能直接写 stdout
3. renderer 不能自己推导业务状态

### 6.5 `layout/AppShell.js`

主布局拆成三块：

1. 主体 transcript viewport
2. 底部 prompt frame
3. footer / overlay 区域

其中：

1. transcript 负责“看历史”
2. prompt frame 负责“当前输入”
3. overlay 负责 slash picker、文件候选、权限框

### 6.6 `components/PromptFrame.js`

这是本次重构的中心组件。

职责：

1. 画出稳定的上边框、输入区、下边框
2. 根据终端宽度自动换行
3. 光标始终留在框内
4. 在 resize 后保持几何完整

关键要求：

1. 它只基于 `prompt state + terminal size` 计算自身布局
2. 不允许直接读 `process.stdout` 当前光标位置
3. 不允许再出现“先画边框，再移动光标回填输入”这种流程

### 6.7 `components/TranscriptViewport.js`

职责：

1. 承接现有 AI 回复、工具输出、状态行、完成面板
2. 支持自动 stick 到底部
3. 支持用户向上翻看历史
4. 支持 resize 时重新换行布局

它将取代当前“输出直接写进宿主 scrollback”的行为。

### 6.8 `components/FooterBar.js`

现有 `hudRenderer` 里的数据仍然能用，但底部渲染方式不能再复用。

可以保留：

1. git/model/token/cost 数据源
2. 状态拼接逻辑

不能保留：

1. `save/restore cursor` 写到底行
2. 定时强刷宿主终端最后一行

新 FooterBar 只渲染应用布局树里的 footer。

### 6.9 `components/OverlayHost.js`

负责承接现有这些“临时层”：

1. slash picker
2. `@` 文件选择
3. 权限确认
4. 计划面板
5. 提示补全候选

原则：

1. overlay 只覆盖布局层
2. 不再通过 `console.log` 在 prompt 周围临时插行

### 6.10 `adapters/replController.js`

这是业务适配层。

职责：

1. 把现有 `router`
2. `ai`
3. `toolUseLoop`
4. `taskPanelState`
5. 状态流 `onChunk` / `onStatus`

统一转成 store action。

它的意义是：

**保留现有业务能力，替换掉终端渲染壳。**

---

## 七、与现有代码的边界

### 7.1 可以直接复用的部分

以下内容应尽量复用：

1. `router.js` 的命令路由
2. `ai.js` 的对话入口
3. `toolUseLoop` 的工具循环
4. `hudRenderer` 的状态数据模型
5. `panels.js` / `toolDisplay.js` 的文本摘要逻辑
6. `formatters.js` 的宽度计算与 markdown 渲染能力

### 7.2 必须废弃的终端做法

以下做法在 full TUI 模式下必须退出主路径：

1. `readline` 拥有输入行
2. monkey-patch `rl._refreshLine()`
3. 业务层直接 `console.log()` 到交互主屏
4. 通过相对 ANSI 在输入区上下补画边框
5. 把临时状态写到底行再 restore cursor

### 7.3 可作为 fallback 保留的路径

以下路径应继续保留：

1. `liteRepl.js`
2. 非 TTY 模式
3. 诊断模式 / 应急降级模式

建议引入：

```text
KHY_FULL_TUI=1
```

用于第一阶段灰度切换。

---

## 八、实施阶段

### Phase 1: 抽离控制层，不改业务

目标：

1. 从 `repl.js` 中剥离“业务控制”与“终端输出”
2. 把 `onChunk`、`onStatus`、`line input` 等都改为 action 驱动

交付物：

1. `replController.js`
2. `replStore.js`
3. 一套最小 action 定义

完成标志：

1. 旧 REPL 仍可运行
2. TUI 壳还未替换
3. 业务输出可同时流向旧 stdout 和新 store mock

### Phase 2: 建立最小 TUI Shell

目标：

1. 进入 alternate screen
2. 渲染 transcript viewport
3. 渲染稳定的 framed prompt
4. 渲染 footer

第一阶段不做复杂 overlay。

完成标志：

1. 纯输入、纯输出、纯 resize 都稳定
2. 用户可以在框内输入并提交
3. AI 回复能进入 transcript viewport

### Phase 3: 接入流式输出与工具进度

目标：

1. `thinking`
2. `tool_progress`
3. `tool_result`
4. 正文流式输出

全部进入 transcript 统一渲染。

完成标志：

1. 不再需要 busy prompt 临时补画
2. 正文和过程边界由布局管理
3. 不再依赖宿主 scrollback 的视觉秩序

### Phase 4: 迁移 overlay 与交互增强

迁移：

1. slash picker
2. `@` 文件候选
3. 权限对话框
4. 计划面板
5. 任务状态视图

完成标志：

1. 旧 REPL 的大部分“临时输出型 UI”都退出主路径
2. prompt 区和 overlay 区都由统一布局管理

### Phase 5: 灰度切换与 fallback

策略：

1. 默认仍走 legacy full REPL
2. 开环境变量切换到 full TUI
3. 在真实 PTY 回归稳定后再考虑默认开启

---

## 九、测试方案

这类重构不能只靠 mock `stdout.write`。

必须同时具备三层测试。

### 9.1 纯布局测试

验证：

1. 不同 `rows/columns` 下 prompt frame 的几何结果
2. 长输入、多行输入、中文宽字符输入时的换行位置
3. footer 与 overlay 不互相挤压

### 9.2 virtual screen 快照测试

验证：

1. resize 前后边框完整
2. transcript 换行稳定
3. 光标位置始终落在输入框内

### 9.3 真实 PTY 端到端测试

必须新增 pseudo-terminal 测试，覆盖：

1. `120 -> 48 -> 97 -> 33` 连续 resize
2. 长输入过程中 resize
3. 流式正文输出过程中 resize
4. 工具输出 + overlay + resize 组合场景

原因：

当前这一类 bug 之所以反复出现，就是因为 mock 环境通过了，真实终端才暴露问题。

---

## 十、验收标准

完成后必须满足以下标准：

1. **输入框稳定**
   - 连续 resize 时，边框始终完整
   - 上下边线不丢失，不歪斜，不残留

2. **光标稳定**
   - 长输入换行后光标位置正确
   - 中文宽字符与 emoji 不导致错位

3. **输出隔离**
   - AI/tool/status 输出只进入 transcript viewport
   - 不允许踩进输入框区域

4. **scrollback 安全**
   - 主屏 shell scrollback 不被污染
   - 退出 TUI 后 shell 恢复正常

5. **恢复能力**
   - 任何异常退出都恢复光标、raw mode、alternate screen

---

## 十一、明确不做的事

本方案第一轮明确不做：

1. 同时重写 `liteRepl.js`
2. 把所有 CLI 命令都迁成 React/JSX 风格组件
3. 一上来就引入完整外部 Ink 依赖
4. 在第一阶段处理所有 overlay 细节

原因：

1. 这会把问题从“输入框稳定性”扩展成“全 CLI 基础设施重建”
2. 项目当前更需要先收住最痛的真实终端问题

---

## 十二、最终结论

如果 KHY 想长期保留“有框输入区”，那么：

1. 不应继续在 `readline + 主屏 stdout + ANSI 相对补画` 上做局部修补
2. full REPL 需要迁移到正式 TUI Shell
3. 该 TUI 必须拥有自己的布局树、状态树和 transcript viewport
4. resize 之后应该“重排”，而不是“补画”

因此这不是一次普通 bugfix，而是一次**交互壳层重构**。

这次重构的目标不是把边框重新画出来，而是把“边框为什么总会坏”这一类问题从架构层面移除。
