Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6e2e18c
docs: add pi-tui narrow-width fix plan
liruifengv Jul 2, 2026
8918803
fix(pi-tui): stop wordWrapLine infinite recursion on wide graphemes a…
liruifengv Jul 2, 2026
3547356
docs: extend pi-tui narrow-width plan with emoji grapheme regression …
liruifengv Jul 2, 2026
7bb7a73
fix(pi-tui): clamp container render width to a minimum of 1
liruifengv Jul 2, 2026
3055f49
fix(pi-tui): truncate overwide rendered lines instead of throwing
liruifengv Jul 2, 2026
152dd11
perf(pi-tui): fast-path overwide line detection and enlarge width cache
liruifengv Jul 2, 2026
23db656
test(pi-tui): assert exact truncated viewport in overwide line test
liruifengv Jul 2, 2026
36f0034
docs: record review amendments in pi-tui narrow-width plan
liruifengv Jul 2, 2026
d00a38f
test(pi-tui): add editor narrow-width regression tests
liruifengv Jul 2, 2026
015aa2b
docs: record task 4 review amendments in pi-tui narrow-width plan
liruifengv Jul 2, 2026
471aec5
fix(pi-tui): guard blank-line padding against negative widths
liruifengv Jul 2, 2026
e81cf52
docs: record task 5 review amendments in pi-tui narrow-width plan
liruifengv Jul 2, 2026
8b29c6e
docs(pi-tui): document local divergences from upstream
liruifengv Jul 2, 2026
2516ec7
chore: add changeset for pi-tui narrow width fixes
liruifengv Jul 2, 2026
abd8995
docs(pi-tui): point Text guard test to its actual test file
liruifengv Jul 2, 2026
e2f4008
Merge branch 'main' into fix/pi-tui-narrow-width
liruifengv Jul 2, 2026
81e14d6
test(pi-tui): translate narrow-width test comments to English
liruifengv Jul 2, 2026
9fb9da7
docs: remove internal pi-tui narrow-width plan
liruifengv Jul 2, 2026
b119c55
docs(pi-tui): translate AGENTS.md to English
liruifengv Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-narrow-terminal-crash.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/pi-tui-narrow-width-crash.md
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions packages/pi-tui/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 14 additions & 3 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/pi-tui/src/components/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-tui/src/components/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-tui/src/components/truncated-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
54 changes: 25 additions & 29 deletions packages/pi-tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

Expand Down
28 changes: 27 additions & 1 deletion packages/pi-tui/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions packages/pi-tui/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
8 changes: 8 additions & 0 deletions packages/pi-tui/test/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
20 changes: 19 additions & 1 deletion packages/pi-tui/test/truncate-to-width.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading