fix(pi-tui): prevent crashes on very narrow terminals (#1303)

* docs: add pi-tui narrow-width fix plan

* fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes at width 1

* docs: extend pi-tui narrow-width plan with emoji grapheme regression coverage

* fix(pi-tui): clamp container render width to a minimum of 1

* fix(pi-tui): truncate overwide rendered lines instead of throwing

* perf(pi-tui): fast-path overwide line detection and enlarge width cache

* test(pi-tui): assert exact truncated viewport in overwide line test

* docs: record review amendments in pi-tui narrow-width plan

* test(pi-tui): add editor narrow-width regression tests

* docs: record task 4 review amendments in pi-tui narrow-width plan

* fix(pi-tui): guard blank-line padding against negative widths

* docs: record task 5 review amendments in pi-tui narrow-width plan

* docs(pi-tui): document local divergences from upstream

* chore: add changeset for pi-tui narrow width fixes

* docs(pi-tui): point Text guard test to its actual test file

* test(pi-tui): translate narrow-width test comments to English

* docs: remove internal pi-tui narrow-width plan

* docs(pi-tui): translate AGENTS.md to English
This commit is contained in:
liruifengv 2026-07-02 17:14:11 +08:00 committed by GitHub
parent c3653a1c50
commit 2639786ce5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 315 additions and 40 deletions

View file

@ -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.

View file

@ -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.

21
packages/pi-tui/AGENTS.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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)
}

View file

@ -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;

View file

@ -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++) {

View file

@ -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;
}

View file

@ -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<string, number>();
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

View file

@ -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));
});
});

View file

@ -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));
});
});

View file

@ -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);
});
});

View file

@ -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));
});
});

View file

@ -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();
});
});
});
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));
});
});