mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
c3653a1c50
commit
2639786ce5
14 changed files with 315 additions and 40 deletions
5
.changeset/cli-narrow-terminal-crash.md
Normal file
5
.changeset/cli-narrow-terminal-crash.md
Normal 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.
|
||||
5
.changeset/pi-tui-narrow-width-crash.md
Normal file
5
.changeset/pi-tui-narrow-width-crash.md
Normal 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
21
packages/pi-tui/AGENTS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue