From 39df1a4dd376c3d476f1f43fdd968a400c11a855 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 1 Jul 2026 12:27:58 +0800 Subject: [PATCH] add refactor pi-tui docs --- handoff.md | 182 ++ ...26-06-30-pi-tui-app-integration-phase-c.md | 430 ++++ ...2026-06-30-pi-tui-ledger-engine-phase-a.md | 1763 +++++++++++++++++ ...6-06-30-pi-tui-stress-hardening-phase-d.md | 336 ++++ ...026-06-30-pi-tui-terminal-infra-phase-b.md | 537 +++++ research.md | 384 ++++ 6 files changed, 3632 insertions(+) create mode 100644 handoff.md create mode 100644 plan/2026-06-30-pi-tui-app-integration-phase-c.md create mode 100644 plan/2026-06-30-pi-tui-ledger-engine-phase-a.md create mode 100644 plan/2026-06-30-pi-tui-stress-hardening-phase-d.md create mode 100644 plan/2026-06-30-pi-tui-terminal-infra-phase-b.md create mode 100644 research.md diff --git a/handoff.md b/handoff.md new file mode 100644 index 000000000..9b79e725d --- /dev/null +++ b/handoff.md @@ -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.)` 上色,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/.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+`,照抄既不现实也修不了 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()`。 + - `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=`(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 -- ` 会跑整个套件**(不过滤);聚焦单文件用 `pnpm --filter @moonshot-ai/kimi-code exec vitest run `。 +- **全局 `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)。 diff --git a/plan/2026-06-30-pi-tui-app-integration-phase-c.md b/plan/2026-06-30-pi-tui-app-integration-phase-c.md new file mode 100644 index 000000000..cdea8c277 --- /dev/null +++ b/plan/2026-06-30-pi-tui-app-integration-phase-c.md @@ -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(); + #partialComposeRoots: Set | null = null; + + public requestComponentRender(component: Component): void { + this.#componentRenderTargets.add(component); + // 由 TUI 的 scheduleRender 触发 doRender;doRender 检查 targets + } + + #resolvePartialComposeRoots(_width: number, _height: number): Set | null { + if (this.#componentRenderTargets.size === 0) return null; + if (this.#frameSegments.length === 0) return null; // 首帧必须全量 + const roots = new Set(); + 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(); + 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 已论证)。 diff --git a/plan/2026-06-30-pi-tui-ledger-engine-phase-a.md b/plan/2026-06-30-pi-tui-ledger-engine-phase-a.md new file mode 100644 index 000000000..2a76cf9db --- /dev/null +++ b/plan/2026-06-30-pi-tui-ledger-engine-phase-a.md @@ -0,0 +1,1763 @@ +# Phase A 实施计划:pi-tui 渲染器账本核心(1:1 对齐 oh-my-pi) + +> **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/tui.ts` 的渲染核心从"上一帧 diff + 散落的 fullRender"替换为 oh-my-pi 的**追加式 native scrollback 账本引擎**(ledger + seam + 范围感知审计 + render intent 分类器 + `#emitFullPaint`/`#emitUpdate` 三形态),Node-pure,**只动 pi-tui 包内部**,不碰 app 层。 + +**Architecture:** 新引擎与旧引擎**并存于一个开关之后**(`PI_TUI_ENGINE=ledger`),旧 `doRender` 保留为回退。新引擎由独立的 `#doRenderLedger()` 驱动,使用新增的 `#composeFrame()`(段式合成,不覆盖 `Container.render`)。每个任务用 `node --test` + `@xterm/headless` 的 `VirtualTerminal` 验证;最后用 render-stress(VT oracle + 种子化随机)做总验收,绿了再切默认。 + +**Tech Stack:** TypeScript(Node 24 type-stripping)、`node --test`、`@xterm/headless`、`node:assert`。纯 TS,**不引入 Bun / pi-natives / pi-utils**。 + +**参考实现(faithful 来源):** `oh-my-pi/packages/tui/src/tui.ts`(3695 行)。本计划每项都标注对应的 OMP 行号,便于逐行对照移植。 + +--- + +## 关键设计决策(先读,决定计划形状) + +1. **并存开关,不大爆炸替换**:新增 `#doRenderLedger()`,由 `doRender()` 按 `process.env["PI_TUI_ENGINE"] === "ledger"` 分发。旧逻辑(`#doRenderLegacy()`)完整保留。这样每个任务都可独立测试,出问题可立即回退。**最后任务**才切默认并删旧代码。 +2. **不覆盖 `Container.render`**:OMP 是 `override render()` 做段式合成。我们为保持旧引擎独立,新增私有 `#composeFrame(width): readonly string[]`(逻辑等同 OMP 的 `render()` override),旧引擎继续用继承的 `render()`。 +3. **Phase A 范围内降级/桩掉的部分**(明确取舍,后续 Phase 补): + - **Overlay**:我们的 app 不用 `TUI.showOverlay`,新引擎硬编码 `hasVisibleOverlay = false`,不移植 `#compositeOverlaysIntoWindow`。 + - **图像**:`ImageBudget` 用 no-op 桩(`beginPass/endPass/takeTransmits/takePurgeIds` 返回空);`isImageLine` 复用我们 `src/terminal-image.ts` 的现有函数;`#emitFullPaint` 的 `imageTransmitBuffer`/`purgeSequence` 传空串。 + - **DECCARA**:`#deccaraFillsEnabled()` 返回 `false`(`planDeccaraFills` 用「原样返回 window」的桩)。 + - **终端能力**:`TERMINAL.supportsScreenToScrollback` → `false`(不用 kitty ED22);`TERMINAL.deccara` → `false`;`TERMINAL.isImageLine` → 我们的 `isImageLine`;同步输出用 Task 1 的简单门控。 + - **resize viewport drag fast path**:`#resizeViewportActive` 的 alt-screen 拖拽优化**放到 Phase B**。Phase A 里几何变化直接走 `fullPaint`(和我们现在一样,但经新分类器)。只移植 `isMultiplexerSession` / `resizeRepaintsInPlace`(分类器要)。 +4. **宽度/截断函数复用我们的 `utils.ts`**:`visibleWidth` / `truncateToWidth` / `sliceByColumn` / `wrapTextWithAnsi` 已存在;`normalizeTerminalOutput` 如缺则补一个最小等价(去 OSC 以外的非法控制字符)。 +5. **硬件光标**:移植 `#cursorControlSequence` + `#targetHardwareCursorState` 的简化版,复用我们现有的 `positionHardwareCursor` 思路但接入新 emitter。 +6. **审计/账本严格 faithful**:`findCommittedPrefixResync` 逐字移植(`export` 出来供 stress 用);三 zones(`auditRows ≤ durableRows ≤ committedRows`)不变量严格保持。 + +--- + +## 约定 + +- **工作目录**:所有命令在 `packages/pi-tui/` 下执行。 +- **跑单个测试**:`node --test test/.test.ts` +- **跑全量测试**:`node --test test/*.test.ts` +- **类型检查**(仓库根):`pnpm --filter @moonshot-ai/pi-tui typecheck` +- **测试模式**:复用 `test/virtual-terminal.ts` 的 `VirtualTerminal`(`getViewport`/`getScrollBuffer`/`resize`/`waitForRender`);Task 1 新增 `LoggingVirtualTerminal`(捕获 emit 字节)。 +- **新引擎默认关**:除 Task 10(stress)和 Task 11(切默认)外,所有新测试通过 `withEnv({ PI_TUI_ENGINE: "ledger" }, …)` 显式打开。 +- **提交规范**:语义化提交 `feat(pi-tui):` / `refactor(pi-tui):` / `test(pi-tui):`;**不要** co-author;**不要**出现 claude 字样。 +- **忠实度**:每个核心函数后标注 `// OMP: `,便于对照 `oh-my-pi/packages/tui/src/tui.ts`。 + +--- + +## 文件结构 + +| 文件 | 动作 | 责任 | +|---|---|---| +| `src/ledger/types.ts` | 新建 | `RenderIntent`、`FrameSegment`、`PreparedLine`、cursor 类型、`SGR_SEQUENCE`、常量 | +| `src/ledger/seam.ts` | 新建 | `NativeScrollbackLiveRegion` / `NativeScrollbackCommittedRows` / `RenderStablePrefix` 接口 + getter | +| `src/ledger/sgr-coalesce.ts` | 新建 | `coalesceAdjacentSgr` + `endsWithIncompleteExtendedColor`(逐字移植) | +| `src/ledger/audit.ts` | 新建 | `findCommittedPrefixResync`(export)+ `rowsEquivalent`/`isBlankRow` | +| `src/ledger/engine.ts` | 新建 | `LedgerTuiEngine` 类:ledger 字段、`#composeFrame`、`#prepareFrame`、`#terminalLine`、`#lineRewriteSequence`、`#auditCommittedPrefix`、`#updateCommittedAuditRows`、`#commit`、`#emitFullPaint`、`#emitUpdate`、`#doRenderLedger` | +| `src/ledger/terminal-caps-stub.ts` | 新建 | Phase A 桩:`isMultiplexerSession`、`resizeRepaintsInPlace`、`TERMINAL` 能力桩、sync 门控 | +| `src/tui.ts` | 修改 | `doRender()` 改为按 `PI_TUI_ENGINE` 分发;旧逻辑改名 `#doRenderLegacy()`;注入 `LedgerTuiEngine` | +| `test/virtual-terminal.ts` | 修改 | 追加 `LoggingVirtualTerminal` | +| `test/ledger-sgr-coalesce.test.ts` | 新建 | SGR 合并单元测试 | +| `test/ledger-audit.test.ts` | 新建 | `findCommittedPrefixResync` 单元测试 | +| `test/ledger-engine-golden.test.ts` | 新建 | 新引擎 golden:基础/流式追加/收缩/超宽 clamp | +| `test/ledger-engine-stress.test.ts` | 新建 | render-stress:VT oracle + 种子化随机 | + +> **为什么拆 `src/ledger/` 子目录**:新引擎逻辑集中、可独立测试,且与旧 `tui.ts` 解耦;切默认后旧 `#doRenderLegacy()` 删除,`LedgerTuiEngine` 可再内联回 `tui.ts`(Task 11 决定)。 + +--- + +## Task 1: 基础类型 + seam 接口 + SGR 合并 + 能力桩 + +**Files:** +- Create: `packages/pi-tui/src/ledger/types.ts` +- Create: `packages/pi-tui/src/ledger/seam.ts` +- Create: `packages/pi-tui/src/ledger/sgr-coalesce.ts` +- Create: `packages/pi-tui/src/ledger/terminal-caps-stub.ts` +- Modify: `packages/pi-tui/test/virtual-terminal.ts` +- Create: `packages/pi-tui/test/ledger-sgr-coalesce.test.ts` + +### Step 1: 写 `src/ledger/types.ts`(OMP: 40-85, 603-657) + +```ts +import type { Component } from "../tui.ts"; + +export const SEGMENT_RESET = "\x1b[0m"; +export const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07"; +export const ERASE_LINE = "\x1b[2K"; +export const ERASE_TO_END_OF_LINE = "\x1b[K"; +export const HIDE_CURSOR = "\x1b[?25l"; +export const SYNC_OUTPUT_BEGIN = "\x1b[?2026h"; +export const SYNC_OUTPUT_END = "\x1b[?2026l"; +export const DISABLE_AUTOWRAP = "\x1b[?7l"; +export const ENABLE_AUTOWRAP = "\x1b[?7h"; +export const ALT_SCREEN_ENTER = "\x1b[?1049h"; +export const ALT_SCREEN_EXIT = "\x1b[?1049l"; + +export const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g; +export const MERGE_TOKEN_CAP = 16; + +export type RenderIntent = + | { kind: "fullPaint"; clearScrollback: boolean } + | { kind: "update"; chunkTo: number; windowTop: number }; + +export interface HardwareCursorState { + row: number; + col: number; + visible: boolean; +} + +export interface HardwareCursorUpdate { + toRow: number; + state: HardwareCursorState | null; + visible?: boolean; +} + +export interface CursorControlResult extends HardwareCursorUpdate { + seq: string; + toCol: number; + visible: boolean; +} + +export interface FrameSegment { + component: Component; + lines: readonly string[]; + start: number; + rowCount: number; + liveLocalStart?: number; + commitLocalEnd?: number; + snapshotLocalEnd?: number; +} + +export interface PreparedLine { + raw: string; + width: number; + line: string; +} +``` + +### Step 2: 写 `src/ledger/seam.ts`(OMP: 184-278) + +```ts +import type { Component } from "../tui.ts"; + +export interface NativeScrollbackLiveRegion { + getNativeScrollbackLiveRegionStart(): number | undefined; + getNativeScrollbackCommitSafeEnd?(): number | undefined; + getNativeScrollbackSnapshotSafeEnd?(): number | undefined; +} + +export interface NativeScrollbackCommittedRows { + setNativeScrollbackCommittedRows(rows: number): void; +} + +export interface RenderStablePrefix { + getRenderStablePrefixRows(): number; +} + +export function getNativeScrollbackLiveRegionStart(component: Component): number | undefined { + return (component as Component & Partial).getNativeScrollbackLiveRegionStart?.(); +} + +export function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined { + return (component as Component & Partial).getNativeScrollbackCommitSafeEnd?.(); +} + +export function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined { + return (component as Component & Partial).getNativeScrollbackSnapshotSafeEnd?.(); +} + +export function setNativeScrollbackCommittedRows(component: Component, rows: number): void { + (component as Component & Partial).setNativeScrollbackCommittedRows?.(rows); +} + +export function getRenderStablePrefixRows(component: Component): number | undefined { + return (component as Component & Partial).getRenderStablePrefixRows?.(); +} +``` + +### Step 3: 写失败的 SGR 合并测试 `test/ledger-sgr-coalesce.test.ts` + +```ts +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { coalesceAdjacentSgr } from "../src/ledger/sgr-coalesce.ts"; + +describe("coalesceAdjacentSgr (ledger)", () => { + it("merges byte-adjacent SGR into one CSI", () => { + assert.strictEqual(coalesceAdjacentSgr("\x1b[39m\x1b[38;2;1;2;3mX"), "\x1b[39;38;2;1;2;3mX"); + }); + it("keeps non-adjacent SGR separate", () => { + assert.strictEqual(coalesceAdjacentSgr("\x1b[31mA\x1b[32mB"), "\x1b[31mA\x1b[32mB"); + }); + it("splits runs over the 16-token cap", () => { + const run = Array.from({ length: 20 }, () => "\x1b[1m").join(""); + const out = coalesceAdjacentSgr(`${run}X`); + assert.ok((out.match(/\x1b\[/g) ?? []).length >= 2); + }); + it("does not merge across incomplete extended color", () => { + const out = coalesceAdjacentSgr("\x1b[38;2m\x1b[1mX"); + assert.ok(out.includes("\x1b[38;2m\x1b[1m"), JSON.stringify(out)); + }); + it("returns plain text unchanged (same reference not required)", () => { + assert.strictEqual(coalesceAdjacentSgr("hello"), "hello"); + }); +}); +``` + +Run: `node --test test/ledger-sgr-coalesce.test.ts` → 应失败(模块不存在)。 + +### Step 4: 写 `src/ledger/sgr-coalesce.ts`(OMP: 659-792,逐字移植,`Bun`→无) + +```ts +import { MERGE_TOKEN_CAP } from "./types.ts"; + +const SGR_COALESCE_ENABLED = process.env["PI_NO_SGR_COALESCE"] !== "1"; +const CC_ESC = 0x1b; +const CC_BRACKET = 0x5b; +const CC_M = 0x6d; +const CC_SEMI = 0x3b; +const CC_COLON = 0x3a; + +function isSgrParamByte(c: number): boolean { + return (c >= 0x30 && c <= 0x39) || c === CC_SEMI || c === CC_COLON; +} + +// OMP: 687-717 +function endsWithIncompleteExtendedColor(params: string): boolean { + const t = params.split(";"); + let i = 0; + while (i < t.length) { + const tok = t[i]; + if (tok === "38" || tok === "48" || tok === "58") { + const mode = t[i + 1]; + if (mode === undefined) return true; + if (mode === "2") { + if (i + 4 >= t.length) return true; + i += 5; + continue; + } + if (mode === "5") { + if (i + 2 >= t.length) return true; + i += 3; + continue; + } + } + i += 1; + } + return false; +} + +// OMP: 719-792 +export function coalesceAdjacentSgr(line: string): string { + if (!SGR_COALESCE_ENABLED || line.indexOf("\x1b[") === -1) return line; + const n = line.length; + let out = ""; + let copiedUpto = 0; + let i = 0; + while (i < n) { + if (line.charCodeAt(i) !== CC_ESC || line.charCodeAt(i + 1) !== CC_BRACKET) { + i++; + continue; + } + let j = i + 2; + while (j < n && isSgrParamByte(line.charCodeAt(j))) j++; + if (j >= n || line.charCodeAt(j) !== CC_M) { + i = j; + continue; + } + const params: string[] = [line.slice(i + 2, j)]; + let k = j + 1; + while (k < n && line.charCodeAt(k) === CC_ESC && line.charCodeAt(k + 1) === CC_BRACKET) { + let p = k + 2; + while (p < n && isSgrParamByte(line.charCodeAt(p))) p++; + if (p >= n || line.charCodeAt(p) !== CC_M) break; + params.push(line.slice(k + 2, p)); + k = p + 1; + } + if (params.length > 1) { + out += line.slice(copiedUpto, i); + let group = ""; + let groupTokens = 0; + let groupOpenSafe = true; + for (let q = 0; q < params.length; q++) { + const norm = params[q]!.length === 0 ? "0" : params[q]!; + let tk = 1; + for (let z = 0; z < norm.length; z++) { + const cc = norm.charCodeAt(z); + if (cc === CC_SEMI || cc === CC_COLON) tk++; + } + if (groupTokens > 0 && (!groupOpenSafe || groupTokens + tk > MERGE_TOKEN_CAP)) { + out += `\x1b[${group}m`; + group = ""; + groupTokens = 0; + } + group += group.length === 0 ? norm : `;${norm}`; + groupTokens += tk; + groupOpenSafe = !endsWithIncompleteExtendedColor(norm); + } + if (group.length > 0) out += `\x1b[${group}m`; + copiedUpto = k; + } + i = k; + } + if (copiedUpto === 0) return line; + return out + line.slice(copiedUpto); +} +``` + +Run: `node --test test/ledger-sgr-coalesce.test.ts` → 应通过。 + +### Step 5: 写 `src/ledger/terminal-caps-stub.ts`(Phase A 桩) + +```ts +// OMP: isMultiplexerSession (388-401) — Bun.env → process.env +export function isMultiplexerSession(env: NodeJS.ProcessEnv = process.env): boolean { + if (env["TMUX"] || env["STY"] || env["ZELLIJ"]) return true; + if (env["CMUX_WORKSPACE_ID"] || env["CMUX_SURFACE_ID"]) return true; + const term = (env["TERM"] ?? "").toLowerCase(); + return term.startsWith("tmux") || term.startsWith("screen"); +} + +// OMP: reportsSizeOnAltScreenToggle (415-420) +function reportsSizeOnAltScreenToggle(env: NodeJS.ProcessEnv = process.env): boolean { + const override = env["PI_TUI_RESIZE_IN_PLACE"]; + if (override === "0" || override === "false") return false; + if (override === "1" || override === "true") return true; + return env["TERM_PROGRAM"]?.toLowerCase() === "warpterminal"; +} + +// OMP: resizeRepaintsInPlace (428-430) +export function resizeRepaintsInPlace(env: NodeJS.ProcessEnv = process.env): boolean { + return isMultiplexerSession(env) || reportsSizeOnAltScreenToggle(env); +} + +// 同步输出门控(简化版;完整 DECRQM 探测留给 Phase B) +const SYNC_KNOWN = ["xterm-kitty", "xterm-ghostty", "wezterm", "alacritty", "foot", "contour", "kitty", "ghostty"]; +export function shouldEnableSyncOutput(env: NodeJS.ProcessEnv = process.env): boolean { + if (env["PI_FORCE_SYNC_OUTPUT"] === "1") return true; + if (env["PI_NO_SYNC_OUTPUT"] === "1") return false; + if (isMultiplexerSession(env)) return false; + const term = env["TERM"] ?? ""; + return SYNC_KNOWN.some((k) => term.includes(k)); +} + +// 能力桩:Phase A 不用 kitty ED22 / deccara;isImageLine 由 engine 注入 +export const TERMINAL_STUB = { + supportsScreenToScrollback: false, + deccara: false, +}; +``` + +### Step 6: 在 `test/virtual-terminal.ts` 末尾追加 `LoggingVirtualTerminal` + +```ts +export class LoggingVirtualTerminal extends VirtualTerminal { + private writes: string[] = []; + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + getWrites(): string { + return this.writes.join(""); + } + clearWrites(): void { + this.writes = []; + } +} +``` + +### Step 7: 提交 + +```bash +git add packages/pi-tui/src/ledger packages/pi-tui/test/virtual-terminal.ts packages/pi-tui/test/ledger-sgr-coalesce.test.ts +git commit -m "feat(pi-tui): scaffold ledger engine types, seam, SGR coalescing" +``` + +--- + +## Task 2: 审计核心 `findCommittedPrefixResync` + +**Files:** +- Create: `packages/pi-tui/src/ledger/audit.ts` +- Create: `packages/pi-tui/test/ledger-audit.test.ts` + +### Step 1: 写失败测试 `test/ledger-audit.test.ts` + +```ts +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { findCommittedPrefixResync } from "../src/ledger/audit.ts"; + +describe("findCommittedPrefixResync", () => { + it("returns -1 when frame matches prefix", () => { + const prefix = ["a", "b", "c"]; + assert.strictEqual(findCommittedPrefixResync(["a", "b", "c"], prefix), -1); + }); + it("returns -1 for a single in-place edit within tolerance", () => { + const prefix = ["a", "b", "c", "d"]; + // one-row edit in the tail sample window → tolerated + assert.strictEqual(findCommittedPrefixResync(["a", "b", "X", "d"], prefix), -1); + }); + it("re-anchors at first shifted row on insertion", () => { + const prefix = ["a", "b", "c", "d"]; + // insertion shifts everything below row 1 + const frame = ["a", "INS", "b", "c", "d"]; + assert.strictEqual(findCommittedPrefixResync(frame, prefix), 1); + }); + it("re-anchors when frame no longer covers prefix", () => { + const prefix = ["a", "b", "c", "d"]; + assert.strictEqual(findCommittedPrefixResync(["a", "b"], prefix), 2); + }); + it("skips the exempt window", () => { + const prefix = ["a", "b", "c", "d"]; + // row 1-2 differ but are exempt + const frame = ["a", "X", "Y", "d"]; + assert.strictEqual(findCommittedPrefixResync(frame, prefix, 4, 1, 3), -1); + }); + it("hard scan catches a finalized forced-row change", () => { + const prefix = ["a", "b", "c", "d"]; + // permanentEnd=4 forces a full hard scan; row 1 changed → re-anchor at 1 + assert.strictEqual(findCommittedPrefixResync(["a", "Z", "c", "d"], prefix, 4, 4, 4, 4), 1); + }); + it("treats SGR-only differences as equivalent", () => { + const prefix = ["\x1b[31mhello\x1b[0m"]; + assert.strictEqual(findCommittedPrefixResync(["\x1b[32mhello\x1b[0m"], prefix), -1); + }); +}); +``` + +Run: `node --test test/ledger-audit.test.ts` → 应失败。 + +### Step 2: 写 `src/ledger/audit.ts`(OMP: 794-906,逐字移植) + +```ts +import { SGR_SEQUENCE } from "./types.ts"; + +const RESYNC_TAIL_LOOKBACK = 24; +const RESYNC_TAIL_SAMPLES = 8; + +// OMP: 795-798 +function rowsEquivalent(a: string, b: string): boolean { + if (a === b) return true; + return a.replace(SGR_SEQUENCE, "") === b.replace(SGR_SEQUENCE, ""); +} + +// OMP: 800-803 +function isBlankRow(row: string): boolean { + if (row.length === 0) return true; + return row.replace(SGR_SEQUENCE, "").trim().length === 0; +} + +// OMP: 848-906 +export function findCommittedPrefixResync( + frame: readonly string[], + prefix: readonly string[], + auditTo: number = prefix.length, + exemptFrom: number = auditTo, + exemptTo: number = exemptFrom, + permanentEnd = 0, +): number { + const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo))); + if (committed === 0) return -1; + const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom))); + const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo))); + const audited = (i: number): boolean => i < exFrom || i >= exTo; + if (frame.length >= committed) { + const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd))); + let hardMismatch = false; + for (let i = exTo; i < hardEnd; i++) { + if (!rowsEquivalent(frame[i]!, prefix[i]!)) { + hardMismatch = true; + break; + } + } + if (!hardMismatch) { + let samples = 0; + let mismatches = 0; + let scanned = 0; + for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) { + const idx = committed - j; + if (!audited(idx)) continue; + scanned++; + const row = frame[idx]!; + const old = prefix[idx]!; + if (row === old) { + if (!isBlankRow(row)) samples++; + continue; + } + if (isBlankRow(row) && isBlankRow(old)) continue; + samples++; + if (!rowsEquivalent(row, old)) mismatches++; + } + if (samples === 0 || mismatches <= 1) return -1; + } + } + const limit = Math.min(committed, frame.length); + for (let i = 0; i < limit; i++) { + if (!audited(i)) continue; + if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i; + } + return limit < committed ? limit : -1; +} +``` + +Run: `node --test test/ledger-audit.test.ts` → 应通过。 + +### Step 3: 提交 + +```bash +git add packages/pi-tui/src/ledger/audit.ts packages/pi-tui/test/ledger-audit.test.ts +git commit -m "feat(pi-tui): add committed-prefix resync audit" +``` + +--- + +## Task 3: LedgerTuiEngine 骨架 + ledger 字段 + 光标控制 + +**Files:** +- Create: `packages/pi-tui/src/ledger/engine.ts`(骨架) +- Modify: `packages/pi-tui/src/ledger/types.ts`(如需扩展) + +### Step 1: 写 `src/ledger/engine.ts` 骨架(字段 + 构造 + 光标控制) + +```ts +import type { Component, Terminal } from "../tui.ts"; +import { isImageLine } from "../terminal-image.ts"; +import { sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts"; +import { findCommittedPrefixResync } from "./audit.ts"; +import { getNativeScrollbackCommitSafeEnd, getNativeScrollbackLiveRegionStart, getNativeScrollbackSnapshotSafeEnd, getRenderStablePrefixRows, setNativeScrollbackCommittedRows } from "./seam.ts"; +import { coalesceAdjacentSgr } from "./sgr-coalesce.ts"; +import { isMultiplexerSession, resizeRepaintsInPlace, shouldEnableSyncOutput, TERMINAL_STUB } from "./terminal-caps-stub.ts"; +import { + type CursorControlResult, + DISABLE_AUTOWRAP, + ENABLE_AUTOWRAP, + ERASE_LINE, + ERASE_TO_END_OF_LINE, + type FrameSegment, + HIDE_CURSOR, + type HardwareCursorState, + type HardwareCursorUpdate, + LINE_TERMINATOR, + type PreparedLine, + type RenderIntent, + SEGMENT_RESET, + SYNC_OUTPUT_BEGIN, + SYNC_OUTPUT_END, +} from "./types.ts"; + +export class LedgerTuiEngine { + // ---- ledger state (OMP: 990-1028) ---- + #committedRows = 0; + #committedPrefix: string[] = []; + #committedPrefixAuditRows = 0; + #committedPrefixDurableRows = 0; + #windowTopRow = 0; + #previousWindow: string[] = []; + #previousFrameLength = 0; + #previousWidth = 0; + #previousHeight = 0; + #hardwareCursorRow = 0; + #showHardwareCursor = process.env["PI_HARDWARE_CURSOR"] !== "0"; + + // ---- seam (per-frame, set by compose) ---- + #nativeScrollbackLiveRegionStart: number | undefined; + #nativeScrollbackCommitSafeEnd: number | undefined; + #nativeScrollbackSnapshotSafeEnd: number | undefined; + + // ---- gesture flags (OMP: 1029-1070) ---- + #fullRedrawCount = 0; + #clearScrollbackOnNextRender = false; + #forceViewportRepaintOnNextRender = false; + #hasEverRendered = false; + #resizeEventPending = false; + + // ---- composed + prepared caches (OMP: 1087-1125) ---- + #composedFrame: string[] = []; + #frameSegments: FrameSegment[] = []; + #composeWidth = -1; + #frameCursorMarkers: { row: number; col: number }[] = []; + #renderStablePrefixRows = 0; + #preparedFrame: string[] = []; + #preparedMeta: PreparedLine[] = []; + #preparedValidRows = 0; + + // ---- paint framing ---- + readonly #syncEnabled: boolean; + readonly #paintBeginSequence: string; + readonly #paintEndSequence: string; + + // children injected by the host TUI (it owns the Container children list) + constructor( + private readonly terminal: Terminal, + private readonly getChildren: () => Component[], + ) { + this.#syncEnabled = shouldEnableSyncOutput(); + this.#paintBeginSequence = this.#syncEnabled + ? `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}` + : `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`; + this.#paintEndSequence = this.#syncEnabled ? `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}` : ENABLE_AUTOWRAP; + } + + get fullRedraws(): number { + return this.#fullRedrawCount; + } + + // ---- cursor control (OMP: 3647-3671, 3120-3130) ---- + #targetHardwareCursorState(cursorPos: { row: number; col: number } | null, totalLines: number): HardwareCursorState | null { + if (!cursorPos || totalLines <= 0) return null; + return { + row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)), + col: Math.max(0, cursorPos.col), + visible: this.#showHardwareCursor, + }; + } + + #cursorControlSequence( + cursorPos: { row: number; col: number } | null, + totalLines: number, + fromRow: number, + ): CursorControlResult { + const target = this.#targetHardwareCursorState(cursorPos, totalLines); + if (!target) { + return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null }; + } + const rowDelta = target.row - fromRow; + let seq = ""; + if (rowDelta > 0) seq += `\x1b[${rowDelta}B`; + else if (rowDelta < 0) seq += `\x1b[${-rowDelta}A`; + seq += `\x1b[${target.col + 1}G`; + seq += target.visible ? "\x1b[?25h" : "\x1b[?25l"; + return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target }; + } + + #recordHardwareCursorUpdate(update: HardwareCursorUpdate): void { + this.#hardwareCursorRow = update.toRow; + if (update.state) this.#showHardwareCursor = update.state.visible; + } +} +``` + +> 后续任务会往这个类里追加方法(compose / prepare / terminalLine / audit / emit / doRender)。本步先建可编译骨架。 + +### Step 2: 类型检查 + +Run(仓库根): `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 通过(`#composeFrame` 等尚未实现,但已声明的方法不报错;如有 unused 警告可接受,后续任务会用到)。 + +### Step 3: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): add LedgerTuiEngine skeleton with ledger state and cursor control" +``` + +--- + +## Task 4: 段式合成 `#composeFrame` + 行摄取 + +**Files:** +- Modify: `packages/pi-tui/src/ledger/engine.ts` + +### Step 1: 在 `LedgerTuiEngine` 内追加 `#ingestFrameRow`(OMP: 1296-1312) + +```ts + #ingestFrameRow(line: string): void { + const CURSOR_MARKER = "\x1b_pi:c\x07"; + let markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex === -1) { + this.#composedFrame.push(line); + return; + } + this.#frameCursorMarkers.push({ row: this.#composedFrame.length, col: visibleWidth(line.slice(0, markerIndex)) }); + let stripped = line; + while (markerIndex !== -1) { + stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length); + markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex); + } + this.#composedFrame.push(stripped); + } + + #pruneFrameCursorMarkers(fromRow: number): void { + this.#frameCursorMarkers = this.#frameCursorMarkers.filter((m) => m.row < fromRow); + } +``` + +### Step 2: 追加 `#composeFrame`(OMP: 1143-1281) + +```ts + #composeFrame(width: number): readonly string[] { + width = Math.max(1, width); + this.#nativeScrollbackLiveRegionStart = undefined; + this.#nativeScrollbackCommitSafeEnd = undefined; + this.#nativeScrollbackSnapshotSafeEnd = undefined; + const children = this.getChildren(); + const previousSegments = this.#frameSegments; + const segments: FrameSegment[] = new Array(children.length); + let chainStable = this.#composeWidth === width; + this.#composeWidth = width; + let offset = 0; + let stableRows = 0; + + for (let index = 0; index < children.length; index++) { + const child = children[index]!; + const previous = previousSegments[index]; + // Phase A: no component-scoped reuse yet (partial roots = null) + let childLines: readonly string[]; + let liveLocalStart: number | undefined; + let commitLocalEnd: number | undefined; + let snapshotLocalEnd: number | undefined; + let reported: number | undefined; + + setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset)); + childLines = child.render(width); + const liveRegionStart = getNativeScrollbackLiveRegionStart(child); + if (liveRegionStart !== undefined) { + liveLocalStart = Number.isFinite(liveRegionStart) + ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart))) + : childLines.length; + const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child); + if (commitSafeEnd !== undefined) { + commitLocalEnd = Number.isFinite(commitSafeEnd) + ? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd))) + : childLines.length; + } + const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child); + if (snapshotSafeEnd !== undefined) { + const snapshotFloor = commitLocalEnd ?? liveLocalStart; + snapshotLocalEnd = Number.isFinite(snapshotSafeEnd) + ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd))) + : childLines.length; + } + } + reported = getRenderStablePrefixRows(child); + + // topmost seam wins + if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) { + this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart; + if (commitLocalEnd !== undefined) this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd; + if (snapshotLocalEnd !== undefined) this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd; + } + + if (chainStable) { + if (previous !== undefined && previous.component === child && previous.start === offset) { + let stableCount = 0; + if (reported !== undefined) { + stableCount = Number.isFinite(reported) + ? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported))) + : 0; + } else if (previous.lines === childLines) { + stableCount = childLines.length; + } + stableRows += stableCount; + if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false; + } else { + chainStable = false; + } + } + segments[index] = { component: child, lines: childLines, start: offset, rowCount: childLines.length, liveLocalStart, commitLocalEnd, snapshotLocalEnd }; + offset += childLines.length; + } + this.#frameSegments = segments; + + const frame = this.#composedFrame; + if (stableRows > frame.length) stableRows = frame.length; + if (stableRows !== offset || frame.length !== offset) { + frame.length = stableRows; + this.#pruneFrameCursorMarkers(stableRows); + for (const segment of segments) { + const lines = segment.lines; + const from = segment.start >= stableRows ? 0 : stableRows - segment.start; + for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!); + } + } + this.#renderStablePrefixRows = stableRows; + this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows); + return frame; + } +``` + +### Step 3: 类型检查 + +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 通过。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): implement ledger frame composition with seam capture" +``` + +--- + +## Task 5: 帧准备 `#prepareFrame` + per-line 终结 + +**Files:** +- Modify: `packages/pi-tui/src/ledger/engine.ts` + +### Step 1: 追加 `#prepareFrame` / `#prepareLinesArray` / `#prepareLine`(OMP: 2900-2943) + +```ts + #prepareFrame(frame: readonly string[], width: number): string[] { + const prepared = this.#preparedFrame; + const meta = this.#preparedMeta; + if (prepared.length > frame.length) { + prepared.length = frame.length; + meta.length = frame.length; + } + for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) { + const raw = frame[i]!; + const cached = meta[i]; + if (cached !== undefined && cached.raw === raw && cached.width === width) { + prepared[i] = cached.line; + continue; + } + const entry = this.#prepareLine(raw, width); + meta[i] = entry; + prepared[i] = entry.line; + } + this.#preparedValidRows = frame.length; + return prepared; + } + + #prepareLine(raw: string, width: number): PreparedLine { + if (isImageLine(raw)) return { raw, width, line: raw }; + const normalized = raw; // Phase A: 假定组件已规范化;如需 normalizeTerminalOutput 在此补 + if (visibleWidth(normalized) <= width) return { raw, width, line: normalized }; + return { raw, width, line: truncateToWidth(normalized, width) }; + } +``` + +### Step 2: 追加 `#terminalLine` + `#lineRewriteSequence`(OMP: 2450-2454, 3083-3099) + +```ts + #terminalLine(line: string): string { + if (isImageLine(line)) return line; + const coalesced = coalesceAdjacentSgr(line); + return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET); + } + + #lineRewriteSequence(line: string, width: number): string { + if (isImageLine(line)) return ERASE_LINE + line; + const terminalLine = this.#terminalLine(line); + const w = visibleWidth(line); + if (w >= width) return terminalLine; + return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine; + } +``` + +### Step 3: 类型检查 + 全量测试(应仍全绿,新代码尚未被 doRender 调用) + +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Run: `node --test test/*.test.ts` +Expected: 全绿。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): implement ledger frame preparation and per-line finalization" +``` + +--- + +## Task 6: 审计方法 `#auditCommittedPrefix` + `#updateCommittedAuditRows` + +**Files:** +- Modify: `packages/pi-tui/src/ledger/engine.ts` + +### Step 1: 追加两个方法(OMP: 2831-2891) + +```ts + // OMP: 2831-2851 + #auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void { + const prefix = this.#committedPrefix; + if (prefix.length === 0) return; + const resyncTo = findCommittedPrefixResync( + rawFrame, + prefix, + prefix.length, + this.#committedPrefixAuditRows, + this.#committedPrefixDurableRows, + permanentEnd, + ); + if (resyncTo < 0) return; + this.#committedRows = resyncTo; + this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo); + this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo); + prefix.length = resyncTo; + if (process.env["PI_DEBUG_REDRAW"] === "1") { + process.stderr.write(`[pi-tui] commit resync at row ${resyncTo}; recommitting\n`); + } + } + + // OMP: 2866-2891 + #updateCommittedAuditRows( + resliced: boolean, + preCommittedRows: number, + preAuditRows: number, + preDurableRows: number, + byteStableBoundary: number, + durableBoundary: number, + hardAudited: boolean, + ): void { + const committed = this.#committedRows; + const auditRows = + resliced || preAuditRows >= preCommittedRows + ? Math.min(committed, byteStableBoundary) + : Math.min(preAuditRows, committed); + const durableRows = + resliced || preDurableRows >= preCommittedRows || hardAudited + ? Math.min(committed, durableBoundary) + : Math.min(preDurableRows, committed); + this.#committedPrefixAuditRows = auditRows; + this.#committedPrefixDurableRows = Math.max(auditRows, durableRows); + } +``` + +### Step 2: 追加 `#commit`(OMP: 3105-3118) + +```ts + // OMP: 3105-3118 — 只记录"屏幕上有什么",不推进 ledger + #commit( + lines: readonly string[], + window: string[], + width: number, + height: number, + hardwareCursor: HardwareCursorUpdate, + ): void { + this.#previousFrameLength = lines.length; + this.#previousWindow = window; + this.#forceViewportRepaintOnNextRender = false; + this.#previousWidth = width; + this.#previousHeight = height; + this.#recordHardwareCursorUpdate(hardwareCursor); + } +``` + +### Step 3: 类型检查 + +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 通过。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): add committed-prefix audit and mark updates to ledger engine" +``` + +--- + +## Task 7: `#emitFullPaint` + `#emitUpdate`(三形态) + +**Files:** +- Modify: `packages/pi-tui/src/ledger/engine.ts` + +### Step 1: 追加 `#emitFullPaint`(OMP: 3182-3240;deccara/images 桩掉) + +```ts + // OMP: 3182-3240 — 唯一 ED3 call site + #emitFullPaint( + frame: readonly string[], + window: string[], + width: number, + height: number, + cursorPos: { row: number; col: number } | null, + options: { clearScrollback: boolean; chunkTo: number; windowTop: number }, + ): void { + this.#fullRedrawCount += 1; + const { chunkTo, windowTop } = options; + let buffer = this.#paintBeginSequence; + if (options.clearScrollback) { + buffer += "\x1b[2J\x1b[H\x1b[3J"; + } else { + // Phase A: TERMINAL.supportsScreenToScrollback = false,不发 kitty ED22 + buffer += "\x1b[2J\x1b[H"; + } + let wroteLine = false; + for (let i = 0; i < chunkTo; i++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#terminalLine(frame[i] ?? ""); + wroteLine = true; + } + for (let screenRow = 0; screenRow < height; screenRow++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#terminalLine(window[screenRow] ?? ""); + wroteLine = true; + } + const contentRows = Math.max(1, Math.min(height, frame.length - windowTop)); + const parkUp = height - contentRows; + if (parkUp > 0) buffer += `\x1b[${parkUp}A`; + const contentBottomRow = windowTop + contentRows - 1; + const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow); + buffer += cursorControl.seq; + buffer += this.#paintEndSequence; + this.terminal.write(buffer); + + this.#committedRows = chunkTo; + this.#windowTopRow = windowTop; + this.#commit(frame, window, width, height, cursorControl); + } +``` + +### Step 2: 追加 `#emitUpdate`(OMP: 3462-3624;三形态内联) + +```ts + // OMP: 3462-3624 + #emitUpdate( + frame: readonly string[], + window: string[], + width: number, + height: number, + cursorPos: { row: number; col: number } | null, + options: { + chunkTo: number; + windowTop: number; + prevWindowTop: number; + prevHardwareCursorRow: number; + forceWindowRewrite: boolean; + }, + ): void { + const { chunkTo, windowTop, prevWindowTop, prevHardwareCursorRow, forceWindowRewrite } = options; + const chunkFrom = this.#committedRows; + const chunkLength = chunkTo - chunkFrom; + const scroll = windowTop - prevWindowTop; + const previousWindow = this.#previousWindow; + const contentRows = Math.max(1, Math.min(height, frame.length - windowTop)); + const contentBottomRow = windowTop + contentRows - 1; + const clampedCursor = Math.min(prevHardwareCursorRow, prevWindowTop + height - 1); + const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevWindowTop)); + + // ---- shape 1: scroll-append ---- + if (!forceWindowRewrite && chunkLength > 0 && chunkLength === scroll && scroll < height && chunkFrom === prevWindowTop) { + let prefixIntact = previousWindow.length === height; + for (let i = 0; prefixIntact && i < chunkLength; i++) { + if (previousWindow[i] !== frame[chunkFrom + i]) prefixIntact = false; + } + if (prefixIntact) { + let buffer = this.#paintBeginSequence; + const moveToBottom = height - 1 - currentScreenRow; + if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`; + for (let r = height - scroll; r < height; r++) { + buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`; + } + let firstChanged = -1; + let lastChanged = -1; + for (let r = 0; r < height - scroll; r++) { + if ((window[r] ?? "") === (previousWindow[r + scroll] ?? "")) continue; + if (firstChanged === -1) firstChanged = r; + lastChanged = r; + } + let cursorFromRow = windowTop + height - 1; + if (firstChanged !== -1) { + const up = height - 1 - firstChanged; + if (up > 0) buffer += `\x1b[${up}A`; + buffer += "\r"; + for (let r = firstChanged; r <= lastChanged; r++) { + if (r > firstChanged) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(window[r] ?? "", width); + } + cursorFromRow = windowTop + lastChanged; + } + const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow); + buffer += cursorControl.seq; + buffer += this.#paintEndSequence; + this.terminal.write(buffer); + this.#committedRows = chunkTo; + this.#windowTopRow = windowTop; + this.#commit(frame, window, width, height, cursorControl); + return; + } + } + + // ---- shape 2: in-window diff ---- + if (chunkLength === 0 && scroll === 0) { + if (forceWindowRewrite) this.#fullRedrawCount += 1; + let firstChanged = forceWindowRewrite ? 0 : -1; + let lastChanged = forceWindowRewrite ? height - 1 : -1; + if (!forceWindowRewrite) { + const comparable = previousWindow.length === height; + for (let r = 0; r < height; r++) { + if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue; + if (firstChanged === -1) firstChanged = r; + lastChanged = r; + } + } + if (firstChanged === -1) { + this.#writeCursorPosition(cursorPos, frame.length); + this.#previousWidth = width; + this.#previousHeight = height; + return; + } + let buffer = this.#paintBeginSequence; + const rowDelta = firstChanged - currentScreenRow; + if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`; + else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`; + buffer += "\r"; + for (let r = firstChanged; r <= lastChanged; r++) { + if (r > firstChanged) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(window[r] ?? "", width); + } + let cursorFromRow = windowTop + lastChanged; + const contentBottomScreenRow = contentBottomRow - windowTop; + if (lastChanged > contentBottomScreenRow) { + buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`; + cursorFromRow = contentBottomRow; + } + const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow); + buffer += cursorControl.seq; + buffer += this.#paintEndSequence; + this.terminal.write(buffer); + this.#commit(frame, window, width, height, cursorControl); + return; + } + + // ---- shape 3: seam rewrite ---- + this.#fullRedrawCount += 1; + let buffer = this.#paintBeginSequence; + if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`; + buffer += "\r"; + let wroteLine = false; + for (let i = chunkFrom; i < chunkTo; i++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(frame[i] ?? "", width); + wroteLine = true; + } + for (let screenRow = 0; screenRow < height; screenRow++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width); + wroteLine = true; + } + const parkUp = height - 1 - (contentBottomRow - windowTop); + if (parkUp > 0) buffer += `\x1b[${parkUp}A`; + const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow); + buffer += cursorControl.seq; + buffer += this.#paintEndSequence; + this.terminal.write(buffer); + this.#committedRows = chunkTo; + this.#windowTopRow = windowTop; + this.#commit(frame, window, width, height, cursorControl); + } +``` + +### Step 3: 追加 `#writeCursorPosition`(in-window diff 的"无变化"早返回用) + +```ts + #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void { + const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow); + if (cursorControl.seq.length > 0) this.terminal.write(cursorControl.seq); + this.#recordHardwareCursorUpdate(cursorControl); + } +``` + +### Step 4: 类型检查 + +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 通过。 + +### Step 5: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): implement ledger emitters (fullPaint + update tri-shape)" +``` + +--- + +## Task 8: `#doRenderLedger`(分类器 + 窗口数学 + 调度) + +**Files:** +- Modify: `packages/pi-tui/src/ledger/engine.ts` + +### Step 1: 追加公共 `doRender()`(OMP: 2465-2822 主体;去掉 overlay/image/ghostty/resize-drag) + +```ts + // 公共入口(由 TUI.doRender 分发调用) + public doRender(): void { + if (this.#stopped) return; + const width = this.terminal.columns; + const height = this.terminal.rows; + + // 1. compose (Phase A: no partial roots, no image budget pass) + const rawFrame = this.#composeFrame(width); + const cursorMarkers = this.#frameCursorMarkers; + + const liveRegionStart = this.#nativeScrollbackLiveRegionStart; + const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd; + const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd; + + // 2. boundaries (OMP: 2599-2604) + const frameLength = rawFrame.length; + const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); + const durableBoundary = Math.max(byteStableBoundary, Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary)); + + // 3. transition state (OMP: 2606-2619) + const prevWindowTop = this.#windowTopRow; + const prevHardwareCursorRow = this.#hardwareCursorRow; + const resizeEventOccurred = this.#resizeEventPending; + this.#resizeEventPending = false; + const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width; + const heightChanged = + (this.#previousHeight > 0 && this.#previousHeight !== height) || + (resizeEventOccurred && this.#previousHeight > 0); + const geometryChanged = widthChanged || heightChanged; + + // 4. audit (OMP: 2634-2658) + let committedRowsResynced = false; + const auditUpper = + this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows; + const hardAuditEnd = Math.min(this.#committedRows, durableBoundary); + const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd; + const auditRan = + this.#hasEverRendered && + !geometryChanged && + !this.#clearScrollbackOnNextRender && + (this.#renderStablePrefixRows < auditUpper || needHardAudit); + if (auditRan) { + const before = this.#committedRows; + this.#auditCommittedPrefix(rawFrame, durableBoundary); + committedRowsResynced = this.#committedRows !== before; + } + const preCommitRows = this.#committedRows; + const preCommitAuditRows = this.#committedPrefixAuditRows; + const preCommitDurableRows = this.#committedPrefixDurableRows; + + // 5. classify + window math (OMP: 2680-2731) + const firstPaint = !this.#hasEverRendered; + const replaceRequested = this.#clearScrollbackOnNextRender; + const geometryRebuild = geometryChanged && !resizeRepaintsInPlace(); + const fullPaint = firstPaint || replaceRequested || geometryRebuild; + let windowTop: number; + let chunkTo: number; + let committedPrefixResliced = false; + if (fullPaint) { + committedPrefixResliced = true; + windowTop = Math.max(0, frameLength - height); + chunkTo = windowTop; + } else if ( + frameLength <= this.#committedRows || + (committedRowsResynced && + frameLength - this.#committedRows < height && + cursorMarkers.some((m) => m.row >= this.#committedRows)) + ) { + windowTop = Math.max(0, frameLength - height); + chunkTo = windowTop; + committedPrefixResliced = true; + this.#committedRows = chunkTo; + this.#committedPrefix = rawFrame.slice(0, chunkTo); + } else { + windowTop = Math.max(this.#committedRows, frameLength - height, 0); + // hasVisibleOverlay = false (Phase A); geometryChanged freezes commits + chunkTo = geometryChanged ? this.#committedRows : windowTop; + if (geometryChanged) { + committedPrefixResliced = true; + this.#committedPrefix = rawFrame.slice(0, this.#committedRows); + } + } + + // 6. cursor marker + window slice (OMP: 2736-2758) + let cursorPos: { row: number; col: number } | null = null; + for (let i = cursorMarkers.length - 1; i >= 0; i--) { + const marker = cursorMarkers[i]!; + if (marker.row >= windowTop) { + cursorPos = marker; + break; + } + } + const frame = this.#prepareFrame(rawFrame, width); + const window: string[] = new Array(height); + for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? ""; + + const intent: RenderIntent = fullPaint + ? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false } + : { kind: "update", chunkTo, windowTop }; + + // 7. emit + ledger advance (OMP: 2779-2822) + if (intent.kind === "fullPaint") { + this.#emitFullPaint(frame, window, width, height, cursorPos, { + clearScrollback: intent.clearScrollback, + chunkTo, + windowTop, + }); + this.#committedPrefix = rawFrame.slice(0, chunkTo); + this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, preCommitDurableRows, byteStableBoundary, durableBoundary, false); + this.#clearScrollbackOnNextRender = false; + this.#hasEverRendered = true; + return; + } + this.#emitUpdate(frame, window, width, height, cursorPos, { + chunkTo, + windowTop, + prevWindowTop, + prevHardwareCursorRow, + forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()), + }); + for (let i = this.#committedPrefix.length; i < chunkTo; i++) { + this.#committedPrefix.push(rawFrame[i] ?? ""); + } + this.#updateCommittedAuditRows( + committedPrefixResliced, + preCommitRows, + preCommitAuditRows, + preCommitDurableRows, + byteStableBoundary, + durableBoundary, + auditRan, + ); + } +``` + +### Step 2: 追加公共控制方法(供 TUI 调用) + +```ts + #stopped = false; + + public requestFullPaint(clearScrollback: boolean): void { + if (clearScrollback) this.#clearScrollbackOnNextRender = true; + else this.#forceViewportRepaintOnNextRender = true; + } + + public notifyResize(): void { + this.#resizeEventPending = true; + } + + public stop(): void { + this.#stopped = true; + } + + public reset(): void { + this.#stopped = false; + this.#committedRows = 0; + this.#committedPrefix = []; + this.#committedPrefixAuditRows = 0; + this.#committedPrefixDurableRows = 0; + this.#windowTopRow = 0; + this.#previousWindow = []; + this.#previousFrameLength = 0; + this.#previousWidth = 0; + this.#previousHeight = 0; + this.#hasEverRendered = false; + this.#clearScrollbackOnNextRender = false; + this.#forceViewportRepaintOnNextRender = false; + this.#composedFrame = []; + this.#frameSegments = []; + this.#composeWidth = -1; + this.#frameCursorMarkers = []; + this.#renderStablePrefixRows = 0; + this.#preparedFrame = []; + this.#preparedMeta = []; + this.#preparedValidRows = 0; + } +``` + +### Step 3: 类型检查 + +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 通过。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/src/ledger/engine.ts +git commit -m "feat(pi-tui): implement ledger doRender classifier and window math" +``` + +--- + +## Task 9: 接入 `TUI`(开关分发) + +**Files:** +- Modify: `packages/pi-tui/src/tui.ts` + +### Step 1: import + 在 `TUI` 类新增字段 + +```ts +import { LedgerTuiEngine } from "./ledger/engine.ts"; +``` + +在 `TUI` 类字段区(`fullRedrawCount` 附近)新增: + +```ts + private ledgerEngine: LedgerTuiEngine | undefined; + private static readonly LEDGER_ENABLED = process.env["PI_TUI_ENGINE"] === "ledger"; +``` + +### Step 2: 重命名旧 `doRender` → `#doRenderLegacy` + +把 `private doRender(): void {`(`tui.ts:1254`)改为 `private doRenderLegacy(): void {`。方法体**完全不变**。 + +### Step 3: 新增分发 `doRender` + 懒初始化 engine + +在 `doRenderLegacy` 之后新增: + +```ts + private getLedgerEngine(): LedgerTuiEngine { + if (!this.ledgerEngine) { + this.ledgerEngine = new LedgerTuiEngine(this.terminal, () => this.children); + } + return this.ledgerEngine; + } + + private doRender(): void { + if (TUI.LEDGER_ENABLED) { + this.getLedgerEngine().doRender(); + } else { + this.doRenderLegacy(); + } + } +``` + +### Step 4: 接线 `requestRender(true)` / `stop` / `fullRedraws` / resize + +- 在 `requestRender(force = true)` 的 `force` 分支开头(`:713` 附近)加: + ```ts + if (force && TUI.LEDGER_ENABLED) { + this.getLedgerEngine().requestFullPaint(true); + } + ``` +- `fullRedraws` getter(`:336`)改为: + ```ts + get fullRedraws(): number { + return TUI.LEDGER_ENABLED && this.ledgerEngine ? this.ledgerEngine.fullRedraws : this.fullRedrawCount; + } + ``` +- 在 `stop()`(约 `:700` 附近,`this.terminal.stop()` 之前)加: + ```ts + this.ledgerEngine?.stop(); + ``` +- 如 TUI 有终端 resize 回调(`terminal.start(onInput, onResize)` 的 onResize),在回调里加 `this.ledgerEngine?.notifyResize()`;若没有可识别的 resize 回调,跳过(几何变化仍由 `previousWidth/Height` 在下一帧检测)。 + +### Step 5: 全量测试(默认走 legacy,应全绿) + +Run: `node --test test/*.test.ts` +Expected: 全绿(默认 `PI_TUI_ENGINE` 未设置 → legacy 路径,行为不变)。 + +### Step 6: 提交 + +```bash +git add packages/pi-tui/src/tui.ts +git commit -m "feat(pi-tui): wire ledger engine behind PI_TUI_ENGINE flag" +``` + +--- + +## Task 10: Golden + render-stress 验收 + +**Files:** +- Create: `packages/pi-tui/test/ledger-engine-golden.test.ts` +- Create: `packages/pi-tui/test/ledger-engine-stress.test.ts` + +### Step 1: 写 golden 测试 `test/ledger-engine-golden.test.ts` + +```ts +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { LoggingVirtualTerminal } from "./virtual-terminal.ts"; + +class TestComponent implements Component { + lines: string[] = []; + render(_w: number): string[] { + return this.lines; + } + invalidate(): void {} +} + +async function withLedger(run: () => Promise): Promise { + const prev = process.env["PI_TUI_ENGINE"]; + process.env["PI_TUI_ENGINE"] = "ledger"; + try { + return await run(); + } finally { + if (prev === undefined) delete process.env["PI_TUI_ENGINE"]; + else process.env["PI_TUI_ENGINE"] = prev; + } +} + +describe("ledger engine golden", () => { + it("renders basic content", async () => { + await withLedger(async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const c = new TestComponent(); + tui.addChild(c); + c.lines = ["Hello", "World"]; + tui.start(); + await terminal.waitForRender(); + const v = terminal.getViewport(); + assert.ok(v[0]?.includes("Hello")); + assert.ok(v[1]?.includes("World")); + tui.stop(); + }); + }); + + it("commits appended rows and repaints window on streaming append", async () => { + await withLedger(async () => { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const c = new TestComponent(); + tui.addChild(c); + c.lines = ["L0", "L1", "L2"]; + tui.start(); + await terminal.waitForRender(); + // append beyond viewport + for (let i = 3; i < 12; i++) { + c.lines = [...c.lines, `L${i}`]; + tui.requestRender(); + await terminal.waitForRender(); + } + const v = terminal.getViewport(); + assert.ok(v.join("\n").includes("L11"), `tail visible: ${v.join("|")}`); + tui.stop(); + }); + }); + + it("clamps over-wide lines instead of throwing", async () => { + await withLedger(async () => { + const terminal = new LoggingVirtualTerminal(10, 4); + const tui = new TUI(terminal); + const c = new TestComponent(); + tui.addChild(c); + c.lines = ["1234567890ABCDEF"]; + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.getViewport()[0]?.includes("1234567890")); + c.lines = ["ok"]; + tui.requestRender(); + await terminal.waitForRender(); + assert.ok(terminal.getViewport()[0]?.includes("ok")); + tui.stop(); + }); + }); + + it("handles content shrink", async () => { + await withLedger(async () => { + const terminal = new LoggingVirtualTerminal(40, 8); + const tui = new TUI(terminal); + const c = new TestComponent(); + tui.addChild(c); + c.lines = ["a", "b", "c", "d"]; + tui.start(); + await terminal.waitForRender(); + c.lines = ["a", "b"]; + tui.requestRender(); + await terminal.waitForRender(); + const v = terminal.getViewport(); + assert.ok(v[0]?.includes("a")); + assert.ok(v[1]?.includes("b")); + tui.stop(); + }); + }); +}); +``` + +Run: `node --test test/ledger-engine-golden.test.ts` → 调试至通过(这是新引擎的首次端到端验证,预计需要迭代修复 wiring)。 + +### Step 2: 写 render-stress 测试 `test/ledger-engine-stress.test.ts` + +> 种子化随机操作序列,把新引擎 emit 的字节喂给 `VirtualTerminal`,断言:(a) 视口与"朴素全量重绘参考"一致;(b) scrollback 只增不减(追加式)。 + +```ts +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class TestComponent implements Component { + lines: string[] = []; + render(_w: number): string[] { + return this.lines; + } + invalidate(): void {} +} + +function mulberry32(seed: number): () => number { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +async function withLedger(run: () => Promise): Promise { + const prev = process.env["PI_TUI_ENGINE"]; + process.env["PI_TUI_ENGINE"] = "ledger"; + try { + return await run(); + } finally { + if (prev === undefined) delete process.env["PI_TUI_ENGINE"]; + else process.env["PI_TUI_ENGINE"] = prev; + } +} + +describe("ledger engine stress (seeded)", () => { + for (const seed of [1, 2, 3, 7, 42, 99]) { + it(`seed=${seed}: viewport consistent, scrollback append-only`, async () => { + await withLedger(async () => { + const rand = mulberry32(seed); + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal); + const c = new TestComponent(); + tui.addChild(c); + c.lines = ["init"]; + tui.start(); + await terminal.waitForRender(); + + let prevScrollbackLen = terminal.getScrollBuffer().length; + for (let step = 0; step < 60; step++) { + const op = rand(); + const len = c.lines.length; + if (op < 0.5) { + // append + c.lines = [...c.lines, `s${step}-a`]; + } else if (op < 0.75 && len > 1) { + // mutate a live tail row + const copy = c.lines.slice(); + copy[copy.length - 1] = `s${step}-m`; + c.lines = copy; + } else if (op < 0.9 && len > 1) { + // shrink + c.lines = c.lines.slice(0, Math.max(1, len - 1 - Math.floor(rand() * 3))); + } else { + // resize + terminal.resize(30 + Math.floor(rand() * 40), 6 + Math.floor(rand() * 6)); + } + tui.requestRender(); + await terminal.waitForRender(); + + // invariant: scrollback length never decreases + const sb = terminal.getScrollBuffer(); + assert.ok(sb.length >= prevScrollbackLen, `seed=${seed} step=${step}: scrollback shrank`); + prevScrollbackLen = sb.length; + + // invariant: viewport tail reflects the latest content + const viewport = terminal.getViewport(); + const lastLine = c.lines[c.lines.length - 1]!; + assert.ok( + viewport.join("\n").includes(lastLine.slice(0, Math.min(10, lastLine.length))), + `seed=${seed} step=${step}: tail not visible`, + ); + } + tui.stop(); + }); + }); + } +}); +``` + +Run: `node --test test/ledger-engine-stress.test.ts` → 这是**Phase A 的总验收**。预计需要多轮迭代修复(分类器/窗口数学/审计的边界)。**只有在 6 个 seed 全绿后**才进入 Task 11。 + +### Step 3: 跑全量测试 + +Run: `node --test test/*.test.ts` +Expected: 全绿(legacy 默认 + ledger 显式开的测试都过)。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/test/ledger-engine-golden.test.ts packages/pi-tui/test/ledger-engine-stress.test.ts +git commit -m "test(pi-tui): add golden and seeded stress harness for ledger engine" +``` + +--- + +## Task 11: 切默认 + 删除旧引擎 + +**Files:** +- Modify: `packages/pi-tui/src/tui.ts` +- Modify: `packages/pi-tui/src/tui.ts`(`fullRedraws` getter 还原) + +> **前置条件**:Task 10 的 golden + 6 个 stress seed 全绿,且全量测试无回归。**不要做**这一步,除非 Task 10 通过。 + +### Step 1: 把默认改为 ledger + +把 `private static readonly LEDGER_ENABLED = process.env["PI_TUI_ENGINE"] === "ledger";` 改为: + +```ts + private static readonly LEGACY_ENABLED = process.env["PI_TUI_ENGINE"] === "legacy"; +``` + +并把 `doRender()` 分发改为: + +```ts + private doRender(): void { + if (TUI.LEGACY_ENABLED) { + this.doRenderLegacy(); + } else { + this.getLedgerEngine().doRender(); + } + } +``` + +`requestRender(true)` 分支里的 `TUI.LEDGER_ENABLED` 判断同步改为 `!TUI.LEGACY_ENABLED`。`fullRedraws` getter 同步改为默认走 engine。 + +### Step 2: 全量测试 + 类型检查 + +Run: `node --test test/*.test.ts` +Run: `pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 全绿(现在默认 ledger;legacy 仅在 `PI_TUI_ENGINE=legacy` 时启用)。 + +### Step 3:(可选)删除旧引擎 + +> 若团队希望保留 `PI_TUI_ENGINE=legacy` 回退一个版本,**跳过本步**,留待后续 PR 清理。 + +删除 `doRenderLegacy()` 方法体及所有 legacy-only 字段(`previousLines`、`maxLinesRendered`、`previousViewportTop`、`clearOnShrink` 等),并删除 `doRender()` 的分发(直接 `this.getLedgerEngine().doRender()`)。删除后跑全量测试。 + +### Step 4: 提交 + +```bash +git add packages/pi-tui/src/tui.ts +git commit -m "refactor(pi-tui): make ledger engine the default renderer" +``` + +--- + +## 验收总清单 + +- [ ] `node --test test/*.test.ts` 全绿(Task 10 后默认仍 legacy;Task 11 后默认 ledger) +- [ ] `pnpm --filter @moonshot-ai/pi-tui typecheck` 通过 +- [ ] golden 4 场景通过 +- [ ] stress 6 seed 通过(scrollback 只增不减 + tail 可见) +- [ ] 现有 27 个测试无回归 +- [ ] 手动:`PI_TUI_ENGINE=ledger` 启动 CLI,流式输出 / resize / 超宽行 / spinner 正常 + +--- + +## Self-Review + +**1. Spec 覆盖(设计目标 → 任务):** + +| 目标 | 任务 | +|---|---| +| 追加式 native scrollback 账本 | Task 3(字段)+ Task 6(审计)+ Task 8(窗口数学) | +| `NativeScrollbackLiveRegion` seam | Task 1(接口)+ Task 4(compose 捕获) | +| 范围感知 committed-prefix 审计 | Task 2 + Task 6 | +| render intent 分类器 | Task 8 | +| `#emitFullPaint`(唯一 ED3) | Task 7 | +| `#emitUpdate` 三形态 | Task 7 | +| SGR 合并 | Task 1 | +| 超宽行 clamp | Task 5(`#prepareLine`) | +| 同步输出门控 | Task 1(简化版) | +| 多路复用检测 | Task 1 | +| render-stress 验收 | Task 10 | + +**明确不在 Phase A(留后续 Phase):** overlay 合成(硬编码 false)、resize viewport drag fast path(Phase B)、完整 DA1/DECRQM(Phase B)、DECCARA(Phase B/C)、ImageBudget 实装(Phase C)、app 层 seam 接入(Phase C)、废弃 sliding window(Phase C)、完整 fuzz + reducer(Phase D)。 + +**2. 占位符扫描:** 无 TBD/TODO。Task 9/10 标注了"预计需要迭代"(这是新引擎首次端到端,属正常的工程预期,非占位)。 + +**3. 类型/接口一致性:** +- `LedgerTuiEngine` 公共 API:`doRender()` / `requestFullPaint(clearScrollback)` / `notifyResize()` / `stop()` / `reset()` / `fullRedraws` — Task 8 定义,Task 9 使用,一致。 +- `Terminal` 类型来自 `../tui.ts`(已有 `write`/`columns`/`rows`);engine 用 `this.terminal.write`,一致。 +- `Component.render(width): string[]` — 我们的 `Component` 接口匹配(`#composeFrame` 调 `child.render(width)`)。 +- `isImageLine` 来自 `../terminal-image.ts`(已存在)。 +- `visibleWidth` / `truncateToWidth` / `sliceByColumn` 来自 `../utils.ts`(已存在)。 +- `RenderIntent` / `FrameSegment` / `PreparedLine` / cursor 类型在 `types.ts` 定义,engine import,一致。 + +**4. 风险标注:** +- Task 9(wiring):engine/TUI 边界(force/stop/resize/fullRedraws)可能需迭代;已给出具体接点。 +- Task 10(stress):新引擎首次端到端,预计多轮修复;stress 是总验收,绿了才切默认(Task 11 前置条件)。 +- overlay 硬编码 false:我们的 app 不用 showOverlay(已确认),安全。 +- `normalizeTerminalOutput` 在 `#prepareLine` 里简化为 `raw`;若 stress 暴露控制字符问题,补一个最小 normalize。 + +**5. 忠实度:** 核心算法(审计、窗口数学、emit 三形态)逐字移植 OMP(标注了行号);仅在依赖替换(Bun→process.env、桩掉 deccara/images/overlay)和 `render`→`#composeFrame` 改名上偏离,属必要适配。 diff --git a/plan/2026-06-30-pi-tui-stress-hardening-phase-d.md b/plan/2026-06-30-pi-tui-stress-hardening-phase-d.md new file mode 100644 index 000000000..6dc84cce6 --- /dev/null +++ b/plan/2026-06-30-pi-tui-stress-hardening-phase-d.md @@ -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/.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 { + 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 { + 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)。 diff --git a/plan/2026-06-30-pi-tui-terminal-infra-phase-b.md b/plan/2026-06-30-pi-tui-terminal-infra-phase-b.md new file mode 100644 index 000000000..d7e82ce89 --- /dev/null +++ b/plan/2026-06-30-pi-tui-terminal-infra-phase-b.md @@ -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/.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 { + 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 | 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)。 diff --git a/research.md b/research.md new file mode 100644 index 000000000..fa9d7c5f3 --- /dev/null +++ b/research.md @@ -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 `、`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`) | **内联** | 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`),`
`/`
  • `/``/`` 会原样显示。oh-my-pi 的 `normalizeHtmlForTerminal`(`:127-227`)转换:`
    `→换行、`

    `/`

  • `→带嵌套 `
      `/`
        ` 自动编号的换行、``/`` 剥标签保内容,并解码实体(`&`、`😀`)。**高价值 / 低-中 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` | 纯函数,修 `
        `/`
      • `/`` 原样显示 | +| **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`。