add refactor pi-tui docs

This commit is contained in:
liruifengv 2026-07-01 12:27:58 +08:00
parent a5614cd680
commit 39df1a4dd3
6 changed files with 3632 additions and 0 deletions

182
handoff.md Normal file
View file

@ -0,0 +1,182 @@
# pi-tui 改造交接Phase B 完成 / Phase C 进行中)
> 接手 agent 请先读本文,再读 `plan/` 下的阶段计划phase-b / phase-c`research.md`oh-my-pi 调研)。本文记录已完成的工作、当前状态、待决事项和下一步。
---
## 0. TL;DR当前状态
- **分支:** `chore/upgrade-pi-tui-0.78.1`(非 main可直接提交。工作树干净`plan/``research.md``handoff.md` 未跟踪(不要提交这些)。
- **Phase B终端基础设施✅ 全部完成**6 task + 整阶段 review + 收尾复审,全部通过)。
- **Phase CApp 层接入):🟡 进行中**。Task 1/2/3 **已完成 + 通过 review**Task 5 **刚开始即被用户中断**未实施Task 6/7 未开始Task 4 经用户确认**推迟到 Phase D**。
- **测试:** pi-tui `node --test packages/pi-tui/test/*.test.ts` **753/753**app `pnpm --filter @moonshot-ai/kimi-code test` 全绿typecheckpi-tui + kimi-code均通过。
- **下一步:** 继续 Phase C Task 5组件级渲染 partial roots + loader 接入)。
---
## 1. 工作流背景
`superpowers:subagent-driven-development` 技能执行:每个 Task 派 **实现者coder→ spec compliance reviewercoder→ code quality reviewercoder**,串行;质量审查若不通过则实现者修复后**重审**。对设计级 scaffold 先派 **plan agent 做 deep-dive** 逐字提取机制,再交实现者。
**关键约束(每个提交都遵守):**
- 语义化提交(`feat`/`fix`/`refactor`/`test`/`docs`/`perf`/`chore`scope 为 `pi-tui``kimi-code`)。
- **绝不**加 co-author**绝不**在任何地方出现 "claude" 字样。
- 只暂存本次改动的文件;`plan/``research.md``handoff.md` 保持未跟踪。
- Node-pure禁止 `Bun.*`、FFI、`pi-natives``pi-utils``Bun.env``process.env`
- 账本引擎仍在 `PI_TUI_ENGINE=ledger` 开关后Phase A Task 11 的"切默认"未做),**保持现状,不要切默认**。
- `apps/kimi-code` 必须通过 `@moonshot-ai/kimi-code-sdk` 消费核心能力,**不**直接依赖 `@moonshot-ai/agent-core`
- app 侧 TUI 改动遵循 `.agents/skills/write-tui/SKILL.md` + `apps/kimi-code/AGENTS.md``printableChar()` 比可打印键、`chalk.hex(colors.<token>)` 上色CI guard
**验证命令:**
```bash
# pi-tui
cd packages/pi-tui && node --test test/*.test.ts
pnpm --filter @moonshot-ai/pi-tui typecheck
# appvitest--filter 下路径要包相对)
pnpm --filter @moonshot-ai/kimi-code exec vitest run test/tui/<file>.test.ts
pnpm --filter @moonshot-ai/kimi-code typecheck
```
**参考实现源(只读对照):** `/Users/moonshot/Desktop/moonshot/oh-my-pi/packages/tui/src/`
---
## 2. Phase B终端基础设施—— ✅ 全部完成
15 个提交BASE `7418b035`
| Task | 提交 | 内容 |
|---|---|---|
| 1 | `8ff1ced5` `c41edab4` | 终端能力表 `terminal-capabilities.ts` + 注入引擎sync/image/ED22 走 caps |
| 2 | `62b414a2` `a077db20` | DA1 哨兵探测 FIFO + DECRQM`terminal-probe.ts`+ 可变 `ProcessTerminalCapabilities` + `engine.refreshSyncFraming()` |
| 3 | `273fd0ad` `90a9da7c` | Headless 模式(`#safeWrite` 唯一出口、`start()` 早返回) |
| 4 | `437492eb` `fc310cc2` `c037f1bc` | Resize viewport deferalt-screen fast path + 120ms settleledger-safe |
| 5 | `539d6396` `c04635da` `d7c822f7` | 输入鲁棒性stdin-buffer partial-hold + paste watchdog + bracketed-paste+ coalesced Escape 修复 + fresh-escape Critical 修复 |
| 收尾 | `8262da57` `d83afaf1` `7a2a7e55` | 探测字节防泄漏 + probe→sync 集成测试 + stdin-buffer 定时器 flake 稳定 |
**Phase B 中解决的几个真问题(接手须知):**
- **kitty 查询与探测 FIFO 冲突:** 我们 `terminal.ts` 已有 `\x1b[>7u\x1b[?u\x1b[c]`,若再跑探测会让键盘 owner 多发 `CSI c` 导致 FIFO 失同步——已拆分为 `start()` 只 push `\x1b[>7u`、探测的第一个探测 `\x1b[?u\x1b[c]` 作为唯一 kitty 查询。改探测/键盘逻辑时别再引入重复 `CSI c`
- **CMUX/TERM 让 mux 检测恒为真:** `isMultiplexerSession()``TMUX/STY/ZELLIJ/CMUX_*/TERM`。任何 mux 相关测试必须显式清理这些 envsnapshot/restore否则非 mux 用例变空壳。见 `test/resize-defer.test.ts``MUX_ENV_KEYS`(含 `TERM`)。
- **WezTerm coalesced Escape 回归:** 移植 OMP `stdin-buffer.ts` 后,`\x1b\x1b[27;129:3u` 被合并成一个 meta-CSI token我们的 `keys.ts` 不识别、Escape 失灵。调研发现 OMP 的 `keys.ts` 委托 Rust native`keys.rs`)且把合并形式解析成 `alt+<inner>`,照抄既不现实也修不了 Escape。改用**方案 (b)-minimal**`keys.ts` `matchesKey``escape` 分支加一条窄规则(剥前导 ESC 后递归,仅当内层本身也是 Escape 才认),~12 行,不误伤 `\x1b\x1b[A`alt+up
- **fresh-escape Critical bug** stdin-buffer 在事件循环卡顿 + 输入洪流时fresh escape 分支会重 hold 而非 flush导致 stale partial 与新按键合并丢键。修复force-flush stale partial + 幂等 `#armFlushTimer`,带确定性复现测试(回退后 8/8 复现丢键 `["\x1b[<\x1b[A]"]`)。
- **探测字节漏进编辑器**违反计划明确目标DA1 FIFO 的核心目的就是防这个。已在 terminal negotiation gate 过滤 DECRPM/OSC11`parseKeyboardProtocolNegotiationSequence`),不让它们到 `forwardInputSequence`
- **测试 flake** Task 5 两段式 flush timer10ms + `setTimeout(0)`)让旧测试的 `wait(15)` 余量太紧——提到 `wait(50)`
**Phase B 范围外 / 后续跟进(已刻意不做):**
- kitty 25ms 去重窗口(`#pendingKittyPrintableAtMs`sibling fix。
- `components/input.ts` 单行 input 的 paste 解码:目前完全不解 re-encoded control。
- 引擎 resize/mux 走 `#caps` 的统一:当前仍读 `process.env`
- in-band resize`?2048h`/ appearance push`?2031h`)的启用:探测已产出字段但未启用。
- Phase A Task 11切 ledger 为默认):未做,且本阶段不做。
---
## 3. Phase CApp 层接入)—— 🟡 进行中
计划:`plan/2026-06-30-pi-tui-app-integration-phase-c.md`。改动集中在 `apps/kimi-code/src/tui/` + `packages/pi-tui/src/ledger/` + `packages/pi-tui/src/components/image.ts`
### 3.1 已确认的产品/架构决策(用户拍板)
1. **决策 2transcript 内存模型):废弃 rendered sliding window。** 旧行不销毁、commit 进 native scrollback组件对象 + renderCache 随 turn 增长Task 4 可在 commit 后清 cache 缓解)。匹配 OMP 模型。
2. **Task 1 live 边界:整个当前 turn 起点**(用现成 `isTurnBoundaryComponent`user-message 组件 = turn 起点。live 覆盖整个当前 turnthinking/工具/assistant零抖动。代价长 turn 的 commit 推迟到 turn 结束。
3. **决策 3StepSummaryappend-only。** 旧 step 留 scrollbacksummary 作为当前 turn 末尾的新行追加summary 不进 `transcriptEntries`(不进 LLM context`mergeAllTurnSteps` 一并改。
4. **Task 4`setNativeScrollbackCommittedRows` 优化):推迟到 Phase D。** 其"跳过重排"收益已被现有 render cache + 引擎 chainStable 覆盖,实际价值只是 commit 后清 cache 的内存优化。
### 3.2 计划与代码的出入deep-dive 校正,接手须知)
1. **`ImageBudget` 桩不存在**——`image.ts` 只有 `Image`Task 6 是从零新建。
2. **`@moonshot-ai/pi-tui` 没导出 seam 子路径**——计划里的 `from "@moonshot-ai/pi-tui/ledger/seam"` 会编译失败;已把 seam 类型加到 `packages/pi-tui/src/index.ts` 导出Task 1 已做)。
3. **`TranscriptEntry` 没有 `transient` 字段**——`transient` 只在 `AssistantMessageComponent` 私有 `lastTransient`。但 Task 1 用 turn-boundary`isTurnBoundaryComponent`),不依赖 `transient`
4. **`StepSummaryComponent` 已经是永远折叠单行**——Task 3 的"collapsed-by-default"现状已满足,不用新增 UI。
5. **`#frameSegments` 只追顶层 child**——partial roots 粒度是顶层容器,所以 Task 5 对 **activity spinner** 有收益(跳过 transcript 重渲),对 transcript 内部 spinner 收益小。
### 3.3 逐 Task 状态
| Task | 主题 | 提交 | 状态 |
|---|---|---|---|
| 1 | TranscriptContainer seamturn 边界)+ seam 导出 | `9c166d8d` | ✅ 实现 + spec ✅ + quality ✅ |
| 2 | 废弃 rendered sliding window | `db719a88` + `2d1cad56`(注释修正) | ✅ 实现 + spec ✅ + quality ✅ |
| 3 | StepSummary append-only | `fe16e74e` + `a5614cd6`over-counting 修复) | ✅ 实现 + spec ✅(发现 over-counting+ 修复 |
| 5 | 组件级渲染partial roots+ loader | — | ⬜ **刚开始即被用户中断,未实施** |
| 6 | ImageBudget 实装 | — | ⬜ 未开始 |
| 7 | 真实 app 验证 + 整阶段 review | — | ⬜ 未开始 |
### 3.4 Phase C 中解决的真问题(接手须知)
- **Task 3 over-counting bug重要** `mergeCurrentTurnSteps` 在每次 `appendTranscriptEntry` 都跑(`kimi-tui.ts:~1743`。append-only 后旧 step 仍在树里,每帧被 `existingSummary.addCounts(...)` 重复计入 summary计数随每次 append 膨胀)。修复:给 `StepSummaryComponent``setCounts(thinking, tool)`(替换而非累加),`mergeCurrentTurnSteps` 每帧**重算当前 overflow 总数并 set**(幂等)。`mergeAllTurnSteps`replay 调一次)不受影响,仍用 `addCounts`。测试已强化(回退 `addCounts` → 4恢复 `setCounts` → 2/3可捕获回归
- **Task 2 metadata 保留:** 废弃销毁后,旧组件仍在 `transcriptContainer.children`,其 metadataWeakMapkey=组件)随之保留(`getTranscriptComponentEntry` 仍能解析,**不**返回 undefined。这是预期的WeakMap 随 children 增长,与组件对象同量级),不是泄漏。已在 `trimTranscriptWindow` 注释里写清楚(`2d1cad56`)。
- **seam 类型导出:** app 侧 `import type { NativeScrollbackLiveRegion } from "@moonshot-ai/pi-tui"`(从 `.` 导出,不是 `/ledger/seam`)。
---
## 4. 当前待办 / 注意事项
### 立即下一步Phase C Task 5组件级渲染 partial roots + loader 接入)
被用户中断的 implementer prompt 已设计好,要点:
- **引擎侧(`packages/pi-tui/src/ledger/engine.ts`**
- `subtreeContains(root, target)` 模块级 helperduck typing `children`,避免 engine→tui 循环 import
- `#componentRenderTargets = new Set<Component>()`
- `public requestComponentRender(component)` 把 target 加进 set。
- `#resolvePartialComposeRoots()`:把 target 映射到顶层 segment root首帧必须全量返回 null
- `#composeFrame` reuse 分支:非 target 且 `previous?.component === child && previous.start === offset` 时**复用** `previous.lines` + seam不调 `child.render`)。首帧全量。`chainStable` 可保守置 false。
- `doRender()` compose 成功后 `this.#componentRenderTargets.clear()`
- **`TUI.requestComponentRender``packages/pi-tui/src/tui.ts``requestRender` 后):** ledger 下 `engine.requestComponentRender(component)`,然后 `this.requestRender()`
- **loader`apps/kimi-code/src/tui/components/chrome/moon-loader.ts:~101`** `this.ui.requestRender()``this.ui.requestComponentRender(this)``MoonLoader` 持有 `this.ui: TUI``extends Text`(是 Component
- **footer goal badge不切**(低频,不在 transcript 子树,全量 requestRender 由 render cache 兜底)。
- **测试:** pi-tui 侧扩展 `ledger-engine-golden.test.ts`A 重 render、B 不重 renderapp 侧扩展 `moon-loader.test.ts`(断言调 `requestComponentRender(this)`)。
- **提交:** `perf: component-scoped render for loader to avoid full-tree repaints`
> 完整 prompt 在上一轮 assistant 消息里(被中断那条)。可直接复用。
### Phase C 剩余
- **Task 6ImageBudget 实装):** 从零新建(非替换桩)。要点:在 `packages/pi-tui/src/components/image.ts``ImageBudget`beginPass/endPass/observe/takePurgeIdscap 默认 8`PI_TUI_IMAGE_CAP` 覆盖);引擎 compose 前后 beginPass/endPassemit 用 `takePurgeIds()` 生成 kitty `d=I` purge`deleteKittyImage` 已存在于 `terminal-image.ts`)。**前置子任务:** 确认 `parseKittyImageHeader`/`encodeKitty` 是否带 `i=<id>`budget 需从 line 解析 imageId
- **Task 7真实 app 验证 + 整阶段 review** 跑 app + pi-tui 全量测试 + typecheck手动验证`PI_TUI_ENGINE=ledger` 启动 CLI长会话 scrollback、流式 reveal、activity spinner 不重排 transcript、resize、StepSummary、图片 demote
### 遗留注意事项
- **`oxfmt` 未在本仓库强制**`lint-staged` 只跑 `oxlint`,无 `format` script/CI。各 task 实现者刻意**不**跑 `oxfmt`(会 reformat 大量无关文件)。如需 repo-wide 格式化,单独立项。
- **`pnpm --filter @moonshot-ai/kimi-code test -- <file>` 会跑整个套件**(不过滤);聚焦单文件用 `pnpm --filter @moonshot-ai/kimi-code exec vitest run <path>`
- **全局 `PI_TUI_ENGINE=ledger` 跑 pi-tui 全量会触发一个 Phase A 遗留失败**`tui-render.test.ts` 的 maxLinesRendered 测试),非 Phase B/C 回归。本阶段验证用默认legacy跑全量ledger 路径由各 ledger 测试自行 `withEnv` 打开。
---
## 5. 关键上下文
### 计划与调研
- 计划:`plan/2026-06-30-pi-tui-terminal-infra-phase-b.md`Phase B`plan/2026-06-30-pi-tui-app-integration-phase-c.md`Phase C、同目录还有 phase-a/d。
- 调研:`research.md`oh-my-pi vs 我们的差异地图、价值/effort 分级、风险)。
- oh-my-pi 源(只读对照):`/Users/moonshot/Desktop/moonshot/oh-my-pi/packages/tui/src/`
### 仓库约定(摘自根 AGENTS.md + write-tui skill
- Node `>=24.15.0`pnpm `10.33.0``engine-strict=true`)。
- 可选属性直接传 `undefined`,不用条件 spread可选属性类型不必再 `| undefined`
- 内部单参数方法不要改成 options 对象。
- 除包的 `index.ts` 外,其它 `index.ts` 优先 `export * from './module'`
- app TUI 架构:`KimiTUI` 是 coordinator控制器在 `controllers/`;组件在 `components/{chrome,dialogs,editor,media,messages,panes}/`transcript 块在 `components/messages/`streaming 在 `controllers/streaming-ui.ts`
### 已完成的 deep-dive结论在本文
- Phase B Task 2DA1 FIFO、Task 5stdin-buffer—— 已落地。
- Phase C 整体 deep-diveagent-30—— 结论见 §3.1/§3.2,逐 task file:line 在当时的 plan-agent 输出里(如需复核可重派 plan agent 读 plan + 代码)。
---
## 6. 如何继续
1. 读本文 + `plan/2026-06-30-pi-tui-app-integration-phase-c.md` Task 5/6/7 + `apps/kimi-code/AGENTS.md`
2. 跑 `git log --oneline 7418b035..HEAD` 确认当前在 `a5614cd6`(或更新)。
3. **继续 Phase C Task 5**:派一个 coder 实现prompt 见 §4 上方,或上一轮被中断的那条),然后 spec + quality review。
4. Task 6ImageBudget→ Task 7验证 + 整阶段 review
5. 整阶段 review 通过后:跑 `gen-changesets` 技能(默认 `minor`,不要自行 `major`)→ PR英文标题 + 填模板,**不要**暴露 agent 身份或 claude 字样)。
如需用 subagent 继续,沿用 `superpowers:subagent-driven-development` 技能(实现者 → spec reviewer → quality reviewer

View file

@ -0,0 +1,430 @@
# Phase C 实施计划App 层接入(废弃 sliding window接通 seam
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 `apps/kimi-code` 的 transcript 接入 Phase A 的账本引擎: transcript 容器上报 `NativeScrollbackLiveRegion` seam区分 byte-stable 历史行与 live 流式尾),废弃"主动销毁旧渲染行"的 rendered sliding window让行 commit 进 native scrollback重想 `mergeCurrentTurnSteps`/`StepSummary` 在 append-only 下的语义,并把 loader 切到组件级渲染。
**Architecture:** 改动集中在 `apps/kimi-code/src/tui/`transcript 容器、streaming、transcript-window、loader+ pi-tui 引擎补 `requestComponentRender`partial roots。**核心认知**:账本引擎的审计会自动处理 app 的组件 swap"duplication, never loss" 重新锚定),所以 Phase C 的主线不是"让 app 不变",而是"正确上报 seam + 放松 rendered window"。
**Tech Stack:** TypeScriptNode 24`node --test`、app 现有测试基座。
**依赖:** Phase A账本引擎默认+ Phase B终端能力/resize defer已完成。
**前置 deep-dive** 启动 Phase C 时,先派 plan agent 逐字提取 `apps/kimi-code/src/tui/kimi-tui.ts``appendTranscriptEntry` / `trimTranscriptWindow` / `mergeCurrentTurnSteps``controllers/streaming-ui.ts` 的 streaming 路径、`components/chrome/gutter-container.ts`,把任务里的设计落为逐行改动。
---
## 关键设计决策核心app 内存模型)
### 决策 1seam 由 `transient` 标志驱动
`AssistantMessageComponent.updateContent(fullText, { transient })``transient` 就是天然的 byte-stable/live 边界:
- `transient: false`(已 finalize的消息 → byte-stable永不重排可 commit。
- `transient: true`(流式中)的消息 → live每帧变不可 commit。
transcript 容器(`GutterContainer` 或包一层 `TranscriptContainer`)实现 `NativeScrollbackLiveRegion`
```ts
getNativeScrollbackLiveRegionStart(): number | undefined {
// 返回"第一个 still-live 行"的局部行索引;其前全是 byte-stable
// 实现:遍历子组件,找到第一个 transient 的 assistant 消息的起始行
}
getNativeScrollbackCommitSafeEnd(): number | undefined {
// 流式 assistant 消息内部可能有"已稳定的前缀"append-only 的 token
// 初期可不实现(默认 = liveRegionStartPhase D 优化时再加
return undefined;
}
```
### 决策 2废弃 rendered sliding window保留 logical window
**当前**`transcript-window.ts` 把 rendered children 限制在最近 50 轮,`trimTranscriptWindow()` 销毁旧行、`mergeCurrentTurnSteps()` 把旧步骤合并成 `StepSummaryComponent`**会替换/销毁已渲染组件**)。
**问题**append-only 账本下,已 commit 的行在 native scrollback 里不可变;销毁组件会触发"frame shrank into committed prefix"分支,导致重新展示 frame tail + 重新切片 prefix + 接受重复(功能正确但有 scrollback 重复,且抵消了账本的主要收益)。
**新模型**
- **Rendered transcript 不再销毁旧行**:让旧行 commit 进 native scrollback组件在行滚出窗口后可被引擎释放引擎只渲染 window`trimTranscriptWindow` 改为只裁剪 **logical** `transcriptEntries`(用于 LLM context不销毁 rendered children。
- **Logical window 保留**`transcriptEntries` 仍限制最近 N 轮(控制 LLM context 大小),但这是逻辑层,不影响渲染。
> **影响**:长会话的 rendered 行数会增长,但引擎只渲染 windowheight 行),已 commit 的行只在 scrollback 里(终端持有),内存不随会话长度线性增长(账本只持 `committedPrefix` 引用 + window。这与 OMP 的模型一致。
### 决策 3`mergeCurrentTurnSteps` / `StepSummary` 改为 append-only
**当前**:把已渲染的多个 step 组件**替换**成一个 `StepSummaryComponent``children[idx] = group`)。
**新模型(推荐)**StepSummary 作为**追加的新行**live region 内),不替换已 commit 的 step。旧 step 留在 scrollback 里summary 作为新内容追加在下方。这是"duplication, never loss"——step 在 scrollback 里summary 在 window 里,可接受。
**备选**保留替换依赖审计重新锚定duplication never loss。功能正确但 scrollback 会有重复。仅在决策 3 推荐方案落地困难时采用。
> **需 app 团队确认**:决策 2/3 改了 transcript 的内存与显示语义,是 Phase C 唯一需要产品/架构拍板的地方。计划默认采用"放松 rendered window + StepSummary append-only"。
### 决策 4组件级渲染partial roots补到引擎
Phase A 的 `#composeFrame` 留了 `partialRoots = null` 的桩。Phase C 实现 `#resolvePartialComposeRoots` + segment reuse并新增 `TUI.requestComponentRender(component)`。loader 的高频帧切到 `requestComponentRender(this)`,避免整树重渲。
---
## 约定
- **工作目录**仓库根app 改动跨 `apps/kimi-code``packages/pi-tui`)。
- **跑 app 测试**`pnpm --filter @moonshot-ai/kimi-code test`(或对应脚本)。
- **跑 pi-tui 测试**`cd packages/pi-tui && node --test test/*.test.ts`
- **提交规范**`feat(kimi-code):` / `refactor(kimi-code):` / `feat(pi-tui):`;无 co-author无 claude。
---
## 文件结构
| 文件 | 动作 | 责任 |
|---|---|---|
| `apps/kimi-code/src/tui/components/chrome/transcript-container.ts` | 新建/修改 | `TranscriptContainer`(或扩 `GutterContainer`)实现 `NativeScrollbackLiveRegion` |
| `apps/kimi-code/src/tui/utils/transcript-window.ts` | 修改 | 改为只裁剪 logical entries不销毁 rendered children |
| `apps/kimi-code/src/tui/kimi-tui.ts` | 修改 | `appendTranscriptEntry` / `mergeCurrentTurnSteps` 接新模型loader `requestComponentRender` |
| `apps/kimi-code/src/tui/controllers/streaming-ui.ts` | 修改 | streaming seam 上报StepSummary append-only |
| `packages/pi-tui/src/ledger/engine.ts` | 修改 | 补 `#resolvePartialComposeRoots` + `requestComponentRender` |
| `packages/pi-tui/src/tui.ts` | 修改 | 暴露 `requestComponentRender` |
| `packages/pi-tui/src/components/image.ts` | 修改 | 实装 `ImageBudget`(替换 Phase A 桩) |
| `apps/kimi-code/src/tui/.../transcript-container.test.ts` | 新建 | seam 行为 |
| `apps/kimi-code/src/tui/.../transcript-window.test.ts` | 新建/修改 | logical window 行为 |
---
## Task 1: TranscriptContainer seam`NativeScrollbackLiveRegion`
**Files:**
- Create/Modify: `apps/kimi-code/src/tui/components/chrome/transcript-container.ts`
- Create: 对应测试
### Step 1: 实现 seam
让 transcript 容器(`GutterContainer` 子类或新 `TranscriptContainer`)实现:
```ts
import type { NativeScrollbackLiveRegion } from "@moonshot-ai/pi-tui/ledger/seam";
class TranscriptContainer extends GutterContainer implements NativeScrollbackLiveRegion {
// 返回第一个 still-livetransient行的局部行索引其前全 byte-stable
getNativeScrollbackLiveRegionStart(): number | undefined {
let row = 0;
for (const child of this.children) {
const lines = child.render(this.width);
if (isLive(child)) {
return row; // 第一个 live 子组件的起始行
}
row += lines.length;
}
return undefined; // 全部 byte-stableshell 语义:滚出的全 commit
}
}
function isLive(component: Component): boolean {
// 通过 transcript-component-metadata 的 WeakMap 查到 TranscriptEntry
// 判定其是否处于 transientstreaming状态。
// 实现细节在 Phase C 启动 deep-dive 时确定。
const entry = getTranscriptComponentEntry(component);
return entry?.kind === "assistant" && entry.transient === true;
}
```
### Step 2: 测试 seam
断言:所有消息 finalized 时 `getNativeScrollbackLiveRegionStart()` 返回 `undefined`;一条 assistant streaming 时返回该消息起始行streaming 结束后返回 `undefined`
### Step 3: 提交
```bash
git add apps/kimi-code/src/tui/components/chrome
git commit -m "feat(kimi-code): report native scrollback seam from transcript container"
```
---
## Task 2: 废弃 rendered sliding window
**Files:**
- Modify: `apps/kimi-code/src/tui/utils/transcript-window.ts`
- Modify: `apps/kimi-code/src/tui/kimi-tui.ts``appendTranscriptEntry` / `trimTranscriptWindow`
### Step 1: 拆分 logical window 与 rendered window
`trimTranscriptWindow` 改为只裁剪 `transcriptEntries`logical**不**调用 `transcriptContainer.removeChild` 销毁 rendered children
```ts
function trimTranscriptWindow(state: TUIState): void {
// 保留:裁剪 logical entries控制 LLM context
const maxTurns = state.options.transcriptWindowTurns ?? 50;
while (logicalTurnCount(state.transcriptEntries) > maxTurns) {
state.transcriptEntries.shift();
}
// 移除:不再销毁 rendered children让它们 commit 进 native scrollback
// 旧while (transcriptContainer.children.length > ...) transcriptContainer.removeChild(...)
}
```
### Step 2: 处理 `transcriptEntries` 与 rendered children 的映射
`getTranscriptComponentEntry`WeakMap需要容忍"entry 被裁剪但 component 仍在 rendered 树里"的情况(返回 undefined`isLive`Task 1需处理 entry 不存在的情况(已 commit 的 entry 不在 logical window 里 → 视为 byte-stable
### Step 3: 测试
断言:超过 50 轮后,`transcriptEntries.length` 被裁剪,但 `transcriptContainer.children.length` 不减少(旧行保留);账本引擎的 `committedRows` 单调增长。
### Step 4: 提交
```bash
git add apps/kimi-code/src/tui/utils/transcript-window.ts apps/kimi-code/src/tui/kimi-tui.ts
git commit -m "refactor(kimi-code): stop destroying rendered transcript rows; let them commit to scrollback"
```
---
## Task 3: `mergeCurrentTurnSteps` / `StepSummary` append-only
**Files:**
- Modify: `apps/kimi-code/src/tui/kimi-tui.ts``mergeCurrentTurnSteps`
- Modify: `apps/kimi-code/src/tui/controllers/streaming-ui.ts`
### Step 1: 改 `mergeCurrentTurnSteps` 为 append-only
把"替换已渲染 step 为 StepSummary"改为"追加一个 StepSummary 行"
```ts
function mergeCurrentTurnSteps(state: TUIState): void {
const steps = collectCompletedSteps(state.currentTurn);
if (steps.length < MERGE_THRESHOLD) return;
// 不再transcriptContainer.children[idx] = new StepSummaryComponent(steps)
// 改为:追加一个 summary旧 step 留在 scrollback
const summary = new StepSummaryComponent(steps, /* collapsed */ true);
transcriptContainer.addChild(summary);
state.transcriptEntries.push({ kind: "step-summary", steps });
}
```
### Step 2: `StepSummaryComponent` 支持 collapsed-by-default
summary 默认折叠(一行"已折叠 N 个步骤"),展开时作为 live region 内的临时内容(不进入 scrollback
### Step 3: 测试
断言merge 后旧 step 组件仍在 `transcriptContainer.children`未被替换summary 是新增的最后一个 child账本审计不会因此触发大规模 resync旧 step 在 byte-stable 区summary 在 live 区)。
### Step 4: 提交
```bash
git add apps/kimi-code/src/tui/kimi-tui.ts apps/kimi-code/src/tui/controllers/streaming-ui.ts
git commit -m "refactor(kimi-code): make step summary append-only under ledger renderer"
```
---
## Task 4: `setNativeScrollbackCommittedRows` 优化(跳过已 commit 块)
**Files:**
- Modify: `apps/kimi-code/src/tui/components/...`(大型 transcript 子组件,如 `AssistantMessageComponent`
### Step 1: 利用引擎的 `setNativeScrollbackCommittedRows` 信号
引擎在 compose 每个 child 前调用 `child.setNativeScrollbackCommittedRows(rowsInScrollback)`。子组件可用它跳过"已经在 immutable scrollback 里"的块的重新布局:
```ts
class AssistantMessageComponent implements NativeScrollbackCommittedRows {
#committedRows = 0;
setNativeScrollbackCommittedRows(rows: number): void {
this.#committedRows = rows;
}
render(width: number): string[] {
// 前 #committedRows 行已在 scrollback直接复用上次的缓存不重排 markdown
if (this.#committedRows > 0 && this.#cachedLines) {
return this.#cachedLines; // 或 slice 出未 commit 的尾
}
// ... 正常渲染
}
}
```
### Step 2: 测试
断言:已 finalize 的 assistant 消息在后续帧不重排 markdownrender 调用计数稳定)。
### Step 3: 提交
```bash
git add apps/kimi-code/src/tui/components
git commit -m "perf(kimi-code): skip re-rendering committed transcript blocks"
```
---
## Task 5: 组件级渲染partial roots+ loader 接入
**Files:**
- Modify: `packages/pi-tui/src/ledger/engine.ts`
- Modify: `packages/pi-tui/src/tui.ts`
- Modify: `apps/kimi-code/src/tui/...`loader
### Step 1: 引擎补 `requestComponentRender` + `#resolvePartialComposeRoots`
`LedgerTuiEngine` 新增:
```ts
#componentRenderTargets = new Set<Component>();
#partialComposeRoots: Set<Component> | null = null;
public requestComponentRender(component: Component): void {
this.#componentRenderTargets.add(component);
// 由 TUI 的 scheduleRender 触发 doRenderdoRender 检查 targets
}
#resolvePartialComposeRoots(_width: number, _height: number): Set<Component> | null {
if (this.#componentRenderTargets.size === 0) return null;
if (this.#frameSegments.length === 0) return null; // 首帧必须全量
const roots = new Set<Component>();
for (const target of this.#componentRenderTargets) {
for (const seg of this.#frameSegments) {
if (seg.component === target || subtreeContains(seg.component, target)) {
roots.add(seg.component);
}
}
}
return roots.size > 0 ? roots : null;
}
```
`#composeFrame` 改为支持 `partialRoots`reuse 未触及 root 的 `previous.lines` + seam见 OMP `render()``reuse` 分支Phase A Task 4 留的桩)。
### Step 2: `TUI.requestComponentRender` 暴露
```ts
requestComponentRender(component: Component): void {
if (TUI.LEDGER_ENABLED) {
const engine = this.getLedgerEngine();
engine.requestComponentRender(component);
this.requestRender(); // 触发调度
} else {
this.requestRender();
}
}
```
### Step 3: loader 切到 `requestComponentRender`
在 loader 动画帧回调里(`kimi-tui.ts:919` 附近、`tui-state.ts:72-74` footer spinner
```ts
// 旧this.state.ui.requestRender();
this.state.ui.requestComponentRender(this);
```
### Step 4: 测试
pi-tui 侧:`requestComponentRender(loaderRoot)` 不重渲 transcript rootrender 计数。app 侧loader 转动时 transcript 不重排。
### Step 5: 提交
```bash
git add packages/pi-tui/src/ledger/engine.ts packages/pi-tui/src/tui.ts apps/kimi-code/src/tui
git commit -m "perf: component-scoped render for loader to avoid full-tree repaints"
```
---
## Task 6: ImageBudget 实装(替换 Phase A 桩)
**Files:**
- Modify: `packages/pi-tui/src/components/image.ts`
- Modify: `packages/pi-tui/src/ledger/engine.ts`(接入 ImageBudget
### Step 1: 实装 `ImageBudget`
```ts
export class ImageBudget {
private live = new Map<number, { lastUsed: number }>();
constructor(private readonly cap: number, private readonly onPurge: () => void) {}
beginPass(): void { /* reset per-frame seen set */ }
endPass(): void { /* demote images not seen this frame */ }
observe(id: number): void { this.live.set(id, { lastUsed: Date.now() }); }
takePurgeIds(): number[] {
if (this.live.size <= this.cap) return [];
const sorted = [...this.live.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed);
const toPurge = sorted.slice(0, this.live.size - this.cap).map(([id]) => id);
for (const id of toPurge) this.live.delete(id);
return toPurge;
}
}
```
### Step 2: 引擎接入
`#composeFrame` 前后调 `imageBudget.beginPass()/endPass()``#emitFullPaint`/`#emitUpdate``takePurgeIds()` 生成 kitty `d=I` purge 序列。
### Step 3: 测试
断言:超过 cap 的旧图像被 demote 为文本purge 序列发出。
### Step 4: 提交
```bash
git add packages/pi-tui/src/components/image.ts packages/pi-tui/src/ledger/engine.ts
git commit -m "feat(pi-tui): bound inline image store with ImageBudget"
```
---
## Task 7: 真实 app 验证
### Step 1: 全量测试
Run: `pnpm --filter @moonshot-ai/kimi-code test`
Run: `cd packages/pi-tui && node --test test/*.test.ts`
Expected: 全绿。
### Step 2: 手动会话验证
启动 CLI默认 ledger跑一个长会话
- 流式 assistant 消息正常 reveal。
- 工具调用 spinner 不引起 transcript 整树重渲(肉眼无闪烁)。
- resize 不闪屏、scrollback 保留。
- 超过 50 轮后旧消息仍可在 scrollback 里看到(向上滚)。
- StepSummary 折叠/展开正常。
- 图片正常显示,超 cap 后旧图 demote。
### Step 3: 性能对比
对比 Phase A 之前legacy与之后ledger
- 流式 token 的每帧 emit 字节数应显著下降scroll-append vs 全量 diff
- spinner 帧的 transcript 重排次数(应为 0组件级渲染
### Step 4: 提交
```bash
git commit -m "chore(kimi-code): validate ledger renderer against real sessions"
```
---
## 验收总清单
- [ ] transcript seam 正确streaming 时 live 边界在 assistant 消息起始finalized 后 undefined。
- [ ] 超过 50 轮后 rendered children 不销毁committedRows 单调增长。
- [ ] StepSummary append-only旧 step 留在 scrollback。
- [ ] loader 转动不重渲 transcript。
- [ ] ImageBudget 超 cap 后 demote。
- [ ] 真实长会话无闪烁、resize 正常、scrollback 完整。
- [ ] 全量测试通过。
---
## Self-Review
**1. 覆盖:** seamTask 1、废弃 rendered windowTask 2、StepSummaryTask 3、commit 信号优化Task 4、组件级渲染Task 5、ImageBudgetTask 6、真实验证Task 7。覆盖 Agent 5 报告的所有 app 接入点。
**2. 核心决策:** 决策 2/3废弃 rendered window + StepSummary append-only是 app 内存模型改动,已在"关键设计决策"明确,需 app 团队确认。这是 Phase C 唯一的产品/架构依赖。
**3. 占位:** Task 1 的 `isLive`、Task 5 的 `subtreeContains` 是设计级描述Phase C 启动 deep-dive 时落实为逐行代码。非 TBD。
**4. 一致性:** `TranscriptContainer implements NativeScrollbackLiveRegion`Task 1与 Phase A 的 seam 接口一致;`requestComponentRender`Task 5补 Phase A 留的 partial roots 桩;`ImageBudget`Task 6替换 Phase A 的 no-op 桩。
**5. 风险:**
- 决策 2/3 若被 app 团队否决(比如必须保留替换式 StepSummary回退到"依赖审计重新锚定"(功能正确但有 scrollback 重复)。
- 废弃 rendered window 后,`getTranscriptComponentEntry` 的 WeakMap 需容忍 entry 被裁剪Task 2 Step 2 已处理)。
- 长会话内存:账本只持 window + committedPrefix 引用,不随会话线性增长(决策 2 已论证)。

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
# Phase D 实施计划render-stress 模糊测试加固
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 Phase A 的 6 个 seed 的简化 stress 升级成 oh-my-pi 级别的渲染正确性验证网:独立的 **shadow commit ledger oracle**(断言 `scrollback == frame[0..C)`)、覆盖更多操作类型的**全 fuzz harness**、失败 seed 的 **delta-debug reducer**、更忠实的 **ghostty-web VT oracle**(可选),以及 CI 集成与 benchmark。这是账本引擎的**最终安全网**,也是后续任何渲染器改动的回归底线。
**Architecture:** 改动集中在 `packages/pi-tui/test/render-stress-*` + `bench/`。shadow ledger 是引擎合约的**独立复刻**(不 import 引擎实现),用它校验引擎 emit 的真实字节喂给 VT 后的 scrollback 状态。reducer 对失败 seed 做 delta-debug 收缩到最小复现。
**Tech Stack:** TypeScriptNode 24`node --test``@xterm/headless`(默认 oracle`ghostty-web`(可选,更忠实)、`tinybench` 或自写 `performance.now`bench
**依赖:** Phase A/B/C 完成(账本引擎 + 终端层 + app 接入稳定)。
---
## 关键设计决策
1. **Shadow ledger 是独立复刻,不 import 引擎**:它重新实现 `findCommittedPrefixResync` 的 commit 法则和窗口数学的**简化版**,独立预测"给定操作序列后 scrollback 应该是什么"。如果 shadow 与引擎一致,引擎正确;如果不一致,要么引擎错、要么 shadow 错(都需要查)。这种"双实现"是模糊测试的核心。
2. **VT oracle 分层**:默认用 `@xterm/headless`(已在 devDep高忠实度场景grapheme/ZWJ/BCE`ghostty-web`(可选,慢但准)。两套 oracle 跑同一批 seed交叉验证。
3. **Reducer 只对失败 seed 工作**fuzz 发现失败 seed 后reducer 通过"删操作/缩短序列/简化内容"的 delta-debug 把复现收缩到最小,写入 `test/render-stress-failures/` 供调试。
4. **Stress 默认 local-only**`SKIP_IN_CI` 标记fuzz 慢且非确定性CI 只跑 golden + 固定 seed。nightly 可跑全量 fuzz。
---
## 约定
- **工作目录**`packages/pi-tui/`
- **跑 stress**`node --test test/render-stress.test.ts`local`PI_TUI_STRESS_SEEDS=1000 node --test ...`(加大 seed 数)。
- **跑 bench**`node bench/<name>.ts`
- **提交规范**`test(pi-tui):` / `chore(pi-tui):`;无 co-author无 claude。
---
## 文件结构
| 文件 | 动作 | 责任 |
|---|---|---|
| `test/render-stress-oracles.ts` | 新建 | shadow commit ledger + VT oraclexterm/ghostty |
| `test/render-stress-harness.ts` | 新建 | 种子化随机 op 生成器 + 执行器 |
| `test/render-stress-reducer.ts` | 新建 | delta-debug 失败收缩 |
| `test/render-stress.test.ts` | 修改 | 接入 oracle + reducer替换 Phase A 的简化版) |
| `bench/emit.ts` | 新建 | emit 字节体积/延迟基准 |
| `bench/compose.ts` | 新建 | frame compose 基准 |
| `bench/parse-key.ts` | 新建 | key 解析基准 |
| `test/render-stress-failures/` | 新建(运行时生成) | reducer 输出的最小复现 |
---
## Task 1: Shadow commit ledger oracle
**Files:**
- Create: `packages/pi-tui/test/render-stress-oracles.ts`
### Step 1: 实现 shadow ledger
> 独立复刻引擎的 commit 法则。给定操作序列append/mutate/shrink/resize和每帧的"组件 lines + seam"shadow 预测每帧结束后 scrollback 应有哪些行。
```ts
export interface ShadowOp {
kind: "append" | "mutate-tail" | "shrink" | "resize" | "finalize-tail";
lines?: string[];
width?: number;
height?: number;
}
export class ShadowLedger {
private scrollback: string[] = [];
private windowTop = 0;
/** 喂入一帧的"组件 lines + seam 边界 + 视口高"shadow 预测 emit 后的 scrollback。 */
applyFrame(frame: string[], liveRegionStart: number | undefined, height: number): void {
const frameLength = frame.length;
const byteStableBoundary = liveRegionStart ?? frameLength;
// commit 地板 = windowTop追加式
const newWindowTop = Math.max(this.scrollback.length, frameLength - height, 0);
// 新 commit 的行 = [scrollback.length, newWindowTop),且必须 ≤ byteStableBoundary
const commitTo = Math.min(newWindowTop, byteStableBoundary);
for (let i = this.scrollback.length; i < commitTo; i++) {
this.scrollback.push(frame[i]!);
}
this.windowTop = newWindowTop;
}
getScrollback(): string[] {
return this.scrollback;
}
}
```
> 注shadow 是引擎法则的**简化**复刻(不含审计 resync/durable-exempt 的 subtle 细节,因为 shadow 假设组件 seam 诚实)。它校验"在 seam 诚实的前提下,引擎是否按 append-only 法则 commit"。审计 resync 由单独的 oracleTask 2覆盖。
### Step 2: VT oracle
```ts
import { VirtualTerminal } from "./virtual-terminal.ts";
export async function runEngineAndCapture(
ops: ShadowOp[],
width: number,
height: number,
): Promise<{ scrollback: string[]; viewport: string[] }> {
// 用 ops 驱动一个 TestComponent + TUIledger每帧 requestRender + waitForRender
// 最后返回 VT 的 getScrollBuffer() + getViewport()。
throw new Error("implement in Phase D");
}
```
### Step 3: 一致性断言
```ts
function assertMatchesOracle(
actual: { scrollback: string[]; viewport: string[] },
shadow: ShadowLedger,
frame: string[],
): void {
const expected = shadow.getScrollback();
// 引擎的 scrollback 应等于 shadow 预测的 scrollback逐行剥离 SGR 后比较)
assert.deepStrictEqual(
actual.scrollback.map(stripSgr),
expected.map(stripSgr),
"scrollback must match shadow ledger",
);
}
```
### Step 4: 提交
```bash
git add packages/pi-tui/test/render-stress-oracles.ts
git commit -m "test(pi-tui): add shadow commit ledger oracle for render stress"
```
---
## Task 2: 全 fuzz harness扩展 op 类型)
**Files:**
- Create: `packages/pi-tui/test/render-stress-harness.ts`
- Modify: `packages/pi-tui/test/render-stress.test.ts`
### Step 1: op 生成器(扩展 Phase A 的 6 seed
```ts
export type StressOp =
| { kind: "append"; text: string }
| { kind: "mutate"; index: number; text: string }
| { kind: "shrink"; toLength: number }
| { kind: "resize"; width: number; height: number }
| { kind: "finalize" } // transient → finalized
| { kind: "overlay-open" } // Phase C 后启用
| { kind: "overlay-close" }
| { kind: "image-place" }; // Phase C 后启用
export function* generateOps(seed: number, count: number): Generator<StressOp> {
const rand = mulberry32(seed);
for (let i = 0; i < count; i++) {
const r = rand();
if (r < 0.4) yield { kind: "append", text: `line-${i}-${rand().toString(36).slice(2, 6)}` };
else if (r < 0.6) yield { kind: "mutate", index: -1, text: `mut-${i}` }; // -1 = tail
else if (r < 0.72) yield { kind: "shrink", toLength: Math.max(1, Math.floor(rand() * 8)) };
else if (r < 0.85) yield { kind: "resize", width: 20 + Math.floor(rand() * 80), height: 4 + Math.floor(rand() * 16) };
else yield { kind: "finalize" };
}
}
```
### Step 2: 替换 Phase A 的简化 stress
把 Phase A 的 `test/ledger-engine-stress.test.ts`6 seed替换为调用 `generateOps` + `assertMatchesOracle` 的版本seed 数默认 50local`PI_TUI_STRESS_SEEDS` 覆盖。
### Step 3: 审计 resync oracle独立校验
增加一类 op`{ kind: "mutate-committed" }`(故意改一个已 commit 的 byte-stable 行,模拟组件重排),断言引擎通过审计 resync 重新锚定scrollback 出现重复但不丢行)。这覆盖 shadow 不覆盖的审计路径。
### Step 4: 跑 fuzz
Run: `PI_TUI_STRESS_SEEDS=200 node --test test/render-stress.test.ts`
Expected: 全绿(若失败,进入 Task 3 reducer
### Step 5: 提交
```bash
git add packages/pi-tui/test/render-stress-harness.ts packages/pi-tui/test/render-stress.test.ts
git commit -m "test(pi-tui): expand render stress fuzz with oracle and more op types"
```
---
## Task 3: Delta-debug reducer
**Files:**
- Create: `packages/pi-tui/test/render-stress-reducer.ts`
### Step 1: 实现 reducer
```ts
/** 对一个失败 seed 的 op 序列做 delta-debug 收缩,返回仍失败的最小子序列。 */
export function reduceFailure(
seed: number,
runOps: (ops: StressOp[]) => Promise<{ ok: boolean; error?: string }>,
): Promise<StressOp[]> {
let ops = [...generateOps(seed, MAX_OPS)];
let changed = true;
while (changed) {
changed = false;
// 1. 尝试删除每个 op
for (let i = ops.length - 1; i >= 0; i--) {
const candidate = ops.slice(0, i).concat(ops.slice(i + 1));
if (candidate.length === 0) continue;
const result = await runOps(candidate);
if (!result.ok) {
ops = candidate;
changed = true;
}
}
// 2. 尝试缩短每个 append 的 text
// 3. 尝试把 resize 的幅度减半
}
return ops;
}
```
### Step 2: 失败时自动写最小复现
在 stress 测试的失败回调里:
```ts
if (!result.ok) {
const minimal = await reduceFailure(seed, runOps);
const path = `test/render-stress-failures/seed-${seed}.json`;
fs.writeFileSync(path, JSON.stringify(minimal, null, 2));
assert.fail(`stress failed seed=${seed}; minimal repro written to ${path}`);
}
```
### Step 3: 最小复现可重放
加一个测试:读取 `test/render-stress-failures/*.json`,重放每个最小复现,断言通过(修复后)或失败(未修复时,作为已知失败记录)。
### Step 4: 提交
```bash
git add packages/pi-tui/test/render-stress-reducer.ts
git commit -m "test(pi-tui): add delta-debug reducer for render stress failures"
```
---
## Task 4: Ghostty-web VT oracle可选高忠实度
**Files:**
- Create: `packages/pi-tui/test/ghostty-oracle.ts`
- Modify: `packages/pi-tui/test/render-stress-oracles.ts`
### Step 1: 接入 ghostty-web
> ghostty-web 是 Ghostty 真实 VT100 解析器编到 WASMgrapheme/ZWJ/BCE 正确,比 `@xterm/headless` 更忠实。OMP 用它做 ground truth。
```ts
// 可选依赖pnpm add -D ghostty-web如可获取
// 实现 GhosttyTerminal implements Terminal同 VirtualTerminal 的接口
```
### Step 2: 双 oracle 交叉验证
stress 的每个 seed 同时在 xterm + ghostty 两个 VT 上跑,断言两者 scrollback/viewport 一致(剥离实现差异后)。差异即潜在的 xterm 近似误差或引擎 bug。
### Step 3: 提交
```bash
git add packages/pi-tui/test/ghostty-oracle.ts packages/pi-tui/test/render-stress-oracles.ts
git commit -m "test(pi-tui): add ghostty-web VT oracle for high-fidelity stress"
```
---
## Task 5: CI 集成 + Benchmark
**Files:**
- Modify: `packages/pi-tui/package.json`scripts
- Create: `packages/pi-tui/bench/emit.ts``compose.ts``parse-key.ts`
### Step 1: CI 脚本
```json
{
"scripts": {
"test": "node --test test/*.test.ts",
"test:stress": "node --test test/render-stress.test.ts",
"test:stress:full": "PI_TUI_STRESS_SEEDS=2000 node --test test/render-stress.test.ts"
}
}
```
CI 跑 `test`(含 golden + 默认 50 seed stressnightly 跑 `test:stress:full`
### Step 2: Benchmark
`bench/emit.ts`:构造一个 1000 行 frame分别跑 legacy 与 ledger 的 doRender对比 emit 字节数 + 延迟。
```ts
import { performance } from "node:perf_hooks";
// ... 构造场景,跑 N 次,输出 p50/p99 延迟 + 字节数
```
### Step 3: 提交
```bash
git add packages/pi-tui/package.json packages/pi-tui/bench
git commit -m "chore(pi-tui): add stress CI scripts and render benchmarks"
```
---
## 验收总清单
- [ ] shadow ledger 与引擎在 200+ seed 下 scrollback 一致。
- [ ] fuzz 覆盖 append/mutate/shrink/resize/finalize/mutate-committed。
- [ ] reducer 能把失败 seed 收缩到 ≤5 op 的最小复现。
- [ ] 可选ghostty-web 与 xterm oracle 交叉验证一致。
- [ ] CI 跑默认 stressnightly 跑全量。
- [ ] benchmark 显示 ledger 相对 legacy 的帧字节/延迟改善。
---
## Self-Review
**1. 覆盖:** shadow oracleTask 1、fuzzTask 2、reducerTask 3、ghosttyTask 4可选、CI+benchTask 5。覆盖 Agent A/E 报告的 render-stress 全套oracles/reducer/scheduler/ghostty
**2. 占位:** Task 1 的 `runEngineAndCapture``throw "implement in Phase D"` scaffold——Phase D 启动时填充。这是设计级计划的正常占位,非 TBD。
**3. 一致性:** `ShadowLedger.applyFrame` 的 commit 法则与 Phase A 引擎的窗口数学一致(`windowTop = max(committedRows, frameLength - height, 0)`commit 地板 = windowTop`StressOp` 类型在 Task 2/3 一致。
**4. 风险:**
- shadow ledger 是简化复刻,不覆盖审计 resync/durable-exempt 的 subtle 细节Task 2 Step 3 用专门的 `mutate-committed` op 补覆盖)。
- ghostty-web 引入 WASM 依赖Task 4 标可选,不阻塞主线)。
- fuzz 非确定性:用固定 seed + reducer 把失败固化成可重放的最小复现Task 3

View file

@ -0,0 +1,537 @@
# Phase B 实施计划:终端层基础设施(能力协商 / resize defer / 输入鲁棒性)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 `packages/pi-tui/src/terminal.ts` 升级到 oh-my-pi 的终端层:启动时按 DA1 哨兵 FIFO 探测能力、按 DECRQM 对账私有模式、按终端能力门控同步输出、resize 拖拽借 alt-screen 只画视口、headless 测试模式、以及 stdin-buffer 的鼠标/粘贴鲁棒性。替换 Phase A 在 `terminal-caps-stub.ts` 里的临时桩,给账本引擎提供真实能力数据。
**Architecture:** 改动集中在 `terminal.ts` + 新增 `terminal-capabilities.ts`(能力表/探测/`TERMINAL` 单例)+ `stdin-buffer.ts` + `bracketed-paste.ts`。账本引擎Phase A通过注入的 `TerminalCapabilities` 接口消费这些能力,**不**直接依赖 OMP 的 `TERMINAL` 全局。所有探测逻辑 Node-pure`Bun.env``process.env``Bun.color` 不需要)。
**Tech Stack:** TypeScriptNode 24`node --test``@xterm/headless``ProcessTerminal` 的能力探测在 headless/非 TTY 下自动跳过,测试用 `VirtualTerminal` + 一个可脚本化回复的 `FakeProbeTerminal`
**依赖:** Phase A 已完成(账本引擎默认启用)。本阶段替换 Phase A 的 `terminal-caps-stub.ts`
**前置 deep-dive** 本计划是**设计级**计划。启动 Phase B 时,应像 Phase A 一样先派 plan agent 逐字提取 `oh-my-pi/packages/tui/src/terminal.ts``terminal-capabilities.ts` 的关键方法DA1 FIFO、`#queryPrivateMode``#resolvePrivateMode``#startOsc11Query``#safeWrite` headless、`#renderResizeViewport`),把任务里的伪码替换成逐字移植。
---
## 关键设计决策
1. **能力对象注入,不引全局 `TERMINAL`**Phase A 的 `LedgerTuiEngine` 目前 import `terminal-caps-stub.ts`。本阶段改为构造时注入 `TerminalCapabilities``supportsScreenToScrollback``deccara``syncEnabled``isImageLine``imageProtocol`),由 `terminal-capabilities.ts` 提供真实实现。引擎保持可测。
2. **探测在 headless/非 TTY 下全跳过**`ProcessTerminal.start()``isTerminalHeadless()``!isTTY` 时早返回,不发任何探测/不挂 SIGWINCH/不进 raw mode。这是测试不喷帧到开发者终端的关键。
3. **resize defer 接入账本引擎**Phase A 把几何变化直接走 `fullPaint`。本阶段新增 `#resizeViewportActive` fast path借 alt-screen 画视口 + 120ms settle并在 settle 后通过 `requestFullPaint(true)` 触发引擎的权威 fullPaint。
4. **stdin 鲁棒性独立可测**`StdinBuffer` 的 partial-hold / paste watchdog 是纯逻辑,用假输入源测。
---
## 约定
- **工作目录**`packages/pi-tui/`
- **跑测试**`node --test test/<name>.test.ts`
- **类型检查**(仓库根):`pnpm --filter @moonshot-ai/pi-tui typecheck`
- **提交规范**`feat(pi-tui):` / `refactor(pi-tui):` / `test(pi-tui):`;无 co-author无 claude。
---
## 文件结构
| 文件 | 动作 | 责任 |
|---|---|---|
| `src/terminal-capabilities.ts` | 新建 | `TerminalCapabilities` 接口、`ProcessTerminalCapabilities`(能力表 + 探测结果)、`shouldEnableSyncOutput``TERMINAL_ID` |
| `src/terminal-probe.ts` | 新建 | DA1 哨兵 FIFO、`probeCapabilities`kitty/OSC11/DECRQM/OSC99 |
| `src/terminal.ts` | 修改 | `ProcessTerminal`headless 模式、接入探测、resize defer、`syncBegin/syncEnd`真实、ConPTY 分块 |
| `src/stdin-buffer.ts` | 修改 | partial-holdSGR 鼠标/转义、paste watchdog |
| `src/bracketed-paste.ts` | 新建 | `decodeReencodedPasteControls`tmux csi-u + xterm 双变体) |
| `src/ledger/terminal-caps-stub.ts` | 修改/删除 | 桩替换为真实能力注入(或删除,改 import `terminal-capabilities.ts` |
| `src/ledger/engine.ts` | 修改 | 构造时接收 `TerminalCapabilities`resize defer fast path |
| `test/terminal-capabilities.test.ts` | 新建 | 能力表/env 覆盖/门控 |
| `test/terminal-probe.test.ts` | 新建 | DA1 FIFO 排序、DECRQM 解析 |
| `test/terminal-headless.test.ts` | 新建 | headless 不发探测/不喷帧 |
| `test/resize-defer.test.ts` | 新建 | resize 拖拽 alt-screen + settle |
| `test/stdin-buffer-partial.test.ts` | 新建 | 鼠标/转义 partial-hold、paste watchdog |
| `test/bracketed-paste.test.ts` | 新建 | tmux 双变体解码 |
---
## Task 1: 真实能力表 + 同步输出门控(替换 Phase A 桩)
**Files:**
- Create: `packages/pi-tui/src/terminal-capabilities.ts`
- Create: `packages/pi-tui/test/terminal-capabilities.test.ts`
- Modify: `packages/pi-tui/src/ledger/engine.ts`(构造接收能力对象)
### Step 1: 写 `TerminalCapabilities` 接口 + 静态表
```ts
export type ImageProtocol = "kitty" | "sixel" | "iterm2" | "none";
export interface TerminalCapabilities {
readonly syncEnabled: boolean;
readonly supportsScreenToScrollback: boolean;
readonly deccara: boolean;
readonly hyperlinks: boolean;
readonly imageProtocol: ImageProtocol;
isImageLine(line: string): boolean;
}
const SYNC_KNOWN = ["xterm-kitty", "xterm-ghostty", "wezterm", "alacritty", "foot", "contour", "kitty", "ghostty"];
export function isMultiplexerSession(env: NodeJS.ProcessEnv = process.env): boolean {
if (env["TMUX"] || env["STY"] || env["ZELLIJ"] || env["CMUX_WORKSPACE_ID"] || env["CMUX_SURFACE_ID"]) return true;
const term = (env["TERM"] ?? "").toLowerCase();
return term.startsWith("tmux") || term.startsWith("screen");
}
export function shouldEnableSyncOutput(env: NodeJS.ProcessEnv = process.env, detected?: boolean): boolean {
if (env["PI_FORCE_SYNC_OUTPUT"] === "1") return true;
if (env["PI_NO_SYNC_OUTPUT"] === "1") return false;
if (typeof detected === "boolean") return detected; // DECRQM 运行时结果优先
if (isMultiplexerSession(env)) return false;
const term = env["TERM"] ?? "";
return SYNC_KNOWN.some((k) => term.includes(k));
}
export function shouldEnableHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean {
if (env["PI_NO_HYPERLINKS"] === "1") return false;
// tmux < 3.4 OSC 8 支持不稳简化mux 下默认关
if (isMultiplexerSession(env)) return false;
return true;
}
```
### Step 2: 写测试 `test/terminal-capabilities.test.ts`
```ts
import assert from "node:assert";
import { describe, it } from "node:test";
import { isMultiplexerSession, shouldEnableSyncOutput, shouldEnableHyperlinks } from "../src/terminal-capabilities.ts";
describe("terminal-capabilities", () => {
it("detects mux via env and TERM fallback", () => {
assert.strictEqual(isMultiplexerSession({ TMUX: "x" }), true);
assert.strictEqual(isMultiplexerSession({ TERM: "screen-256color" }), true);
assert.strictEqual(isMultiplexerSession({ TERM: "xterm-256color" }), false);
});
it("sync: force on overrides mux", () => {
assert.strictEqual(shouldEnableSyncOutput({ PI_FORCE_SYNC_OUTPUT: "1", TMUX: "x" }), true);
});
it("sync: off in mux by default", () => {
assert.strictEqual(shouldEnableSyncOutput({ TMUX: "x", TERM: "xterm-kitty" }), false);
});
it("sync: on for known direct terminal", () => {
assert.strictEqual(shouldEnableSyncOutput({ TERM: "xterm-kitty" }), true);
});
it("sync: DECRQM result overrides static table", () => {
assert.strictEqual(shouldEnableSyncOutput({ TERM: "dumb" }, true), true);
assert.strictEqual(shouldEnableSyncOutput({ TERM: "xterm-kitty" }, false), false);
});
it("hyperlinks off in mux", () => {
assert.strictEqual(shouldEnableHyperlinks({ TMUX: "x" }), false);
});
});
```
Run: `node --test test/terminal-capabilities.test.ts` → 通过。
### Step 3: 让 `LedgerTuiEngine` 构造时接收 `TerminalCapabilities`
`engine.ts` 构造器改为:
```ts
import type { TerminalCapabilities } from "../terminal-capabilities.ts";
constructor(
private readonly terminal: Terminal,
private readonly getChildren: () => Component[],
private readonly caps: TerminalCapabilities,
) {
this.#syncEnabled = caps.syncEnabled;
// ... paintBegin/End 用 caps.syncEnabled
}
```
`#terminalLine`/`#prepareLine` 里的 `isImageLine` 调用改为 `caps.isImageLine(...)``#emitFullPaint` 的 ED22 分支改为 `if (this.caps.supportsScreenToScrollback) buffer += "\x1b[22J";`
`tui.ts``getLedgerEngine()` 改为传入一个默认的 `TerminalCapabilities`Phase B Task 2 之前先用静态表构造一个 `ProcessTerminalCapabilities` 的静态实例Task 2 之后换成探测结果)。
### Step 4: 提交
```bash
git add packages/pi-tui/src/terminal-capabilities.ts packages/pi-tui/src/ledger/engine.ts packages/pi-tui/test/terminal-capabilities.test.ts
git commit -m "feat(pi-tui): add terminal capability table and inject into ledger engine"
```
---
## Task 2: DA1 哨兵探测 FIFO + DECRQM 对账
**Files:**
- Create: `packages/pi-tui/src/terminal-probe.ts`
- Create: `packages/pi-tui/test/terminal-probe.test.ts`
- Modify: `packages/pi-tui/src/terminal.ts`
### Step 1: 写 `terminal-probe.ts`DA1 哨兵 FIFO
> 机制:每个探测发完查询紧跟 `CSI c`DA1。终端按序处理哪个回复先到就 resolve 哪个探测——保证不死等、探测字节不漏进编辑器。详见 OMP `terminal.ts:360-365, 752-794`
```ts
export interface ProbeResult {
kittyKeyboard: boolean;
syncOutput: boolean | undefined; // DECRQM ?2026
inBandResize: boolean | undefined; // DECRQM ?2048
appearancePush: boolean | undefined; // DECRQM ?2031
background: { r: number; g: number; b: number } | undefined;
}
export interface ProbeIO {
write(data: string): void;
onReply(cb: (data: string) => void): () => void; // returns unsubscribe
}
/** 串行跑所有启动探测;任一探测 300ms 超时则跳过。 */
export async function probeCapabilities(io: ProbeIO, opts: { timeoutMs?: number } = {}): Promise<ProbeResult> {
const timeoutMs = opts.timeoutMs ?? 300;
// TODO(Phase B 启动时逐字移植 OMP 的 Da1SentinelOwner 队列)
// 1. 依次发kitty keyboard query + DA1、OSC 11 bg + DA1、DECRQM ?2026/?2048/?2031 + DA1、OSC 99 + DA1
// 2. 一个 reply 收集器按 DA1 哨兵把字节路由到对应 probe 的 resolve
// 3. 每个 probe 独立 timeoutMs 超时
// 这里先给出骨架 + 测试契约;实现时替换为 OMP 逐字代码。
throw new Error("probeCapabilities: implement during Phase B execution from OMP terminal.ts verbatim");
}
```
### Step 2: 写测试 `test/terminal-probe.test.ts`(用一个可脚本回复的 FakeProbeIO
```ts
import assert from "node:assert";
import { describe, it } from "node:test";
// import { probeCapabilities } from "../src/terminal-probe.ts";
describe("probeCapabilities (DA1 FIFO)", () => {
it("resolves each probe when its DA1 sentinel returns, in order", async () => {
// TODO Phase B: FakeProbeIO 记录写入,按顺序回放 kitty/DA1、OSC11/DA1、DECRQM/DA1 回复,
// 断言每个 probe 在 300ms 内 resolve、字段正确。
assert.ok(true, "scaffold");
});
it("times out a probe that the terminal ignores without hanging others", async () => {
// TODO Phase B: 只回放 kitty 回复,不回放 DECRQM断言 syncOutput 为 undefined 且整体不超 ~350ms。
assert.ok(true, "scaffold");
});
});
```
> 注:本任务在 Phase B 启动 deep-dive 后填充真实实现与测试。骨架先占位以便类型/接口对齐。
### Step 3: `ProcessTerminal` 接入探测
`ProcessTerminal.start()` 里(非 headless 时)调用 `probeCapabilities`,把结果写入一个 `ProcessTerminalCapabilities` 实例,并据 `syncOutput`DECRQM 结果)更新 `syncBegin/syncEnd`
### Step 4: 提交
```bash
git add packages/pi-tui/src/terminal-probe.ts packages/pi-tui/src/terminal.ts packages/pi-tui/test/terminal-probe.test.ts
git commit -m "feat(pi-tui): probe terminal capabilities via DA1 sentinel FIFO"
```
---
## Task 3: Headless 测试模式
**Files:**
- Modify: `packages/pi-tui/src/terminal.ts`
- Create: `packages/pi-tui/test/terminal-headless.test.ts`
### Step 1: 在 `terminal.ts` 新增 headless 判定
```ts
let headlessOverride: boolean | undefined;
export function setTerminalHeadless(value: boolean | undefined): void {
headlessOverride = value;
}
export function isTerminalHeadless(): boolean {
if (headlessOverride !== undefined) return headlessOverride;
// node --test / bun test 下默认 headlessPI_TUI_HEADLESS=1 强制
if (process.env["PI_TUI_HEADLESS"] === "1") return true;
if (process.env["NODE_ENV"] === "test") return true;
return false;
}
```
### Step 2: `ProcessTerminal.start()` / `#safeWrite` 接入
```ts
// ProcessTerminal.start()
start(onInput, onResize) {
if (isTerminalHeadless() || !process.stdout.isTTY) {
// headless不进 raw mode、不发探测、不挂 SIGWINCH、不写 teardown
this.inputHandler = onInput;
this.resizeHandler = onResize;
return;
}
// ... 原有逻辑
}
// #safeWrite 包装所有 process.stdout.write
#safeWrite(data: string): void {
if (isTerminalHeadless()) return;
process.stdout.write(data);
}
```
`ProcessTerminal` 里所有 `process.stdout.write(...)` 替换为 `this.#safeWrite(...)`
### Step 3: 写测试 `test/terminal-headless.test.ts`
```ts
import assert from "node:assert";
import { describe, it } from "node:test";
import { ProcessTerminal, setTerminalHeadless } from "../src/terminal.ts";
describe("ProcessTerminal headless", () => {
it("does not write probes or frames when headless", () => {
setTerminalHeadless(true);
try {
const term = new ProcessTerminal();
const writes: string[] = [];
const orig = process.stdout.write;
(process.stdout as unknown as { write: (d: string) => boolean }).write = (d: string) => {
writes.push(d);
return true;
};
term.start(() => {}, () => {});
term.write("\x1b[?2026h");
term.stop();
(process.stdout as unknown as { write: typeof orig }).write = orig;
assert.strictEqual(writes.length, 0, `headless must not write: ${writes.join("|")}`);
} finally {
setTerminalHeadless(undefined);
}
});
});
```
Run: `node --test test/terminal-headless.test.ts` → 通过。
### Step 4: 提交
```bash
git add packages/pi-tui/src/terminal.ts packages/pi-tui/test/terminal-headless.test.ts
git commit -m "feat(pi-tui): add headless terminal mode to keep tests from painting"
```
---
## Task 4: Resize viewport defer接入账本引擎
**Files:**
- Modify: `packages/pi-tui/src/ledger/engine.ts`
- Modify: `packages/pi-tui/src/tui.ts`SIGWINCH 回调)
- Create: `packages/pi-tui/test/resize-defer.test.ts`
### Step 1: 在 `LedgerTuiEngine` 新增 resize defer 字段 + 方法OMP: 3249-3295
```ts
#resizeViewportActive = false;
#resizeViewportSettleTimer: ReturnType<typeof setTimeout> | undefined;
#resizeAltActive = false;
#resizeViewportPaintCount = 0;
static readonly #RESIZE_SETTLE_MS = 120;
// 由 TUI 的 SIGWINCH 回调调用(非 mux
public beginResizeViewport(): void {
if (isMultiplexerSession()) return; // mux 不走 alt-screen
if (!this.#resizeViewportActive) {
this.terminal.write("\x1b[?1049h");
this.#resizeViewportActive = true;
this.#resizeAltActive = true;
}
this.#armResizeSettle();
}
#armResizeSettle(): void {
if (this.#resizeViewportSettleTimer) clearTimeout(this.#resizeViewportSettleTimer);
this.#resizeViewportSettleTimer = setTimeout(() => this.#settleResizeViewport(), LedgerTuiEngine.#RESIZE_SETTLE_MS);
}
#settleResizeViewport(): void {
this.#resizeViewportSettleTimer = undefined;
if (this.#resizeViewportActive) {
this.#resizeViewportActive = false;
this.#resizeAltActive = false;
this.terminal.write("\x1b[?1049l");
}
// settle 后一次权威 fullPaint清 scrollbackrewrap
this.requestFullPaint(true);
this.doRender();
}
// 视口 fast path只画底部 height 行到 alt-screen不推进 ledger
#renderResizeViewport(width: number, height: number): void {
const rawFrame = this.#composeFrame(width);
const frame = this.#prepareFrame(rawFrame, width);
const start = Math.max(0, frame.length - height);
let buffer = this.#paintBeginSequence + "\x1b[H";
for (let r = 0; r < height; r++) {
if (r > 0) buffer += "\r\n";
buffer += "\x1b[2K" + this.#terminalLine(frame[start + r] ?? "");
}
buffer += this.#paintEndSequence;
this.terminal.write(buffer);
this.#resizeViewportPaintCount += 1;
}
```
### Step 2: 在 `doRender()` 顶部加 fast-path 短路OMP: 2536-2545
```ts
public doRender(): void {
if (this.#stopped) return;
const width = this.terminal.columns;
const height = this.terminal.rows;
if (this.#resizeViewportActive) {
// 拖拽中:只画视口,不推进 ledger/commit/diff
this.#renderResizeViewport(width, height);
this.#armResizeSettle();
return;
}
// ... 原有分类器主体
}
```
### Step 3: TUI 的 SIGWINCH 回调接入
在 TUI 的终端 `onResize` 回调里:
```ts
this.ledgerEngine?.notifyResize();
if (!isMultiplexerSession()) this.ledgerEngine?.beginResizeViewport();
```
mux 下只 `notifyResize()`,由分类器的 `geometryChanged` 触发 mux 安全路径。)
### Step 4: 写测试 `test/resize-defer.test.ts`
断言:连续 `terminal.resize(...)` 中不写 `\x1b[3J`、写 `\x1b[?1049h`settle等 150ms后写 `\x1b[?1049l``fullRedraws` 增加mux env 下不写 `\x1b[?1049h`
Run: `node --test test/resize-defer.test.ts` → 通过。
### Step 5: 提交
```bash
git add packages/pi-tui/src/ledger/engine.ts packages/pi-tui/src/tui.ts packages/pi-tui/test/resize-defer.test.ts
git commit -m "feat(pi-tui): defer authoritative repaint during resize drags"
```
---
## Task 5: 输入鲁棒性stdin-buffer partial-hold + paste watchdog + bracketed-paste
**Files:**
- Create: `packages/pi-tui/src/bracketed-paste.ts`
- Modify: `packages/pi-tui/src/stdin-buffer.ts`
- Create: `packages/pi-tui/test/bracketed-paste.test.ts`
- Create: `packages/pi-tui/test/stdin-buffer-partial.test.ts`
### Step 1: 写 `bracketed-paste.ts`OMP: bracketed-paste.ts:37-41
```ts
// 解 tmux extended-keys-format 的 csi-u 和 xterm 两种变体,回到字面控制字节
const CSI_U = /\x1b\[(\d+);(\d+)u/g;
const XTERM_MOD = /\x1b\[27;(\d+);(\d+)~/g;
export function decodeReencodedPasteControls(text: string): string {
// csi-u: ESC [ code ; mod u
text = text.replace(CSI_U, (_m, code) => String.fromCharCode(Number(code)));
// xterm modifyOtherKeys: ESC [ 27 ; mod ; code ~
text = text.replace(XTERM_MOD, (_m, _mod, code) => String.fromCharCode(Number(code)));
return text;
}
```
### Step 2: 测试 `test/bracketed-paste.test.ts`
```ts
import assert from "node:assert";
import { describe, it } from "node:test";
import { decodeReencodedPasteControls } from "../src/bracketed-paste.ts";
describe("decodeReencodedPasteControls", () => {
it("decodes csi-u Ctrl+J", () => {
assert.strictEqual(decodeReencodedPasteControls("\x1b[106;5u"), "\n");
});
it("decodes xterm modifyOtherKeys Ctrl+J", () => {
assert.strictEqual(decodeReencodedPasteControls("\x1b[27;5;106~"), "\n");
});
it("leaves plain text unchanged", () => {
assert.strictEqual(decodeReencodedPasteControls("hello"), "hello");
});
});
```
### Step 3: `stdin-buffer.ts` 加 partial-hold + paste watchdog
> OMP `stdin-buffer.ts:471-599`:把"无歧义的部分序列"SGR 鼠标前缀 `ESC [<…`、kitty 激活时的悬挂转义hold 到 `PARTIAL_HOLD_MAX_MS=150` 再 flush粘贴 inactivity watchdog1000ms+ 64 MiB 上限 + `#abortPaste`
具体移植在 Phase B 启动 deep-dive 时逐字对照;测试覆盖:
- `[<35;8;16M` 分两次到达 → 最终作为一次 SGR 鼠标事件,不漏文本。
- 悬挂 `ESC[` 在 kitty 激活时 hold 150ms 后 flush。
- 粘贴 1000ms 无结束符 → abortPaste 恢复,不挂起。
### Step 4: 提交
```bash
git add packages/pi-tui/src/bracketed-paste.ts packages/pi-tui/src/stdin-buffer.ts packages/pi-tui/test/bracketed-paste.test.ts packages/pi-tui/test/stdin-buffer-partial.test.ts
git commit -m "fix(pi-tui): harden stdin buffering against split mouse/escape and paste stalls"
```
---
## Task 6: 端到端接线 + 全量验证
### Step 1: 跑全量测试
Run: `node --test test/*.test.ts`
Expected: 全绿(含 Phase A 的 ledger stress
### Step 2: 类型检查
Run: `pnpm --filter @moonshot-ai/pi-tui typecheck`
Expected: 通过。
### Step 3: 手动验证
启动 CLI默认 ledger + 真实能力探测):
- 在 kitty 下确认同步输出开启(无闪烁)。
- 在 tmux 下确认 resize 不 ED3 清 pane scrollback。
- 跑 `bun test`/`pnpm test` 在交互终端不喷帧headless
### Step 4: 提交(如有收尾修改)
```bash
git commit -m "chore(pi-tui): wire terminal infrastructure end-to-end"
```
---
## 验收总清单
- [ ] 能力探测在 kitty 下拿到 sync/DECRQM 结果;在忽略探测的终端上 300ms 内超时跳过。
- [ ] headless 下 `ProcessTerminal` 不写任何字节。
- [ ] resize 拖拽不写 `\x1b[3J`settle 后一次权威重排。
- [ ] tmux 下 resize 不借 alt-screen。
- [ ] stdin 分片鼠标/转义不漏文本;粘贴 watchdog 不挂起。
- [ ] Phase A 的 ledger stress 仍全绿(回归)。
- [ ] 全量测试 + 类型检查通过。
---
## Self-Review
**1. 覆盖:** 能力协商Task 1-2、headlessTask 3、resize deferTask 4、输入鲁棒性Task 5、端到端Task 6。覆盖 Agent B 报告的高价值项DA1 FIFO、DECRQM、headless、partial-hold、paste watchdog、tmux paste 双变体、resize defer
**2. 占位:** Task 2 的 `probeCapabilities` 标注了 `TODO(Phase B 启动时逐字移植)`——这是**有意的 scaffold**,因为本计划是设计级,真实实现需 Phase B 启动时 deep-read `terminal.ts`。这不是"留空等以后",而是明确的执行入口。
**3. 一致性:** `TerminalCapabilities` 接口在 Task 1 定义、Task 1 engine 构造接收、Task 2 `ProcessTerminalCapabilities` 实现,一致。`isMultiplexerSession``terminal-caps-stub.ts` 迁移到 `terminal-capabilities.ts`engine import 路径同步更新。
**4. 风险:** DA1 FIFO 是最微妙的一块(多终端回复排序),必须在 Phase B 启动时逐字移植 OMP不能凭记忆写。resize defer 与 app 全屏接管的交互已在 Phase A 计划 Task 6 的 bug 修复里处理(`requestRender(true)` 绕过 defer

384
research.md Normal file
View file

@ -0,0 +1,384 @@
# pi-tui fork 深度调研oh-my-pi vs 我们
> 调研对象
>
> - **我们的 fork**`packages/pi-tui``@moonshot-ai/pi-tui` 0.80.2,仓库内)
> - **oh-my-pi 的 fork**`oh-my-pi/packages/tui``@oh-my-pi/pi-tui` 16.1.16,本地工作区)
> - 报告路径中的行号引用 **oh-my-pi** 时均指 `oh-my-pi/packages/tui`,引用 **我们** 时均指 `packages/pi-tui`
---
## 0. 一句话结论
我们的 fork 基本是**上游 0.80.2 的原样 vendor**(仅 2 个提交vendor + integrate几乎没有本地修改而 oh-my-pi 是把 pi-tui 在生产环境里**重负载跑了 6 个月、迭代了 1500+ 次提交、发过 ~150 个版本**的深度演化版本。
因此,"oh-my-pi 改了什么"几乎等价于"**社区在真实 CLI Agent 场景下踩过的所有坑 + 性能优化 + 鲁棒性加固**",参考价值极高。最值得借鉴的不是某一个功能,而是它把 pi-tui 从一个"差分渲染 TUI 库"升级成一个"**面向流式 Agent 会话、保护 native scrollback、按终端能力自适应、可被模糊测试验证**"的运行时。
---
## 1. 背景与血缘
两者**同源**,均派生自上游 `@mariozechner/pi-tui`(即 `badlogic` / `pi-mono` 仓库,作者 Mario Zechner
- 我们的包 vendored 自 `@earendil-works/pi-tui` 0.80.2(这是 badlogic 上游的一个中间 fork版本停在 0.80.2。
- oh-my-pi作者 Can Boluk仓库 `can1357/oh-my-pi`)是另一条更激进的 fork已演进到主版本 **16**。它的 CHANGELOG 里大量出现 `ports pi-mono <hash>``badlogic/pi-mono#nnn`,说明它仍在持续 cherry-pick 上游,但核心已经重写。
**体量对比**
| 维度 | 我们 (`packages/pi-tui`) | oh-my-pi (`packages/tui`) |
|---|---|---|
| 包名 / 版本 | `@moonshot-ai/pi-tui` 0.80.2 | `@oh-my-pi/pi-tui` 16.1.16 |
| 触及本包的提交数 | 2vendor + integrate | **1562** |
| src 文件数 | 28 | 35 |
| src LOC | 12,118 | 21,065+74% |
| 测试文件数 | 27 | 70+159% |
| 测试 LOC | 13,475 | 28,678+113% |
| benchmark | 无 | 6 个 |
| CHANGELOG | 无 | 1775 行 / 152 个版本条目 |
| 运行时 | Node `>=22.19``node --test` | Bun `>=1.3.14``bun test` |
| 构建 | `tsdown``dist/` | **无 bundler**,直接 ship `src/*.ts` |
| 关键外部依赖 | `get-east-asian-width``marked` | `marked``lru-cache``@oh-my-pi/pi-natives`(Rust NAPI)、`@oh-my-pi/pi-utils` |
> 重要认知oh-my-pi 把包名也改了(`packages/tui``@oh-my-pi/pi-tui`),并深度依赖 Bun + Rust native。**不能整体替换**,只能按模块 cherry-pick。
---
## 2. 总体差异地图
### 2.1 oh-my-pi 新增的模块(我们没有)
| 模块 | 作用 | 详见 |
|---|---|---|
| `src/terminal-capabilities.ts` (1049 行) | 终端能力探测、图像协议编码、`renderImage``TERMINAL` 单例 | §4, §6 |
| `src/kitty-graphics.ts` | Kitty Unicode 占位符图像 | §6 |
| `src/latex-to-unicode.ts` (1994 行) + `src/latex-block.ts` (461 行) | LaTeX 数学 → Unicode/ANSI + 堆叠分式 | §7 |
| `src/bracketed-paste.ts` | bracketed paste 解析 + tmux 重编码解码 | §5 |
| `src/mouse.ts` | SGR 鼠标报告解析 | §5 |
| `src/deccara.ts` | Kitty 矩形 SGR 背景填充优化器 | §5 |
| `src/ttyid.ts` | 稳定 TTY 标识(`ttyname`/mux/env | §5 |
| `src/loop-watchdog.ts` | 事件循环卡顿探针 | §5 |
| `src/symbols.ts` | 集中管理 box-drawing / spinner 等符号主题 | §7 |
| `src/components/scroll-view.ts` | 固定高度视口 + 滚动条 | §8 |
| `src/components/tab-bar.ts` | 水平 Tab 栏 | §8 |
| `bench/*` | 解析/布局/key/图像等微基准 | §9 |
### 2.2 我们有、但 oh-my-pi 删/搬的模块
| 我们的文件 | oh-my-pi 处置 | 现在在哪 | 我们的取舍建议 |
|---|---|---|---|
| `native-modifiers.ts` + `native/`darwin/win32 C 预编译) | **删除** | Kitty 协议修饰键 bitmask在 Rust `crates/pi-natives/src/keys.rs` | 若需原生修饰键轮询就保留否则接受删除Kitty/CSI-u 已带修饰键) |
| `undo-stack.ts`(泛型 `UndoStack<S>` | **内联** | editor/input 内的私有数组 + `structuredClone`cap 100 + 合并 | 可跟随删除,独立类已无必要 |
| `word-navigation.ts` | **替换并内联** | `utils.ts``moveWordLeft/Right``getWordNavKind`v13.7.5 | 采纳其归并方式 |
| `terminal-colors.ts`OSC 11 / Mode 2031 | **折叠 + native** | `terminal.ts``TerminalAppearance` + Rust `appearance.rs``MacAppearanceObserver` | 采纳其折叠(更鲁棒,且本就该在 terminal 上) |
| `terminal-image.ts` | **迁移并大幅扩展** | `terminal-capabilities.ts` + `kitty-graphics.ts` + native `sixel.rs`v11.0.0 | 概念上采纳,按需摘特性 |
---
## 3. 核心差异渲染引擎(**最重要**
`src/tui.ts`:我们 1714 行oh-my-pi **3695 行**。差距几乎全在渲染核心。我们的模型是经典的"上一帧 diff + viewport 跟踪 + 退化为破坏性整帧重绘"oh-my-pi 则围绕"**追加式 native scrollback 契约**"重写了引擎。架构文档见 `oh-my-pi/docs/tui-core-renderer.md``tui-runtime-internals.md`
### 3.1 我们baseline
`doRender``tui.ts:1254-1620`)把整棵树渲染成 `newLines`,合成 overlay再和 `previousLines` 做 diff 找 `firstChanged..lastChanged`;通过 `previousViewportTop`/`maxLinesRendered` 间接跟踪滚动;在**宽度变化、高度变化、内容收缩、changed 区越过 viewport、图像过高**等情况下退化为 `fullRender`ED2 + home + ED3`tui.ts:1284-1325`)——这会**清掉 scrollback**。超宽行直接抛错。`?2026` 同步输出**硬编码、无门控**。节流 16ms~60fps
### 3.2 oh-my-pi 的核心改造
**① 追加式 native scrollback 账本ledger**
引擎维护一个不可变账本(`tui.ts:995-1028`
- `committedRows`C已经物理滚入历史的行
- `windowTopRow`W当前帧 grid 第 0 行对应的内容行
- 两个审计标记 `auditRows ≤ durableRows`,把 C 切成三个区:**byte-stable-audited / durable-exempt / forced-overflow**
组件通过 `NativeScrollbackLiveRegion``tui.ts:220-224`)上报一个"接缝seam"含两个嵌套端点byte-stable B`commitSafeEnd`)与 durable D`snapshotSafeEnd`)。
> **关键设计转变**:接缝不再"决定能不能提交",只"决定提交后归类到哪个审计区"。**追加式提交地板 = `windowTop`**——任何滚出窗口的行一定会进历史,绝不会"既没提交、也没绘制"CHANGELOG 16.1.10 修复的正是流式输出在 barrier 下方溢出后"人间蒸发"的 bug
**② 范围感知的 committed-prefix 审计**
`#auditCommittedPrefix``tui.ts:2831-2851`)每帧校验历史行是否被组件重新排版:
- 对新变成 permanent 的 forced-overflow 行做**完整硬扫描**
- 对尾部做 SGR 剥离采样(最近 24 行里 ≤8 行非空)
- 中间 durable 区 `[auditRows, durableRows)` **豁免**——这样流式表格重新对齐列时不会喷出重复快照
- 发现插入/删除时,在第一个移动的行**重新锚定 C 并重新提交**——**"宁可重复,绝不丢失"**
这是一个非常微妙、踩过大量坑才沉淀出来的洞察。
**③ 四种发射形态emitter**
`#doRender``tui.ts:2465-2822`)计算 `W=max(C, Lheight)``chunkTo=W`,再发射四种字节形态之一:
- `#emitFullPaint`:仅用于用户手势
- `#emitUpdate` 三种子形态scroll-append只发 chunk 行、in-window diff相对移动 + 变化区间、seam rewritechunk + 整窗)
- **update 路径永不发 ED2/ED3、永不 absolute home**
### 3.3 native scrollback 保护
- **ED3 被收敛到唯一一处**`#emitFullPaint({clearScrollback:true})``tui.ts:3196`),仅用户手势(会话替换/分支/resume、非 mux 下 resize、`resetDisplay`)可达。首帧用 kitty ED22 保留进入前的 shell 屏幕。删除了旧的 `PI_TUI_ED3_SAFE`/`PI_CLEAR_ON_SHRINK`/`PI_TUI_DEBUG` 开关。
- **多路复用感知**`isMultiplexerSession()``tui.ts:389-401`)识别 `TMUX`/`STY`/`ZELLIJ`/`CMUX_*`,并带 `TERM=tmux*/screen*` 兜底(`sudo`/`su`/`ssh` 剥了环境也能识别。mux 下 resize 走 50ms 防抖,**绝不 ED3**——pane 历史保留旧 wrap。
- **resize viewport defer"transcript flash" 修复)**:非 mux 拖拽时,每次 SIGWINCH 只通过 `#renderResizeViewport``tui.ts:3280`)画视口,借用 alternate screen 让宽度重排碎片不进 scrollback权威 ED3 重排在 120ms 静默窗口后一次性触发。拖拽中普通渲染**留在 fast path**(修复 16.0.11 中"还在动画的 tool block 闪一下又消失")。
- **Warp alternate-screen 反馈回路**`ConPTY settle`Windows Terminal 150ms 合并窗口)等终端特判。
### 3.4 性能优化
| 优化 | 机制 | 收益 | 可移植性 |
|---|---|---|---|
| **SGR 合并** | `coalesceAdjacentSgr``tui.ts:725-789`)把字节相邻的 SGR 合并成一个 `CSI … m`16 参数上限(防 xterm.js 32 参数截断);跨不完整扩展色不合并;`PI_NO_SGR_COALESCE` 兜底 | 每帧 SGR 序列 **-30~40%** | **高价值 / 低 effort**(纯函数,可直接落到我们的 emit 路径) |
| **同步输出门控** | `?2026` 按终端能力开关(`shouldEnableSynchronizedOutputByDefault``PI_NO_SYNC_OUTPUT`/`PI_FORCE_SYNC_OUTPUT` 覆盖,运行时 DECRQM 对账 | 避免在 mux/坏终端上闪烁 | 中价值 / 中 effort需能力探测器 |
| **组件级渲染** | `requestComponentRender``tui.ts:1887`)只重渲请求的子树根,复用其它根的行+接缝 | spinner/光标闪烁不再整树重渲 | 高价值 / 高 effort依赖 stable-prefix 帧模型) |
| **tight layout** | `setTuiTight`/`getPaddingX` 去掉 1 格横向 padding | 更紧凑 | 低-中价值 / 低 effort |
| 节流 | 降到 ~30fps + `RenderScheduler` 抽象 | — | — |
### 3.5 Overlay 系统
- 我们focus 模型更丰富(`OverlayHandle.focus/unfocus/isFocused``nonCapturing``overlayFocusRestore` 状态机);无 `fullscreen`
- oh-my-pioverlay **只合成进 window slice**,且可见时**冻结提交**`tui.ts:2346, 2726`——overlay 像素永不进 scrollback关闭无需破坏性重建。新增 `OverlayOptions.fullscreen`:最上层 fullscreen overlay 借用 alternate buffer。`maxHeight` 默认夹到可用行。
> 注意:我们的 overlay focus 恢复机比 oh-my-pi 更细;可反过来保留我们的,借鉴它的 `fullscreen` + commit-freeze。
### 3.6 渲染模糊测试(最值得参考的"合约定义"
`render-stress-{harness,oracles,reducer,scheduler,subprocess}.ts` + `virtual-terminal.ts`:把**真实发出的 ANSI 字节**喂给 ghostty-web VT跑随机操作序列与终端尺寸由一个**独立的 shadow commit ledger** 校验(`scrollback == frame[0..C)`)。带 reducer + subprocess 回放以最小化失败 seed。这是**任何移植的回归网**,也是引擎行为的权威合约。
---
## 4. 终端能力协商
oh-my-pi 把"启动时探测终端能力"做成了一套严谨的协议(文档:我们也有 OSC 11 / `?2026` / bracketed paste / kitty 查询,但差异在**门控、探测纪律、headless 安全**)。
### 4.1 统一 DA1 哨兵探测 FIFO
oh-my-pi 把所有启动探测kitty keyboard、OSC 11 背景、DECRQM 私有模式、OSC 99 通知)串行到一个 `Da1SentinelOwner[]` 队列(`terminal.ts:360-365, 752-794`):每个探测发完查询紧跟一个 `CSI c`DA1。终端按序处理**哪个回复先到就 resolve 哪个探测**——保证:(a) 在忽略某探测的终端上**永不死等**(b) 探测字节**不会漏进编辑器**。OSC 11 也走这套Neovim / bat / fish / terminal-colorsaurus 同款技巧)。证据:`kitty-keyboard-da1-ordering.test.ts`DA1-before-kitty 排序,`#2042`)。
### 4.2 DECRQM 私有模式探测
通过 `CSI ? mode $ p` + DA1 哨兵探测 `?2026`(sync)、`?2048`(in-band resize)、`?2031`(appearance)、xterm `?1010/?1011`(scroll-to-bottom)`terminal.ts:1046-1079`
- 开 sync、启用 DEC 2048 in-band resize顺带推导 cell 像素尺寸)
- 确认 Mode 2031 push 后**停掉 OSC 11 轮询**
- 关掉 xterm scroll-to-bottom避免编辑器打字把"正在滚动的阅读者"拽到尾部(`#2732`
**我们是无条件发 `?2026`/`?2031`,从不探测 2048。**
### 4.3 静态能力表 + 环境覆盖
`terminal-capabilities.ts` 提供 `shouldEnableSynchronizedOutputByDefault``:187-220`):用户覆盖 → `TERM_FEATURES``Sy` token可穿透 SSH/mux`WT_SESSION` → 已知直连终端 → mux 默认关。另有 `shouldEnableHyperlinksByDefault`tmux ≥3.4 才开 OSC 8`TERMINAL_ID` 检测、可变的 `TERMINAL` 单例集中管理图像/通知协议。
### 4.4 防闪烁
- **同步输出按能力开关**:运行时 DECRQM 结果可在 mux/foot/contour/mintty 上**打开**它;环境开关可在 2026 有问题的终端上**关掉**它。
- **Headless 测试模式**`#headless = isTerminalHeadless()``terminal.ts:410, 470-471`)为真时 `start()` 早返回、`#safeWrite` 空操作——**即使 `process.stdout.isTTY` 为真**,也不进 raw mode、不探测、不挂 SIGWINCH、不写 teardown。我们的等价判断只看 `!isTTY`,所以在交互式终端跑测试会**往开发者屏幕喷帧/探测**。证据:`process-terminal-headless.test.ts`
- **ConPTY 写分块**`chunkForConPTY``terminal.ts:62-115`16 KiB、按换行对齐+ `isConPTYHosted`(覆盖 WSL避免 Windows Terminal 大 paint 卡视口;`ensureWindowsConsoleUtf8` FFI 每次写前重申 `CP_UTF8`,杀子进程导致的乱码。
---
## 5. 输入 / 键鼠 / 粘贴 / Watchdog
oh-my-pi 修了一堆**我们很可能还没修的输入 bug**。
### 5.1 输入鲁棒性(我们很可能仍有的 bug
| Bug | oh-my-pi 修法 | 我们的状况 | 可移植性 |
|---|---|---|---|
| tmux 下 bracketed paste 漏 `[27;5;106~` 转义尾巴 | `bracketed-paste.ts` `decodeReencodedPasteControls``:37-41`)同时解 tmux `extended-keys-format` 的 csi-u 和 xterm 两种变体 | 我们只解 csi-u且只在 `editor.ts:1149-1154` 内联;**xterm 变体仍漏** | **高价值 / 低 effort**(扩一个正则分支) |
| 鼠标/转义序列尾巴被当文本(如 `[<35;8;16M``[B` 漏进设置搜索) | `stdin-buffer.ts` 把"无歧义的部分序列"SGR 鼠标前缀、kitty 激活时的悬挂转义hold 住,超时后到 `PARTIAL_HOLD_MAX_MS=150` 再 flush并加 `setTimeout(0)` 防事件循环卡顿撕序列 | 我们的 buffer **完全没有** | **高价值 / 中 effort**(集中在 `stdin-buffer.ts` |
| 粘贴结束符丢失导致挂起/内存膨胀 | 粘贴 inactivity watchdog1000ms+ 64 MiB 字节上限 + `#abortPaste` 恢复(`stdin-buffer.ts:471-501` | **无** | 同上 |
| Kitty enable-level 协商错误 | 根据上报 flags 选 `>1u`/`>7u`,任何 `CSI ?u` 回复即视为支持DA1 抢跑启用 modifyOtherKeys 后kitty 回复到达再**撤销**,避免 stacked-mode 回归(`#3259` | 我们无条件开 level 7协商 buffer 是自写的 | 中价值 / 中 effort |
| VS Code 集成终端小键盘被当导航键 | `keys.ts` `decodeKittyKeypadText`/`matchesKeypadKey``:517-529`)把数字键区 codepoint 映成数字/运算符 | **无** | 中价值 / 低 effort |
### 5.2 其他输入模块
- **`mouse.ts`**`parseSgrMouse``:34-45`)解 button/col/row/release/wheel/motion/leftClick门控到 fullscreen overlay。**我们没有对应模块。**
- **`ttyid.ts`**`getTerminalId``:41-83`)通过 `bun:ffi``ttyname(3)`,并回退到 mux/env`ZELLIJ_PANE_ID`/`TMUX_PANE`/`KITTY_WINDOW_ID`/`WT_SESSION`)。用于 per-tty 会话命名/面包屑。**依赖 FFI。**
- **`deccara.ts`**`planDeccaraFills``:240-313`)把纯色面板尾部背景填充空格替换成 Kitty 矩形 SGR `CSI …$r` 批处理kitty-only、mux 关、`PI_NO_DECCARA` 兜底、`bun test` 下强制关。**med/high effort且耦合渲染器。**
- **`loop-watchdog.ts`**常驻事件循环卡顿探针250ms 间隔/阈值),每次阻塞打一条 `ui.loop-blocked`,带当前 phase`unref`、generation 防护、`stop()` 撤定时器。注意 `docs/advisor-watchdog.md` 的"watchdog"是另一个不相关的 advisor 功能(双评审模型),不是这个探针。
- **崩溃安全 emergency restore**`emergencyTerminalRestore``terminal.ts:236-280`)按跟踪的 alt-screen 状态门控 `?1049l`,防止 Windows 上"死帧时 shell 提示符残留"。**高价值 / 低 effort。**
---
## 6. 图像与图形协议
**结论oh-my-pi 是整体重写,不是打补丁。** `terminal-image.ts` 被删,逻辑拆到 `terminal-capabilities.ts`(能力 + 协议编码 + `renderImage`)和 `kitty-graphics.ts`Unicode 占位符),`components/image.ts` 从 126 → 444 行。
| 能力 | oh-my-pi | 我们 | 可移植性 |
|---|---|---|---|
| **Sixel** | `ImageProtocol.Sixel``encodeSixel`(经 native `@oh-my-pi/pi-natives`、Windows-Terminal-preview 探测 | 无 | 中价值 / 高 effort依赖 native sixel 编码器) |
| **Kitty Unicode 占位符** | `kitty-graphics.ts` 把图像渲染成真实文本 cell`U+10EEEE` + 行/列组合变音符号297 项表),能扛横向切片/重排/重叠;超 297 cell 或不支持时回退直接放置 | 用 `a=p` 光标定位 | **高价值 / 中 effort**(自包含、无 native 依赖,变音表可移植) |
| **transmit-once + 稳定 id** | `a=t` 只发一次 base64`a=p` 重放小序列;`imageKey` 把逻辑图映到稳定 id重绘**替换**而非堆叠 | 每帧重传 | 高价值 / 中 effort |
| **ImageBudget** | `ImageBudget`(默认上限 8只保留最近 N 张,旧的降级为文本,经 `d=I` purge + 强制整帧重绘;含 resize "stable pass" 重放 | 无 | 高价值 / 中-高 effort需渲染器配合 beginPass/endPass/purge |
| **缓存失效** | 缓存键含宽度 **+** 协议、cell px、占位符标志、抑制态 | 只按宽度 | 中价值 / 低 effort |
`terminal-colors.ts`OSC 背景查询/scheme在 oh-my-pi 没有等价物——它用 `Bun.color` 量化颜色。
---
## 7. Markdown / LaTeX / 文本
### 7.1 Markdown大改858 → 1823 行,全是真 bug 修复)
- **HTML 标签处理(我们很可能仍有此 bug**:我们把 block/inline `html` token 当原始文本(`markdown.ts:470-475, 569-574``<br>`/`<li>`/`<span>`/`<text>` 会原样显示。oh-my-pi 的 `normalizeHtmlForTerminal``:127-227`)转换:`<br>`→换行、`<p>`/`<li>`→带嵌套 `<ol>`/`<ul>` 自动编号的换行、`<span>`/`<text>` 剥标签保内容,并解码实体(`&amp;``&#x1F600;`)。**高价值 / 低-中 effort纯函数可直落。**
- **数学/LaTeX 集成**:四个 Marked 扩展在 emphasis/link 规则破坏前**隔离**数学inline `math`、own-line `$$`/`\[`、bare-env、`soleDisplayMath` 提升),经 `latexToUnicode`/`latexToBlock` 渲染。
- **Mermaid ASCII**```` ```mermaid ```` 经主题 hook `resolveMermaidAscii` 渲染成 ASCII 并裁宽;我们当普通代码块。**中价值 / 低 effort。**
- **增量流式 lex**`#lexTokens`/`#freezeStablePrefix``:788-850`)在追加式增长时只重 lex 追加的尾部(在 `\n\n` 边界冻结),把流式揭示从 **O(N²)→O(N)**`markdown-incremental-lex.test.ts` 断言每一步与冷全量 lex **字节一致**。**高价值 / 中 effort。**
- **L2 渲染缓存**:模块级 `LRUCache`,按 text/width/theme/能力探测为键,跨组件重建存活。
- 其他OSC 66 双倍高 H1、内联 hex 色卡、主题化表格边框、自定义 HR tokenizer。
### 7.2 LaTeX / 数学(**最大独立功能,值得移植**
- `latex-to-unicode.ts`**1994 行,新**bare-fragment LaTeX → Unicode/ANSI。覆盖上下标、希腊字母、大算符`∫∑∏`)、关系/箭头、数学字体Unicode Mathematical Alphanumeric block、组合重音、简单分式、根式、矩阵以及 `\textcolor`/`\color`/`\colorbox`/`\fcolorbox` 的 ANSI 颜色。
- `latex-block.ts`**461 行,新**):真正的二维 display 布局——baseline 对齐的 `Box` 引擎把 `\frac` 的分子/横线/分母**堆叠**起来,保持 `\\` 矩阵行。
> **关键判断**~2450 行但**与渲染管线零耦合**,只需要 `visibleWidth` + 一个 `trueColor` 标志。唯一外部依赖是 `Bun.color`(用于 `\textcolor` 和 hex 色卡),可剥。**高价值 / 中 effort**——是单个最大的独立收益,能修一整类"数学被渲染成原始字符"的 bug下划线、`\frac`、矩阵)。
### 7.3 文本 / Utils
- **Tab 宽度标准化**oh-my-pi 删除了可配置 `tabWidth`,硬编码 `DEFAULT_TAB_WIDTH=3`v16.0.11breaking change。我们还有可配置 tab width`tab-width.test.ts`)。**低价值**——除非真想放弃可配置性,否则不跟。
- **`symbols.ts`26 行)**`SymbolTheme`/`BoxSymbols`(圆角/尖角/表格边框、`quoteBorder``hrChar``colorSwatch``spinnerFrames`),集中管理让 ASCII/Unicode/Nerd-font 主题干净降级。**中价值 / 低 effort。**
- **`fuzzy.ts` 重写**word-local 匹配 + `SearchIndex`,防"image provider"跨无关词匹配。属于搜索排序,与渲染正交,**对我们低价值**。
---
## 8. 编辑器 / 自动补全 / 组件
### 8.1 三个可直接借鉴的新组件(低 effort
| 组件 | 作用 | 可移植性 |
|---|---|---|
| **`box.ts` 的 border 特性** | `BoxBorder`chars + 可选 color画彩色边框**内容放不下时自动去边框**,绝不溢出给定宽度 | **高价值 / 低 effort**(纯增量、向后兼容,替代我们手撸的边框) |
| **`ScrollView`** | 固定高度视口 + 可选右侧滚动条 + 标准滚动键;`totalRows` 让调用方传预切窗口而滚动条几何反映全量 buffer | **高价值 / 低 effort**(自包含,只依赖 utils/keys/tui可用于 overlay/plan-review/任何有界列表) |
| **`TabBar`** | 水平 tab 栏active/inactive/muted/hover 态,键盘循环跳过 muted鼠标命中测试溢出时从离 active 最远处先折叠 | **中价值 / 低 effort**(自包含,做多面板设置/视图时用得上) |
### 8.2 编辑器(全是真 bug 修复)
- **原子 token 安全的 word delete + grapheme word nav**:用 grapheme **kind classifier**`utils.ts:288-434``WordNavKind`/`moveWordLeft/Right`)替换我们的 `Intl.Segmenter`,并让 word-delete 跨过 `[Paste #N]`/`[Image #N]` 这类原子占位符——**Ctrl+W / Alt+Backspace 不会再吃掉半个 marker**。**高价值 / 中 effort。**
- **内联且有界的 undo**:删除独立 `UndoStack` 类,改成私有数组 + `structuredClone` + `MAX_UNDO_STACK=100` + shift-oldest + 合并;`#withUndoSuspended``undoPastTransientText`(命令式自动补全不污染 undo。**cap 100 是可移植的安全网**(我们目前无界)。
- **粘贴大修**:抽出 `bracketed-paste.ts`(修 tmux 重编码editor `#sanitizePastedText` 对 macOS NFD 拖拽做 NFC 归一化;`onLargePaste` 钩子 + `insertPaste`/`pasteText`
- **`kill-ring.ts`**:加 60 条上限。
### 8.3 自动补全
- **前导空白 slash 命令 + 同步 Enter 补全**`/` 是第一个非空白 token 即触发Enter 在异步 provider resolve 前**同步**补全。修"缩进/空行后 slash 失效"和"Enter 时异步过期"。**高价值 / 中 effort。**
- **动态 slash 描述**`SlashCommand.getAutocompleteDescription?: () => string`,仅在候选匹配时惰性求值。
- **skill 排序**`skill:*` 命令在空前缀下可预测上浮。
### 8.4 现有组件改动
- **`loader.ts`**:从整树 `requestRender()` 切到**组件级 `requestComponentRender(this)`**,消除大 transcript 下 12.5Hz 整树重绘的卡顿;加 30fps `animated` 消息着色器、宽度夹紧、`dispose()`、同步输出感知。**高价值 / 中 effort**(需 TUI 的 `requestComponentRender` API
- **`select-list.ts`**7.5KB→17KBtype-to-filter 模糊搜索、两列描述折行、鼠标命中行映射。
- **`settings-list.ts`**7.9KB→28KB基于 ScrollView 的滚动、type-to-search、分栏布局、子菜单、鼠标支持**large依赖 ScrollView+mouse**)。
---
## 9. 构建工具链与测试体系
### 9.1 工具链
| 轴 | 我们 | oh-my-pi |
|---|---|---|
| 运行时 | Node `>=22.19``node --test` | Bun `>=1.3.14``bun test --parallel` |
| 构建 | `tsdown``dist/index.mjs` + `.d.mts` | **无 bundler**,直接 ship `src/*.ts` |
| 类型检查 | `tsc --noEmit` | `tsgo --noEmit` |
| Lint/fmt | oxlint/oxfmt仓库级 | Biome |
| 导出 | `#/*` 内部映射;公开 `exports``"."` | `"."``"./*"``"./components/*"``"./*.js"`(每个模块都可深导入) |
**影响**oh-my-pi 的 `./*` 深导出让消费方依赖内部文件布局——强大但脆弱。硬依赖 `@oh-my-pi/pi-natives` 意味着采纳其代码会拖入 Rust 工具链和 Bun-only API`Bun.env``Bun.nanoseconds``Bun.hash``Bun.color`)。**我们的包保持 Node 可移植、自包含,这是优势。**
### 9.2 测试哲学
- **数量**70 个 `*.test.ts`(含 helper 80vs 我们 27/33。
- **issue 驱动回归**10 个 `issue-XXXX-repro.test.ts` 钉到 GitHub issue。
- **属性/模糊测试**`render-stress-*`7 文件)——种子化随机场景、**oracle**、**delta-debugging reducer**最小复现、worker **scheduler**、隔离 **subprocess**
- **真值 VT oracle**`virtual-terminal.ts` 背后是 **Ghostty 真实 VT100 解析器编到 WASM**`ghostty-web`grapheme/ZWJ/BCE 正确——vs 我们的 `@xterm/headless` 近似。
- **Headless harness**`process-terminal-render-harness.ts` + `setTerminalHeadless()` mock TTY 描述符,让全量渲染确定可测。
- **Benchmark**`bench/` 微基准(`Bun.nanoseconds()`)。
> 我们是常规单元/组件 + 少量回归 reprooh-my-pi 在"混沌下的渲染正确性"上投入远超我们。**render-stress 套件是最值得参考的测试资产。**
---
## 10. 可参考 / 移植建议(按 value/effort 分级)
> 总原则:**整体替换不可行**Bun + Rust 耦合、工具链不同、会破坏我们的 Node 可移植性)。按模块 cherry-pick优先摘"纯函数 / 自包含 / 有测试"的部分。
### 🟢 第一梯队:低 effort / 高 value建议优先做
| 项 | 位置 | 说明 |
|---|---|---|
| **SGR 合并** | `tui.ts:725-789` | 纯函数,每帧 SGR -30~40%16 参数安全上限 + `PI_NO_SGR_COALESCE` 兜底。直接落到 emit 路径 |
| **Headless 测试模式** | `terminal.ts:410/470/1318` | 一个标志位贯穿 `start`/`safeWrite`,杜绝测试喷帧到开发者终端 |
| **崩溃安全 emergency restore** | `terminal.ts:236-280` | 一行门控,防 Windows 死帧残留 |
| **tmux xterm 变体 paste 修复** | `bracketed-paste.ts:37-41` | 我们已有一半,加一个 `27;5;n~` 分支 |
| **Box border** | `components/box.ts` | 纯增量、向后兼容,替代手撸边框 |
| **ScrollView** | `components/scroll-view.ts` | 自包含可复用视口,解 overlay/plan-review |
| **Markdown HTML 标签归一化** | `markdown.ts:127-227` | 纯函数,修 `<br>`/`<li>`/`<span>` 原样显示 |
| **undo cap 100** | editor | 给我们目前无界的 undo 加安全网 |
### 🟡 第二梯队:中 effort / 高 value值得规划
| 项 | 说明 |
|---|---|
| **StdinBuffer partial-hold + paste watchdog** | 修鼠标/转义尾巴漏文本 + 截断粘贴挂起;集中在 `stdin-buffer.ts`,最大用户可见输入鲁棒性收益 |
| **DA1 哨兵能力 FIFO + DECRQM 门控** | 修探测死等、DA1-before-kitty 排序;让 sync/resize/appearance 能力驱动而非无条件 |
| **粘贴大修bracketed-paste + onLargePaste + NFC** | 修 tmux/kitty/macOS 粘贴损坏handler 文件可直接落editor 接线是工作量 |
| **原子 token 安全 word delete + grapheme word nav** | 修 word-delete 吃半个 `[Paste/Image]` marker |
| **slash 自动补全前导空白 + 同步 Enter** | 修"缩进后 slash 失效"和 Enter 异步过期 |
| **Loader 组件级渲染** | 大 transcript 卡顿的真实性能收益(需 `requestComponentRender` |
| **Kitty transmit-once + 稳定 id + ImageBudget** | 约束图像内存/scrollback 幽灵(需渲染器接线) |
| **增量 Markdown lex** | 流式渲染 O(N²)→O(N),保正确 |
| **多路复用 + resize viewport defer** | 修 tmux scrollback 被清、resize 拖拽闪屏(部分自包含) |
### 🔵 第三梯队:高 effort / 高 value长期架构演进
| 项 | 说明 |
|---|---|
| **追加式 native scrollback 账本 + seam 契约** | 负载最重的核心修复,结构性消除 yank/闪屏/丢行;即使不整体移植也值得参照 |
| **范围感知 committed-prefix 审计** | "宁可重复不丢失"的重新排版恢复durable-exempt 接缝是精髓 |
| **组件级渲染 + stable-prefix 帧模型** | 第二梯队 Loader 优化的前提 |
| **render-stress 模糊测试 + shadow ledger** | 合约定义 + 回归网reducer/scheduler/subprocess 可直接采用 |
| **LaTeX → Unicode + 堆叠数学** | 最大的独立功能,与渲染管线零耦合,可单独立项移植 |
### ⚫ 不建议 / 需谨慎
- **整体换 bun + pi-nativesRust**:破坏我们的 Node 可移植性,得不偿失。
- **Sixel**:依赖 native sixel 编码器,且主要服务 Windows Terminal暂缓。
- **深导出 `./*`**:让消费方依赖内部文件布局,脆弱;保持我们当前的 `"."` 入口更稳。
- **删除 tab-width 配置**breaking + 低价值,除非产品真想放弃。
- **`fuzzy.ts` 重写**:搜索排序,与渲染正交,对我们低价值。
- **DECCARA**:除非"背景面板带宽"经测量确实是瓶颈,否则不优先(渲染器耦合 + kitty-only
- **`LoopWatchdog` 的 phase API**:依赖 `@oh-my-pi/pi-utils`,移植需先拆 phase 概念;价值中等。
---
## 11. 风险与注意事项
1. **Bun / Rust 耦合**oh-my-pi 大量代码依赖 `Bun.*` API 和 `@oh-my-pi/pi-natives`Rust NAPI。移植时要把 `Bun.color`/`Bun.env`/`Bun.nanoseconds` 等换成 Node 等价物;凡是调用 pi-natives 的sixel、keys、appearance需重写或放弃。
2. **行号会漂移**:本报告引用的 oh-my-pi 行号对应其 16.1.16 版本,后续提交会移动。
3. **CHANGELOG 是自报的**:偶见中间状态后来被重构(如 v11.8 的 `UndoStack` 类现已不存在)。结论以代码为准。
4. **Breaking changes 多**oh-my-pi 的 16 个大版本里有 11 处 breaking change`isXxx()` key helper、改 `Editor` 构造、删 `getCursorPosition()`、删 `terminal-image` 导出、删 tab-width 配置等)。参考时**不要照搬 API 形态**,只借鉴机制。
5. **我们的优势要保留**Node 可移植、自包含、无 Rust 依赖、overlay focus 模型更细。移植时不应牺牲这些。
6. **未跑测试**:本次调研为只读静态分析,所有"证据"来自源码 + 测试源 + 注释 + CHANGELOG未实际执行任一测试套件。
---
## 12. 结论与建议路线
oh-my-pi 对 pi-tui 的改造可以归纳为一条主线:**把"差分渲染"升级成"保护 native scrollback 的流式会话运行时"**,并围绕它补齐了终端能力协商、输入鲁棒性、图像/数学/Markdown 渲染、可测试性。
建议分三步走:
1. **Quick wins1-2 周)**SGR 合并、Headless 模式、emergency restore、tmux paste 变体、Box border、ScrollView、Markdown HTML 归一化、undo cap。这些都是低风险、有测试、可直接落地的能立刻改善帧体积、测试体验和若干输入/显示 bug。
2. **输入与能力2-4 周)**StdinBuffer partial-hold + paste watchdog、DA1 哨兵能力 FIFO + DECRQM 门控、粘贴大修、word-delete marker 修复、slash 补全修复。系统性提升输入鲁棒性和跨终端兼容。
3. **架构演进(按季度规划)**:参照 append-only native scrollback 账本 + seam 契约 + render-stress 模糊测试,逐步把我们的渲染器迁移到"保护 scrollback"的模型;并行评估 LaTeX 数学渲染作为一个独立可交付功能。
**最高优先级的参照物**render-stress 模糊测试套件(它定义了合约、能拦住任何后续移植的回归)和 SGR 合并(最低成本、最确定的字节体积收益)。
---
## 附录:信息来源
- 本报告由对两个 fork 的静态源码对比、oh-my-pi 的 `packages/tui/CHANGELOG.md`1775 行 / 152 版本)、以及 `oh-my-pi/docs/` 下的 `tui-core-renderer.md``tui-runtime-internals.md``tui.md``keybindings.md` 综合而成。
- oh-my-pi 关键模块行号:渲染核心 `tui.ts`3695 行)、终端 `terminal.ts``terminal-capabilities.ts`1049 行)、`kitty-graphics.ts``latex-to-unicode.ts`1994 行)、`latex-block.ts`461 行)、`bracketed-paste.ts``mouse.ts``deccara.ts``ttyid.ts``loop-watchdog.ts`
- 我们关键模块:`tui.ts`1714 行,`doRender``:1254-1620`)、`terminal.ts``terminal-image.ts``terminal-colors.ts``components/markdown.ts`858 行)、`editor.ts``autocomplete.ts`