perf(tui): keep long conversations responsive (#1119)

* perf(tui): cache rendered message lines across frames

Cache render(width) output in the transcript container and message components, returning cached lines when content, theme, and width are unchanged. Removes the per-frame full-transcript re-render that caused the TUI to lag as history grew.

* perf(tui): bound transcript with sliding window and step merging

Keep the TUI responsive as conversations grow by bounding the live
transcript:

- Sliding window: keep only the most recent 50 turns in the component
  tree; older turns are destroyed (entry + component).
- Step merging: within each turn, keep only the most recent 30
  thinking / tool steps rendered; older ones collapse into a summary.
- Expand (Ctrl+O) only reaches the most recent 3 turns.

All thresholds are overridable via KIMI_CODE_TUI_* env vars; 0
disables the corresponding feature.

* chore: add changeset for tui transcript window

* chore(tui): remove KIMI_TUI_PERF render timing log
This commit is contained in:
liruifengv 2026-06-26 18:32:32 +08:00 committed by GitHub
parent f1c8175f9c
commit b0b2aee8c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 862 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep the terminal responsive in long conversations by caching rendered message lines.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn.

View file

@ -10,8 +10,20 @@
*/
import { Container } from '@earendil-works/pi-tui';
import type { Component } from '@earendil-works/pi-tui';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
interface TranscriptRenderCache {
width: number;
childRefs: Component[];
childRenderRefs: string[][];
prefixed: string[][];
out: string[];
}
export class GutterContainer extends Container {
private renderCache: TranscriptRenderCache | undefined;
constructor(
private readonly leftPad: number,
private readonly rightPad: number,
@ -19,15 +31,56 @@ export class GutterContainer extends Container {
super();
}
override invalidate(): void {
this.renderCache = undefined;
super.invalidate();
}
override render(width: number): string[] {
const inner = Math.max(1, width - this.leftPad - this.rightPad);
const lead = ' '.repeat(this.leftPad);
const out: string[] = [];
const cache = this.renderCache;
const cacheValid =
isRenderCacheEnabled() &&
cache !== undefined &&
cache.width === width &&
cache.childRefs.length === this.children.length;
const childRefs: Component[] = [];
const childRenderRefs: string[][] = [];
const prefixed: string[][] = [];
let allReused = cacheValid;
let i = 0;
for (const child of this.children) {
for (const line of child.render(inner)) {
out.push(lead + line);
const lines = child.render(inner);
childRefs.push(child);
childRenderRefs.push(lines);
const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines;
if (reused) {
prefixed.push(cache.prefixed[i]!);
} else {
allReused = false;
prefixed.push(lines.map((line) => lead + line));
}
i++;
}
let out: string[];
if (allReused) {
out = cache!.out;
} else {
out = [];
for (const lines of prefixed) {
for (const line of lines) out.push(line);
}
}
if (isRenderCacheEnabled()) {
this.renderCache = { width, childRefs, childRenderRefs, prefixed, out };
}
return out;
}
}

View file

@ -11,6 +11,7 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
type AssistantMarkdownOptions = {
transient?: boolean;
@ -24,13 +25,21 @@ export class AssistantMessageComponent implements Component {
private lastTransient = false;
private showBullet: boolean;
private renderCache: { width: number; lines: string[] } | undefined;
constructor(showBullet: boolean = true) {
this.showBullet = showBullet;
this.contentContainer = new Container();
}
private markRenderDirty(): void {
this.renderCache = undefined;
}
setShowBullet(show: boolean): void {
if (this.showBullet === show) return;
this.showBullet = show;
this.markRenderDirty();
}
updateContent(text: string, opts?: AssistantMarkdownOptions): void {
@ -41,6 +50,7 @@ export class AssistantMessageComponent implements Component {
this.lastText = displayText;
this.lastTransient = transient;
this.markRenderDirty();
if (displayText.length === 0) {
this.contentContainer.clear();
@ -64,6 +74,7 @@ export class AssistantMessageComponent implements Component {
// Markdown caches ANSI colour codes keyed on (text, width). When the
// theme changes the cached strings contain stale colours, so we rebuild
// the Markdown child with the new theme while preserving transient mode.
this.markRenderDirty();
this.contentContainer.clear();
this.markdown = undefined;
@ -85,6 +96,14 @@ export class AssistantMessageComponent implements Component {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
if (
isRenderCacheEnabled() &&
this.renderCache !== undefined &&
this.renderCache.width === safeWidth
) {
return this.renderCache.lines;
}
const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT;
const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix));
const contentLines = this.contentContainer.render(contentWidth);
@ -95,6 +114,10 @@ export class AssistantMessageComponent implements Component {
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
lines.push(p + contentLines[i]);
}
return lines.map((line) => truncateToWidth(line, safeWidth, '…'));
const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…'));
if (isRenderCacheEnabled()) {
this.renderCache = { width: safeWidth, lines: rendered };
}
return rendered;
}
}

View file

@ -0,0 +1,32 @@
import type { Component } from '@earendil-works/pi-tui';
import { currentTheme } from '#/tui/theme';
/**
* A collapsed summary of older steps within a turn. Accumulates counts of
* merged steps (thinking blocks and tool calls) and renders them as a single
* muted line, e.g. `… thinking 5 times, call 50 tools`.
*/
export class StepSummaryComponent implements Component {
private thinking = 0;
private tool = 0;
get isEmpty(): boolean {
return this.thinking === 0 && this.tool === 0;
}
addCounts(thinking: number, tool: number): void {
this.thinking += thinking;
this.tool += tool;
}
invalidate(): void {}
render(_width: number): string[] {
const parts: string[] = [];
if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`);
if (this.tool > 0) parts.push(`call ${this.tool} tools`);
if (parts.length === 0) return [];
return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)];
}
}

View file

@ -15,6 +15,7 @@ import {
} from '#/tui/constant/rendering';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
export type ThinkingRenderMode = 'live' | 'finalized';
@ -32,6 +33,8 @@ export class ThinkingComponent implements Component {
// once the transcript accumulates many finalized thinking blocks.
private readonly textComponent: Text;
private renderCache: { width: number; lines: string[] } | undefined;
constructor(
text: string,
showMarker: boolean = true,
@ -48,13 +51,19 @@ export class ThinkingComponent implements Component {
}
}
private markRenderDirty(): void {
this.renderCache = undefined;
}
invalidate(): void {
this.markRenderDirty();
this.textComponent.setText(this.styled(this.text));
}
setText(text: string): void {
if (this.text === text) return;
this.text = text;
this.markRenderDirty();
this.textComponent.setText(this.styled(text));
}
@ -64,6 +73,7 @@ export class ThinkingComponent implements Component {
finalize(): void {
this.mode = 'finalized';
this.markRenderDirty();
this.stopSpinner();
}
@ -74,12 +84,22 @@ export class ThinkingComponent implements Component {
setExpanded(expanded: boolean): void {
if (this.expanded === expanded) return;
this.expanded = expanded;
this.markRenderDirty();
}
render(width: number): string[] {
if (
isRenderCacheEnabled() &&
this.renderCache !== undefined &&
this.renderCache.width === width
) {
return this.renderCache.lines;
}
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : [''];
let rendered: string[];
if (this.mode === 'live') {
const visibleLines =
contentLines.length > THINKING_PREVIEW_LINES
@ -89,39 +109,45 @@ export class ThinkingComponent implements Component {
'textDim',
`${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `,
);
return [
rendered = [
'',
spinner + currentTheme.fg('textDim', 'thinking...'),
...visibleLines.map((line) => MESSAGE_INDENT + line),
];
} else {
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;
}
}
const rendered: string[] = [''];
for (let i = 0; i < contentLines.length; i++) {
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
rendered.push(p + contentLines[i]);
if (isRenderCacheEnabled()) {
this.renderCache = { width, lines: rendered };
}
if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
return rendered;
}
// Leading blank + first PREVIEW_LINES content lines + hint line.
const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES);
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
const hintWidth = Math.max(0, width - indentWidth);
truncated.push(
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
);
return truncated;
return rendered;
}
private startSpinner(): void {
if (this.ui === undefined || this.spinnerInterval !== undefined) return;
this.spinnerInterval = setInterval(() => {
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
this.markRenderDirty();
this.ui?.requestRender();
}, BRAILLE_SPINNER_INTERVAL_MS);
}

View file

@ -25,6 +25,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';
import { appendStreamingArgsPreview } from '#/tui/utils/event-payload';
import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress';
import { PlanBoxComponent } from './plan-box';
@ -463,6 +464,8 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] {
}
class PrefixedWrappedLine implements Component {
private renderCache: { width: number; lines: string[] } | undefined;
constructor(
private readonly firstPrefix: string,
private readonly continuationPrefix: string,
@ -473,12 +476,18 @@ class PrefixedWrappedLine implements Component {
private readonly tailLines?: number,
) { }
invalidate(): void { }
invalidate(): void {
this.renderCache = undefined;
}
render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) {
return this.renderCache.lines;
}
const prefixWidth = Math.max(
visibleWidth(this.firstPrefix),
visibleWidth(this.continuationPrefix),
@ -489,11 +498,15 @@ class PrefixedWrappedLine implements Component {
this.tailLines !== undefined && wrapped.length > this.tailLines
? wrapped.slice(wrapped.length - this.tailLines)
: wrapped;
return lines
const rendered = lines
.map((line, index) =>
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
)
.map((line) => truncateToWidth(line, safeWidth, '…'));
if (isRenderCacheEnabled()) {
this.renderCache = { width: safeWidth, lines: rendered };
}
return rendered;
}
}
@ -620,7 +633,49 @@ export class ToolCallComponent extends Container {
this.startDetachHintTimer();
}
private renderCache:
| { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] }
| undefined;
override render(width: number): string[] {
const cache = this.renderCache;
const cacheValid =
isRenderCacheEnabled() &&
cache !== undefined &&
cache.width === width &&
cache.childRefs.length === this.children.length;
const childRefs: Component[] = [];
const childLines: string[][] = [];
let allReused = cacheValid;
let i = 0;
for (const child of this.children) {
const lines = child.render(width);
childRefs.push(child);
childLines.push(lines);
if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) {
allReused = false;
}
i++;
}
if (allReused) {
return cache!.lines;
}
const out: string[] = [];
for (const lines of childLines) {
for (const line of lines) out.push(line);
}
if (isRenderCacheEnabled()) {
this.renderCache = { width, lines: out, childRefs, childLines };
}
return out;
}
override invalidate(): void {
this.renderCache = undefined;
this.headerText.setText(this.buildHeader());
this.rebuildBody();
super.invalidate();

View file

@ -8,6 +8,7 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
export class UserMessageComponent implements Component {
private text: string;
@ -15,6 +16,8 @@ export class UserMessageComponent implements Component {
private spacerComponent: Spacer;
private imageThumbnails: ImageThumbnail[];
private renderCache: { width: number; lines: string[] } | undefined;
constructor(text: string, images?: ImageAttachment[], bullet?: string) {
this.text = text;
this.bullet = bullet;
@ -22,7 +25,12 @@ export class UserMessageComponent implements Component {
this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? [];
}
private markRenderDirty(): void {
this.renderCache = undefined;
}
invalidate(): void {
this.markRenderDirty();
for (const img of this.imageThumbnails) {
img.invalidate?.();
}
@ -32,6 +40,14 @@ export class UserMessageComponent implements Component {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];
if (
isRenderCacheEnabled() &&
this.renderCache !== undefined &&
this.renderCache.width === safeWidth
) {
return this.renderCache.lines;
}
const marker = this.bullet ?? USER_MESSAGE_BULLET;
const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : '';
const bulletWidth = visibleWidth(bullet);
@ -44,7 +60,8 @@ export class UserMessageComponent implements Component {
lines.push(line);
}
// Text — re-dye on every render so theme switches are reflected
// Text is re-dyed from the current theme; invalidate() (theme change) clears
// the render cache so the new colours are picked up on the next render.
const coloredText = currentTheme.boldFg('roleUser', this.text);
const textLines = new Text(coloredText, 0, 0).render(contentWidth);
for (let i = 0; i < textLines.length; i++) {
@ -60,7 +77,7 @@ export class UserMessageComponent implements Component {
}
}
return lines.map((line) => {
const rendered = lines.map((line) => {
// Inline image sequences (Kitty / iTerm2) carry their own placement
// information and have zero visible width, but pi-tui's truncateToWidth
// treats the embedded base64 payload as visible text and would chop the
@ -69,6 +86,10 @@ export class UserMessageComponent implements Component {
if (isImageLine(line)) return line;
return truncateToWidth(line, safeWidth, '…');
});
if (isRenderCacheEnabled()) {
this.renderCache = { width: safeWidth, lines: rendered };
}
return rendered;
}
}

View file

@ -57,6 +57,7 @@ export interface SessionReplayHost {
setAppState(patch: Partial<AppState>): void;
showError(msg: string): void;
appendTranscriptEntry(entry: TranscriptEntry): void;
mergeAllTurnSteps(): void;
}
function extractBashTag(
@ -90,6 +91,7 @@ export class SessionReplayRenderer {
this.hydrateSnapshot(main);
this.renderRecords(main);
this.applyTerminalBackgroundAgentStatuses(main);
this.host.mergeAllTurnSteps();
return true;
} catch (error) {
const message = formatErrorMessage(error);

View file

@ -35,6 +35,7 @@ export interface StreamingUIHost {
deferUserMessages: boolean;
shiftQueuedMessage(): QueuedMessage | undefined;
pushTranscriptEntry(entry: TranscriptEntry): void;
mergeCurrentTurnSteps(): void;
}
export class StreamingUIController {
@ -638,6 +639,7 @@ export class StreamingUIController {
this._activeThinkingComponent.finalize();
this._activeThinkingComponent = undefined;
this.host.state.ui.requestRender();
this.host.mergeCurrentTurnSteps();
}
onToolCallStart(toolCall: ToolCallBlockData): void {
@ -684,6 +686,7 @@ export class StreamingUIController {
tc.setResult(result);
this._pendingToolComponents.delete(toolCallId);
state.ui.requestRender();
this.host.mergeCurrentTurnSteps();
return;
}
@ -698,6 +701,7 @@ export class StreamingUIController {
state.transcriptContainer.addChild(completed);
state.ui.requestRender();
}
this.host.mergeCurrentTurnSteps();
}
setTodoList(todos: readonly TodoItem[]): void {

View file

@ -80,6 +80,7 @@ import {
StatusMessageComponent,
} from './components/messages/status-message';
import { ThinkingComponent } from './components/messages/thinking';
import { StepSummaryComponent } from './components/messages/step-summary';
import { ToolCallComponent } from './components/messages/tool-call';
import { UserMessageComponent } from './components/messages/user-message';
import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane';
@ -136,7 +137,16 @@ import { installTerminalFocusTracking } from './utils/terminal-focus';
import { notifyTerminalOnce } from './utils/terminal-notification';
import { installTerminalThemeTracking } from './utils/terminal-theme';
import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard';
import { markTranscriptComponent } from './utils/transcript-component-metadata';
import { getTranscriptComponentEntry, markTranscriptComponent } from './utils/transcript-component-metadata';
import {
TRANSCRIPT_EXPAND_TURNS,
TRANSCRIPT_HYSTERESIS,
TRANSCRIPT_KEEP_RECENT_STEPS,
TRANSCRIPT_MAX_TURNS,
TRANSCRIPT_WINDOW_ENABLED,
groupTurns,
turnsToTrim,
} from './utils/transcript-window';
import { formatBashOutputForDisplay } from './utils/shell-output';
import { nextTranscriptId } from './utils/transcript-id';
@ -1728,6 +1738,10 @@ export class KimiTUI {
if (component) {
markTranscriptComponent(component, entry);
this.state.transcriptContainer.addChild(component);
}
const trimmed = this.trimTranscriptWindow();
const merged = this.mergeCurrentTurnSteps();
if (component || trimmed || merged) {
this.state.ui.requestRender();
}
}
@ -1804,6 +1818,210 @@ export class KimiTUI {
this.renderWelcome();
}
private isTurnBoundaryComponent(child: Component): boolean {
if (!(child instanceof UserMessageComponent)) return false;
const entry = getTranscriptComponentEntry(child);
if (entry === undefined) return false;
// Live user messages have an undefined turnId; replayed user messages get a
// `replay:N` turnId. Both start a new turn. Steer messages carry a defined
// non-replay turnId and are not boundaries.
return entry.turnId === undefined || entry.turnId.startsWith('replay:');
}
private trimTranscriptWindow(): boolean {
if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false;
// Session replay already caps history to its own turn limit; trimming during
// replay would shrink it further and fight that limit.
if (this.state.appState.isReplaying) return false;
const children = this.state.transcriptContainer.children;
// Trim whole turns by *position* in the child list rather than by entry
// lookup — otherwise only the (registered) user message would be removed and
// the rest of the turn would be left behind.
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
const turns = groupTurns(this.state.transcriptEntries);
const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS);
if (toRemove.size === 0) return false;
let boundariesToRemove = 0;
for (const entry of toRemove) {
if (entry.kind === 'user' && entry.turnId === undefined) boundariesToRemove++;
}
if (boundariesToRemove === 0) {
this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e));
return true;
}
let boundariesSeen = 0;
let cutoff = 0;
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) {
if (boundariesSeen === boundariesToRemove) {
cutoff = i;
break;
}
boundariesSeen++;
}
}
const componentsToRemove: Component[] = [];
for (let i = 0; i < cutoff; i++) {
const child = children[i]!;
if (child instanceof WelcomeComponent) continue;
componentsToRemove.push(child);
}
for (const child of componentsToRemove) {
// pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist.
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
this.state.transcriptContainer.removeChild(child);
if (hasDispose(child)) child.dispose();
}
this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e));
return true;
}
mergeCurrentTurnSteps(): boolean {
if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false;
const children = this.state.transcriptContainer.children;
// Find the start of the current turn (last turn-starting user message).
let turnStart = -1;
for (let i = children.length - 1; i >= 0; i--) {
if (this.isTurnBoundaryComponent(children[i]!)) {
turnStart = i;
break;
}
}
if (turnStart < 0) return false;
// Locate an existing summary, the assistant message, and the mergeable steps.
let summaryIndex = -1;
const stepIndices: number[] = [];
for (let i = turnStart + 1; i < children.length; i++) {
const child = children[i]!;
if (child instanceof StepSummaryComponent) {
summaryIndex = i;
continue;
}
if (child instanceof AssistantMessageComponent) continue;
stepIndices.push(i);
}
if (stepIndices.length <= TRANSCRIPT_KEEP_RECENT_STEPS) return false;
const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS;
const toMergeIndices = stepIndices.slice(0, mergeCount);
let thinkingCount = 0;
let toolCount = 0;
for (const idx of toMergeIndices) {
const child = children[idx]!;
if (child instanceof ThinkingComponent) thinkingCount++;
else if (child instanceof ToolCallComponent) toolCount++;
}
if (thinkingCount === 0 && toolCount === 0) return false;
let summary: StepSummaryComponent;
if (summaryIndex >= 0) {
summary = children[summaryIndex] as StepSummaryComponent;
summary.addCounts(thinkingCount, toolCount);
} else {
summary = new StepSummaryComponent();
summary.addCounts(thinkingCount, toolCount);
}
// Rebuild children: keep everything except the merged steps, with the summary
// sitting right after the user message.
const toMergeSet = new Set(toMergeIndices);
const newChildren: Component[] = [];
for (let i = 0; i <= turnStart; i++) newChildren.push(children[i]!);
newChildren.push(summary);
for (let i = turnStart + 1; i < children.length; i++) {
if (i === summaryIndex) continue;
if (toMergeSet.has(i)) continue;
newChildren.push(children[i]!);
}
for (const idx of toMergeIndices) {
const child = children[idx]!;
if (hasDispose(child)) child.dispose();
}
children.splice(0, children.length, ...newChildren);
return true;
}
mergeAllTurnSteps(): void {
if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return;
const children = this.state.transcriptContainer.children;
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
if (boundaries.length === 0) return;
const newChildren: Component[] = [];
const toDispose: Component[] = [];
for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!);
for (let t = 0; t < boundaries.length; t++) {
const turnStart = boundaries[t]!;
const turnEnd = t + 1 < boundaries.length ? boundaries[t + 1]! : children.length;
newChildren.push(children[turnStart]!);
let summaryIndex = -1;
const stepIndices: number[] = [];
for (let i = turnStart + 1; i < turnEnd; i++) {
const child = children[i]!;
if (child instanceof StepSummaryComponent) summaryIndex = i;
else if (child instanceof AssistantMessageComponent) continue;
else stepIndices.push(i);
}
if (stepIndices.length > TRANSCRIPT_KEEP_RECENT_STEPS) {
const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS;
const toMergeIndices = stepIndices.slice(0, mergeCount);
let thinkingCount = 0;
let toolCount = 0;
for (const idx of toMergeIndices) {
const child = children[idx]!;
if (child instanceof ThinkingComponent) thinkingCount++;
else if (child instanceof ToolCallComponent) toolCount++;
}
let summary: StepSummaryComponent;
if (summaryIndex >= 0) {
summary = children[summaryIndex] as StepSummaryComponent;
summary.addCounts(thinkingCount, toolCount);
} else {
summary = new StepSummaryComponent();
summary.addCounts(thinkingCount, toolCount);
}
newChildren.push(summary);
for (const idx of toMergeIndices) toDispose.push(children[idx]!);
const toMergeSet = new Set(toMergeIndices);
for (let i = turnStart + 1; i < turnEnd; i++) {
if (i === summaryIndex) continue;
if (toMergeSet.has(i)) continue;
newChildren.push(children[i]!);
}
} else {
for (let i = turnStart + 1; i < turnEnd; i++) newChildren.push(children[i]!);
}
}
for (const child of toDispose) {
if (hasDispose(child)) child.dispose();
}
children.splice(0, children.length, ...newChildren);
}
showStatus(message: string, color?: ColorToken): void {
this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color));
this.state.ui.requestRender();
@ -1995,10 +2213,27 @@ export class KimiTUI {
toggleToolOutputExpansion(): void {
this.state.toolOutputExpanded = !this.state.toolOutputExpanded;
for (const child of this.state.transcriptContainer.children) {
if (isExpandable(child)) {
child.setExpanded(this.state.toolOutputExpanded);
}
const children = this.state.transcriptContainer.children;
// A component is expandable only if it sits at or after the start of the
// (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most
// recent `expandTurns` turns. Position-based so it also covers streaming
// components that have no entry in the metadata map.
const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
}
const expandCutoff =
TRANSCRIPT_EXPAND_TURNS <= 0
? children.length
: boundaries.length > TRANSCRIPT_EXPAND_TURNS
? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]!
: 0;
for (let i = 0; i < children.length; i++) {
const child = children[i]!;
if (!isExpandable(child)) continue;
child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff);
}
this.state.ui.requestRender();
}

View file

@ -0,0 +1,28 @@
/**
* Render-cache toggle for TUI message components.
*
* The transcript re-renders the entire component tree on every frame, and
* most message components rebuild their `render(width)` output from scratch
* even when their content has not changed. Caching the rendered lines (keyed
* on width + a dirty flag) turns an unchanged message's render into an O(1)
* array reference return, which is the dominant per-frame cost once the
* transcript grows long.
*
* The cache is on by default and can be disabled with
* `KIMI_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks
* compare cached vs. uncached runs in the same process).
*/
let enabled = process.env['KIMI_TUI_NO_RENDER_CACHE'] !== '1';
export function isRenderCacheEnabled(): boolean {
return enabled;
}
/**
* Override the cache at runtime. Intended for benchmarks / tests only;
* production code should not call this.
*/
export function setRenderCacheEnabled(value: boolean): void {
enabled = value;
}

View file

@ -0,0 +1,109 @@
/**
* Sliding window for the TUI transcript.
*
* The transcript grows unbounded as the conversation goes on. To keep the TUI
* responsive and bounded, we only keep the most recent N *turns* (a turn = a
* user prompt plus everything the assistant does in response, identified by a
* shared `turnId`), and destroy older turns wholesale (component + entry).
*
* All threshold logic here is pure so it can be unit-tested in isolation; the
* constants are the production defaults passed in by the TUI.
*/
import type { TranscriptEntry } from '../types';
/**
* Read a non-negative integer env var, falling back to `fallback` when it is
* unset, empty, negative, or not an integer. `0` is a valid value (call sites
* treat it as "feature disabled").
*/
export function readEnvInt(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) return fallback;
return value;
}
/** Master switch for the sliding window. */
export const TRANSCRIPT_WINDOW_ENABLED = true;
/** Keep the most recent N turns. `0` disables trimming. */
export const TRANSCRIPT_MAX_TURNS = readEnvInt('KIMI_CODE_TUI_MAX_TURNS', 50);
/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */
export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('KIMI_CODE_TUI_EXPAND_TURNS', 3);
/** Only trim once the window exceeds maxTurns by this much (avoids churn). */
export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 10);
/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */
export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30);
export interface TranscriptTurn {
readonly turnId: string | undefined;
readonly entries: TranscriptEntry[];
}
/**
* Group consecutive entries into turns by `turnId`. Entries with the same
* non-undefined `turnId` that are adjacent belong to the same turn.
*
* Entries with an undefined `turnId` are buffered and attached to the *next*
* defined turn. This matters because a user message is appended (with
* `turnId: undefined`) before its turn actually starts, so without this
* buffering every user message would become its own single-entry turn at the
* front and get trimmed first. Any undefined entries left at the tail (no
* following turn) become their own turn.
*/
export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] {
const turns: TranscriptTurn[] = [];
let current: TranscriptTurn | undefined;
let pendingUndefined: TranscriptEntry[] = [];
for (const entry of entries) {
const turnId = entry.turnId;
if (turnId === undefined) {
pendingUndefined.push(entry);
continue;
}
if (current !== undefined && current.turnId === turnId) {
current.entries.push(entry);
} else {
current = { turnId, entries: [...pendingUndefined, entry] };
pendingUndefined = [];
turns.push(current);
}
}
if (pendingUndefined.length > 0) {
turns.push({ turnId: undefined, entries: pendingUndefined });
}
return turns;
}
/**
* Decide which entries to destroy so the remaining turns fit within
* `maxTurns`. Returns an empty set when the turn count is within
* `maxTurns + hysteresis`. Oldest turns are removed first; the most recent
* turn is never removed (it is the active / just-finished turn).
*/
export function turnsToTrim(
turns: readonly TranscriptTurn[],
maxTurns: number,
hysteresis: number,
): Set<TranscriptEntry> {
const toRemove = new Set<TranscriptEntry>();
if (turns.length <= maxTurns + hysteresis) return toRemove;
let remaining = turns.length;
// `turns.length - 1` keeps the most recent turn off-limits.
for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) {
const turn = turns[i]!;
for (const entry of turn.entries) toRemove.add(entry);
remaining--;
}
return toRemove;
}

View file

@ -0,0 +1,115 @@
/**
* Benchmark for the message-component render cache (Phase 1 + 1.5).
*
* Measures the cost of re-rendering a long transcript when *nothing* has
* changed the common steady-state frame. With the render cache enabled
* ("cached (warm)") every message returns its previously computed lines, and
* the GutterContainer returns its cached concatenation, so the cost is roughly
* O(number of messages). With it disabled ("uncached") every message rebuilds
* its output (Markdown, Text, truncation) and the container rebuilds the full
* line array, which is O(total rendered lines) and dominates CPU as the
* transcript grows.
*
* Run:
* pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/render-memo.bench.ts
*/
import { bench, describe } from 'vitest';
import type { Component } from '@earendil-works/pi-tui';
import { GutterContainer } from '#/tui/components/chrome/gutter-container';
import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message';
import { ThinkingComponent } from '#/tui/components/messages/thinking';
import { UserMessageComponent } from '#/tui/components/messages/user-message';
import { setRenderCacheEnabled } from '#/tui/utils/render-cache';
const WIDTH = 100;
const TRANSCRIPT_TURNS = 200;
const GUTTER = 2;
const USER_TEXT =
'Can you refactor the streaming renderer so that finalized assistant messages stop being re-rendered on every frame? Please keep the diff minimal and avoid touching the engine.';
const ASSISTANT_TEXT = [
'Here is a summary of the change:',
'',
'- cache the rendered lines per message component',
'- invalidate the cache when content, theme, or width changes',
'- keep the diff renderer untouched',
'',
'```ts',
'render(width: number): string[] {',
' if (this.cache && this.cache.width === width) return this.cache.lines;',
' const lines = this.compute(width);',
' this.cache = { width, lines };',
' return lines;',
'}',
'```',
'',
'This keeps the steady-state frame cheap while preserving correctness.',
].join('\n');
const THINKING_TEXT = [
'Let me reason through the invalidation paths carefully.',
'The cache must be cleared on content changes, theme switches, and width changes.',
'Width changes already trigger a full repaint, so they fall out naturally.',
'Theme switches flow through invalidate(), so that is the hook to clear the cache.',
'Streaming updates go through updateContent/setText, which already short-circuit when unchanged.',
].join('\n');
function buildMessages(turns: number): Component[] {
const components: Component[] = [];
for (let i = 0; i < turns; i++) {
components.push(new UserMessageComponent(`[${i}] ${USER_TEXT}`));
const assistant = new AssistantMessageComponent();
assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`);
components.push(assistant);
components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized'));
}
return components;
}
function buildGutter(turns: number): GutterContainer {
const gutter = new GutterContainer(GUTTER, GUTTER);
for (const message of buildMessages(turns)) gutter.addChild(message);
return gutter;
}
describe('render memo — flat child render', () => {
const messages = buildMessages(TRANSCRIPT_TURNS);
// Warm up: populate every component's cache so the "cached" case measures
// steady-state cache hits rather than first-render cost.
setRenderCacheEnabled(true);
for (const message of messages) message.render(WIDTH);
bench('cached (warm)', () => {
setRenderCacheEnabled(true);
for (const message of messages) message.render(WIDTH);
});
bench('uncached', () => {
setRenderCacheEnabled(false);
for (const message of messages) message.render(WIDTH);
});
});
describe('render memo — via GutterContainer', () => {
const gutter = buildGutter(TRANSCRIPT_TURNS);
setRenderCacheEnabled(true);
gutter.render(WIDTH);
bench('cached (warm)', () => {
setRenderCacheEnabled(true);
gutter.render(WIDTH);
});
bench('uncached', () => {
setRenderCacheEnabled(false);
gutter.render(WIDTH);
});
});

View file

@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { TranscriptEntry } from '#/tui/types';
import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window';
let seq = 0;
function makeEntry(
turnId: string | undefined,
kind: TranscriptEntry['kind'] = 'assistant',
): TranscriptEntry {
return { id: String(++seq), kind, turnId, renderMode: 'markdown', content: '' };
}
function tool(turnId: string): TranscriptEntry {
return makeEntry(turnId, 'tool_call');
}
function msg(turnId: string | undefined): TranscriptEntry {
return makeEntry(turnId, 'assistant');
}
describe('groupTurns', () => {
it('groups consecutive entries with the same turnId', () => {
const turns = groupTurns([msg('a'), tool('a'), msg('b')]);
expect(turns.map((t) => t.turnId)).toEqual(['a', 'b']);
expect(turns[0]!.entries).toHaveLength(2);
expect(turns[1]!.entries).toHaveLength(1);
});
it('attaches leading undefined turnId entries to the following turn', () => {
// A user message (undefined turnId) followed by its response should be one turn.
const turns = groupTurns([msg(undefined), tool('1'), msg('1')]);
expect(turns).toHaveLength(1);
expect(turns[0]!.turnId).toBe('1');
expect(turns[0]!.entries).toHaveLength(3);
});
it('attaches multiple consecutive undefined entries to the following turn', () => {
const turns = groupTurns([msg(undefined), msg(undefined), msg('a')]);
expect(turns).toHaveLength(1);
expect(turns[0]!.turnId).toBe('a');
expect(turns[0]!.entries).toHaveLength(3);
});
it('makes trailing undefined entries their own turn', () => {
const turns = groupTurns([msg('a'), msg(undefined)]);
expect(turns).toHaveLength(2);
expect(turns[0]!.turnId).toBe('a');
expect(turns[1]!.turnId).toBeUndefined();
expect(turns[1]!.entries).toHaveLength(1);
});
});
describe('turnsToTrim', () => {
it('returns empty when turn count is within maxTurns', () => {
const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns
expect(turnsToTrim(turns, 5, 1).size).toBe(0);
});
it('does not trim within the hysteresis band', () => {
const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns
expect(turnsToTrim(turns, 2, 1).size).toBe(0); // 3 <= 2 + 1
});
it('trims oldest turns first', () => {
const entries = [msg('a'), msg('b'), msg('c'), msg('d')]; // 4 turns
const turns = groupTurns(entries);
const removed = turnsToTrim(turns, 2, 0);
expect(removed.has(entries[0]!)).toBe(true);
expect(removed.has(entries[1]!)).toBe(true);
expect(removed.has(entries[2]!)).toBe(false);
expect(removed.has(entries[3]!)).toBe(false);
});
it('never trims the most recent turn', () => {
// A single turn is never removed, even if it is huge.
const entries = Array.from({ length: 200 }, () => tool('solo'));
const turns = groupTurns(entries); // 1 turn
const removed = turnsToTrim(turns, 2, 0);
expect(removed.size).toBe(0);
});
});
describe('readEnvInt', () => {
const KEY = 'KIMI_CODE_TUI_TEST_INT';
afterEach(() => {
delete process.env[KEY];
});
it('returns fallback when unset', () => {
expect(readEnvInt(KEY, 7)).toBe(7);
});
it('reads a valid integer', () => {
process.env[KEY] = '42';
expect(readEnvInt(KEY, 7)).toBe(42);
});
it('accepts 0', () => {
process.env[KEY] = '0';
expect(readEnvInt(KEY, 7)).toBe(0);
});
it('falls back on negative', () => {
process.env[KEY] = '-1';
expect(readEnvInt(KEY, 7)).toBe(7);
});
it('falls back on non-integer', () => {
process.env[KEY] = 'abc';
expect(readEnvInt(KEY, 7)).toBe(7);
});
it('falls back on empty/whitespace', () => {
process.env[KEY] = ' ';
expect(readEnvInt(KEY, 7)).toBe(7);
});
});