mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
f1c8175f9c
commit
b0b2aee8c5
15 changed files with 862 additions and 33 deletions
5
.changeset/tui-render-cache.md
Normal file
5
.changeset/tui-render-cache.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Keep the terminal responsive in long conversations by caching rendered message lines.
|
||||
5
.changeset/tui-transcript-window.md
Normal file
5
.changeset/tui-transcript-window.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal file
32
apps/kimi-code/src/tui/components/messages/step-summary.ts
Normal 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(', ')}`)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal file
28
apps/kimi-code/src/tui/utils/render-cache.ts
Normal 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;
|
||||
}
|
||||
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal file
109
apps/kimi-code/src/tui/utils/transcript-window.ts
Normal 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;
|
||||
}
|
||||
115
apps/kimi-code/test/tui/render-memo.bench.ts
Normal file
115
apps/kimi-code/test/tui/render-memo.bench.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
apps/kimi-code/test/tui/utils/transcript-window.test.ts
Normal file
116
apps/kimi-code/test/tui/utils/transcript-window.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue