fix: prevent tui width overflows (#783)

* fix: prevent tui width overflows

* fix: address tui review feedback
This commit is contained in:
liruifengv 2026-06-15 21:11:01 +08:00 committed by GitHub
parent e10b25f9be
commit e2a407ce31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 546 additions and 113 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width.

View file

@ -30,8 +30,9 @@ export class DeviceCodeBoxComponent implements Component {
render(width: number): string[] {
const { title, url, code, hint } = this.params;
const border = (s: string): string => currentTheme.fg('primary', s);
const safeWidth = Math.max(28, width);
const innerWidth = Math.max(10, safeWidth - 4);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
const titleLine = truncateToWidth(currentTheme.boldFg('textStrong', title), innerWidth, '…');
@ -52,6 +53,10 @@ export class DeviceCodeBoxComponent implements Component {
contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…'));
}
if (safeWidth < 4) {
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
}
const lines: string[] = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
@ -69,6 +74,6 @@ export class DeviceCodeBoxComponent implements Component {
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -21,8 +21,25 @@ export class WelcomeComponent implements Component {
invalidate(): void {}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s);
const innerWidth = Math.max(10, width - 4);
const isLoggedOut = !this.state.model;
const activeModel = this.state.availableModels[this.state.model];
if (safeWidth < 24) {
const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!');
const prompt = isLoggedOut
? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.')
: chalk.hex(currentTheme.palette.textDim)('Send /help for help information.');
const model = isLoggedOut
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
return ['', title, prompt, `Model: ${model}`].map((line) =>
truncateToWidth(line, safeWidth, '…'),
);
}
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
// Logo + side-by-side text.
@ -36,7 +53,6 @@ export class WelcomeComponent implements Component {
textWidth,
'…',
);
const isLoggedOut = !this.state.model;
const dim = chalk.hex(currentTheme.palette.textDim);
const labelStyle = chalk.bold.hex(currentTheme.palette.textDim);
const rightRow1 = truncateToWidth(
@ -53,7 +69,6 @@ export class WelcomeComponent implements Component {
renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1);
}
const activeModel = this.state.availableModels[this.state.model];
const modelValue = isLoggedOut
? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider')
: (activeModel?.displayName ?? activeModel?.model ?? this.state.model);
@ -73,8 +88,8 @@ export class WelcomeComponent implements Component {
const lines: string[] = [
'',
primary('╭' + '─'.repeat(width - 2) + '╮'),
primary('│') + ' '.repeat(width - 2) + primary('│'),
primary('╭' + '─'.repeat(safeWidth - 2) + '╮'),
primary('│') + ' '.repeat(safeWidth - 2) + primary('│'),
];
for (const content of contentLines) {
@ -84,10 +99,10 @@ export class WelcomeComponent implements Component {
lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│'));
}
lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'));
lines.push(primary('╰' + '─'.repeat(width - 2) + '╯'));
lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│'));
lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -89,8 +89,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
override render(width: number): string[] {
this.input.focused = this.focused && !this.done;
const safeWidth = Math.max(28, width);
const innerWidth = Math.max(10, safeWidth - 4);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
const border = (s: string): string => currentTheme.fg('primary', s);
@ -116,6 +117,10 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
footerLine,
];
if (safeWidth < 4) {
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
}
const lines: string[] = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
@ -132,7 +137,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
private submit(value: string): void {

View file

@ -53,7 +53,7 @@ function maskInputLine(raw: string): string {
// Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.)
// while masking every other visible character.
const parts = content.split(/(\x1B(?:\[[0-9;]*m|_pi:c\x07))/);
const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/);
const maskedContent = parts
.map((part, index) => {
if (index % 2 === 1) return part; // ANSI sequence
@ -137,8 +137,9 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
this.urlInput.focused = dialogActive && this.activeField === 'url';
this.tokenInput.focused = dialogActive && this.activeField === 'token';
const safeWidth = Math.max(36, width);
const innerWidth = Math.max(10, safeWidth - 4);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
const border = (s: string): string => currentTheme.fg('primary', s);
@ -189,6 +190,10 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
footerLine,
];
if (safeWidth < 4) {
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
}
const lines: string[] = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
@ -205,7 +210,7 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
private toggleField(): void {

View file

@ -67,8 +67,9 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
override render(width: number): string[] {
this.input.focused = this.focused && !this.done;
const safeWidth = Math.max(28, width);
const innerWidth = Math.max(10, safeWidth - 4);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
const border = (s: string): string => currentTheme.fg('primary', s);
@ -84,6 +85,10 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine];
if (safeWidth < 4) {
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))];
}
const lines: string[] = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
@ -100,7 +105,7 @@ export class FeedbackInputDialogComponent extends Container implements Focusable
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
private submit(value: string): void {

View file

@ -241,8 +241,9 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
override render(width: number): string[] {
this.input.focused = this.focused && !this.done;
const safeWidth = Math.max(28, width);
const innerWidth = Math.max(10, safeWidth - 4);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const innerWidth = Math.max(1, safeWidth - 4);
const pad = ' ';
const border = (s: string): string => currentTheme.fg('primary', s);
const title = truncateToWidth(
@ -265,6 +266,10 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
ELLIPSIS,
);
const contentLines = [title, '', subtitle, '', ...inputLines, '', footer];
if (safeWidth < 4) {
return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS))];
}
const lines = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
@ -280,7 +285,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS));
}
private submit(value: string): void {

View file

@ -22,22 +22,25 @@ const MAX_IMAGE_WIDTH = 40;
export class ImageThumbnail extends Container {
private readonly attachment: ImageAttachment;
private lastRenderWidth = 80;
private lastBuiltWidth: number | undefined;
private lastBuiltInline: boolean | undefined;
constructor(attachment: ImageAttachment) {
super();
this.attachment = attachment;
this.buildChildren();
this.buildChildren(this.lastRenderWidth);
}
private buildChildren(): void {
private buildChildren(width: number): void {
this.clear();
const caps = getCapabilities();
const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2';
if (!supportsInline) {
// Non-graphic terminal — show the placeholder text in accent colour so
// it's clearly an attachment reference but doesn't shout.
this.addChild(new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0));
this.lastBuiltWidth = width;
this.lastBuiltInline = false;
return;
}
@ -51,16 +54,36 @@ export class ImageThumbnail extends Container {
theme,
{
maxHeightCells: MAX_IMAGE_ROWS,
maxWidthCells: MAX_IMAGE_WIDTH,
maxWidthCells: Math.max(1, Math.min(MAX_IMAGE_WIDTH, width - 2)),
filename: this.attachment.placeholder,
},
{ widthPx: this.attachment.width, heightPx: this.attachment.height },
);
this.addChild(image);
this.lastBuiltWidth = width;
this.lastBuiltInline = true;
}
override render(width: number): string[] {
const safeWidth = Math.max(0, width);
this.lastRenderWidth = safeWidth;
if (safeWidth < MAX_IMAGE_WIDTH + 2) {
return new Text(currentTheme.fg('accent', this.attachment.placeholder), 0, 0).render(
safeWidth,
);
}
const caps = getCapabilities();
const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2';
if (this.lastBuiltWidth !== safeWidth || this.lastBuiltInline !== supportsInline) {
this.buildChildren(safeWidth);
}
return super.render(safeWidth);
}
override invalidate(): void {
this.buildChildren();
this.buildChildren(this.lastRenderWidth);
super.invalidate();
}
}

View file

@ -5,8 +5,7 @@
* to align after the bullet.
*/
import type { Component } from '@earendil-works/pi-tui';
import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui';
import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { STATUS_BULLET } from '#/tui/constant/symbols';
@ -52,8 +51,11 @@ export class AssistantMessageComponent implements Component {
render(width: number): string[] {
if (this.lastText.trim().length === 0) return [];
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT;
const contentWidth = Math.max(1, width - visibleWidth(prefix));
const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix));
const contentLines = this.contentContainer.render(contentWidth);
const lines: string[] = [''];
@ -62,6 +64,6 @@ export class AssistantMessageComponent implements Component {
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
lines.push(p + contentLines[i]);
}
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -1,5 +1,4 @@
import type { Component } from '@earendil-works/pi-tui';
import { Text } from '@earendil-works/pi-tui';
import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols';
@ -13,6 +12,9 @@ export class BackgroundAgentStatusComponent implements Component {
invalidate(): void {}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const tone: keyof ColorPalette =
this.data.phase === 'started'
? 'primary'
@ -29,11 +31,11 @@ export class BackgroundAgentStatusComponent implements Component {
: '');
const textComponent = new Text(text, 0, 0);
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length);
const contentLines = textComponent.render(contentWidth);
return [
'',
...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line),
];
].map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -32,27 +32,36 @@ export class CronMessageComponent implements Component {
}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const missed = this.data.missedCount !== undefined;
const titleToken: keyof ColorPalette = this.data.stale === true || missed ? 'warning' : 'accent';
const bullet = currentTheme.boldFg(titleToken, STATUS_BULLET);
const bulletWidth = visibleWidth(bullet);
const contentWidth = Math.max(1, width - bulletWidth);
const contentWidth = Math.max(1, safeWidth - bulletWidth);
const continuationIndent = ' '.repeat(bulletWidth);
const lines: string[] = [];
for (const line of this.spacer.render(width)) {
for (const line of this.spacer.render(safeWidth)) {
lines.push(line);
}
const title = currentTheme.boldFg(titleToken, this.title);
lines.push(`${bullet}${title}`);
const titleLines = new Text(currentTheme.boldFg(titleToken, this.title), 0, 0).render(contentWidth);
for (let i = 0; i < titleLines.length; i += 1) {
lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`);
}
if (this.detail !== undefined) {
lines.push(`${' '.repeat(bulletWidth)}${currentTheme.fg('textDim', this.detail)}`);
const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth);
for (const line of detailLines) {
lines.push(`${continuationIndent}${line}`);
}
}
const promptLines = this.promptText.render(contentWidth);
for (const line of promptLines) {
lines.push(`${' '.repeat(bulletWidth)}${line}`);
lines.push(`${continuationIndent}${line}`);
}
return lines;

View file

@ -13,8 +13,13 @@
* Stop after 20 turns (7/20) (or a dim "no stop condition" note)
*/
import type { Component } from '@earendil-works/pi-tui';
import { Text, visibleWidth } from '@earendil-works/pi-tui';
import {
Text,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
} from '@earendil-works/pi-tui';
import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk';
import { MESSAGE_INDENT } from '#/tui/constant/rendering';
@ -30,10 +35,18 @@ const MAX_OBJECTIVE_LINES = 6;
const MAX_CRITERION_LINES = 3;
const LABEL_WIDTH = 11;
function renderLifecycleLine(label: string): string[] {
function renderLifecycleLine(label: string, width: number): string[] {
if (width <= 0) return [''];
const marker = currentTheme.boldFg('primary', STATUS_BULLET);
const text = currentTheme.boldFg('primary', label);
return ['', marker + text];
const text = new Text(currentTheme.boldFg('primary', label), 0, 0);
const contentWidth = Math.max(1, width - visibleWidth(STATUS_BULLET));
return [
'',
...text
.render(contentWidth)
.map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line.trimEnd()),
];
}
/**
@ -44,17 +57,18 @@ function renderLifecycleLine(label: string): string[] {
export class GoalSetMessageComponent implements Component {
invalidate(): void {}
render(_width: number): string[] {
return renderLifecycleLine('Goal set');
render(width: number): string[] {
return renderLifecycleLine('Goal set', width);
}
}
export class UpcomingGoalAddedMessageComponent implements Component {
invalidate(): void {}
render(_width: number): string[] {
render(width: number): string[] {
return renderLifecycleLine(
'Upcoming goal added. It will start after the current goal is complete.',
width,
);
}
}
@ -99,8 +113,9 @@ export class GoalStatusMessageComponent implements Component {
invalidate(): void {}
render(width: number): string[] {
const panelContentWidth = Math.max(1, width - 6);
const panel = new UsagePanelComponent(
() => buildGoalReportLines(this.goal),
() => buildGoalReportLines(this.goal, panelContentWidth),
'primary',
goalPanelTitle(this.goal),
);
@ -113,7 +128,7 @@ export function goalPanelTitle(goal: GoalSnapshot): string {
return ` Goal · ${goal.status} `;
}
export function buildGoalReportLines(goal: GoalSnapshot): string[] {
export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRAP_WIDTH): string[] {
const statusColor = statusToken(goal.status);
const bar = (s: string) => currentTheme.fg(statusColor, s);
const value = (s: string) => currentTheme.fg('text', s);
@ -127,12 +142,14 @@ export function buildGoalReportLines(goal: GoalSnapshot): string[] {
(goal.status === 'paused' && reason !== undefined) || goal.status === 'blocked' || isComplete;
const lines: string[] = [];
// Condition as a blockquote left-trail.
for (const line of wrap(goal.objective, WRAP_WIDTH, MAX_OBJECTIVE_LINES)) {
// Condition as a blockquote left-trail. Reserve the visible "▌ " prefix before
// wrapping so the panel doesn't clip rows that exactly fit the panel interior.
const blockquoteWrapWidth = Math.max(1, wrapWidth - visibleWidth('▌ '));
for (const line of wrap(goal.objective, blockquoteWrapWidth, MAX_OBJECTIVE_LINES)) {
lines.push(`${bar('▌')} ${value(line)}`);
}
if (goal.completionCriterion !== undefined) {
for (const line of wrap(`${goal.completionCriterion}`, WRAP_WIDTH, MAX_CRITERION_LINES)) {
for (const line of wrap(`${goal.completionCriterion}`, blockquoteWrapWidth, MAX_CRITERION_LINES)) {
lines.push(`${bar('▌')} ${muted(line)}`);
}
}
@ -194,22 +211,12 @@ function statusToken(status: GoalStatus): ColorToken {
/** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */
function wrap(text: string, width: number, maxLines: number): string[] {
const words = text.replaceAll(/\s+/g, ' ').trim().split(' ');
const lines: string[] = [];
let current = '';
for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (candidate.length > width && current.length > 0) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current.length > 0) lines.push(current);
const safeWidth = Math.max(1, width);
const lines = wrapTextWithAnsi(text.replaceAll(/\s+/g, ' ').trim(), safeWidth);
if (lines.length === 0) return [''];
if (lines.length <= maxLines) return lines;
const clipped = lines.slice(0, maxLines);
clipped[maxLines - 1] = `${clipped[maxLines - 1]!.slice(0, Math.max(0, width - 1))}`;
const lastLine = clipped[maxLines - 1] ?? '';
clipped[maxLines - 1] = truncateToWidth(`${lastLine}`, safeWidth, '…');
return clipped;
}

View file

@ -7,8 +7,7 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Component, MarkdownTheme } from '@earendil-works/pi-tui';
import { Markdown, visibleWidth } from '@earendil-works/pi-tui';
import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
@ -53,6 +52,12 @@ export class PlanBoxComponent implements Component {
}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
if (safeWidth < LEFT_MARGIN + 4) {
return this.markdown.render(Math.max(1, safeWidth)).map((line) => truncateToWidth(line, safeWidth, '…'));
}
if (this.cachedLines !== undefined && this.cachedWidth === width) {
return this.cachedLines;
}
@ -62,7 +67,7 @@ export class PlanBoxComponent implements Component {
// " └──...──┘"
// width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4
// content width = horzLen - 2 * SIDE_PADDING = width - 6
const horzLen = Math.max(2, width - LEFT_MARGIN - 2);
const horzLen = Math.max(2, safeWidth - LEFT_MARGIN - 2);
const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING);
const paint = (s: string): string => chalk.hex(this.borderHex)(s);
@ -83,17 +88,22 @@ export class PlanBoxComponent implements Component {
}
lines.push(bottom);
const fitted = lines.map((line) => truncateToWidth(line, safeWidth, '…'));
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
this.cachedLines = fitted;
return fitted;
}
private buildTitle(horzLen: number): string {
const fallback = ' plan ';
const statusSuffix = this.buildStatusSuffix();
const fallbackWithStatus = ` plan${statusSuffix} `;
const budget = horzLen - 1;
const fallbackTitle = visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback;
const budget = Math.max(0, horzLen - 1);
const fallbackTitle = truncateToWidth(
visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback,
budget,
'…',
);
const planPath = this.planPath;
if (planPath === undefined || planPath.length === 0) return fallbackTitle;
const basename = path.basename(planPath);

View file

@ -1,4 +1,4 @@
import type { Component } from '@earendil-works/pi-tui';
import { truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
@ -10,11 +10,14 @@ export class SwarmModeMarkerComponent implements Component {
invalidate(): void {}
render(_width: number): string[] {
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const token = this.state === 'inactive' ? 'textDim' : 'success';
const marker = currentTheme.boldFg(token, STATUS_BULLET);
const label = currentTheme.boldFg(token, swarmMarkerLabel(this.state));
return ['', marker + label];
return ['', truncateToWidth(marker + label, safeWidth, '…')];
}
}

View file

@ -5,8 +5,7 @@
* Supports expand/collapse via Ctrl+O (shared with tool output).
*/
import type { Component, TUI } from '@earendil-works/pi-tui';
import { Text } from '@earendil-works/pi-tui';
import { Text, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui';
import {
BRAILLE_SPINNER_FRAMES,
@ -110,8 +109,11 @@ export class ThinkingComponent implements Component {
// 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(
MESSAGE_INDENT + currentTheme.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`),
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
);
return truncated;
}

View file

@ -5,7 +5,7 @@
import { isAbsolute, relative, sep } from 'node:path';
import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui';
import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
import type { Component, TUI } from '@earendil-works/pi-tui';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
@ -472,19 +472,24 @@ class PrefixedWrappedLine implements Component {
invalidate(): void { }
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const prefixWidth = Math.max(
visibleWidth(this.firstPrefix),
visibleWidth(this.continuationPrefix),
);
const contentWidth = Math.max(1, width - prefixWidth);
const contentWidth = Math.max(1, safeWidth - prefixWidth);
const wrapped = new Text(this.text, 0, 0).render(contentWidth);
const lines =
this.tailLines !== undefined && wrapped.length > this.tailLines
? wrapped.slice(wrapped.length - this.tailLines)
: wrapped;
return lines.map((line, index) =>
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
);
return lines
.map((line, index) =>
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
)
.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -1,5 +1,4 @@
import type { Component } from '@earendil-works/pi-tui';
import { Text } from '@earendil-works/pi-tui';
import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
import { currentTheme } from '#/tui/theme';
@ -65,6 +64,12 @@ export class TruncatedOutputComponent implements Component {
this.textComponent.invalidate();
}
private renderHint(width: number, hint: string): string {
const indentWidth = Math.min(this.indent, Math.max(0, width));
const hintWidth = Math.max(0, width - indentWidth);
return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…'));
}
render(width: number): string[] {
const contentLines = this.textComponent.render(width);
@ -76,7 +81,7 @@ export class TruncatedOutputComponent implements Component {
if (this.tail) {
const shown = contentLines.slice(contentLines.length - this.maxLines);
return [
' '.repeat(this.indent) + currentTheme.dim(`... (${String(remaining)} earlier lines)`),
this.renderHint(width, `... (${String(remaining)} earlier lines)`),
...shown,
];
}
@ -85,7 +90,7 @@ export class TruncatedOutputComponent implements Component {
const hint = this.expandHint
? `... (${String(remaining)} more lines, ctrl+o to expand)`
: `... (${String(remaining)} more lines)`;
return [...shown, ' '.repeat(this.indent) + currentTheme.dim(hint)];
return [...shown, this.renderHint(width, hint)];
}
}

View file

@ -19,7 +19,7 @@ import type { ColorToken } from '#/tui/theme';
const LEFT_MARGIN = 2;
const SIDE_PADDING = 1;
const MIN_INTERIOR_WIDTH = 20;
const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING;
type Colorize = (text: string) => string;
@ -219,23 +219,30 @@ export class UsagePanelComponent implements Component {
}
render(width: number): string[] {
const paint = (s: string): string => currentTheme.fg(this.borderToken, s);
const indent = ' '.repeat(LEFT_MARGIN);
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const availableInterior = Math.max(
MIN_INTERIOR_WIDTH,
width - LEFT_MARGIN - 2 - 2 * SIDE_PADDING,
);
const paint = (s: string): string => currentTheme.fg(this.borderToken, s);
const availableInterior = safeWidth - BOX_OVERHEAD;
if (availableInterior < 1) {
return [
truncateToWidth(this.title.trim(), safeWidth, '…'),
...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')),
];
}
const indent = ' '.repeat(LEFT_MARGIN);
const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
const contentWidth = Math.max(
MIN_INTERIOR_WIDTH,
Math.min(availableInterior, longestLine, Math.max(longestLine, this.title.length)),
1,
Math.min(availableInterior, Math.max(longestLine, visibleWidth(this.title))),
);
const horzLen = contentWidth + 2 * SIDE_PADDING;
const title = truncateToWidth(this.title, horzLen, '…');
const trailingDashLen = Math.max(0, horzLen - this.title.length);
const trailingDashLen = Math.max(0, horzLen - visibleWidth(title));
const top =
indent + paint('╭') + paint(this.title) + paint('─'.repeat(trailingDashLen)) + paint('╮');
indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮');
const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯');
const out: string[] = [top];
@ -245,6 +252,6 @@ export class UsagePanelComponent implements Component {
out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│'));
}
out.push(bottom);
return out;
return out.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -2,8 +2,7 @@
* Renders a user message in the transcript.
*/
import type { Component } from '@earendil-works/pi-tui';
import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui';
import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
@ -28,14 +27,17 @@ export class UserMessageComponent implements Component {
}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
const bullet = currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET);
const bulletWidth = visibleWidth(bullet);
const contentWidth = Math.max(1, width - bulletWidth);
const contentWidth = Math.max(1, safeWidth - bulletWidth);
const lines: string[] = [];
// Spacer
for (const line of this.spacerComponent.render(width)) {
for (const line of this.spacerComponent.render(safeWidth)) {
lines.push(line);
}
@ -55,6 +57,6 @@ export class UserMessageComponent implements Component {
}
}
return lines;
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
}
}

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box';
@ -60,4 +61,19 @@ describe('DeviceCodeBoxComponent', () => {
const joined = component.render(80).map(strip).join('\n');
expect(joined).not.toContain('Press Ctrl-C');
});
it('keeps every line within narrow widths', () => {
const component = new DeviceCodeBoxComponent({
title,
url,
code,
hint,
});
for (const width of [39, 20, 10, 4]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -91,4 +92,12 @@ describe('WelcomeComponent', () => {
expect(off).toBe(base);
});
it('keeps every line within the requested width on narrow terminals', () => {
for (const width of [0, 1, 2, 4, 10, 39, 80]) {
for (const line of new WelcomeComponent(appState).render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -0,0 +1,21 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog';
describe('ApiKeyInputDialogComponent', () => {
it('keeps every line within narrow widths', () => {
const dialog = new ApiKeyInputDialogComponent(
'Kimi Code',
['Paste your API key below.', 'It will be stored locally.'],
() => {},
);
dialog.focused = true;
for (const width of [39, 20, 10]) {
for (const line of dialog.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it, vi } from 'vitest';
import {
@ -67,4 +68,14 @@ describe('CustomRegistryImportDialogComponent', () => {
value: { url: 'https://example.com/api.json', apiKey: 'sk-tok' },
});
});
it('keeps every line within narrow widths', () => {
const { dialog } = makeDialog('https://example.com/very/long/registry/path.json');
for (const width of [39, 35, 20, 10]) {
for (const line of dialog.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { beforeAll, describe, expect, it } from 'vitest';
@ -55,6 +56,16 @@ describe('FeedbackInputDialogComponent', () => {
expect(rendered).toContain(`${ansiOpen}`);
});
it('keeps every line within narrow widths', () => {
const { dialog } = makeDialog();
for (const width of [39, 20, 10, 4]) {
for (const line of dialog.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('typing then pressing Enter submits the trimmed value', () => {
const { dialog, collected } = makeDialog();
for (const ch of 'hello world ') {

View file

@ -245,6 +245,19 @@ describe('GoalQueueEditDialogComponent', () => {
}
});
it('keeps the edit dialog within narrow widths', () => {
const dialog = new GoalQueueEditDialogComponent({
goal: goal('g1', 'A very long queued objective for width testing'),
onDone: vi.fn(),
});
for (const width of [24, 20, 10]) {
for (const line of dialog.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('keeps accepting input after save returns control to the mounted dialog', () => {
const onDone = vi.fn();
const dialog = new GoalQueueEditDialogComponent({

View file

@ -0,0 +1,54 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
const getCapabilitiesMock = vi.hoisted(() => vi.fn());
vi.mock('@earendil-works/pi-tui', async () => {
const actual = (await vi.importActual('@earendil-works/pi-tui')) as Record<string, unknown>;
return {
...actual,
getCapabilities: getCapabilitiesMock,
};
});
const image: ImageAttachment = {
id: 1,
kind: 'image',
bytes: new Uint8Array([137, 80, 78, 71]),
mime: 'image/png',
width: 800,
height: 600,
placeholder: '[image #1 (800×600)]',
};
describe('ImageThumbnail', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps rendered output within narrow widths', () => {
getCapabilitiesMock.mockReturnValue({ images: undefined } as never);
const component = new ImageThumbnail(image);
for (const width of [39, 20, 3, 1]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('does not rebuild inline image children on repeated same-width renders', () => {
getCapabilitiesMock.mockReturnValue({ images: 'kitty' } as never);
const bufferFrom = vi.spyOn(Buffer, 'from');
const component = new ImageThumbnail(image);
bufferFrom.mockClear();
component.render(80);
component.render(80);
expect(bufferFrom).not.toHaveBeenCalled();
});
});

View file

@ -27,6 +27,17 @@ describe('AssistantMessageComponent', () => {
expect(visibleWidth(lines[1] ?? '')).toBe(8);
});
it('keeps assistant lines within very narrow widths', () => {
const component = new AssistantMessageComponent();
component.updateContent('abcdef');
for (const width of [1, 2, 4, 10, 39]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('renders unknown markdown fence languages as plain text without stderr noise', () => {
const stderr = captureProcessWrite('stderr');
try {

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status';
@ -43,4 +44,18 @@ describe('BackgroundAgentStatusComponent', () => {
'✗ explore agent failed in background (Explore project structure · boom)',
);
});
it('keeps status lines within very narrow widths', () => {
const component = new BackgroundAgentStatusComponent({
phase: 'started',
headline: 'explore agent started in background',
detail: 'Explore project structure',
});
for (const width of [1, 2, 4, 10, 39]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,6 +1,7 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers';
import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers';
import type { GoalChange } from '@moonshot-ai/kimi-code-sdk';
@ -107,3 +108,15 @@ describe('GoalMarkerComponent', () => {
expect(strip(marker.render(80))).not.toContain('(ctrl+o)');
});
});
describe('SwarmModeMarkerComponent', () => {
it('keeps marker lines within very narrow widths', () => {
const marker = new SwarmModeMarkerComponent('active');
for (const width of [1, 2, 10, 39]) {
for (const line of marker.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { visibleWidth } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
buildGoalReportLines,
@ -115,6 +116,14 @@ describe('GoalSetMessageComponent', () => {
chalk.hex(darkColors.primary).bold('Goal set'),
);
});
it('keeps the lifecycle line within narrow widths', () => {
for (const width of [39, 20, 10, 4]) {
for (const line of new GoalSetMessageComponent().render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});
describe('UpcomingGoalAddedMessageComponent', () => {
@ -131,6 +140,14 @@ describe('UpcomingGoalAddedMessageComponent', () => {
),
);
});
it('wraps the upcoming-goal confirmation within narrow widths', () => {
for (const width of [39, 20, 10, 4]) {
for (const line of new UpcomingGoalAddedMessageComponent().render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});
describe('GoalStatusMessageComponent', () => {
@ -140,6 +157,24 @@ describe('GoalStatusMessageComponent', () => {
expect(rendered[0]).toBe('');
expect(strip([rendered[1]!])).toContain('╭ Goal · active ');
});
it('wraps objective blockquotes without clipping them at 80 columns', () => {
const rendered = new GoalStatusMessageComponent(
goal({ objective: 'word '.repeat(30).trim() }),
).render(80);
expect(strip(rendered)).not.toContain('...');
});
it('keeps the status box within narrow widths', () => {
const rendered = new GoalStatusMessageComponent(goal({ objective: '管理飞书日历的技能描述 '.repeat(4).trim() }));
for (const width of [39, 24, 20, 10]) {
for (const line of rendered.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});
describe('GoalCompletionMessageComponent', () => {

View file

@ -1,5 +1,7 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { CronMessageComponent } from '#/tui/components/messages/cron-message';
import { NoticeMessageComponent } from '#/tui/components/messages/status-message';
function strip(text: string): string {
@ -19,3 +21,21 @@ describe('NoticeComponent', () => {
expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md');
});
});
describe('CronMessageComponent', () => {
it('keeps title, detail, and prompt within narrow widths', () => {
const component = new CronMessageComponent('Please investigate the reminder payload and report back.', {
cron: '*/15 * * * *',
jobId: 'job-with-a-very-long-identifier-for-width-testing',
recurring: true,
missedCount: 3,
stale: true,
});
for (const width of [39, 20, 10, 4]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,4 +1,4 @@
import type { TUI } from '@earendil-works/pi-tui';
import { visibleWidth, type TUI } from '@earendil-works/pi-tui';
import { describe, expect, it, vi } from 'vitest';
import { ThinkingComponent } from '#/tui/components/messages/thinking';
@ -80,4 +80,13 @@ describe('ThinkingComponent', () => {
expect(collapsed).not.toContain('line7');
expect(collapsed).toContain('ctrl+o to expand');
});
it('keeps the finalized truncation footer within the requested render width', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.finalize();
for (const line of component.render(37)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
}
});
});

View file

@ -1,4 +1,4 @@
import type { TUI } from '@earendil-works/pi-tui';
import { visibleWidth, type TUI } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -49,6 +49,27 @@ describe('ToolCallComponent', () => {
expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`);
});
it('keeps collapsed tool-call lines within very narrow widths', () => {
const component = new ToolCallComponent(
{
id: 'call_narrow_read',
name: 'Read',
args: { path: 'very/long/path/to/foo.ts' },
},
{
tool_call_id: 'call_narrow_read',
output: 'content',
is_error: false,
},
);
for (const width of [1, 2, 4, 10, 39]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('keeps collapsed tool results short and expands on demand', () => {
const component = new ToolCallComponent(
{

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated';
@ -59,4 +60,18 @@ describe('TruncatedOutputComponent', () => {
expect(out).toContain('d');
expect(out).not.toContain('more lines, ctrl+o');
});
it('keeps the truncation footer within the requested render width', () => {
const output = Array.from({ length: 20 }, (_, i) => `line ${String(i)}`).join('\n');
const component = new TruncatedOutputComponent(output, {
expanded: false,
isError: false,
maxLines: 3,
indent: 2,
});
for (const line of component.render(37)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
}
});
});

View file

@ -68,6 +68,16 @@ describe('UsagePanelComponent', () => {
}
});
it('keeps the bordered panel within narrow terminal widths', () => {
const component = new UsagePanelComponent(() => ['Session usage', ' kimi input 2.0k'], 'primary');
for (const width of [39, 24, 20, 10, 4, 1]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('rebuilds its body from the active palette on invalidate', () => {
// Emit the resolved palette value as visible text so the assertion holds
// regardless of chalk's colour level in the test environment.

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { UserMessageComponent } from '#/tui/components/messages/user-message';
@ -20,4 +21,14 @@ describe('UserMessageComponent', () => {
expect(out).not.toContain('\u001B_G');
expect(out).not.toContain('\u001B]1337;File=');
});
it('keeps user lines within very narrow widths', () => {
const component = new UserMessageComponent('please inspect the attached output', []);
for (const width of [1, 2, 4, 10, 39]) {
for (const line of component.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
});

View file

@ -1,3 +1,4 @@
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';
import { PlanBoxComponent } from '#/tui/components/messages/plan-box';
@ -89,6 +90,21 @@ describe('PlanBoxComponent', () => {
expect(top).not.toContain('plan:');
});
it('keeps every line within narrow widths', () => {
const box = new PlanBoxComponent(
'# Hello\n\n' + 'step with a fairly long description '.repeat(4),
theme,
darkColors.success,
'/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md',
);
for (const width of [39, 14, 10, 8, 4, 1]) {
for (const line of box.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
});
it('renders all plan lines without a truncation footer', () => {
const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n');
const box = new PlanBoxComponent(plan, theme, darkColors.success);