From 6e2e18c1a1fd58de9699c3f723d71b42582d9ec1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 13:43:22 +0800 Subject: [PATCH 01/18] docs: add pi-tui narrow-width fix plan --- plan/pi-tui-narrow-width-fix.md | 635 ++++++++++++++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 plan/pi-tui-narrow-width-fix.md diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md new file mode 100644 index 0000000000..a6bc6e6878 --- /dev/null +++ b/plan/pi-tui-narrow-width-fix.md @@ -0,0 +1,635 @@ +# pi-tui 窄终端崩溃修复实施计划 + +> **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:** 彻底修复终端宽度过窄时 kimi-code TUI 崩溃退出的问题(`packages/pi-tui` vendored 库),并用回归测试和文档防止未来 re-vendor 时再次丢失修复。 + +**Architecture:** 借鉴 oh-my-pi 的"永不崩溃"策略——1 个根因修复(`wordWrapLine` 对不可再分的宽 grapheme 停止递归)+ 2 个集中式咽喉防御(`Container.render` 入口宽度钳制到 ≥1;TUI 写终端前对超宽行统一截断、删除上游的 fail-fast throw)+ 少量组件级负宽度加固。修复全部落在 vendored 源码 `packages/pi-tui/src/`,不改 app 侧。 + +**Tech Stack:** TypeScript(Node 24 原生跑 TS)、`node --test` + `node:assert`(pi-tui 测试套件不是 vitest)、`VirtualTerminal`(xterm-headless 测试终端)。 + +--- + +## 背景与根因(执行者需要知道的全部上下文) + +**Bug 现象**:终端拖窄(≤7 列)且输入框有中文/emoji 时,TUI 进程崩溃退出。 + +**根因链**(均已实测验证): + +1. **主因——栈溢出**:`packages/pi-tui/src/components/editor.ts` 的 `wordWrapLine()`(L114-206)在 L163-179 处理"单个 segment 比 maxWidth 宽"时递归调用自身且参数不变。当 `maxWidth === 1` 且遇到宽字符(CJK/emoji,宽度 2)时无限递归 → `RangeError: Maximum call stack size exceeded`。编辑器 `render(width)`(L464-479)在 width ≤ 7 时会把 `layoutWidth` 压到 1(kimi-code 的 CustomEditor 用 `paddingX: 4`),因此中文用户几乎必现。崩溃被 `uncaughtException` 接住后直接退出进程。 +2. **次因——主动 throw**:`packages/pi-tui/src/tui.ts` 差分渲染路径(L1542-1570)对任何"渲染行宽 > 终端宽"的行写崩溃日志并 `throw`(上游的 fail-fast 设计)。窄宽度下 `Text`/`Markdown`/`Box`/`Input`/编辑器溢出 chunk 都可能产出超宽行。注意 `fullRender` 路径没有这个检查,所以炸点在 resize 后第一次差分渲染。 +3. **三因——负宽度 repeat**:`text.ts:90`、`markdown.ts:226`、`markdown.ts:464`、`truncated-text.ts:26` 的裸 `" ".repeat(width)` / `"─".repeat(...)` 在负 width 下抛 `RangeError: Invalid count value`。 +4. **无下限钳制**:宽度传播链 `terminal.columns`(terminal.ts:465)→ `doRender`(tui.ts:1256)→ `Container.render`(tui.ts:280)全程无 clamp。 + +**历史教训**:老分支 `origin/fix/tui-narrow-width-crash` 上有过三个修复(`a4188455` 的 pnpm patch 等),从未合入 main;vendor 提交 `7859b0af` 按上游 0.80.2 原样落库后修复彻底丢失。因此本计划包含守护测试(Task 1-5)和分歧文档(Task 6)。 + +**oh-my-pi 参照**(`/Users/moonshot/Desktop/moonshot/oh-my-pi/packages/tui`,仅供理解,不要复制其代码):`Container.render` 入口 `Math.max(1, width)`(commit `bb7f28848`);写终端前 `#prepareLine` 统一截断超宽行、删除上游 throw(`c40a22b3c`、`9ed5a70d0`);`padding(n)` 对 `n <= 0` 返回空串。 + +**运行命令**(都在仓库根目录执行): +- 跑 pi-tui 全部测试:`pnpm --filter @moonshot-ai/pi-tui test`(即 `node --test test/*.test.ts`) +- 跑单个测试文件:`node --test test/editor.test.ts`(cwd 为 `packages/pi-tui`) +- 类型检查:`pnpm --filter @moonshot-ai/pi-tui typecheck` + +**范围外(明确不做)**: +- 不改 app 侧(`apps/kimi-code`)的 GutterContainer / CustomEditor——核心修复后它们的超宽输出会被统一截断兜底。 +- 不恢复老分支的 pnpm patch 方式——pi-tui 已 vendored,直接改源码。 +- 不做 "terminal too small" 提示屏(oh-my-pi 也没做,策略是钳到 1 列 + 截断)。 +- 不改 `utils.ts` 的 `wrapSingleLine`/`breakLongWord` 行为(宽度 1 时产出宽度 2 的行,由集中截断兜底)。 +- 不单独给 `editor.ts:565` 的行拼接加截断——Task 1 后编辑器在极窄宽度下产出的溢出行(如 w=5 时宽 6)统一由 Task 3 的集中截断兜底,视觉上最多损失最右侧一列,属可接受降级。 +- markdown.ts 表格路径的 `"─".repeat(columnWidths)`(L803/823/850)不改——列宽由内容计算,恒为正。 + +**Git 纪律**:每个 Task 末尾的 commit 步骤需要用户事先明确授权;未授权则跳过所有 commit 步骤,改为最后统一由用户处理。Commit message 用英文、符合 Conventional Commits,不加任何 co-author。 + +--- + +### Task 1: `wordWrapLine` 递归守卫(根因修复) + +**Files:** +- Modify: `packages/pi-tui/src/components/editor.ts:163-179` +- Test: `packages/pi-tui/test/editor.test.ts` + +**原理**:递归 `wordWrapLine(grapheme, maxWidth)` 只有在 segment 含多个 grapheme(如粘贴标记这种原子多字符 segment)时才能取得进展;当 segment 本身就是单个 grapheme(中文字符在 maxWidth=1 时)递归参数不变、永不终止。守卫:单 grapheme 时不递归,把它保留为当前打开的 chunk,允许视觉上溢出 1 列(由 Task 3 的集中截断兜底)。 + +- [ ] **Step 1: 写失败测试** + +在 `packages/pi-tui/test/editor.test.ts` 文件末尾追加(该文件已 import `wordWrapLine`、`assert`、`describe`、`it`): + +```ts +describe("wordWrapLine narrow width", () => { + it("does not recurse infinitely on a wide grapheme at maxWidth 1", () => { + const chunks = wordWrapLine("中", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["中"], + ); + }); + + it("splits CJK text into per-grapheme overflow chunks at maxWidth 1", () => { + const chunks = wordWrapLine("中文文本", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["中", "文", "文", "本"], + ); + assert.deepStrictEqual( + chunks.map((c) => [c.startIndex, c.endIndex]), + [ + [0, 1], + [1, 2], + [2, 3], + [3, 4], + ], + ); + }); + + it("handles mixed narrow and wide graphemes at maxWidth 1", () => { + const chunks = wordWrapLine("ab中cd", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["a", "b", "中", "c", "d"], + ); + }); + + it("still re-wraps multi-grapheme atomic segments at narrow widths", () => { + // 粘贴标记以单个原子 segment 传入(preSegmented),内部仍可按 + // grapheme 拆分,递归必须保留这个能力。 + const marker = "[paste #1]"; + const preSegmented: Intl.SegmentData[] = [{ segment: marker, index: 0, input: marker }]; + const chunks = wordWrapLine(marker, 3, preSegmented); + assert.ok(chunks.length > 1); + assert.strictEqual(chunks.map((c) => c.text).join(""), marker); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd packages/pi-tui && node --test test/editor.test.ts` +Expected: FAIL —— 前三个用例报 `RangeError: Maximum call stack size exceeded`;第四个用例(marker)通过。 + +- [ ] **Step 3: 实现守卫** + +在 `packages/pi-tui/src/components/editor.ts` 中,把: + +```ts + if (gWidth > maxWidth) { + // Single atomic segment wider than maxWidth (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. + const subChunks = wordWrapLine(grapheme, maxWidth); +``` + +改为: + +```ts + if (gWidth > maxWidth) { + // Single atomic segment wider than maxWidth (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. + const subSegments = [...graphemeSegmenter.segment(grapheme)]; + if (subSegments.length <= 1) { + // An indivisible grapheme wider than maxWidth (e.g. a CJK + // character at maxWidth 1) cannot be split further — + // re-wrapping it would recurse forever. Keep it as the + // current open chunk and let it overflow by one column; + // the TUI paint layer truncates overwide lines. + currentWidth = gWidth; + wrapOppIndex = -1; + continue; + } + const subChunks = wordWrapLine(grapheme, maxWidth, subSegments); +``` + +说明: +- 到达该分支时恒有 `chunkStart === charIndex`(`gWidth > maxWidth` 蕴含前面的 overflow 检查必然执行了 force-break 或本来就是空 chunk),所以只需把 `currentWidth` 设为 `gWidth` 即把该 grapheme 保留为打开的 chunk;后续 grapheme 触发 overflow 时会正常把它 push 出去,末尾的 `chunks.push(line.slice(chunkStart))` 也能收尾,不会产生空尾 chunk。 +- `subSegments` 顺手传给递归调用(`preSegmented` 参数),避免递归内部重复分词;其 `index` 相对于 `grapheme` 起点,与递归内 slice 语义一致。 +- `graphemeSegmenter` 在 editor.ts 顶部(L18)已定义,函数内可直接使用。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd packages/pi-tui && node --test test/editor.test.ts` +Expected: PASS(全部用例,包括原有用例) + +- [ ] **Step 5: Commit(需用户授权)** + +```bash +git add packages/pi-tui/src/components/editor.ts packages/pi-tui/test/editor.test.ts +git commit -m "fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes at width 1" +``` + +--- + +### Task 2: `Container.render` 入口宽度钳制 + +**Files:** +- Modify: `packages/pi-tui/src/tui.ts:280` +- Test: `packages/pi-tui/test/tui-render.test.ts` + +- [ ] **Step 1: 写失败测试** + +在 `packages/pi-tui/test/tui-render.test.ts` 中,先把 import 行: + +```ts +import { type Component, TUI } from "../src/tui.ts"; +``` + +改为: + +```ts +import { type Component, Container, TUI } from "../src/tui.ts"; +``` + +然后在文件末尾追加: + +```ts +describe("Container width clamping", () => { + it("clamps non-positive widths to 1 before rendering children", () => { + const container = new Container(); + const received: number[] = []; + container.addChild({ + render(width: number): string[] { + received.push(width); + return []; + }, + invalidate(): void {}, + }); + container.render(0); + container.render(-3); + assert.deepStrictEqual(received, [1, 1]); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` +Expected: FAIL —— `AssertionError`,实际为 `[0, -3]`,期望 `[1, 1]`。 + +- [ ] **Step 3: 实现钳制** + +在 `packages/pi-tui/src/tui.ts` 的 `Container` 类中,把: + +```ts + render(width: number): string[] { + const lines: string[] = []; + for (const child of this.children) { +``` + +改为: + +```ts + render(width: number): string[] { + // Extremely narrow terminals can report tiny or even non-positive + // column counts; never propagate a width below 1 into components. + width = Math.max(1, width); + const lines: string[] = []; + for (const child of this.children) { +``` + +说明:`TUI extends Container` 且不覆写 `render`,`doRender` 里的 `this.render(width)`(tui.ts:1271)会经过这里,因此顶层组件树拿到的宽度恒 ≥1;嵌套 `Container` 同样自带钳制。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit(需用户授权)** + +```bash +git add packages/pi-tui/src/tui.ts packages/pi-tui/test/tui-render.test.ts +git commit -m "fix(pi-tui): clamp container render width to a minimum of 1" +``` + +--- + +### Task 3: 超宽行统一截断,删除 fail-fast throw(机制兜底) + +**Files:** +- Modify: `packages/pi-tui/src/tui.ts:1278-1281`(插入截断循环) +- Modify: `packages/pi-tui/src/tui.ts:1542-1570`(删除 throw 块) +- Test: `packages/pi-tui/test/tui-render.test.ts` + +**原理**:在 `doRender` 中、overlay 合成和光标标记提取之后、`applyLineResets` 之前,对所有非图片行做一次宽度检查并截断。这样 `fullRender` 和差分两条路径都被覆盖;截断发生在 `applyLineResets` 之前,被截掉的 ANSI 样式会由每行末尾追加的 `SEGMENT_RESET` 关闭,不会泄漏。截断用 `sliceByColumn(line, 0, width, true)`(tui.ts 已 import,strict 模式丢弃跨界宽字符)——这与 overlay 合成 `compositeLineAt` 使用的是同一套 ANSI 感知切割。 + +- [ ] **Step 1: 写失败测试** + +在 `packages/pi-tui/test/tui-render.test.ts` 末尾追加(复用文件里已有的 `TestComponent`,其 `lines` 字段可直接改写): + +```ts +describe("TUI overwide line handling", () => { + it("truncates lines wider than the terminal instead of throwing", async () => { + const terminal = new VirtualTerminal(4, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["ok"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + // 改成超宽行并触发差分渲染路径(修复前这里会 throw)。 + component.lines = ["xxxxxxxxxx", "你好世界"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("xxxx"))); + assert.ok( + !viewport.some((line) => line.includes("xxxxx")), + "ASCII line should be truncated to terminal width", + ); + assert.ok(viewport.some((line) => line.includes("你好"))); + assert.ok( + !viewport.some((line) => line.includes("你好世")), + "CJK line should be truncated to terminal width", + ); + + tui.stop(); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` +Expected: FAIL —— 渲染 tick 抛出 `Error: Rendered line 0 exceeds terminal width (10 > 4)`(以 uncaught exception 形式使测试文件失败)。 + +- [ ] **Step 3: 实现截断 + 删除 throw** + +修改一(插入截断循环):在 `packages/pi-tui/src/tui.ts` 中,把: + +```ts + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = this.extractCursorPosition(newLines, height); + + newLines = this.applyLineResets(newLines); +``` + +改为: + +```ts + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = this.extractCursorPosition(newLines, height); + + // Never write a line wider than the terminal: truncate defensively + // instead of crashing. Extremely narrow terminals can make + // components overflow by a column (e.g. wide graphemes at width 1). + // applyLineResets() runs afterwards, so truncated lines still get + // their trailing reset and cannot leak styles. + for (let i = 0; i < newLines.length; i++) { + const line = newLines[i]!; + if (!isImageLine(line) && visibleWidth(line) > width) { + newLines[i] = sliceByColumn(line, 0, width, true); + } + } + + newLines = this.applyLineResets(newLines); +``` + +修改二(删除差分路径的 throw 块):把: + +```ts + buffer += "\x1b[2K"; // Clear current line + if (!isImage && visibleWidth(line) > width) { + // Log all lines to crash file for debugging + const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${i} visible width: ${visibleWidth(line)}`, + "", + "=== All rendered lines ===", + ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); + } + buffer += line; +``` + +改为: + +```ts + buffer += "\x1b[2K"; // Clear current line + buffer += line; +``` + +说明: +- `isImageLine`、`visibleWidth`、`sliceByColumn` 均已在 tui.ts 顶部 import,无需新增 import。 +- 删除 throw 块后 `fs`/`os`/`path` 仍被 `logRedraw`(tui.ts:1327-1333)使用,import 保留。 +- 删除后该循环内的 `isImage` 变量仍被上方 kitty 图片逻辑使用,保留。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` +Expected: PASS(包括文件内原有 kitty 图片相关用例) + +- [ ] **Step 5: 跑全套 pi-tui 测试防止误伤** + +Run: `pnpm --filter @moonshot-ai/pi-tui test` +Expected: PASS。特别关注 `tui-overlay-style-leak.test.ts`(样式泄漏)与 `tui-shrink.test.ts`(内容收缩)不回归。 + +- [ ] **Step 6: Commit(需用户授权)** + +```bash +git add packages/pi-tui/src/tui.ts packages/pi-tui/test/tui-render.test.ts +git commit -m "fix(pi-tui): truncate overwide rendered lines instead of throwing" +``` + +--- + +### Task 4: 编辑器窄宽度端到端回归测试 + +**Files:** +- Test: `packages/pi-tui/test/editor.test.ts` + +依赖 Task 1-3 全部完成(w=5 + paddingX=4 的用例需要 Task 1 消除栈溢出、Task 3 消除超宽 throw 才能通过)。本 Task 只加测试,不改实现。 + +- [ ] **Step 1: 追加端到端测试** + +在 `packages/pi-tui/test/editor.test.ts` 末尾追加(`Editor`、`createTestTUI`、`defaultEditorTheme`、`TUI`、`VirtualTerminal`、`visibleWidth` 均已在该文件 import): + +```ts +describe("Editor narrow width rendering", () => { + it("renders CJK text without crashing at widths 1-8 (default padding)", () => { + for (let width = 1; width <= 8; width++) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("你好世界"); + assert.doesNotThrow(() => editor.render(width), `width ${width}`); + } + }); + + it("renders CJK text without crashing at widths 1-8 (paddingX 4, matches kimi-code)", () => { + for (let width = 1; width <= 8; width++) { + const editor = new Editor(createTestTUI(), defaultEditorTheme, { paddingX: 4 }); + editor.setText("你好,世界!"); + assert.doesNotThrow(() => editor.render(width), `width ${width}`); + } + }); + + it("recalls history without crashing after rendering at width 1", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("你好世界"); + editor.render(1); // 窄渲染把 lastWidth 钉在 1,复现历史导航崩溃路径 + assert.doesNotThrow(() => { + (editor as unknown as { navigateHistory(direction: 1 | -1): void }).navigateHistory(-1); + }); + assert.strictEqual(editor.getText(), "你好世界"); + }); + + it("renders inside a TUI at 5 columns without crashing or overflowing", async () => { + const terminal = new VirtualTerminal(5, 12); + const tui = new TUI(terminal); + const editor = new Editor(tui, defaultEditorTheme, { paddingX: 4 }); + tui.addChild(editor); + editor.setText("你好世界"); + tui.start(); + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + assert.ok(viewport.every((line) => visibleWidth(line) <= 5)); + tui.stop(); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认通过** + +Run: `cd packages/pi-tui && node --test test/editor.test.ts` +Expected: PASS。(可选交叉验证:临时 `git stash` Task 1 的 editor.ts 改动再跑一次,应看到前两个用例栈溢出,验证测试确实盯住了根因;随后 `git stash pop` 恢复。) + +- [ ] **Step 3: Commit(需用户授权)** + +```bash +git add packages/pi-tui/test/editor.test.ts +git commit -m "test(pi-tui): add editor narrow-width regression tests" +``` + +--- + +### Task 5: 组件裸 `repeat` 负宽度加固 + +**Files:** +- Modify: `packages/pi-tui/src/components/text.ts:90` +- Modify: `packages/pi-tui/src/components/markdown.ts:226` +- Modify: `packages/pi-tui/src/components/markdown.ts:464` +- Modify: `packages/pi-tui/src/components/truncated-text.ts:26` +- Test: `packages/pi-tui/test/tui-render.test.ts`、`packages/pi-tui/test/markdown.test.ts`、`packages/pi-tui/test/truncated-text.test.ts` + +**原理**:`Container.render` 钳制后顶层宽度恒 ≥1,但中间组件(如 Box)自行推导子宽度时仍可能把负值直接传给子组件的 `render()`。这 4 处裸 `repeat` 是仅剩的会直接抛 `RangeError` 的点,用 `Math.max(0, ...)` 加固。 + +- [ ] **Step 1: 写失败测试** + +`packages/pi-tui/test/tui-render.test.ts` 顶部追加 import: + +```ts +import { Text } from "../src/components/text.ts"; +``` + +文件末尾追加: + +```ts +describe("Text negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const text = new Text("你好", 1, 1); + assert.doesNotThrow(() => text.render(0)); + assert.doesNotThrow(() => text.render(-1)); + }); +}); +``` + +`packages/pi-tui/test/markdown.test.ts` 末尾追加(该文件已 import `Markdown`、`defaultMarkdownTheme`、`assert`、`describe`、`it`): + +```ts +describe("Markdown negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const markdown = new Markdown("# Title\n\ntext\n\n---", 1, 1, defaultMarkdownTheme); + assert.doesNotThrow(() => markdown.render(0)); + assert.doesNotThrow(() => markdown.render(-1)); + }); +}); +``` + +`packages/pi-tui/test/truncated-text.test.ts` 末尾追加(沿用该文件已有的 import 与构造方式,`TruncatedText` 构造签名为 `(text, paddingX = 0, paddingY = 0)`): + +```ts +describe("TruncatedText negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const component = new TruncatedText("hello", 1, 1); + assert.doesNotThrow(() => component.render(0)); + assert.doesNotThrow(() => component.render(-1)); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts test/markdown.test.ts test/truncated-text.test.ts` +Expected: FAIL —— 三个新用例在 `render(-1)` 处抛 `RangeError: Invalid count value: -1`。 + +- [ ] **Step 3: 实现加固(4 处同构小改)** + +`packages/pi-tui/src/components/text.ts:90`、`packages/pi-tui/src/components/markdown.ts:226`、`packages/pi-tui/src/components/truncated-text.ts:26` 三处,把: + +```ts + const emptyLine = " ".repeat(width); +``` + +改为: + +```ts + const emptyLine = " ".repeat(Math.max(0, width)); +``` + +`packages/pi-tui/src/components/markdown.ts:464`,把: + +```ts + lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); +``` + +改为: + +```ts + lines.push(this.theme.hr("─".repeat(Math.max(0, Math.min(width, 80))))); +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd packages/pi-tui && node --test test/tui-render.test.ts test/markdown.test.ts test/truncated-text.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit(需用户授权)** + +```bash +git add packages/pi-tui/src/components/text.ts packages/pi-tui/src/components/markdown.ts packages/pi-tui/src/components/truncated-text.ts packages/pi-tui/test/tui-render.test.ts packages/pi-tui/test/markdown.test.ts packages/pi-tui/test/truncated-text.test.ts +git commit -m "fix(pi-tui): guard blank-line padding against negative widths" +``` + +--- + +### Task 6: 记录与上游的本地分歧(防 re-vendor 回归) + +**Files:** +- Create: `packages/pi-tui/AGENTS.md` + +- [ ] **Step 1: 创建 AGENTS.md** + +写入以下内容: + +```markdown +# pi-tui Agent Guide + +`packages/pi-tui` 是从上游 pi-mono 的 pi-tui vendor 进来的副本(基线:上游 0.80.2,见 commit `7859b0af`)。它不再通过 pnpm patch 打补丁——所有本地修复直接改源码。 + +## 与上游的本地分歧(re-vendor 时必须逐条保留) + +从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: + +1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width"。 +2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 +3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"。 +4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` — 负宽度 repeat 防御**:空行/分隔线的 `repeat` 参数钳到 ≥0。守护测试:各自测试文件的 "negative width safety"。 + +## 同步上游后的验收 + +- 必须跑 `pnpm --filter @moonshot-ai/pi-tui test` 且全绿;上述守护测试任何一个失败都说明本地分歧被覆盖丢失。 + +## 测试 + +- 本包测试用 `node --test`(`pnpm --filter @moonshot-ai/pi-tui test`),不是 vitest;根目录 `vitest run` 不会执行本包测试。 +- 新增窄宽度相关测试优先加进对应组件的现有测试文件。 +``` + +- [ ] **Step 2: Commit(需用户授权)** + +```bash +git add packages/pi-tui/AGENTS.md +git commit -m "docs(pi-tui): document local divergences from upstream" +``` + +--- + +### Task 7: 全量验证 + changeset + +**Files:** +- Create: `.changeset/`(由 gen-changesets 技能生成) + +- [ ] **Step 1: pi-tui 全套测试 + 类型检查** + +Run: `pnpm --filter @moonshot-ai/pi-tui test && pnpm --filter @moonshot-ai/pi-tui typecheck` +Expected: 测试全绿,tsc 无报错。 + +- [ ] **Step 2: 根仓库测试(确认下游无回归)** + +Run: `pnpm test` +Expected: PASS(vitest projects 模式;pi-tui 本身被排除,但 `apps/kimi-code` 等依赖方的用例会覆盖到集成路径)。 + +- [ ] **Step 3: 手工冒烟(可选但推荐)** + +本地启动 kimi-code TUI,输入中文后把终端窗口拖到 5 列以内再拖回:进程不退出、UI 随宽度恢复正常。 + +- [ ] **Step 4: 生成 changeset** + +调用 `gen-changesets` 技能(`.agents/skills/gen-changesets/SKILL.md`)并遵循其内部规则生成 changeset(英文 changelog 文案;本次为 bug 修复,绝不写 `major`——若技能规则判断出 major 倾向,必须停下来找用户确认)。变更要点供撰写参考:fix narrow-terminal crashes — editor word-wrap infinite recursion at 1-column layout width, overwide rendered lines now truncated instead of throwing, container render width clamped, blank-line padding guarded against negative widths。 + +- [ ] **Step 5: Commit changeset(需用户授权)** + +```bash +git add .changeset/ +git commit -m "chore: add changeset for pi-tui narrow width fixes" +``` From 89188033e74c286c63948bfabc3bfaa5027f9b09 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 13:46:14 +0800 Subject: [PATCH 02/18] fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes at width 1 --- packages/pi-tui/src/components/editor.ts | 13 ++++++- packages/pi-tui/test/editor.test.ts | 45 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index f7b7fbcdfe..f8380c9a20 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -166,7 +166,18 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl // The segment remains logically atomic for cursor // movement / editing — the split is purely visual for word-wrap layout. - const subChunks = wordWrapLine(grapheme, maxWidth); + const subSegments = [...graphemeSegmenter.segment(grapheme)]; + if (subSegments.length <= 1) { + // An indivisible grapheme wider than maxWidth (e.g. a CJK + // character at maxWidth 1) cannot be split further — + // re-wrapping it would recurse forever. Keep it as the + // current open chunk and let it overflow by one column; + // the TUI paint layer truncates overwide lines. + currentWidth = gWidth; + wrapOppIndex = -1; + continue; + } + const subChunks = wordWrapLine(grapheme, maxWidth, subSegments); for (let j = 0; j < subChunks.length - 1; j++) { const sc = subChunks[j]!; chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 0f33370e10..c19c15b47c 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4049,3 +4049,48 @@ describe("Editor component", () => { }); }); }); + +describe("wordWrapLine narrow width", () => { + it("does not recurse infinitely on a wide grapheme at maxWidth 1", () => { + const chunks = wordWrapLine("中", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["中"], + ); + }); + + it("splits CJK text into per-grapheme overflow chunks at maxWidth 1", () => { + const chunks = wordWrapLine("中文文本", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["中", "文", "文", "本"], + ); + assert.deepStrictEqual( + chunks.map((c) => [c.startIndex, c.endIndex]), + [ + [0, 1], + [1, 2], + [2, 3], + [3, 4], + ], + ); + }); + + it("handles mixed narrow and wide graphemes at maxWidth 1", () => { + const chunks = wordWrapLine("ab中cd", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["a", "b", "中", "c", "d"], + ); + }); + + it("still re-wraps multi-grapheme atomic segments at narrow widths", () => { + // 粘贴标记以单个原子 segment 传入(preSegmented),内部仍可按 + // grapheme 拆分,递归必须保留这个能力。 + const marker = "[paste #1]"; + const preSegmented: Intl.SegmentData[] = [{ segment: marker, index: 0, input: marker }]; + const chunks = wordWrapLine(marker, 3, preSegmented); + assert.ok(chunks.length > 1); + assert.strictEqual(chunks.map((c) => c.text).join(""), marker); + }); +}); From 354735690de8373a9f971b2ca488455d93089067 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 13:59:12 +0800 Subject: [PATCH 03/18] docs: extend pi-tui narrow-width plan with emoji grapheme regression coverage --- plan/pi-tui-narrow-width-fix.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md index a6bc6e6878..14d2a444f4 100644 --- a/plan/pi-tui-narrow-width-fix.md +++ b/plan/pi-tui-narrow-width-fix.md @@ -448,12 +448,30 @@ describe("Editor narrow width rendering", () => { }); ``` -- [ ] **Step 2: 运行测试确认通过** +- [ ] **Step 2: 在 Task 1 的 `describe("wordWrapLine narrow width")` 组内追加 emoji grapheme 守护用例** + +来自 Task 1 质量审查的补充:现有守卫用例全是 BMP 单 code-unit 的 CJK。若守卫被误写成 `grapheme.length <= 1`(code unit 与 grapheme 混淆是最典型的错法),CJK 用例拦不住,而 ZWJ emoji 用户会重新栈溢出。在该 describe 组末尾追加: + +```ts + it("does not recurse infinitely on a multi-code-unit grapheme at maxWidth 1", () => { + // Guards "grapheme count, not code-unit length": a ZWJ family emoji + // is 11 code units but 1 grapheme (width 2). A guard mistakenly + // written as `grapheme.length <= 1` passes the BMP CJK cases yet + // recurses forever on this input. + const chunks = wordWrapLine("👨‍👩‍👧‍👦", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["👨‍👩‍👧‍👦"], + ); + }); +``` + +- [ ] **Step 3: 运行测试确认通过** Run: `cd packages/pi-tui && node --test test/editor.test.ts` Expected: PASS。(可选交叉验证:临时 `git stash` Task 1 的 editor.ts 改动再跑一次,应看到前两个用例栈溢出,验证测试确实盯住了根因;随后 `git stash pop` 恢复。) -- [ ] **Step 3: Commit(需用户授权)** +- [ ] **Step 4: Commit(需用户授权)** ```bash git add packages/pi-tui/test/editor.test.ts @@ -580,7 +598,7 @@ git commit -m "fix(pi-tui): guard blank-line padding against negative widths" 从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: -1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width"。 +1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width"。 2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"。 4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` — 负宽度 repeat 防御**:空行/分隔线的 `repeat` 参数钳到 ≥0。守护测试:各自测试文件的 "negative width safety"。 From 7bb7a73caa1f0a16ff9e3f77da628f1b13a8478e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:00:48 +0800 Subject: [PATCH 04/18] fix(pi-tui): clamp container render width to a minimum of 1 --- packages/pi-tui/src/tui.ts | 3 +++ packages/pi-tui/test/tui-render.test.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 8959da4b0e..7c5da90e47 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -278,6 +278,9 @@ export class Container implements Component { } render(width: number): string[] { + // Extremely narrow terminals can report tiny or even non-positive + // column counts; never propagate a width below 1 into components. + width = Math.max(1, width); const lines: string[] = []; for (const child of this.children) { const childLines = child.render(width); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 90c0bcc045..8b090b587e 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -9,7 +9,7 @@ import { setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import { type Component, Container, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { @@ -799,4 +799,21 @@ describe("TUI scrollback preservation", () => { tui.stop(); }); +}); + +describe("Container width clamping", () => { + it("clamps non-positive widths to 1 before rendering children", () => { + const container = new Container(); + const received: number[] = []; + container.addChild({ + render(width: number): string[] { + received.push(width); + return []; + }, + invalidate(): void {}, + }); + container.render(0); + container.render(-3); + assert.deepStrictEqual(received, [1, 1]); + }); }); \ No newline at end of file From 3055f49fa4216df15a91726fd13eab446adea952 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:17:50 +0800 Subject: [PATCH 05/18] fix(pi-tui): truncate overwide rendered lines instead of throwing --- packages/pi-tui/src/tui.ts | 40 ++++++++----------------- packages/pi-tui/test/tui-render.test.ts | 36 ++++++++++++++++++++-- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 7c5da90e47..da0240423d 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -1281,6 +1281,18 @@ export class TUI extends Container { // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); + // Never write a line wider than the terminal: truncate defensively + // instead of crashing. Extremely narrow terminals can make + // components overflow by a column (e.g. wide graphemes at width 1). + // applyLineResets() runs afterwards, so truncated lines still get + // their trailing reset and cannot leak styles. + for (let i = 0; i < newLines.length; i++) { + const line = newLines[i]!; + if (!isImageLine(line) && visibleWidth(line) > width) { + newLines[i] = sliceByColumn(line, 0, width, true); + } + } + newLines = this.applyLineResets(newLines); // Helper to clear scrollback and viewport and render all new lines @@ -1543,34 +1555,6 @@ export class TUI extends Container { } buffer += "\x1b[2K"; // Clear current line - if (!isImage && visibleWidth(line) > width) { - // Log all lines to crash file for debugging - const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); - const crashData = [ - `Crash at ${new Date().toISOString()}`, - `Terminal width: ${width}`, - `Line ${i} visible width: ${visibleWidth(line)}`, - "", - "=== All rendered lines ===", - ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), - "", - ].join("\n"); - fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); - fs.writeFileSync(crashLogPath, crashData); - - // Clean up terminal state before throwing - this.stop(); - - const errorMsg = [ - `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, - "", - "This is likely caused by a custom TUI component not truncating its output.", - "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", - "", - `Debug log written to: ${crashLogPath}`, - ].join("\n"); - throw new Error(errorMsg); - } buffer += line; } diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 8b090b587e..235695d50e 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -814,6 +814,38 @@ describe("Container width clamping", () => { }); container.render(0); container.render(-3); - assert.deepStrictEqual(received, [1, 1]); + container.render(5); + assert.deepStrictEqual(received, [1, 1, 5]); }); -}); \ No newline at end of file +}); + +describe("TUI overwide line handling", () => { + it("truncates lines wider than the terminal instead of throwing", async () => { + const terminal = new VirtualTerminal(4, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["ok"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + // 改成超宽行并触发差分渲染路径(修复前这里会 throw)。 + component.lines = ["xxxxxxxxxx", "你好世界"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport.some((line) => line.includes("xxxx"))); + assert.ok( + !viewport.some((line) => line.includes("xxxxx")), + "ASCII line should be truncated to terminal width", + ); + assert.ok(viewport.some((line) => line.includes("你好"))); + assert.ok( + !viewport.some((line) => line.includes("你好世")), + "CJK line should be truncated to terminal width", + ); + + tui.stop(); + }); +}); From 152dd114d4d12783548d5a9730291a5fc06d8501 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:47:36 +0800 Subject: [PATCH 06/18] perf(pi-tui): fast-path overwide line detection and enlarge width cache --- packages/pi-tui/src/tui.ts | 13 +++++++-- packages/pi-tui/src/utils.ts | 28 ++++++++++++++++++- .../pi-tui/test/truncate-to-width.test.ts | 20 ++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index da0240423d..c8924d47c9 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -16,7 +16,14 @@ import { type TerminalColorScheme, } from "./terminal-colors.ts"; import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; -import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; +import { + asciiVisibleWidth, + extractSegments, + normalizeTerminalOutput, + sliceByColumn, + sliceWithWidth, + visibleWidth, +} from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; @@ -1288,7 +1295,9 @@ export class TUI extends Container { // their trailing reset and cannot leak styles. for (let i = 0; i < newLines.length; i++) { const line = newLines[i]!; - if (!isImageLine(line) && visibleWidth(line) > width) { + if (isImageLine(line)) continue; + const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); + if (lineWidth > width) { newLines[i] = sliceByColumn(line, 0, width, true); } } diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts index 3823c386b7..88b1baac15 100644 --- a/packages/pi-tui/src/utils.ts +++ b/packages/pi-tui/src/utils.ts @@ -42,7 +42,7 @@ const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; // Cache for non-ASCII strings -const WIDTH_CACHE_SIZE = 512; +const WIDTH_CACHE_SIZE = 4096; const widthCache = new Map(); export const cjkBreakRegex = @@ -270,6 +270,32 @@ export function visibleWidth(str: string): number { return width; } +/** + * Fast visible-width scan for lines whose printable content is plain ASCII, + * skipping over ANSI escape sequences. Returns the visible width, or + * `undefined` when the line contains control characters or non-ASCII + * content (caller should fall back to visibleWidth()). Early-exits as soon + * as the width exceeds `limit`, returning the partial count (> limit). + */ +export function asciiVisibleWidth(line: string, limit: number): number | undefined { + let width = 0; + let i = 0; + while (i < line.length) { + const code = line.charCodeAt(i); + if (code === 0x1b) { + const ansi = extractAnsiCode(line, i); + if (!ansi) return undefined; + i += ansi.length; + continue; + } + if (code < 0x20 || code > 0x7e) return undefined; + width++; + if (width > limit) return width; + i++; + } + return width; +} + /** * Normalize text for terminal output without changing logical editor content. * Some terminals render precomposed Thai/Lao AM vowels inconsistently during diff --git a/packages/pi-tui/test/truncate-to-width.test.ts b/packages/pi-tui/test/truncate-to-width.test.ts index 321ba8983d..a0e4424f0a 100644 --- a/packages/pi-tui/test/truncate-to-width.test.ts +++ b/packages/pi-tui/test/truncate-to-width.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts"; +import { asciiVisibleWidth, normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts"; describe("truncateToWidth", () => { it("keeps output within width for very large unicode input", () => { @@ -74,3 +74,21 @@ describe("visibleWidth", () => { assert.strictEqual(visibleWidth(normalizeTerminalOutput("ຳabc")), visibleWidth("ຳabc")); }); }); + +describe("asciiVisibleWidth", () => { + it("measures ASCII lines and skips ANSI sequences", () => { + assert.strictEqual(asciiVisibleWidth("hello", 80), 5); + assert.strictEqual(asciiVisibleWidth("\x1b[31mhello\x1b[0m", 80), 5); + }); + + it("returns undefined for non-ASCII, control chars, or lone ESC", () => { + assert.strictEqual(asciiVisibleWidth("你好", 80), undefined); + assert.strictEqual(asciiVisibleWidth("a\tb", 80), undefined); + assert.strictEqual(asciiVisibleWidth("a\x1b", 80), undefined); + }); + + it("early-exits once width exceeds the limit", () => { + const result = asciiVisibleWidth("x".repeat(100), 4); + assert.ok(result !== undefined && result > 4); + }); +}); From 23db656d42b8c4588c47fc0f6992d88ff8320260 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:47:36 +0800 Subject: [PATCH 07/18] test(pi-tui): assert exact truncated viewport in overwide line test --- packages/pi-tui/test/tui-render.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 235695d50e..026427d503 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -830,21 +830,17 @@ describe("TUI overwide line handling", () => { await terminal.waitForRender(); // 改成超宽行并触发差分渲染路径(修复前这里会 throw)。 - component.lines = ["xxxxxxxxxx", "你好世界"]; + component.lines = ["xxxxxxxxxx", "\x1b[31myyyyyyyyyy\x1b[0m", "你好世界"]; tui.requestRender(); await terminal.waitForRender(); const viewport = terminal.getViewport(); - assert.ok(viewport.some((line) => line.includes("xxxx"))); - assert.ok( - !viewport.some((line) => line.includes("xxxxx")), - "ASCII line should be truncated to terminal width", - ); - assert.ok(viewport.some((line) => line.includes("你好"))); - assert.ok( - !viewport.some((line) => line.includes("你好世")), - "CJK line should be truncated to terminal width", - ); + // 截断生效时每个逻辑行恰占一个 viewport 行;若截断丢失, + // xterm 会把超宽行自动折行,后续行整体下移,下面的精确断言会失败。 + assert.strictEqual(viewport[0], "xxxx"); + assert.strictEqual(viewport[1], "yyyy"); + assert.strictEqual(viewport[2], "你好"); + assert.strictEqual(viewport[3], ""); tui.stop(); }); From 36f00349d49e1b5935a29fc9d512283dddea12a0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:54:17 +0800 Subject: [PATCH 08/18] docs: record review amendments in pi-tui narrow-width plan --- plan/pi-tui-narrow-width-fix.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md index 14d2a444f4..373458441e 100644 --- a/plan/pi-tui-narrow-width-fix.md +++ b/plan/pi-tui-narrow-width-fix.md @@ -392,6 +392,10 @@ git add packages/pi-tui/src/tui.ts packages/pi-tui/test/tui-render.test.ts git commit -m "fix(pi-tui): truncate overwide rendered lines instead of throwing" ``` +- [x] **审查修正记录(已执行)**:质量审查实测发现两个问题并已修复(commit `152dd114`、`23db656d`): + 1. Critical:截断循环每帧全量 `visibleWidth` 在长会话(ANSI/CJK 行)实测 12-40ms/帧。修复:`utils.ts` 新增导出 `asciiVisibleWidth(line, limit)`(复用 `extractAnsiCode` 跳过 ANSI、ASCII 快扫、超限早退,非 ASCII/控制字符/残缺 ESC 返回 undefined 回退 `visibleWidth`),截断循环改 `asciiVisibleWidth(line, width) ?? visibleWidth(line)`;`WIDTH_CACHE_SIZE` 512→4096;`test/truncate-to-width.test.ts` 加 "asciiVisibleWidth" 单元用例。修复后 styled-ASCII 场景 30.7→~1-2ms/帧。已知边界:>4096 条 distinct 非 ASCII 行仍会缓存抖动(合成极端),根治留待 prepared-frame 行级缓存后续任务。 + 2. Important:原 `includes` 断言被 xterm 自动折行架空(删掉截断循环测试仍绿)。修复:组件行改三行(含 `\x1b[31m` 样式行压快路径),断言改 `viewport[0..3]` 精确 `strictEqual`,判别力已双向验证。 + --- ### Task 4: 编辑器窄宽度端到端回归测试 @@ -600,7 +604,7 @@ git commit -m "fix(pi-tui): guard blank-line padding against negative widths" 1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width"。 2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 -3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"。 +3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` — 负宽度 repeat 防御**:空行/分隔线的 `repeat` 参数钳到 ≥0。守护测试:各自测试文件的 "negative width safety"。 ## 同步上游后的验收 From d00a38f1dba9775f6a8310ed7fe8a61c3cac5259 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 14:58:17 +0800 Subject: [PATCH 09/18] test(pi-tui): add editor narrow-width regression tests --- packages/pi-tui/test/editor.test.ts | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index c19c15b47c..210a66e9b4 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4093,4 +4093,68 @@ describe("wordWrapLine narrow width", () => { assert.ok(chunks.length > 1); assert.strictEqual(chunks.map((c) => c.text).join(""), marker); }); + + it("does not recurse infinitely on a multi-code-unit grapheme at maxWidth 1", () => { + // Guards "grapheme count, not code-unit length": a ZWJ family emoji + // is 11 code units but 1 grapheme (width 2). A guard mistakenly + // written as `grapheme.length <= 1` passes the BMP CJK cases yet + // recurses forever on this input. + const chunks = wordWrapLine("👨‍👩‍👧‍👦", 1); + assert.deepStrictEqual( + chunks.map((c) => c.text), + ["👨‍👩‍👧‍👦"], + ); + }); +}); + +describe("Editor narrow width rendering", () => { + it("renders CJK text without crashing at widths 1-8 (default padding)", () => { + for (let width = 1; width <= 8; width++) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("你好世界"); + assert.doesNotThrow(() => editor.render(width), `width ${width}`); + } + }); + + it("renders CJK text without crashing at widths 1-8 (paddingX 4, matches kimi-code)", () => { + for (let width = 1; width <= 8; width++) { + const editor = new Editor(createTestTUI(), defaultEditorTheme, { paddingX: 4 }); + editor.setText("你好,世界!"); + assert.doesNotThrow(() => editor.render(width), `width ${width}`); + } + }); + + it("recalls history without crashing after rendering at width 1", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.addToHistory("你好世界"); + editor.render(1); // 窄渲染把 lastWidth 钉在 1 + assert.doesNotThrow(() => { + (editor as unknown as { navigateHistory(direction: 1 | -1): void }).navigateHistory(-1); + // 导航召回 CJK 文本后在钉住的窄宽度下重排版——守卫缺失时这里栈溢出。 + editor.render(1); + }); + assert.strictEqual(editor.getText(), "你好世界"); + }); + + it("renders inside a TUI at 5 columns without crashing or overflowing", async () => { + const terminal = new VirtualTerminal(5, 12); + const tui = new TUI(terminal); + const editor = new Editor(tui, defaultEditorTheme, { paddingX: 4 }); + tui.addChild(editor); + editor.setText("你好世界"); + tui.start(); + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + // 精确断言可见行:截断回滚时超宽行会被 xterm 自动折行、结构错位, + // 这些断言会红;恒真的 every(visibleWidth<=5) 断言已被移除。 + // 内容行宽 6(左 padding 2 + CJK 字 2 + 右 padding 2)被截到 5, + // 因此行尾保留一个空格。 + assert.strictEqual(viewport[0], "─────"); + assert.strictEqual(viewport[1], " 你 "); + assert.strictEqual(viewport[2], " 好 "); + assert.strictEqual(viewport[3], " 世 "); + assert.strictEqual(viewport[4], " 界 "); + assert.strictEqual(viewport[5], "─────"); + tui.stop(); + }); }); From 015aa2bd52ae149507f5a091dc4ce06c1e2a12c1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:19:42 +0800 Subject: [PATCH 10/18] docs: record task 4 review amendments in pi-tui narrow-width plan --- plan/pi-tui-narrow-width-fix.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md index 373458441e..e8e9cd6199 100644 --- a/plan/pi-tui-narrow-width-fix.md +++ b/plan/pi-tui-narrow-width-fix.md @@ -482,6 +482,11 @@ git add packages/pi-tui/test/editor.test.ts git commit -m "test(pi-tui): add editor narrow-width regression tests" ``` +- [x] **审查修正记录(已执行,均已 amend 进上述 commit,最终 SHA `d00a38f1`)**: + 1. 历史回溯用例判别力:计划原版只把 `navigateHistory(-1)` 包进 `doesNotThrow`,实测对守卫回滚零判别力(私有方法不触发 wrap)。修正:块内追加召回后的 `editor.render(1)`,守卫禁用时该用例栈溢出变红。 + 2. 5 列 TUI 用例断言:计划原版 `viewport.every(visibleWidth(line) <= 5)` 在 5 列 xterm 上结构性恒真(物理行最多 5 格)。修正:改为 `viewport[0..5]` 精确 `strictEqual`(实测值 `"─────", " 你 ", " 好 ", " 世 ", " 界 ", "─────"`,内容行含截断保留的尾空格)——同时获得对 Task 3 截断回滚的判别力(回滚时结构错位变红,双向已验证)。 + 3. 守护矩阵(审查者在 /tmp 副本实测):editor.ts 守卫回滚 → editor.test.ts 8 红;Container 钳制回滚 → tui-render "Container width clamping" 红;截断回滚 → tui-render "TUI overwide line handling" 红 + editor 5 列用例红。三种分歧单独回滚均有守护测试变红。 + --- ### Task 5: 组件裸 `repeat` 负宽度加固 @@ -602,7 +607,7 @@ git commit -m "fix(pi-tui): guard blank-line padding against negative widths" 从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: -1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width"。 +1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` — 负宽度 repeat 防御**:空行/分隔线的 `repeat` 参数钳到 ≥0。守护测试:各自测试文件的 "negative width safety"。 From 471aec5e1a319ca276b0d41f54043d4baf7f319c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:23:40 +0800 Subject: [PATCH 11/18] fix(pi-tui): guard blank-line padding against negative widths --- packages/pi-tui/src/components/editor.ts | 4 ++-- packages/pi-tui/src/components/markdown.ts | 4 ++-- packages/pi-tui/src/components/text.ts | 2 +- packages/pi-tui/src/components/truncated-text.ts | 2 +- packages/pi-tui/test/editor.test.ts | 7 +++++++ packages/pi-tui/test/markdown.test.ts | 8 ++++++++ packages/pi-tui/test/truncated-text.test.ts | 8 ++++++++ packages/pi-tui/test/tui-render.test.ts | 9 +++++++++ 8 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index f8380c9a20..45f64ba904 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -525,7 +525,7 @@ export class Editor implements Component, Focusable { result.push(this.borderColor(truncateToWidth(indicator, width))); } } else { - result.push(horizontal.repeat(width)); + result.push(horizontal.repeat(Math.max(0, width))); } // Render each visible layout line @@ -583,7 +583,7 @@ export class Editor implements Component, Focusable { const remaining = width - visibleWidth(indicator); result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); } else { - result.push(horizontal.repeat(width)); + result.push(horizontal.repeat(Math.max(0, width))); } // Add autocomplete list if active diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index 9231cd456d..34a0df9223 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -223,7 +223,7 @@ export class Markdown implements Component { } // Add top/bottom padding (empty lines) - const emptyLine = " ".repeat(width); + const emptyLine = " ".repeat(Math.max(0, width)); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; @@ -461,7 +461,7 @@ export class Markdown implements Component { } case "hr": - lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); + lines.push(this.theme.hr("─".repeat(Math.max(0, Math.min(width, 80))))); if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } diff --git a/packages/pi-tui/src/components/text.ts b/packages/pi-tui/src/components/text.ts index 3809a48a86..2573d5abb2 100644 --- a/packages/pi-tui/src/components/text.ts +++ b/packages/pi-tui/src/components/text.ts @@ -87,7 +87,7 @@ export class Text implements Component { } // Add top/bottom padding (empty lines) - const emptyLine = " ".repeat(width); + const emptyLine = " ".repeat(Math.max(0, width)); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; diff --git a/packages/pi-tui/src/components/truncated-text.ts b/packages/pi-tui/src/components/truncated-text.ts index c26b889296..5e6ea8305a 100644 --- a/packages/pi-tui/src/components/truncated-text.ts +++ b/packages/pi-tui/src/components/truncated-text.ts @@ -23,7 +23,7 @@ export class TruncatedText implements Component { const result: string[] = []; // Empty line padded to width - const emptyLine = " ".repeat(width); + const emptyLine = " ".repeat(Math.max(0, width)); // Add vertical padding above for (let i = 0; i < this.paddingY; i++) { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 210a66e9b4..2a2645e6a1 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4157,4 +4157,11 @@ describe("Editor narrow width rendering", () => { assert.strictEqual(viewport[5], "─────"); tui.stop(); }); + + it("does not throw at zero or negative widths", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("你好世界"); + assert.doesNotThrow(() => editor.render(0)); + assert.doesNotThrow(() => editor.render(-1)); + }); }); diff --git a/packages/pi-tui/test/markdown.test.ts b/packages/pi-tui/test/markdown.test.ts index c166a582ab..895bfa6e40 100644 --- a/packages/pi-tui/test/markdown.test.ts +++ b/packages/pi-tui/test/markdown.test.ts @@ -1440,3 +1440,11 @@ bar`, }); }); }); + +describe("Markdown negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const markdown = new Markdown("# Title\n\ntext\n\n---", 1, 1, defaultMarkdownTheme); + assert.doesNotThrow(() => markdown.render(0)); + assert.doesNotThrow(() => markdown.render(-1)); + }); +}); diff --git a/packages/pi-tui/test/truncated-text.test.ts b/packages/pi-tui/test/truncated-text.test.ts index a25034e27f..2d8a852a48 100644 --- a/packages/pi-tui/test/truncated-text.test.ts +++ b/packages/pi-tui/test/truncated-text.test.ts @@ -127,3 +127,11 @@ describe("TruncatedText component", () => { assert.ok(!stripped.includes("Second line")); }); }); + +describe("TruncatedText negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const component = new TruncatedText("hello", 1, 1); + assert.doesNotThrow(() => component.render(0)); + assert.doesNotThrow(() => component.render(-1)); + }); +}); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 026427d503..21244bebff 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Image } from "../src/components/image.ts"; +import { Text } from "../src/components/text.ts"; import { deleteKittyImage, encodeKitty, @@ -845,3 +846,11 @@ describe("TUI overwide line handling", () => { tui.stop(); }); }); + +describe("Text negative width safety", () => { + it("does not throw at zero or negative widths", () => { + const text = new Text("你好", 1, 1); + assert.doesNotThrow(() => text.render(0)); + assert.doesNotThrow(() => text.render(-1)); + }); +}); From e81cf52c651880e89b0a9c065dcbd36aaaf0cdd0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:38:10 +0800 Subject: [PATCH 12/18] docs: record task 5 review amendments in pi-tui narrow-width plan --- plan/pi-tui-narrow-width-fix.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md index e8e9cd6199..79000c3e96 100644 --- a/plan/pi-tui-narrow-width-fix.md +++ b/plan/pi-tui-narrow-width-fix.md @@ -587,6 +587,10 @@ git add packages/pi-tui/src/components/text.ts packages/pi-tui/src/components/ma git commit -m "fix(pi-tui): guard blank-line padding against negative widths" ``` +- [x] **审查修正记录(已执行,amend 进上述 commit,最终 SHA `471aec5e`,共 8 文件)**: + 1. 实现者自审发现同类风险遗漏,经批准并入:`editor.ts` 上/下边框两处 `horizontal.repeat(width)` → `Math.max(0, width)`,并在 editor.test.ts 的 "Editor narrow width rendering" 组内补 `does not throw at zero or negative widths` 用例(红阶段命中 editor.ts:528)。 + 2. 质量审查变异测试确认:6 处守卫回滚 → 恰 4 个新用例红;`Math.max(0,...)` 选型与库内惯例一致。已知事实:markdown.ts hr 处的守卫当前从 `render()` 入口不可达(所有调用点已钳 ≥1),属纯防御,保留。 + --- ### Task 6: 记录与上游的本地分歧(防 re-vendor 回归) @@ -610,7 +614,7 @@ git commit -m "fix(pi-tui): guard blank-line padding against negative widths" 1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 -4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` — 负宽度 repeat 防御**:空行/分隔线的 `repeat` 参数钳到 ≥0。守护测试:各自测试文件的 "negative width safety"。 +4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:前三者各自测试文件的 "negative width safety",编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 ## 同步上游后的验收 From 8b29c6eedf261c5ff4c82e6dbdbfca204fb5d4eb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:38:57 +0800 Subject: [PATCH 13/18] docs(pi-tui): document local divergences from upstream --- packages/pi-tui/AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/pi-tui/AGENTS.md diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md new file mode 100644 index 0000000000..78f7f810b0 --- /dev/null +++ b/packages/pi-tui/AGENTS.md @@ -0,0 +1,21 @@ +# pi-tui Agent Guide + +`packages/pi-tui` 是从上游 pi-mono 的 pi-tui vendor 进来的副本(基线:上游 0.80.2,见 commit `7859b0af`)。它不再通过 pnpm patch 打补丁——所有本地修复直接改源码。 + +## 与上游的本地分歧(re-vendor 时必须逐条保留) + +从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: + +1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 +2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 +3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 +4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:前三者各自测试文件的 "negative width safety",编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 + +## 同步上游后的验收 + +- 必须跑 `pnpm --filter @moonshot-ai/pi-tui test` 且全绿;上述守护测试任何一个失败都说明本地分歧被覆盖丢失。 + +## 测试 + +- 本包测试用 `node --test`(`pnpm --filter @moonshot-ai/pi-tui test`),不是 vitest;根目录 `vitest run` 不会执行本包测试。 +- 新增窄宽度相关测试优先加进对应组件的现有测试文件。 From 2516ec7781c59798107ed4abfc78bf8d73064d11 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:39:46 +0800 Subject: [PATCH 14/18] chore: add changeset for pi-tui narrow width fixes --- .changeset/cli-narrow-terminal-crash.md | 5 +++++ .changeset/pi-tui-narrow-width-crash.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/cli-narrow-terminal-crash.md create mode 100644 .changeset/pi-tui-narrow-width-crash.md diff --git a/.changeset/cli-narrow-terminal-crash.md b/.changeset/cli-narrow-terminal-crash.md new file mode 100644 index 0000000000..af4eb18c54 --- /dev/null +++ b/.changeset/cli-narrow-terminal-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text. diff --git a/.changeset/pi-tui-narrow-width-crash.md b/.changeset/pi-tui-narrow-width-crash.md new file mode 100644 index 0000000000..edaee2c2b4 --- /dev/null +++ b/.changeset/pi-tui-narrow-width-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Fix crashes on very narrow terminals: word-wrapping wide graphemes no longer recurses infinitely at one-column width, render width is clamped to a minimum of one column, and overwide rendered lines are truncated instead of throwing. From abd89952b3de003dd71dafa2adb06fa052dab8a7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 15:51:57 +0800 Subject: [PATCH 15/18] docs(pi-tui): point Text guard test to its actual test file --- packages/pi-tui/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 78f7f810b0..f1b127186d 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -9,7 +9,7 @@ 1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 -4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:前三者各自测试文件的 "negative width safety",编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 +4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:"negative width safety" 用例——Text 的在 `test/tui-render.test.ts`(Text 无独立测试文件),Markdown/TruncatedText 的在各自测试文件;编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 ## 同步上游后的验收 From 81e14d6ed772b90034c32a003afeb9eff6ca64e5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 17:05:08 +0800 Subject: [PATCH 16/18] test(pi-tui): translate narrow-width test comments to English --- packages/pi-tui/test/editor.test.ts | 21 +++++++++++++-------- packages/pi-tui/test/tui-render.test.ts | 8 +++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 2a2645e6a1..35463df8a2 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4085,8 +4085,9 @@ describe("wordWrapLine narrow width", () => { }); it("still re-wraps multi-grapheme atomic segments at narrow widths", () => { - // 粘贴标记以单个原子 segment 传入(preSegmented),内部仍可按 - // grapheme 拆分,递归必须保留这个能力。 + // Paste markers arrive as one atomic pre-segmented unit; they can + // still be broken down grapheme by grapheme — recursion must keep + // that ability. const marker = "[paste #1]"; const preSegmented: Intl.SegmentData[] = [{ segment: marker, index: 0, input: marker }]; const chunks = wordWrapLine(marker, 3, preSegmented); @@ -4127,10 +4128,11 @@ describe("Editor narrow width rendering", () => { it("recalls history without crashing after rendering at width 1", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("你好世界"); - editor.render(1); // 窄渲染把 lastWidth 钉在 1 + editor.render(1); // narrow render pins lastWidth at 1 assert.doesNotThrow(() => { (editor as unknown as { navigateHistory(direction: 1 | -1): void }).navigateHistory(-1); - // 导航召回 CJK 文本后在钉住的窄宽度下重排版——守卫缺失时这里栈溢出。 + // Recalling CJK text re-wraps it at the pinned narrow width — + // without the guard this overflows the stack. editor.render(1); }); assert.strictEqual(editor.getText(), "你好世界"); @@ -4145,10 +4147,13 @@ describe("Editor narrow width rendering", () => { tui.start(); await terminal.waitForRender(); const viewport = terminal.getViewport(); - // 精确断言可见行:截断回滚时超宽行会被 xterm 自动折行、结构错位, - // 这些断言会红;恒真的 every(visibleWidth<=5) 断言已被移除。 - // 内容行宽 6(左 padding 2 + CJK 字 2 + 右 padding 2)被截到 5, - // 因此行尾保留一个空格。 + // Assert the exact visible rows: without truncation, xterm + // auto-wraps the overwide rows and the structure shifts, turning + // these assertions red (a tautological every(visibleWidth <= 5) + // check cannot fail on a 5-column terminal and was removed). + // Each content row is 6 columns wide (left padding 2 + CJK char 2 + // + right padding 2) and is truncated to 5, leaving the trailing + // space. assert.strictEqual(viewport[0], "─────"); assert.strictEqual(viewport[1], " 你 "); assert.strictEqual(viewport[2], " 好 "); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 21244bebff..77d33416bd 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -830,14 +830,16 @@ describe("TUI overwide line handling", () => { tui.start(); await terminal.waitForRender(); - // 改成超宽行并触发差分渲染路径(修复前这里会 throw)。 + // Switch to overwide lines and re-render through the differential + // path (this threw before the fix). component.lines = ["xxxxxxxxxx", "\x1b[31myyyyyyyyyy\x1b[0m", "你好世界"]; tui.requestRender(); await terminal.waitForRender(); const viewport = terminal.getViewport(); - // 截断生效时每个逻辑行恰占一个 viewport 行;若截断丢失, - // xterm 会把超宽行自动折行,后续行整体下移,下面的精确断言会失败。 + // With truncation each logical line occupies exactly one viewport + // row; without it, xterm auto-wraps the overwide lines and shifts + // the following rows, failing the exact assertions below. assert.strictEqual(viewport[0], "xxxx"); assert.strictEqual(viewport[1], "yyyy"); assert.strictEqual(viewport[2], "你好"); From 9fb9da76df953f0a3784d62cc522687534a963a7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 17:06:39 +0800 Subject: [PATCH 17/18] docs: remove internal pi-tui narrow-width plan --- plan/pi-tui-narrow-width-fix.md | 666 -------------------------------- 1 file changed, 666 deletions(-) delete mode 100644 plan/pi-tui-narrow-width-fix.md diff --git a/plan/pi-tui-narrow-width-fix.md b/plan/pi-tui-narrow-width-fix.md deleted file mode 100644 index 79000c3e96..0000000000 --- a/plan/pi-tui-narrow-width-fix.md +++ /dev/null @@ -1,666 +0,0 @@ -# pi-tui 窄终端崩溃修复实施计划 - -> **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:** 彻底修复终端宽度过窄时 kimi-code TUI 崩溃退出的问题(`packages/pi-tui` vendored 库),并用回归测试和文档防止未来 re-vendor 时再次丢失修复。 - -**Architecture:** 借鉴 oh-my-pi 的"永不崩溃"策略——1 个根因修复(`wordWrapLine` 对不可再分的宽 grapheme 停止递归)+ 2 个集中式咽喉防御(`Container.render` 入口宽度钳制到 ≥1;TUI 写终端前对超宽行统一截断、删除上游的 fail-fast throw)+ 少量组件级负宽度加固。修复全部落在 vendored 源码 `packages/pi-tui/src/`,不改 app 侧。 - -**Tech Stack:** TypeScript(Node 24 原生跑 TS)、`node --test` + `node:assert`(pi-tui 测试套件不是 vitest)、`VirtualTerminal`(xterm-headless 测试终端)。 - ---- - -## 背景与根因(执行者需要知道的全部上下文) - -**Bug 现象**:终端拖窄(≤7 列)且输入框有中文/emoji 时,TUI 进程崩溃退出。 - -**根因链**(均已实测验证): - -1. **主因——栈溢出**:`packages/pi-tui/src/components/editor.ts` 的 `wordWrapLine()`(L114-206)在 L163-179 处理"单个 segment 比 maxWidth 宽"时递归调用自身且参数不变。当 `maxWidth === 1` 且遇到宽字符(CJK/emoji,宽度 2)时无限递归 → `RangeError: Maximum call stack size exceeded`。编辑器 `render(width)`(L464-479)在 width ≤ 7 时会把 `layoutWidth` 压到 1(kimi-code 的 CustomEditor 用 `paddingX: 4`),因此中文用户几乎必现。崩溃被 `uncaughtException` 接住后直接退出进程。 -2. **次因——主动 throw**:`packages/pi-tui/src/tui.ts` 差分渲染路径(L1542-1570)对任何"渲染行宽 > 终端宽"的行写崩溃日志并 `throw`(上游的 fail-fast 设计)。窄宽度下 `Text`/`Markdown`/`Box`/`Input`/编辑器溢出 chunk 都可能产出超宽行。注意 `fullRender` 路径没有这个检查,所以炸点在 resize 后第一次差分渲染。 -3. **三因——负宽度 repeat**:`text.ts:90`、`markdown.ts:226`、`markdown.ts:464`、`truncated-text.ts:26` 的裸 `" ".repeat(width)` / `"─".repeat(...)` 在负 width 下抛 `RangeError: Invalid count value`。 -4. **无下限钳制**:宽度传播链 `terminal.columns`(terminal.ts:465)→ `doRender`(tui.ts:1256)→ `Container.render`(tui.ts:280)全程无 clamp。 - -**历史教训**:老分支 `origin/fix/tui-narrow-width-crash` 上有过三个修复(`a4188455` 的 pnpm patch 等),从未合入 main;vendor 提交 `7859b0af` 按上游 0.80.2 原样落库后修复彻底丢失。因此本计划包含守护测试(Task 1-5)和分歧文档(Task 6)。 - -**oh-my-pi 参照**(`/Users/moonshot/Desktop/moonshot/oh-my-pi/packages/tui`,仅供理解,不要复制其代码):`Container.render` 入口 `Math.max(1, width)`(commit `bb7f28848`);写终端前 `#prepareLine` 统一截断超宽行、删除上游 throw(`c40a22b3c`、`9ed5a70d0`);`padding(n)` 对 `n <= 0` 返回空串。 - -**运行命令**(都在仓库根目录执行): -- 跑 pi-tui 全部测试:`pnpm --filter @moonshot-ai/pi-tui test`(即 `node --test test/*.test.ts`) -- 跑单个测试文件:`node --test test/editor.test.ts`(cwd 为 `packages/pi-tui`) -- 类型检查:`pnpm --filter @moonshot-ai/pi-tui typecheck` - -**范围外(明确不做)**: -- 不改 app 侧(`apps/kimi-code`)的 GutterContainer / CustomEditor——核心修复后它们的超宽输出会被统一截断兜底。 -- 不恢复老分支的 pnpm patch 方式——pi-tui 已 vendored,直接改源码。 -- 不做 "terminal too small" 提示屏(oh-my-pi 也没做,策略是钳到 1 列 + 截断)。 -- 不改 `utils.ts` 的 `wrapSingleLine`/`breakLongWord` 行为(宽度 1 时产出宽度 2 的行,由集中截断兜底)。 -- 不单独给 `editor.ts:565` 的行拼接加截断——Task 1 后编辑器在极窄宽度下产出的溢出行(如 w=5 时宽 6)统一由 Task 3 的集中截断兜底,视觉上最多损失最右侧一列,属可接受降级。 -- markdown.ts 表格路径的 `"─".repeat(columnWidths)`(L803/823/850)不改——列宽由内容计算,恒为正。 - -**Git 纪律**:每个 Task 末尾的 commit 步骤需要用户事先明确授权;未授权则跳过所有 commit 步骤,改为最后统一由用户处理。Commit message 用英文、符合 Conventional Commits,不加任何 co-author。 - ---- - -### Task 1: `wordWrapLine` 递归守卫(根因修复) - -**Files:** -- Modify: `packages/pi-tui/src/components/editor.ts:163-179` -- Test: `packages/pi-tui/test/editor.test.ts` - -**原理**:递归 `wordWrapLine(grapheme, maxWidth)` 只有在 segment 含多个 grapheme(如粘贴标记这种原子多字符 segment)时才能取得进展;当 segment 本身就是单个 grapheme(中文字符在 maxWidth=1 时)递归参数不变、永不终止。守卫:单 grapheme 时不递归,把它保留为当前打开的 chunk,允许视觉上溢出 1 列(由 Task 3 的集中截断兜底)。 - -- [ ] **Step 1: 写失败测试** - -在 `packages/pi-tui/test/editor.test.ts` 文件末尾追加(该文件已 import `wordWrapLine`、`assert`、`describe`、`it`): - -```ts -describe("wordWrapLine narrow width", () => { - it("does not recurse infinitely on a wide grapheme at maxWidth 1", () => { - const chunks = wordWrapLine("中", 1); - assert.deepStrictEqual( - chunks.map((c) => c.text), - ["中"], - ); - }); - - it("splits CJK text into per-grapheme overflow chunks at maxWidth 1", () => { - const chunks = wordWrapLine("中文文本", 1); - assert.deepStrictEqual( - chunks.map((c) => c.text), - ["中", "文", "文", "本"], - ); - assert.deepStrictEqual( - chunks.map((c) => [c.startIndex, c.endIndex]), - [ - [0, 1], - [1, 2], - [2, 3], - [3, 4], - ], - ); - }); - - it("handles mixed narrow and wide graphemes at maxWidth 1", () => { - const chunks = wordWrapLine("ab中cd", 1); - assert.deepStrictEqual( - chunks.map((c) => c.text), - ["a", "b", "中", "c", "d"], - ); - }); - - it("still re-wraps multi-grapheme atomic segments at narrow widths", () => { - // 粘贴标记以单个原子 segment 传入(preSegmented),内部仍可按 - // grapheme 拆分,递归必须保留这个能力。 - const marker = "[paste #1]"; - const preSegmented: Intl.SegmentData[] = [{ segment: marker, index: 0, input: marker }]; - const chunks = wordWrapLine(marker, 3, preSegmented); - assert.ok(chunks.length > 1); - assert.strictEqual(chunks.map((c) => c.text).join(""), marker); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd packages/pi-tui && node --test test/editor.test.ts` -Expected: FAIL —— 前三个用例报 `RangeError: Maximum call stack size exceeded`;第四个用例(marker)通过。 - -- [ ] **Step 3: 实现守卫** - -在 `packages/pi-tui/src/components/editor.ts` 中,把: - -```ts - if (gWidth > maxWidth) { - // Single atomic segment wider than maxWidth (e.g. paste marker - // in a narrow terminal). Re-wrap it at grapheme granularity. - - // The segment remains logically atomic for cursor - // movement / editing — the split is purely visual for word-wrap layout. - const subChunks = wordWrapLine(grapheme, maxWidth); -``` - -改为: - -```ts - if (gWidth > maxWidth) { - // Single atomic segment wider than maxWidth (e.g. paste marker - // in a narrow terminal). Re-wrap it at grapheme granularity. - - // The segment remains logically atomic for cursor - // movement / editing — the split is purely visual for word-wrap layout. - const subSegments = [...graphemeSegmenter.segment(grapheme)]; - if (subSegments.length <= 1) { - // An indivisible grapheme wider than maxWidth (e.g. a CJK - // character at maxWidth 1) cannot be split further — - // re-wrapping it would recurse forever. Keep it as the - // current open chunk and let it overflow by one column; - // the TUI paint layer truncates overwide lines. - currentWidth = gWidth; - wrapOppIndex = -1; - continue; - } - const subChunks = wordWrapLine(grapheme, maxWidth, subSegments); -``` - -说明: -- 到达该分支时恒有 `chunkStart === charIndex`(`gWidth > maxWidth` 蕴含前面的 overflow 检查必然执行了 force-break 或本来就是空 chunk),所以只需把 `currentWidth` 设为 `gWidth` 即把该 grapheme 保留为打开的 chunk;后续 grapheme 触发 overflow 时会正常把它 push 出去,末尾的 `chunks.push(line.slice(chunkStart))` 也能收尾,不会产生空尾 chunk。 -- `subSegments` 顺手传给递归调用(`preSegmented` 参数),避免递归内部重复分词;其 `index` 相对于 `grapheme` 起点,与递归内 slice 语义一致。 -- `graphemeSegmenter` 在 editor.ts 顶部(L18)已定义,函数内可直接使用。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cd packages/pi-tui && node --test test/editor.test.ts` -Expected: PASS(全部用例,包括原有用例) - -- [ ] **Step 5: Commit(需用户授权)** - -```bash -git add packages/pi-tui/src/components/editor.ts packages/pi-tui/test/editor.test.ts -git commit -m "fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes at width 1" -``` - ---- - -### Task 2: `Container.render` 入口宽度钳制 - -**Files:** -- Modify: `packages/pi-tui/src/tui.ts:280` -- Test: `packages/pi-tui/test/tui-render.test.ts` - -- [ ] **Step 1: 写失败测试** - -在 `packages/pi-tui/test/tui-render.test.ts` 中,先把 import 行: - -```ts -import { type Component, TUI } from "../src/tui.ts"; -``` - -改为: - -```ts -import { type Component, Container, TUI } from "../src/tui.ts"; -``` - -然后在文件末尾追加: - -```ts -describe("Container width clamping", () => { - it("clamps non-positive widths to 1 before rendering children", () => { - const container = new Container(); - const received: number[] = []; - container.addChild({ - render(width: number): string[] { - received.push(width); - return []; - }, - invalidate(): void {}, - }); - container.render(0); - container.render(-3); - assert.deepStrictEqual(received, [1, 1]); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` -Expected: FAIL —— `AssertionError`,实际为 `[0, -3]`,期望 `[1, 1]`。 - -- [ ] **Step 3: 实现钳制** - -在 `packages/pi-tui/src/tui.ts` 的 `Container` 类中,把: - -```ts - render(width: number): string[] { - const lines: string[] = []; - for (const child of this.children) { -``` - -改为: - -```ts - render(width: number): string[] { - // Extremely narrow terminals can report tiny or even non-positive - // column counts; never propagate a width below 1 into components. - width = Math.max(1, width); - const lines: string[] = []; - for (const child of this.children) { -``` - -说明:`TUI extends Container` 且不覆写 `render`,`doRender` 里的 `this.render(width)`(tui.ts:1271)会经过这里,因此顶层组件树拿到的宽度恒 ≥1;嵌套 `Container` 同样自带钳制。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit(需用户授权)** - -```bash -git add packages/pi-tui/src/tui.ts packages/pi-tui/test/tui-render.test.ts -git commit -m "fix(pi-tui): clamp container render width to a minimum of 1" -``` - ---- - -### Task 3: 超宽行统一截断,删除 fail-fast throw(机制兜底) - -**Files:** -- Modify: `packages/pi-tui/src/tui.ts:1278-1281`(插入截断循环) -- Modify: `packages/pi-tui/src/tui.ts:1542-1570`(删除 throw 块) -- Test: `packages/pi-tui/test/tui-render.test.ts` - -**原理**:在 `doRender` 中、overlay 合成和光标标记提取之后、`applyLineResets` 之前,对所有非图片行做一次宽度检查并截断。这样 `fullRender` 和差分两条路径都被覆盖;截断发生在 `applyLineResets` 之前,被截掉的 ANSI 样式会由每行末尾追加的 `SEGMENT_RESET` 关闭,不会泄漏。截断用 `sliceByColumn(line, 0, width, true)`(tui.ts 已 import,strict 模式丢弃跨界宽字符)——这与 overlay 合成 `compositeLineAt` 使用的是同一套 ANSI 感知切割。 - -- [ ] **Step 1: 写失败测试** - -在 `packages/pi-tui/test/tui-render.test.ts` 末尾追加(复用文件里已有的 `TestComponent`,其 `lines` 字段可直接改写): - -```ts -describe("TUI overwide line handling", () => { - it("truncates lines wider than the terminal instead of throwing", async () => { - const terminal = new VirtualTerminal(4, 10); - const tui = new TUI(terminal); - const component = new TestComponent(); - component.lines = ["ok"]; - tui.addChild(component); - tui.start(); - await terminal.waitForRender(); - - // 改成超宽行并触发差分渲染路径(修复前这里会 throw)。 - component.lines = ["xxxxxxxxxx", "你好世界"]; - tui.requestRender(); - await terminal.waitForRender(); - - const viewport = terminal.getViewport(); - assert.ok(viewport.some((line) => line.includes("xxxx"))); - assert.ok( - !viewport.some((line) => line.includes("xxxxx")), - "ASCII line should be truncated to terminal width", - ); - assert.ok(viewport.some((line) => line.includes("你好"))); - assert.ok( - !viewport.some((line) => line.includes("你好世")), - "CJK line should be truncated to terminal width", - ); - - tui.stop(); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` -Expected: FAIL —— 渲染 tick 抛出 `Error: Rendered line 0 exceeds terminal width (10 > 4)`(以 uncaught exception 形式使测试文件失败)。 - -- [ ] **Step 3: 实现截断 + 删除 throw** - -修改一(插入截断循环):在 `packages/pi-tui/src/tui.ts` 中,把: - -```ts - // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); - - newLines = this.applyLineResets(newLines); -``` - -改为: - -```ts - // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); - - // Never write a line wider than the terminal: truncate defensively - // instead of crashing. Extremely narrow terminals can make - // components overflow by a column (e.g. wide graphemes at width 1). - // applyLineResets() runs afterwards, so truncated lines still get - // their trailing reset and cannot leak styles. - for (let i = 0; i < newLines.length; i++) { - const line = newLines[i]!; - if (!isImageLine(line) && visibleWidth(line) > width) { - newLines[i] = sliceByColumn(line, 0, width, true); - } - } - - newLines = this.applyLineResets(newLines); -``` - -修改二(删除差分路径的 throw 块):把: - -```ts - buffer += "\x1b[2K"; // Clear current line - if (!isImage && visibleWidth(line) > width) { - // Log all lines to crash file for debugging - const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); - const crashData = [ - `Crash at ${new Date().toISOString()}`, - `Terminal width: ${width}`, - `Line ${i} visible width: ${visibleWidth(line)}`, - "", - "=== All rendered lines ===", - ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), - "", - ].join("\n"); - fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); - fs.writeFileSync(crashLogPath, crashData); - - // Clean up terminal state before throwing - this.stop(); - - const errorMsg = [ - `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, - "", - "This is likely caused by a custom TUI component not truncating its output.", - "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", - "", - `Debug log written to: ${crashLogPath}`, - ].join("\n"); - throw new Error(errorMsg); - } - buffer += line; -``` - -改为: - -```ts - buffer += "\x1b[2K"; // Clear current line - buffer += line; -``` - -说明: -- `isImageLine`、`visibleWidth`、`sliceByColumn` 均已在 tui.ts 顶部 import,无需新增 import。 -- 删除 throw 块后 `fs`/`os`/`path` 仍被 `logRedraw`(tui.ts:1327-1333)使用,import 保留。 -- 删除后该循环内的 `isImage` 变量仍被上方 kitty 图片逻辑使用,保留。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts` -Expected: PASS(包括文件内原有 kitty 图片相关用例) - -- [ ] **Step 5: 跑全套 pi-tui 测试防止误伤** - -Run: `pnpm --filter @moonshot-ai/pi-tui test` -Expected: PASS。特别关注 `tui-overlay-style-leak.test.ts`(样式泄漏)与 `tui-shrink.test.ts`(内容收缩)不回归。 - -- [ ] **Step 6: Commit(需用户授权)** - -```bash -git add packages/pi-tui/src/tui.ts packages/pi-tui/test/tui-render.test.ts -git commit -m "fix(pi-tui): truncate overwide rendered lines instead of throwing" -``` - -- [x] **审查修正记录(已执行)**:质量审查实测发现两个问题并已修复(commit `152dd114`、`23db656d`): - 1. Critical:截断循环每帧全量 `visibleWidth` 在长会话(ANSI/CJK 行)实测 12-40ms/帧。修复:`utils.ts` 新增导出 `asciiVisibleWidth(line, limit)`(复用 `extractAnsiCode` 跳过 ANSI、ASCII 快扫、超限早退,非 ASCII/控制字符/残缺 ESC 返回 undefined 回退 `visibleWidth`),截断循环改 `asciiVisibleWidth(line, width) ?? visibleWidth(line)`;`WIDTH_CACHE_SIZE` 512→4096;`test/truncate-to-width.test.ts` 加 "asciiVisibleWidth" 单元用例。修复后 styled-ASCII 场景 30.7→~1-2ms/帧。已知边界:>4096 条 distinct 非 ASCII 行仍会缓存抖动(合成极端),根治留待 prepared-frame 行级缓存后续任务。 - 2. Important:原 `includes` 断言被 xterm 自动折行架空(删掉截断循环测试仍绿)。修复:组件行改三行(含 `\x1b[31m` 样式行压快路径),断言改 `viewport[0..3]` 精确 `strictEqual`,判别力已双向验证。 - ---- - -### Task 4: 编辑器窄宽度端到端回归测试 - -**Files:** -- Test: `packages/pi-tui/test/editor.test.ts` - -依赖 Task 1-3 全部完成(w=5 + paddingX=4 的用例需要 Task 1 消除栈溢出、Task 3 消除超宽 throw 才能通过)。本 Task 只加测试,不改实现。 - -- [ ] **Step 1: 追加端到端测试** - -在 `packages/pi-tui/test/editor.test.ts` 末尾追加(`Editor`、`createTestTUI`、`defaultEditorTheme`、`TUI`、`VirtualTerminal`、`visibleWidth` 均已在该文件 import): - -```ts -describe("Editor narrow width rendering", () => { - it("renders CJK text without crashing at widths 1-8 (default padding)", () => { - for (let width = 1; width <= 8; width++) { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.setText("你好世界"); - assert.doesNotThrow(() => editor.render(width), `width ${width}`); - } - }); - - it("renders CJK text without crashing at widths 1-8 (paddingX 4, matches kimi-code)", () => { - for (let width = 1; width <= 8; width++) { - const editor = new Editor(createTestTUI(), defaultEditorTheme, { paddingX: 4 }); - editor.setText("你好,世界!"); - assert.doesNotThrow(() => editor.render(width), `width ${width}`); - } - }); - - it("recalls history without crashing after rendering at width 1", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - editor.addToHistory("你好世界"); - editor.render(1); // 窄渲染把 lastWidth 钉在 1,复现历史导航崩溃路径 - assert.doesNotThrow(() => { - (editor as unknown as { navigateHistory(direction: 1 | -1): void }).navigateHistory(-1); - }); - assert.strictEqual(editor.getText(), "你好世界"); - }); - - it("renders inside a TUI at 5 columns without crashing or overflowing", async () => { - const terminal = new VirtualTerminal(5, 12); - const tui = new TUI(terminal); - const editor = new Editor(tui, defaultEditorTheme, { paddingX: 4 }); - tui.addChild(editor); - editor.setText("你好世界"); - tui.start(); - await terminal.waitForRender(); - const viewport = terminal.getViewport(); - assert.ok(viewport.every((line) => visibleWidth(line) <= 5)); - tui.stop(); - }); -}); -``` - -- [ ] **Step 2: 在 Task 1 的 `describe("wordWrapLine narrow width")` 组内追加 emoji grapheme 守护用例** - -来自 Task 1 质量审查的补充:现有守卫用例全是 BMP 单 code-unit 的 CJK。若守卫被误写成 `grapheme.length <= 1`(code unit 与 grapheme 混淆是最典型的错法),CJK 用例拦不住,而 ZWJ emoji 用户会重新栈溢出。在该 describe 组末尾追加: - -```ts - it("does not recurse infinitely on a multi-code-unit grapheme at maxWidth 1", () => { - // Guards "grapheme count, not code-unit length": a ZWJ family emoji - // is 11 code units but 1 grapheme (width 2). A guard mistakenly - // written as `grapheme.length <= 1` passes the BMP CJK cases yet - // recurses forever on this input. - const chunks = wordWrapLine("👨‍👩‍👧‍👦", 1); - assert.deepStrictEqual( - chunks.map((c) => c.text), - ["👨‍👩‍👧‍👦"], - ); - }); -``` - -- [ ] **Step 3: 运行测试确认通过** - -Run: `cd packages/pi-tui && node --test test/editor.test.ts` -Expected: PASS。(可选交叉验证:临时 `git stash` Task 1 的 editor.ts 改动再跑一次,应看到前两个用例栈溢出,验证测试确实盯住了根因;随后 `git stash pop` 恢复。) - -- [ ] **Step 4: Commit(需用户授权)** - -```bash -git add packages/pi-tui/test/editor.test.ts -git commit -m "test(pi-tui): add editor narrow-width regression tests" -``` - -- [x] **审查修正记录(已执行,均已 amend 进上述 commit,最终 SHA `d00a38f1`)**: - 1. 历史回溯用例判别力:计划原版只把 `navigateHistory(-1)` 包进 `doesNotThrow`,实测对守卫回滚零判别力(私有方法不触发 wrap)。修正:块内追加召回后的 `editor.render(1)`,守卫禁用时该用例栈溢出变红。 - 2. 5 列 TUI 用例断言:计划原版 `viewport.every(visibleWidth(line) <= 5)` 在 5 列 xterm 上结构性恒真(物理行最多 5 格)。修正:改为 `viewport[0..5]` 精确 `strictEqual`(实测值 `"─────", " 你 ", " 好 ", " 世 ", " 界 ", "─────"`,内容行含截断保留的尾空格)——同时获得对 Task 3 截断回滚的判别力(回滚时结构错位变红,双向已验证)。 - 3. 守护矩阵(审查者在 /tmp 副本实测):editor.ts 守卫回滚 → editor.test.ts 8 红;Container 钳制回滚 → tui-render "Container width clamping" 红;截断回滚 → tui-render "TUI overwide line handling" 红 + editor 5 列用例红。三种分歧单独回滚均有守护测试变红。 - ---- - -### Task 5: 组件裸 `repeat` 负宽度加固 - -**Files:** -- Modify: `packages/pi-tui/src/components/text.ts:90` -- Modify: `packages/pi-tui/src/components/markdown.ts:226` -- Modify: `packages/pi-tui/src/components/markdown.ts:464` -- Modify: `packages/pi-tui/src/components/truncated-text.ts:26` -- Test: `packages/pi-tui/test/tui-render.test.ts`、`packages/pi-tui/test/markdown.test.ts`、`packages/pi-tui/test/truncated-text.test.ts` - -**原理**:`Container.render` 钳制后顶层宽度恒 ≥1,但中间组件(如 Box)自行推导子宽度时仍可能把负值直接传给子组件的 `render()`。这 4 处裸 `repeat` 是仅剩的会直接抛 `RangeError` 的点,用 `Math.max(0, ...)` 加固。 - -- [ ] **Step 1: 写失败测试** - -`packages/pi-tui/test/tui-render.test.ts` 顶部追加 import: - -```ts -import { Text } from "../src/components/text.ts"; -``` - -文件末尾追加: - -```ts -describe("Text negative width safety", () => { - it("does not throw at zero or negative widths", () => { - const text = new Text("你好", 1, 1); - assert.doesNotThrow(() => text.render(0)); - assert.doesNotThrow(() => text.render(-1)); - }); -}); -``` - -`packages/pi-tui/test/markdown.test.ts` 末尾追加(该文件已 import `Markdown`、`defaultMarkdownTheme`、`assert`、`describe`、`it`): - -```ts -describe("Markdown negative width safety", () => { - it("does not throw at zero or negative widths", () => { - const markdown = new Markdown("# Title\n\ntext\n\n---", 1, 1, defaultMarkdownTheme); - assert.doesNotThrow(() => markdown.render(0)); - assert.doesNotThrow(() => markdown.render(-1)); - }); -}); -``` - -`packages/pi-tui/test/truncated-text.test.ts` 末尾追加(沿用该文件已有的 import 与构造方式,`TruncatedText` 构造签名为 `(text, paddingX = 0, paddingY = 0)`): - -```ts -describe("TruncatedText negative width safety", () => { - it("does not throw at zero or negative widths", () => { - const component = new TruncatedText("hello", 1, 1); - assert.doesNotThrow(() => component.render(0)); - assert.doesNotThrow(() => component.render(-1)); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts test/markdown.test.ts test/truncated-text.test.ts` -Expected: FAIL —— 三个新用例在 `render(-1)` 处抛 `RangeError: Invalid count value: -1`。 - -- [ ] **Step 3: 实现加固(4 处同构小改)** - -`packages/pi-tui/src/components/text.ts:90`、`packages/pi-tui/src/components/markdown.ts:226`、`packages/pi-tui/src/components/truncated-text.ts:26` 三处,把: - -```ts - const emptyLine = " ".repeat(width); -``` - -改为: - -```ts - const emptyLine = " ".repeat(Math.max(0, width)); -``` - -`packages/pi-tui/src/components/markdown.ts:464`,把: - -```ts - lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); -``` - -改为: - -```ts - lines.push(this.theme.hr("─".repeat(Math.max(0, Math.min(width, 80))))); -``` - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cd packages/pi-tui && node --test test/tui-render.test.ts test/markdown.test.ts test/truncated-text.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit(需用户授权)** - -```bash -git add packages/pi-tui/src/components/text.ts packages/pi-tui/src/components/markdown.ts packages/pi-tui/src/components/truncated-text.ts packages/pi-tui/test/tui-render.test.ts packages/pi-tui/test/markdown.test.ts packages/pi-tui/test/truncated-text.test.ts -git commit -m "fix(pi-tui): guard blank-line padding against negative widths" -``` - -- [x] **审查修正记录(已执行,amend 进上述 commit,最终 SHA `471aec5e`,共 8 文件)**: - 1. 实现者自审发现同类风险遗漏,经批准并入:`editor.ts` 上/下边框两处 `horizontal.repeat(width)` → `Math.max(0, width)`,并在 editor.test.ts 的 "Editor narrow width rendering" 组内补 `does not throw at zero or negative widths` 用例(红阶段命中 editor.ts:528)。 - 2. 质量审查变异测试确认:6 处守卫回滚 → 恰 4 个新用例红;`Math.max(0,...)` 选型与库内惯例一致。已知事实:markdown.ts hr 处的守卫当前从 `render()` 入口不可达(所有调用点已钳 ≥1),属纯防御,保留。 - ---- - -### Task 6: 记录与上游的本地分歧(防 re-vendor 回归) - -**Files:** -- Create: `packages/pi-tui/AGENTS.md` - -- [ ] **Step 1: 创建 AGENTS.md** - -写入以下内容: - -```markdown -# pi-tui Agent Guide - -`packages/pi-tui` 是从上游 pi-mono 的 pi-tui vendor 进来的副本(基线:上游 0.80.2,见 commit `7859b0af`)。它不再通过 pnpm patch 打补丁——所有本地修复直接改源码。 - -## 与上游的本地分歧(re-vendor 时必须逐条保留) - -从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: - -1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 -2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 -3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 -4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:前三者各自测试文件的 "negative width safety",编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 - -## 同步上游后的验收 - -- 必须跑 `pnpm --filter @moonshot-ai/pi-tui test` 且全绿;上述守护测试任何一个失败都说明本地分歧被覆盖丢失。 - -## 测试 - -- 本包测试用 `node --test`(`pnpm --filter @moonshot-ai/pi-tui test`),不是 vitest;根目录 `vitest run` 不会执行本包测试。 -- 新增窄宽度相关测试优先加进对应组件的现有测试文件。 -``` - -- [ ] **Step 2: Commit(需用户授权)** - -```bash -git add packages/pi-tui/AGENTS.md -git commit -m "docs(pi-tui): document local divergences from upstream" -``` - ---- - -### Task 7: 全量验证 + changeset - -**Files:** -- Create: `.changeset/`(由 gen-changesets 技能生成) - -- [ ] **Step 1: pi-tui 全套测试 + 类型检查** - -Run: `pnpm --filter @moonshot-ai/pi-tui test && pnpm --filter @moonshot-ai/pi-tui typecheck` -Expected: 测试全绿,tsc 无报错。 - -- [ ] **Step 2: 根仓库测试(确认下游无回归)** - -Run: `pnpm test` -Expected: PASS(vitest projects 模式;pi-tui 本身被排除,但 `apps/kimi-code` 等依赖方的用例会覆盖到集成路径)。 - -- [ ] **Step 3: 手工冒烟(可选但推荐)** - -本地启动 kimi-code TUI,输入中文后把终端窗口拖到 5 列以内再拖回:进程不退出、UI 随宽度恢复正常。 - -- [ ] **Step 4: 生成 changeset** - -调用 `gen-changesets` 技能(`.agents/skills/gen-changesets/SKILL.md`)并遵循其内部规则生成 changeset(英文 changelog 文案;本次为 bug 修复,绝不写 `major`——若技能规则判断出 major 倾向,必须停下来找用户确认)。变更要点供撰写参考:fix narrow-terminal crashes — editor word-wrap infinite recursion at 1-column layout width, overwide rendered lines now truncated instead of throwing, container render width clamped, blank-line padding guarded against negative widths。 - -- [ ] **Step 5: Commit changeset(需用户授权)** - -```bash -git add .changeset/ -git commit -m "chore: add changeset for pi-tui narrow width fixes" -``` From b119c55e9e0a6278502d22a9dcf17c6cd1577c74 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 2 Jul 2026 17:07:54 +0800 Subject: [PATCH 18/18] docs(pi-tui): translate AGENTS.md to English --- packages/pi-tui/AGENTS.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index f1b127186d..c5b5fdd8f6 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -1,21 +1,21 @@ # pi-tui Agent Guide -`packages/pi-tui` 是从上游 pi-mono 的 pi-tui vendor 进来的副本(基线:上游 0.80.2,见 commit `7859b0af`)。它不再通过 pnpm patch 打补丁——所有本地修复直接改源码。 +`packages/pi-tui` is a vendored copy of pi-tui from the upstream pi-mono project (baseline: upstream 0.80.2, see commit `7859b0af`). It is no longer patched via pnpm patches — all local fixes are applied directly to the source. -## 与上游的本地分歧(re-vendor 时必须逐条保留) +## Local divergences from upstream (must be preserved on every re-vendor) -从上游同步代码时,绝不能直接整目录覆盖。以下本地修复必须在同步后重新核对,全部有测试守护: +Never overwrite this directory wholesale when syncing from upstream. Each of the following local fixes must be re-verified after a sync; all of them are guarded by tests: -1. **`src/components/editor.ts` — `wordWrapLine` 单 grapheme 递归守卫**:segment 不可再分(单 grapheme)且比 `maxWidth` 宽时不再递归(上游在 maxWidth=1 + CJK 时无限递归栈溢出)。守卫必须基于 grapheme 数(`graphemeSegmenter.segment(...)`)而非 code-unit 长度——`grapheme.length` 对 ZWJ emoji 会误判。守护测试:`test/editor.test.ts` 的 "wordWrapLine narrow width" 与 "Editor narrow width rendering"。 -2. **`src/tui.ts` — `Container.render` 宽度钳制**:入口 `width = Math.max(1, width)`。守护测试:`test/tui-render.test.ts` 的 "Container width clamping"。 -3. **`src/tui.ts` — 超宽行截断替代 throw**:`doRender` 在 `applyLineResets` 前对超宽行统一 `sliceByColumn` 截断;上游差分渲染路径的"写崩溃日志 + throw"块已删除,不要在同步时带回来。性能约束:截断检查每帧扫全部行,必须先走 `utils.ts` 的 `asciiVisibleWidth` 快路径(ANSI 感知 ASCII 快扫 + 超限早退),仅对非 ASCII 行回退 `visibleWidth`;配套 `WIDTH_CACHE_SIZE` 为 4096。已知边界:>4096 条 distinct 非 ASCII 行时宽度缓存 FIFO 抖动(约 30ms/帧),根治需 prepared-frame 行级缓存,属后续任务。守护测试:`test/tui-render.test.ts` 的 "TUI overwide line handling"(精确 viewport 断言)、`test/truncate-to-width.test.ts` 的 "asciiVisibleWidth"。 -4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — 负宽度 repeat 防御**:空行/分隔线/编辑器上下边框的 `repeat` 参数钳到 ≥0(editor 上/下边框两处、markdown 的 emptyLine 与 hr——hr 处现从 render 入口不可达,属纯防御)。守护测试:"negative width safety" 用例——Text 的在 `test/tui-render.test.ts`(Text 无独立测试文件),Markdown/TruncatedText 的在各自测试文件;编辑器为 `test/editor.test.ts` "Editor narrow width rendering" 组内的 "does not throw at zero or negative widths"。 +1. **`src/components/editor.ts` — `wordWrapLine` single-grapheme recursion guard**: when a segment cannot be split further (a single grapheme) and is wider than `maxWidth`, do not recurse (upstream recurses infinitely and overflows the stack at maxWidth=1 with CJK). The guard must be based on grapheme count (`graphemeSegmenter.segment(...)`), not code-unit length — `grapheme.length` misjudges ZWJ emoji. Guarding tests: "wordWrapLine narrow width" and "Editor narrow width rendering" in `test/editor.test.ts`. +2. **`src/tui.ts` — `Container.render` width clamp**: `width = Math.max(1, width)` at the entry point. Guarding test: "Container width clamping" in `test/tui-render.test.ts`. +3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` before `applyLineResets`; the upstream "write crash log + throw" block in the differential render path has been removed — do not bring it back when syncing. Performance constraint: the truncation check scans every line every frame, so it must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Known boundary: with more than 4096 distinct non-ASCII lines the width cache FIFO thrashes (~30ms/frame); the real fix is a prepared-frame per-row cache, tracked as follow-up work. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`. +4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — negative-width `repeat` guards**: the `repeat` counts for blank lines, horizontal rules, and the editor's top/bottom borders are clamped to ≥ 0 (two editor border sites; markdown's emptyLine and hr — the hr site is currently unreachable from the render entry and is purely defensive). Guarding tests: the "negative width safety" cases — Text's lives in `test/tui-render.test.ts` (Text has no dedicated test file), Markdown's and TruncatedText's live in their own test files; the editor's is "does not throw at zero or negative widths" inside the "Editor narrow width rendering" group in `test/editor.test.ts`. -## 同步上游后的验收 +## Acceptance after syncing from upstream -- 必须跑 `pnpm --filter @moonshot-ai/pi-tui test` 且全绿;上述守护测试任何一个失败都说明本地分歧被覆盖丢失。 +- `pnpm --filter @moonshot-ai/pi-tui test` must pass in full; any failure among the guarding tests above means a local divergence was overwritten and lost. -## 测试 +## Testing -- 本包测试用 `node --test`(`pnpm --filter @moonshot-ai/pi-tui test`),不是 vitest;根目录 `vitest run` 不会执行本包测试。 -- 新增窄宽度相关测试优先加进对应组件的现有测试文件。 +- This package's tests run with `node --test` (`pnpm --filter @moonshot-ai/pi-tui test`), not vitest; the root `vitest run` does not execute them. +- Prefer adding new narrow-width tests to the existing test file of the corresponding component.