From 0cbe08abe5412d0c689b65434c49e083ec2f4af9 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 15 Jun 2026 22:47:57 +0800 Subject: [PATCH 1/3] fix: prevent TUI crashes when terminal is resized to narrow widths - Truncate ThinkingComponent live spinner and all rendered lines to width - Truncate GoalCompletionMessageComponent output to width - Truncate CronMessageComponent output to width - Return placeholder in GutterContainer when gutters exceed width - Add narrow-width tests and update width-safety rules in AGENTS.md and write-tui skill --- .agents/skills/write-tui/SKILL.md | 2 + .changeset/fix-tui-narrow-width-crash.md | 5 ++ apps/kimi-code/AGENTS.md | 10 ++++ .../tui/components/chrome/gutter-container.ts | 7 ++- .../tui/components/messages/cron-message.ts | 4 +- .../src/tui/components/messages/goal-panel.ts | 5 +- .../src/tui/components/messages/thinking.ts | 48 +++++++++++-------- .../chrome/gutter-container.test.ts | 14 ++++-- .../components/messages/cron-message.test.ts | 43 +++++++++++++++++ .../components/messages/goal-panel.test.ts | 11 +++++ .../tui/components/messages/thinking.test.ts | 10 ++++ 11 files changed, 130 insertions(+), 29 deletions(-) create mode 100644 .changeset/fix-tui-narrow-width-crash.md create mode 100644 apps/kimi-code/test/tui/components/messages/cron-message.test.ts diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md index 3952b84b5..e742aa20d 100644 --- a/.agents/skills/write-tui/SKILL.md +++ b/.agents/skills/write-tui/SKILL.md @@ -56,6 +56,7 @@ The feature type decides the landing spot: - reverse-rpc tests → `test/tui/reverse-rpc/`. - Pure utility tests → next to the corresponding utils tests. - Do not create a generic `some-feature.test.ts` just to land a small feature; extend the nearest existing test file. +- Every component that builds lines manually should have a narrow-width test that verifies all returned lines fit within the requested `width`. ## Theme system mechanics @@ -83,3 +84,4 @@ Apply / switch flow: - Run lint / format / test on the files you changed. - For any dialog/selector/input/toggle list, walk the self-check list at the end of [DESIGN.md](./DESIGN.md). - Keep `printableChar()` for printable-key comparisons (CI guard) and `chalk.hex(colors.)` for color (CI guard). +- **Width safety**: if your component builds lines manually in `render(width)`, ensure every returned line fits within `width`. The safest pattern is a final `lines.map(line => truncateToWidth(line, width, '…'))`. Add a narrow-width test case (e.g. `width` of `5`, `2`, or `1`) asserting `visibleWidth(line) <= width` for every line. See the Width Safety Rules in `apps/kimi-code/AGENTS.md`. diff --git a/.changeset/fix-tui-narrow-width-crash.md b/.changeset/fix-tui-narrow-width-crash.md new file mode 100644 index 000000000..77acf7420 --- /dev/null +++ b/.changeset/fix-tui-narrow-width-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix TUI crashes when the terminal is resized to a very narrow width. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index 11184d958..a3c26b2f2 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -51,6 +51,16 @@ Main directories: - Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code. - Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI. +## Width Safety Rules (normative) + +pi-tui requires every line returned by `Component.render(width)` to fit within the requested `width`. When a line overflows, pi-tui throws a hard crash and writes `~/.pi/agent/pi-crash.log`. The following rules prevent this: + +- If a component builds lines manually (not just returning the output of pi-tui `Text`, `Markdown`, `Container`, `Input`, `SelectList`, etc.), it must ensure every emitted line is `<= width`. +- The simplest and recommended final guard is to map the result through `truncateToWidth(line, width, '…')` before returning. +- Prefer `Text` or `Markdown` for large blocks of plain text / formatted text; they handle wrapping and truncation automatically. +- When using prefixes, bullets, or indentation, account for their visible width (`visibleWidth`) when computing the content width, and still apply a final truncation guard. +- New or modified TUI components must include a test that renders at very narrow widths (e.g. `width` of `5`, `2`, or `1`) and asserts `visibleWidth(line) <= width` for every returned line. + ## Color Rules (normative) The theme apply/switch mechanics live in the `write-tui` skill. The following rules are hard and guard-enforced: diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 39ed97c49..422cea9aa 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -20,7 +20,12 @@ export class GutterContainer extends Container { } override render(width: number): string[] { - const inner = Math.max(1, width - this.leftPad - this.rightPad); + // If the terminal is narrower than the gutters themselves, skip content + // rather than clamping the inner width to 1 and producing an over-wide line. + if (width < this.leftPad + this.rightPad) { + return ['']; + } + const inner = width - this.leftPad - this.rightPad; const lead = ' '.repeat(this.leftPad); const out: string[] = []; for (const child of this.children) { diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 5ca2acf02..139c4ebdc 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,5 +1,5 @@ import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import { Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -64,7 +64,7 @@ export class CronMessageComponent implements Component { lines.push(`${continuationIndent}${line}`); } - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index be9df81af..e9c04ca3d 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -79,12 +79,13 @@ export class GoalCompletionMessageComponent implements Component { invalidate(): void {} render(width: number): string[] { + const safeWidth = Math.max(0, width); const [headline = '', ...details] = this.message.trim().split(/\r?\n/); if (headline.length === 0) return []; const bullet = currentTheme.boldFg('success', STATUS_BULLET); const bulletWidth = visibleWidth(STATUS_BULLET); - const contentWidth = Math.max(1, width - bulletWidth); + const contentWidth = Math.max(1, safeWidth - bulletWidth); const lines: string[] = ['']; const headlineText = new Text(currentTheme.boldFg('success', headline), 0, 0); @@ -103,7 +104,7 @@ export class GoalCompletionMessageComponent implements Component { } } - return lines; + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 6ff652d4f..a1ec7d4bc 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -80,6 +80,8 @@ export class ThinkingComponent implements Component { const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + let result: string[]; + if (this.mode === 'live') { const visibleLines = contentLines.length > THINKING_PREVIEW_LINES @@ -89,33 +91,37 @@ export class ThinkingComponent implements Component { 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - return [ + result = [ '', spinner + currentTheme.fg('textDim', 'thinking...'), ...visibleLines.map((line) => MESSAGE_INDENT + line), ]; + } else { + const rendered: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + rendered.push(p + contentLines[i]); + } + + if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { + result = rendered; + } else { + // Leading blank + first PREVIEW_LINES content lines + hint line. + const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); + const remaining = contentLines.length - THINKING_PREVIEW_LINES; + const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; + const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); + const hintWidth = Math.max(0, width - indentWidth); + truncated.push( + ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), + ); + result = truncated; + } } - const rendered: string[] = ['']; - for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; - rendered.push(p + contentLines[i]); - } - - if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) { - return rendered; - } - - // Leading blank + first PREVIEW_LINES content lines + hint line. - const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES); - const remaining = contentLines.length - THINKING_PREVIEW_LINES; - const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`; - const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width)); - const hintWidth = Math.max(0, width - indentWidth); - truncated.push( - ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')), - ); - return truncated; + // Final guard: pi-tui requires every returned line to fit within the + // requested width, even when the terminal is extremely narrow. + return result.map((line) => truncateToWidth(line, width, '…')); } private startSpinner(): void { diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index c26e97a17..6f2681370 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -28,12 +28,20 @@ describe('GutterContainer', () => { expect(seenWidth).toHaveBeenCalledWith(15); }); - it('clamps inner width to at least 1 when gutters would otherwise consume it', () => { + it('returns an empty placeholder when gutters consume the whole width', () => { const seenWidth = vi.fn<(w: number) => string[]>(() => ['x']); const c = new GutterContainer(5, 5); c.addChild(new FakeChild(seenWidth)); - c.render(2); - expect(seenWidth).toHaveBeenCalledWith(1); + expect(c.render(2)).toEqual(['']); + expect(seenWidth).not.toHaveBeenCalled(); + }); + + it('keeps emitted lines within the requested width when gutters fit exactly', () => { + const c = new GutterContainer(3, 2); + c.addChild(new FakeChild((w) => ['a'.repeat(w)])); + const lines = c.render(6); + expect(lines.length).toBe(1); + expect(lines[0]).toBe(' a'); }); it('stacks lines from multiple children in order', () => { diff --git a/apps/kimi-code/test/tui/components/messages/cron-message.test.ts b/apps/kimi-code/test/tui/components/messages/cron-message.test.ts new file mode 100644 index 000000000..1c429999e --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/cron-message.test.ts @@ -0,0 +1,43 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { CronMessageComponent } from '#/tui/components/messages/cron-message'; +import type { CronTranscriptData } from '#/tui/types'; + +function component(data: Partial = {}): CronMessageComponent { + const fullData: CronTranscriptData = { + cron: '* * * * *', + jobId: 'job-123', + recurring: true, + ...data, + }; + return new CronMessageComponent('Run daily summary', fullData); +} + +describe('CronMessageComponent', () => { + it('renders a scheduled reminder with cron detail', () => { + const lines = component().render(80); + const rendered = lines.join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); + + expect(rendered).toContain('Scheduled reminder fired'); + expect(rendered).toContain('* * * * *'); + expect(rendered).toContain('job job-123'); + expect(rendered).toContain('Run daily summary'); + }); + + it('renders a missed-reminder variant', () => { + const lines = component({ missedCount: 3 }).render(80); + const rendered = lines.join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); + + expect(rendered).toContain('Missed scheduled reminders'); + expect(rendered).toContain('3 missed'); + }); + + it('keeps every line within the requested render width, even when very narrow', () => { + for (const width of [80, 40, 20, 10, 5, 2, 1]) { + for (const line of component().render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index b5b89ab0d..7f8f954b0 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -191,4 +191,15 @@ describe('GoalCompletionMessageComponent', () => { ' Worked 1 turn over 2m28s, using 766.9k tokens.', ); }); + + it('keeps every line within the requested render width, even when very narrow', () => { + const message = '✓ Goal complete.\nWorked 1 turn over 2m28s, using 766.9k tokens.'; + const component = new GoalCompletionMessageComponent(message); + + for (const width of [80, 40, 20, 10, 5, 2, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 40f609be1..ec9c70959 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -89,4 +89,14 @@ describe('ThinkingComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('keeps every live line within the requested render width, even when very narrow', () => { + const component = new ThinkingComponent('step', true, 'live'); + + for (const width of [80, 13, 12, 5, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); }); From 7e150035c811301e70ba7d3fffbca4a0e6bc850b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 15 Jun 2026 22:54:58 +0800 Subject: [PATCH 2/3] fix: guard CustomEditor against pi-tui wordWrapLine stack overflow at narrow widths pi-tui's editor wordWrapLine recurses infinitely when layoutWidth is 1 and the text contains wide graphemes (e.g. CJK). Skip rendering CustomEditor when the terminal is narrower than paddingX to avoid the crash. --- .../src/tui/components/editor/custom-editor.ts | 9 +++++++++ .../tui/components/editor/custom-editor.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index ded2af648..08e979c86 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -213,6 +213,15 @@ export class CustomEditor extends Editor { } override render(width: number): string[] { + // pi-tui's editor wordWrapLine recurses infinitely when the layout + // width becomes 1 and the text contains a wide grapheme (e.g. CJK). + // At widths below our paddingX, the editor clamps padding to 0 and + // can produce a 1-column layout width. Skip rendering entirely in + // that case rather than crashing. + if (width < this.getPaddingX()) { + return ['']; + } + const lines = super.render(width); if (lines.length < 3) return lines; const firstContentIdx = 1; diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index fa30293cc..457cb916d 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -4,6 +4,7 @@ import type { AutocompleteSuggestions, TUI, } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; @@ -280,3 +281,17 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onUndo).toHaveBeenCalledOnce(); }); }); + +describe('CustomEditor narrow width safety', () => { + it('does not crash when rendered at extremely narrow widths with wide characters', () => { + const editor = makeEditor(); + editor.setText('你好世界'); + + for (const width of [0, 1, 2, 3]) { + const lines = editor.render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); +}); From a4188455292b42a7d4ff890dbea13c8d0349d187 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 15 Jun 2026 23:00:23 +0800 Subject: [PATCH 3/3] fix: patch pi-tui wordWrapLine to avoid stack overflow at width 1 --- .../components/editor/custom-editor.test.ts | 17 ++++++++++++++ patches/@earendil-works__pi-tui@0.74.0.patch | 22 +++++++++++++++++++ pnpm-lock.yaml | 9 ++++++-- pnpm-workspace.yaml | 2 ++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 patches/@earendil-works__pi-tui@0.74.0.patch diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 457cb916d..445dafc68 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -294,4 +294,21 @@ describe('CustomEditor narrow width safety', () => { } } }); + + it('does not crash when recalling history at an extremely narrow width', () => { + const editor = makeEditor(); + editor.setText('你好世界'); + editor.addToHistory('你好世界'); + editor.setText(''); + + // Simulate that the last render happened at a 1-column layout width. + (editor as unknown as { lastWidth: number }).lastWidth = 1; + + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (editor as any).navigateHistory(-1); + }).not.toThrow(); + + expect(editor.getText()).toBe('你好世界'); + }); }); diff --git a/patches/@earendil-works__pi-tui@0.74.0.patch b/patches/@earendil-works__pi-tui@0.74.0.patch new file mode 100644 index 000000000..67bbbf860 --- /dev/null +++ b/patches/@earendil-works__pi-tui@0.74.0.patch @@ -0,0 +1,22 @@ +diff --git a/dist/components/editor.js b/dist/components/editor.js +index 50f92259fa88f4efe406a60b7db8e54a9eedace6..a8cd036a627209ea462153535fa5f029de32fd32 100644 +--- a/dist/components/editor.js ++++ b/dist/components/editor.js +@@ -125,6 +125,17 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + // 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. ++ // ++ // Guard: when maxWidth is 1 (terminal extremely narrow) a wide ++ // grapheme cannot be split further; re-wrapping would recurse ++ // forever. Emit the grapheme as its own overflow chunk instead. ++ if (maxWidth <= 1) { ++ chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length }); ++ chunkStart = charIndex + grapheme.length; ++ currentWidth = 0; ++ wrapOppIndex = -1; ++ continue; ++ } + const subChunks = wordWrapLine(grapheme, maxWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a182fe633..1524fbbee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,11 @@ overrides: ssh2@1.17.0>cpu-features: '-' ssh2@1.17.0>nan: '-' +patchedDependencies: + '@earendil-works/pi-tui@0.74.0': + hash: 09ee31c81aedf5144bed54fca31c852f66b28f03e6650e0e3286b9089951cb45 + path: patches/@earendil-works__pi-tui@0.74.0.patch + importers: .: @@ -68,7 +73,7 @@ importers: devDependencies: '@earendil-works/pi-tui': specifier: ^0.74.0 - version: 0.74.0 + version: 0.74.0(patch_hash=09ee31c81aedf5144bed54fca31c852f66b28f03e6650e0e3286b9089951cb45) '@moonshot-ai/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter @@ -6058,7 +6063,7 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-tui@0.74.0': + '@earendil-works/pi-tui@0.74.0(patch_hash=09ee31c81aedf5144bed54fca31c852f66b28f03e6650e0e3286b9089951cb45)': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 610c96158..c2ede4797 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,3 +8,5 @@ packages: overrides: "ssh2@1.17.0>cpu-features": "-" "ssh2@1.17.0>nan": "-" +patchedDependencies: + '@earendil-works/pi-tui@0.74.0': patches/@earendil-works__pi-tui@0.74.0.patch