mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: prevent tui width overflows (#783)
* fix: prevent tui width overflows * fix: address tui review feedback
This commit is contained in:
parent
e10b25f9be
commit
e2a407ce31
37 changed files with 546 additions and 113 deletions
5
.changeset/soft-wave-carry.md
Normal file
5
.changeset/soft-wave-carry.md
Normal 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.
|
||||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, '…')];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ') {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue