Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-tui-dynamic-height-jitter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix visible jitter when dynamic-height UI components collapse during rendering.
23 changes: 22 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/todo-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,19 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos {
export class TodoPanelComponent implements Component {
private todos: readonly TodoItem[] = [];
private expanded = false;
private lastRenderedLines = 0;
private placeholderLines = 0;

setTodos(todos: readonly TodoItem[]): void {
const wasEmpty = this.todos.length === 0;
this.todos = todos.map((t) => ({ title: t.title, status: t.status }));
if (!wasEmpty && this.todos.length === 0) {
// Cleared from non-empty: hold a placeholder at the last rendered
// height until the next AI output starts (clearPlaceholder).
this.placeholderLines = this.lastRenderedLines;
} else if (this.todos.length > 0) {
this.placeholderLines = 0;
}
}

getTodos(): readonly TodoItem[] {
Expand All @@ -126,6 +136,11 @@ export class TodoPanelComponent implements Component {
clear(): void {
this.todos = [];
this.expanded = false;
this.placeholderLines = 0;
}

clearPlaceholder(): void {
this.placeholderLines = 0;
}

isEmpty(): boolean {
Expand All @@ -148,7 +163,12 @@ export class TodoPanelComponent implements Component {
invalidate(): void {}

render(width: number): string[] {
if (this.todos.length === 0) return [];
if (this.todos.length === 0) {
if (this.placeholderLines > 0) {
return Array.from({ length: this.placeholderLines }, () => '');
}
return [];
}
const c = currentTheme.palette;
const lines: string[] = [
chalk.hex(c.border)('─'.repeat(width)),
Expand Down Expand Up @@ -178,6 +198,7 @@ export class TodoPanelComponent implements Component {
}
}

this.lastRenderedLines = lines.length;
return lines.map((line) => truncateToWidth(line, width));
}
}
Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ interface CustomEditorOptions {

export class CustomEditor extends Editor {
public onEscape?: () => void;
private wasShowingAutocomplete = false;
/**
* Fired for every input that is not a lone Escape. Used to disarm a pending
* double-Esc so only two consecutive Escape presses trigger the shortcut.
Expand Down Expand Up @@ -259,6 +260,13 @@ export class CustomEditor extends Editor {

override render(width: number): string[] {
const lines = super.render(width);
const showing = this.isShowingAutocomplete();
if (this.wasShowingAutocomplete && !showing) {
// Autocomplete dropdown just closed: the editor returned fewer lines,
// which would pull the footer up and leave stale rows in scrollback.
this.tui.requestRender(true);
Comment on lines +264 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the forced autocomplete redraw out of render

This call runs from inside CustomEditor.render(), while TUI.doRender() is already rendering the frame. requestRender(true) mutates the TUI diff state synchronously (clearing previousLines and changing previous dimensions), so on the ordinary autocomplete-close path the current render can take the first-render/full-frame path without clearing and then overwrite the scheduled forced render state, leaving the intended cleanup ineffective or duplicating the UI. Trigger the forced redraw from the close/input path after the current render instead of mutating TUI state during render.

Useful? React with 👍 / 👎.

}
this.wasShowingAutocomplete = showing;
if (lines.length < 3) return lines;
const firstContentIdx = 1;
const isBash = this.inputMode === 'bash';
Expand Down
18 changes: 17 additions & 1 deletion apps/kimi-code/src/tui/components/media/diff-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions {
readonly maxLines?: number;
readonly isIncomplete?: boolean;
readonly expandKeyHint?: string;
/** When set, keep the tail of the body (latest changes) instead of the head. */
readonly tail?: boolean;
}

interface Cluster {
Expand Down Expand Up @@ -254,7 +256,8 @@ export function renderDiffLinesClustered(

if (clusters.length === 0) return output;

const cap = maxLines !== undefined && maxLines >= 0 ? maxLines : Number.POSITIVE_INFINITY;
const cap =
maxLines !== undefined && maxLines >= 0 && !opts.tail ? maxLines : Number.POSITIVE_INFINITY;
let body = 0;
let prevEnd = -1;
let truncated = false;
Expand Down Expand Up @@ -305,5 +308,18 @@ export function renderDiffLinesClustered(
}
}

if (opts.tail && maxLines !== undefined && maxLines >= 0 && output.length > maxLines) {
const header = output[0]!;
const bodyLines = output.slice(1);
const keep = Math.max(1, maxLines - 1);
const hidden = bodyLines.length - keep;
const hint = opts.expandKeyHint ?? 'ctrl+o';
return [
header,
s.meta(` … ${String(hidden)} earlier lines hidden (${hint} to expand)`),
...bodyLines.slice(bodyLines.length - keep),
];
}

return output;
}
12 changes: 11 additions & 1 deletion apps/kimi-code/src/tui/components/messages/agent-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ export class AgentGroupComponent extends Container {
});
if (this.shouldShowDetachHint(snapshots)) {
this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0));
} else {
// Keep a placeholder row so the group height does not shrink when the
// last running agent finishes and the hint disappears.
this.bodyContainer.addChild(new Spacer(1));
}

this.lastFlushPhases.clear();
Expand Down Expand Up @@ -200,7 +204,13 @@ export class AgentGroupComponent extends Container {
return;
}
if (snap.phase === 'done' || snap.phase === 'backgrounded') {
// Terminal states omit the second line.
// Keep the second line so the row count stays stable across the
// running -> done transition. latestActivity falls back to the last
// finished sub-tool ("Used {name} ({keyArg})") in terminal states, so
// it shows what the agent did last.
const activity =
snap.latestActivity ?? (snap.phase === 'done' ? 'Completed' : 'Backgrounded');
this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0));
return;
}
// Running or not-yet-started agents show latest activity, with a fallback.
Expand Down
38 changes: 38 additions & 0 deletions apps/kimi-code/src/tui/components/messages/fixed-height-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Component } from '@moonshot-ai/pi-tui';

