mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
add refactor pi-tui docs
This commit is contained in:
parent
a5614cd680
commit
39df1a4dd3
6 changed files with 3632 additions and 0 deletions
182
handoff.md
Normal file
182
handoff.md
Normal 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 C(App 层接入):🟡 进行中**。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` 全绿;typecheck(pi-tui + kimi-code)均通过。
|
||||
- **下一步:** 继续 Phase C Task 5(组件级渲染 partial roots + loader 接入)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 工作流背景
|
||||
|
||||
按 `superpowers:subagent-driven-development` 技能执行:每个 Task 派 **实现者(coder)→ spec compliance reviewer(coder)→ code quality reviewer(coder)**,串行;质量审查若不通过则实现者修复后**重审**。对设计级 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
|
||||
|
||||
# app(vitest;--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 defer(alt-screen fast path + 120ms settle,ledger-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 相关测试必须显式清理这些 env(snapshot/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 timer(10ms + `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 C(App 层接入)—— 🟡 进行中
|
||||
|
||||
计划:`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. **决策 2(transcript 内存模型):废弃 rendered sliding window。** 旧行不销毁、commit 进 native scrollback;组件对象 + renderCache 随 turn 增长(Task 4 可在 commit 后清 cache 缓解)。匹配 OMP 模型。
|
||||
2. **Task 1 live 边界:整个当前 turn 起点**(用现成 `isTurnBoundaryComponent`,user-message 组件 = turn 起点)。live 覆盖整个当前 turn(thinking/工具/assistant),零抖动。代价:长 turn 的 commit 推迟到 turn 结束。
|
||||
3. **决策 3(StepSummary):append-only。** 旧 step 留 scrollback,summary 作为当前 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 seam(turn 边界)+ 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`,其 metadata(WeakMap,key=组件)随之保留(`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)` 模块级 helper(duck 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 不重 render);app 侧扩展 `moon-loader.test.ts`(断言调 `requestComponentRender(this)`)。
|
||||
- **提交:** `perf: component-scoped render for loader to avoid full-tree repaints`。
|
||||
|
||||
> 完整 prompt 在上一轮 assistant 消息里(被中断那条)。可直接复用。
|
||||
|
||||
### Phase C 剩余
|
||||
|
||||
- **Task 6(ImageBudget 实装):** 从零新建(非替换桩)。要点:在 `packages/pi-tui/src/components/image.ts` 加 `ImageBudget` 类(beginPass/endPass/observe/takePurgeIds,cap 默认 8,可 `PI_TUI_IMAGE_CAP` 覆盖);引擎 compose 前后 beginPass/endPass;emit 用 `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 2(DA1 FIFO)、Task 5(stdin-buffer)—— 已落地。
|
||||
- Phase C 整体 deep-dive(agent-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 6(ImageBudget)→ Task 7(验证 + 整阶段 review)。
|
||||
5. 整阶段 review 通过后:跑 `gen-changesets` 技能(默认 `minor`,不要自行 `major`)→ PR(英文标题 + 填模板,**不要**暴露 agent 身份或 claude 字样)。
|
||||
|
||||
如需用 subagent 继续,沿用 `superpowers:subagent-driven-development` 技能(实现者 → spec reviewer → quality reviewer)。
|
||||
430
plan/2026-06-30-pi-tui-app-integration-phase-c.md
Normal file
430
plan/2026-06-30-pi-tui-app-integration-phase-c.md
Normal 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:** TypeScript(Node 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 内存模型)
|
||||
|
||||
### 决策 1:seam 由 `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)
|
||||
// 初期可不实现(默认 = liveRegionStart),Phase 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 行数会增长,但引擎只渲染 window(height 行),已 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-live(transient)行的局部行索引;其前全 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-stable(shell 语义:滚出的全 commit)
|
||||
}
|
||||
}
|
||||
|
||||
function isLive(component: Component): boolean {
|
||||
// 通过 transcript-component-metadata 的 WeakMap 查到 TranscriptEntry,
|
||||
// 判定其是否处于 transient(streaming)状态。
|
||||
// 实现细节在 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 消息在后续帧不重排 markdown(render 调用计数稳定)。
|
||||
|
||||
### 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 触发 doRender;doRender 检查 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 root(render 计数)。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. 覆盖:** seam(Task 1)、废弃 rendered window(Task 2)、StepSummary(Task 3)、commit 信号优化(Task 4)、组件级渲染(Task 5)、ImageBudget(Task 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 已论证)。
|
||||
1763
plan/2026-06-30-pi-tui-ledger-engine-phase-a.md
Normal file
1763
plan/2026-06-30-pi-tui-ledger-engine-phase-a.md
Normal file
File diff suppressed because it is too large
Load diff
336
plan/2026-06-30-pi-tui-stress-hardening-phase-d.md
Normal file
336
plan/2026-06-30-pi-tui-stress-hardening-phase-d.md
Normal 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:** TypeScript(Node 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 oracle(xterm/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 由单独的 oracle(Task 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 + TUI(ledger),每帧 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 数默认 50(local),`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 解析器编到 WASM,grapheme/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 stress);nightly 跑 `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 跑默认 stress,nightly 跑全量。
|
||||
- [ ] benchmark 显示 ledger 相对 legacy 的帧字节/延迟改善。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. 覆盖:** shadow oracle(Task 1)、fuzz(Task 2)、reducer(Task 3)、ghostty(Task 4,可选)、CI+bench(Task 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)。
|
||||
537
plan/2026-06-30-pi-tui-terminal-infra-phase-b.md
Normal file
537
plan/2026-06-30-pi-tui-terminal-infra-phase-b.md
Normal 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:** TypeScript(Node 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-hold(SGR 鼠标/转义)、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 下默认 headless;PI_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(清 scrollback,rewrap)
|
||||
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 watchdog(1000ms)+ 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)、headless(Task 3)、resize defer(Task 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
384
research.md
Normal 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 |
|
||||
| 触及本包的提交数 | 2(vendor + 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, L−height)`、`chunkTo=W`,再发射四种字节形态之一:
|
||||
|
||||
- `#emitFullPaint`:仅用于用户手势
|
||||
- `#emitUpdate` 三种子形态:scroll-append(只发 chunk 行)、in-window diff(相对移动 + 变化区间)、seam rewrite(chunk + 整窗)
|
||||
- **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-pi:overlay **只合成进 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 watchdog(1000ms)+ 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>` 剥标签保内容,并解码实体(`&`、`😀`)。**高价值 / 低-中 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.11,breaking 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→17KB):type-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 80)vs 我们 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()`)。
|
||||
|
||||
> 我们是常规单元/组件 + 少量回归 repro;oh-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-natives(Rust)**:破坏我们的 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 wins(1-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`。
|
||||
Loading…
Add table
Add a link
Reference in a new issue