diff --git a/.changeset/cli-narrow-terminal-crash.md b/.changeset/cli-narrow-terminal-crash.md new file mode 100644 index 000000000..af4eb18c5 --- /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 000000000..edaee2c2b --- /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. diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md new file mode 100644 index 000000000..c5b5fdd8f --- /dev/null +++ b/packages/pi-tui/AGENTS.md @@ -0,0 +1,21 @@ +# pi-tui Agent Guide + +`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. + +## 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` 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` must pass in full; any failure among the guarding tests above means a local divergence was overwritten and lost. + +## Testing + +- 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. diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index f7b7fbcdf..45f64ba90 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 }); @@ -514,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 @@ -572,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 9231cd456..34a0df922 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 3809a48a8..2573d5abb 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 c26b88929..5e6ea8305 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/src/tui.ts b/packages/pi-tui/src/tui.ts index 8959da4b0..c8924d47c 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"; @@ -278,6 +285,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); @@ -1278,6 +1288,20 @@ 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)) continue; + const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); + if (lineWidth > width) { + newLines[i] = sliceByColumn(line, 0, width, true); + } + } + newLines = this.applyLineResets(newLines); // Helper to clear scrollback and viewport and render all new lines @@ -1540,34 +1564,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/src/utils.ts b/packages/pi-tui/src/utils.ts index 3823c386b..88b1baac1 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/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 0f33370e1..35463df8a 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4049,3 +4049,124 @@ 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", () => { + // 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); + 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); // narrow render pins lastWidth at 1 + assert.doesNotThrow(() => { + (editor as unknown as { navigateHistory(direction: 1 | -1): void }).navigateHistory(-1); + // 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(), "你好世界"); + }); + + 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 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], " 好 "); + assert.strictEqual(viewport[3], " 世 "); + assert.strictEqual(viewport[4], " 界 "); + 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 c166a582a..895bfa6e4 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/truncate-to-width.test.ts b/packages/pi-tui/test/truncate-to-width.test.ts index 321ba8983..a0e4424f0 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); + }); +}); diff --git a/packages/pi-tui/test/truncated-text.test.ts b/packages/pi-tui/test/truncated-text.test.ts index a25034e27..2d8a852a4 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 90c0bcc04..77d33416b 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, @@ -9,7 +10,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 +800,59 @@ describe("TUI scrollback preservation", () => { tui.stop(); }); -}); \ No newline at end of file +}); + +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); + container.render(5); + assert.deepStrictEqual(received, [1, 1, 5]); + }); +}); + +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(); + + // 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(); + // 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], "你好"); + assert.strictEqual(viewport[3], ""); + + 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)); + }); +});