export interface FixedHeightWindowOptions {
height: number;
lines?: string[];
tail?: boolean; // default true
}

export class FixedHeightWindow implements Component {
private lines: string[];
private readonly height: number;
private readonly tail: boolean;

constructor(opts: FixedHeightWindowOptions) {
this.height = Math.max(0, opts.height);
this.tail = opts.tail ?? true;
this.lines = opts.lines ?? [];
}

setLines(lines: string[]): void {
this.lines = lines;
}

invalidate(): void {}

render(_width: number): string[] {
if (this.height === 0) return [];
const src = this.lines;
let shown: string[];
if (src.length > this.height) {
shown = this.tail ? src.slice(src.length - this.height) : src.slice(0, this.height);
} else {
shown = [...src];
}
while (shown.length < this.height) shown.push('');
return shown;
}
}
53 changes: 38 additions & 15 deletions apps/kimi-code/src/tui/components/messages/shell-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { currentTheme } from '#/tui/theme';

import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';

import { FixedHeightWindow } from './fixed-height-window';

const RUNNING_TAIL_LINES = 5;
const TIMER_INTERVAL_MS = 1000;
// Cap the live running buffer so a command that spews output for minutes can't
Expand Down Expand Up @@ -105,26 +107,47 @@ export class ShellRunComponent extends Container {
if (this.backgrounded) {
return ` ${currentTheme.fg('textDim', 'Moved to background.')}`;
}
const elapsed = Math.floor((Date.now() - this.startedAt) / 1000);
const dim = (s: string): string => currentTheme.fg('textDim', s);

if (!this.running) {
return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError)
.split('\n')
// Finished: show the tail of the final output in the same fixed
// window as the running view so the card height does not change.
const allLines = formatBashOutputForDisplay(
this.finalStdout,
this.finalStderr,
this.finalIsError,
).split('\n');
const window = new FixedHeightWindow({
height: RUNNING_TAIL_LINES,
tail: true,
lines: allLines,
Comment on lines +121 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep finished shell output accessible

When a ! shell command finishes with more than five lines, this fixed window keeps only the tail. The status line says ctrl+o to expand, but ShellRunComponent does not implement setExpanded, and KimiTUI.toggleToolOutputExpansion() only updates transcript children that pass isExpandable, so the earlier output can no longer be viewed in the TUI; before this change the final view rendered the full formatBashOutputForDisplay output. Please either preserve the full final output or add real expansion support for this component.

Useful? React with 👍 / 👎.

});
const body = window
.render(80)
.map((line) => ` ${line}`)
.join('\n');
const hidden = Math.max(0, allLines.length - RUNNING_TAIL_LINES);
const status = ` ${dim(
`completed${hidden > 0 ? ` · +${String(hidden)} lines` : ''} · ctrl+o to expand`,
)}`;
return `${body}\n${status}\n `;
}
const elapsed = Math.floor((Date.now() - this.startedAt) / 1000);
const dim = (s: string): string => currentTheme.fg('textDim', s);

