Skip to content
Merged
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/tui-render-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep the terminal responsive in long conversations by caching rendered message lines.
5 changes: 5 additions & 0 deletions .changeset/tui-transcript-window.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 56 additions & 3 deletions apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,77 @@
*/

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,
) {
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;
}
}
25 changes: 24 additions & 1 deletion apps/kimi-code/src/tui/components/messages/assistant-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -41,6 +50,7 @@ export class AssistantMessageComponent implements Component {

this.lastText = displayText;
this.lastTransient = transient;
this.markRenderDirty();

if (displayText.length === 0) {
this.contentContainer.clear();
Expand All @@ -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;

Expand All @@ -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);
Expand All @@ -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;
}
}
32 changes: 32 additions & 0 deletions apps/kimi-code/src/tui/components/messages/step-summary.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}`)];
}
}
66 changes: 46 additions & 20 deletions apps/kimi-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand All @@ -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));
}

Expand All @@ -64,6 +73,7 @@ export class ThinkingComponent implements Component {

finalize(): void {
this.mode = 'finalized';
this.markRenderDirty();
this.stopSpinner();
}

Expand All @@ -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
Expand All @@ -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 (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
return rendered;
if (isRenderCacheEnabled()) {
this.renderCache = { width, lines: 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);
}
Expand Down
Loading
Loading