Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/long-session-tui-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/agent-core": patch
---

Improve TUI performance and resume speed for long-running sessions.
5 changes: 5 additions & 0 deletions .changeset/pi-tui-frame-line-reuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Speed up the terminal renderer in long sessions by reusing processed lines across frames.
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
const children = host.state.transcriptContainer.children;
const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count);
if (lastUserComponentIndex !== undefined) {
// Structural removal only: the container's ref-checked render cache
// detects the child-list change; no tree-wide invalidate needed.
removeUndoContextComponents(children, lastUserComponentIndex);
host.state.transcriptContainer.invalidate();
}

const preservedEntries = entries.slice(lastUserIndex).filter(
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
* prefixed with `left` plain spaces. Right padding is logical only — we
* never emit trailing spaces, since terminals already paint background to
* the edge and adding them would just churn the diff renderer.
*
* The render cache below validates per child (component identity + the
* identity of its rendered line array), so structural child-list changes —
* append, splice-removal, in-place replacement — are picked up correctly
* without a tree-wide `invalidate()`. Reserve `invalidate()` for global
* style changes that genuinely dirty every child (e.g. theme switches).
*/

import { Container } from '@moonshot-ai/pi-tui';
Expand Down
14 changes: 9 additions & 5 deletions apps/kimi-code/src/tui/components/messages/step-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import type { Component } from '@moonshot-ai/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`.
* A collapsed summary of older content within a turn. Accumulates counts of
* merged steps (thinking blocks and tool calls) and folded assistant messages,
* rendering them as a single muted line, e.g.
* `… thinking 5 times, call 50 tools, 12 messages`.
*/
export class StepSummaryComponent implements Component {
private thinking = 0;
private tool = 0;
private message = 0;

get isEmpty(): boolean {
return this.thinking === 0 && this.tool === 0;
return this.thinking === 0 && this.tool === 0 && this.message === 0;
}

addCounts(thinking: number, tool: number): void {
addCounts(thinking: number, tool: number, message = 0): void {
this.thinking += thinking;
this.tool += tool;
this.message += message;
}

invalidate(): void {}
Expand All @@ -26,6 +29,7 @@ export class StepSummaryComponent implements Component {
const parts: string[] = [];
if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`);
if (this.tool > 0) parts.push(`call ${this.tool} tools`);
if (this.message > 0) parts.push(`${this.message} messages`);
if (parts.length === 0) return [];
return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)];
}
Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/src/tui/components/messages/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,17 @@ export class UserMessageComponent implements Component {
function isImageLine(line: string): boolean {
return line.includes('\u001B_G') || line.includes('\u001B]1337;File=');
}

/**
* Invisible turn-boundary marker for replay. Some replayed records start a
* new turn without anything to show — the goal driver's synthetic
* continuation prompt is model-facing and never rendered live — but the
* transcript still needs a mounted boundary component so step/assistant
* folding (and window trimming) can find the turn edges. Renders zero lines.
*/
export class ReplayTurnBoundaryComponent implements Component {
invalidate(): void {}
render(_width: number): string[] {
return [];
}
}
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,8 +936,9 @@ export class SessionEventHandler {
const children = state.transcriptContainer.children;
const idx = children.indexOf(spinner);
if (idx >= 0) {
// In-place replacement is picked up by the container's ref-checked
// render cache; a tree-wide invalidate is unnecessary (and costly).
children[idx] = status;
state.transcriptContainer.invalidate();
} else {
state.transcriptContainer.addChild(status);
}
Expand Down
15 changes: 14 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@moonshot-ai/kimi-code-sdk';

import { ToolCallComponent } from '../components/messages/tool-call';
import { ReplayTurnBoundaryComponent } from '../components/messages/user-message';
import { currentTheme } from '../theme';
import type { TodoItem } from '../components/chrome/todo-panel';
import type {
Expand All @@ -23,6 +24,7 @@ import { formatBackgroundAgentTranscript } from '../utils/background-agent-statu
import { formatBackgroundTaskTranscript } from '../utils/background-task-status';
import { buildGoalCompletionMessage } from '../utils/goal-completion';
import { formatBashOutputForDisplay } from '../utils/shell-output';
import { markTranscriptComponent } from '../utils/transcript-component-metadata';
import {
appStateFromResumeAgent,
backgroundOrigin,
Expand Down Expand Up @@ -305,6 +307,16 @@ export class SessionReplayRenderer {
// reminders, stop-hook reasons, …) are model-facing only: the live event
// stream never renders them, so replay must not leak them either.
if (message.origin?.kind === 'system_trigger') {
if (message.origin.name === 'goal_continuation') {
// The goal driver's synthetic "continue" prompt starts a new replay
// turn even though nothing visible is mounted: advance the turn and
// mark an invisible boundary so each goal round groups under its own
// turn and step/assistant folding can find the turn edges.
this.advanceTurn(context);
const boundary = new ReplayTurnBoundaryComponent();
markTranscriptComponent(boundary, replayEntry(context, 'user', '', 'plain'));
this.host.state.transcriptContainer.addChild(boundary);
}
return;
}

Expand Down Expand Up @@ -647,8 +659,9 @@ export class SessionReplayRenderer {
(child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId,
);
if (childIndex >= 0) {
// Structural removal only: the container's ref-checked render cache
// detects the child-list change; no tree-wide invalidate needed.
children.splice(childIndex, 1);
state.transcriptContainer.invalidate();
}
}

Expand Down
10 changes: 8 additions & 2 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface StreamingUIHost {
shiftQueuedMessage(): QueuedMessage | undefined;
pushTranscriptEntry(entry: TranscriptEntry): void;
mergeCurrentTurnSteps(): void;
mergeCompletedTurnAssistants(): void;
}

export class StreamingUIController {
Expand Down Expand Up @@ -555,6 +556,9 @@ export class StreamingUIController {
const completedTurnKey =
this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`;
this.finalizeLiveTextBuffers('idle');
// The finished turn keeps only its conclusion-bearing tail; intermediate
// chatter folds into the step summary.
this.host.mergeCompletedTurnAssistants();
this.resetToolCallState();
this._currentTurnId = undefined;

Expand Down Expand Up @@ -835,8 +839,9 @@ export class StreamingUIController {
const children = state.transcriptContainer.children;
const idx = children.indexOf(solo);
if (idx >= 0) {
// In-place replacement is picked up by the container's ref-checked
// render cache; a tree-wide invalidate is unnecessary (and costly).
children[idx] = group;
state.transcriptContainer.invalidate();
} else {
state.transcriptContainer.addChild(group);
}
Expand Down Expand Up @@ -892,8 +897,9 @@ export class StreamingUIController {
const children = state.transcriptContainer.children;
const idx = children.indexOf(solo);
if (idx >= 0) {
// In-place replacement is picked up by the container's ref-checked
// render cache; a tree-wide invalidate is unnecessary (and costly).
children[idx] = group;
state.transcriptContainer.invalidate();
} else {
state.transcriptContainer.addChild(group);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/controllers/subagent-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,9 @@ export class SubAgentEventHandler {
const children = this.host.state.transcriptContainer.children;
const index = children.indexOf(progress);
if (index >= 0) {
// Structural removal only: GutterContainer's ref-checked render cache
// detects the child-list change; no tree-wide invalidate needed.
children.splice(index, 1);
this.host.state.transcriptContainer.invalidate();
}
this.host.updateActivityPane();
}
Expand Down
Loading
Loading