// Running: dim tail of the combined output + timing + hint.
const trimmed = sanitizeShellOutput(this.combined).trimEnd();
let body: string;
let extra = 0;
if (trimmed.length === 0) {
body = ` ${dim('Running…')}`;
} else {
const lines = trimmed.split('\n');
const tail = lines.slice(-RUNNING_TAIL_LINES);
extra = Math.max(0, lines.length - RUNNING_TAIL_LINES);
body = tail.map((line) => ` ${dim(line)}`).join('\n');
}
const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`;
const allLines = trimmed.length === 0 ? ['Running…'] : trimmed.split('\n');
const window = new FixedHeightWindow({
height: RUNNING_TAIL_LINES,
tail: true,
lines: allLines,
});
const body = window
.render(80)
.map((line) => ` ${dim(line)}`)
.join('\n');
const extra = Math.max(0, allLines.length - RUNNING_TAIL_LINES);
const timing = ` ${dim(`${extra > 0 ? `+${String(extra)} lines ` : ''}(${String(elapsed)}s)`)}`;
const hint = ` ${dim('(ctrl+b to run in background)')}`;
return `${body}\n${timing}\n${hint}`;
} catch {
Expand Down
38 changes: 23 additions & 15 deletions apps/kimi-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,35 @@ export class ThinkingComponent implements Component {
spinner + currentTheme.fg('textDim', 'thinking...'),
...visibleLines.map((line) => MESSAGE_INDENT + line),
];
} else {
} else if (this.expanded) {
const lines: string[] = [''];
for (let i = 0; i < contentLines.length; i++) {
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
lines.push(p + contentLines[i]);
}

if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
rendered = lines;
} else {
// Leading blank + first PREVIEW_LINES content lines + hint line.
const truncated = lines.slice(0, 1 + THINKING_PREVIEW_LINES);
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
const hintWidth = Math.max(0, width - indentWidth);
truncated.push(
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
);
rendered = truncated;
rendered = lines;
} else if (contentLines.length > THINKING_PREVIEW_LINES) {
// Finalized, collapsed, long content: 2 content rows + hint (4 rows
// total), matching the live mode's collapsed height.
const first = contentLines[0] ?? '';
const second = contentLines[1] ?? '';
const header =
(this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT) + first;
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);
const tailLine =
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…'));
rendered = ['', header, MESSAGE_INDENT + second, tailLine];
} else {
// Finalized, collapsed, short content: natural height, no blank padding.
const lines: string[] = [''];
for (let i = 0; i < contentLines.length; i++) {
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
lines.push(p + contentLines[i]);
}
rendered = lines;
}

if (isRenderCacheEnabled()) {
Expand Down
Loading
Loading