Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .agents/skills/write-tui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.<token>)` 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`.
5 changes: 5 additions & 0 deletions .changeset/fix-tui-narrow-width-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix TUI crashes when the terminal is resized to a very narrow width.
10 changes: 10 additions & 0 deletions apps/kimi-code/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/components/messages/cron-message.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -64,7 +64,7 @@ export class CronMessageComponent implements Component {
lines.push(`${continuationIndent}${line}`);
}

return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

Expand Down
5 changes: 3 additions & 2 deletions apps/kimi-code/src/tui/components/messages/goal-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -103,7 +104,7 @@ export class GoalCompletionMessageComponent implements Component {
}
}

return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

Expand Down
48 changes: 27 additions & 21 deletions apps/kimi-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
32 changes: 32 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -280,3 +281,34 @@ 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));
}
}
});

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('你好世界');
});
});
43 changes: 43 additions & 0 deletions apps/kimi-code/test/tui/components/messages/cron-message.test.ts
Original file line number Diff line number Diff line change
@@ -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<CronTranscriptData> = {}): 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);
}
}
});
});
11 changes: 11 additions & 0 deletions apps/kimi-code/test/tui/components/messages/goal-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
});
10 changes: 10 additions & 0 deletions apps/kimi-code/test/tui/components/messages/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
});
22 changes: 22 additions & 0 deletions patches/@earendil-works__pi-tui@0.74.0.patch
Original file line number Diff line number Diff line change
@@ -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];
9 changes: 7 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading