From 3d699b282fc1859a79ce98a82c88278c28dcd71d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 00:11:09 +0800 Subject: [PATCH 1/7] fix(pi-tui): reuse processed lines across frames in the renderer Each frame previously re-truncated, re-normalized, and re-compared every transcript line, so steady-state frames (spinner ticks, streaming flushes) cost O(total lines x chars) and pegged one core in long sessions. Keep the previous frame's raw lines, processed output, and per-line kitty image ids; a line whose raw string reference is unchanged reuses its processed output verbatim, and image-id consumers read the cache instead of re-scanning text. --- .changeset/pi-tui-frame-line-reuse.md | 5 + apps/kimi-code/test/tui/tui-frame.bench.ts | 101 +++++++++++++ packages/pi-tui/AGENTS.md | 3 +- packages/pi-tui/src/tui.ts | 114 ++++++++++----- packages/pi-tui/test/tui-render.test.ts | 161 +++++++++++++++++++++ 5 files changed, 347 insertions(+), 37 deletions(-) create mode 100644 .changeset/pi-tui-frame-line-reuse.md create mode 100644 apps/kimi-code/test/tui/tui-frame.bench.ts diff --git a/.changeset/pi-tui-frame-line-reuse.md b/.changeset/pi-tui-frame-line-reuse.md new file mode 100644 index 0000000000..8bf331fc3a --- /dev/null +++ b/.changeset/pi-tui-frame-line-reuse.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Reuse the processed output of unchanged lines across frames so a steady-state frame only pays for the lines that actually changed, instead of re-processing the whole transcript on every spinner tick and streaming flush. diff --git a/apps/kimi-code/test/tui/tui-frame.bench.ts b/apps/kimi-code/test/tui/tui-frame.bench.ts new file mode 100644 index 0000000000..2fc06ec027 --- /dev/null +++ b/apps/kimi-code/test/tui/tui-frame.bench.ts @@ -0,0 +1,101 @@ +/** + * Benchmark for the TUI steady-state frame (Phase: doRender fast path). + * + * Measures the cost of one frame over a very long transcript when only a + * single line changed — the shape every spinner tick and streaming flush + * produces. Component render caches return the same string references for + * unchanged content, so doRender's processed-line reuse turns the frame into + * O(total lines) pointer comparisons plus O(changed lines) real work. A + * regression here re-introduces the per-frame full-transcript processing that + * pegged CPU in long sessions. + * + * Run: + * pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/tui-frame.bench.ts + */ + +import type { Component, Terminal } from '@moonshot-ai/pi-tui'; +import { TUI } from '@moonshot-ai/pi-tui'; +import { bench, describe } from 'vitest'; + +const WIDTH = 120; +const HEIGHT = 40; +const TRANSCRIPT_LINES = 30_000; + +/** Terminal stub that discards output — we benchmark frame computation, not xterm parsing. */ +class StubTerminal implements Terminal { + /** + * Counts writes so the frame's output is an observable side effect; an + * empty write would let the JIT eliminate the whole frame as dead code. + */ + writes = 0; + start(): void {} + stop(): void {} + async drainInput(): Promise {} + write(): void { + this.writes++; + } + get columns(): number { + return WIDTH; + } + get rows(): number { + return HEIGHT; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(): void {} + setProgress(): void {} +} + +/** Returns the same array reference every frame, mirroring the app's cached message components. */ +class StaticTranscript implements Component { + constructor(private readonly lines: string[]) {} + render(): string[] { + return this.lines; + } + invalidate(): void {} +} + +class SpinnerComponent implements Component { + frame = 0; + render(): string[] { + return [`⠋ working (frame ${this.frame})`]; + } + invalidate(): void {} +} + +describe('TUI steady-state frame', () => { + const terminal = new StubTerminal(); + const tui = new TUI(terminal); + const spinner = new SpinnerComponent(); + tui.addChild( + new StaticTranscript( + Array.from( + { length: TRANSCRIPT_LINES }, + (_, i) => `transcript line ${i} — the quick brown fox jumps over the lazy dog`, + ), + ), + ); + tui.addChild(spinner); + tui.start(); + + // doRender is private; the bench drives it directly so the measurement is + // one frame's computation without the 16ms render throttle in between. + const renderFrame = (): void => { + spinner.frame++; + (tui as unknown as { doRender(): void }).doRender(); + }; + renderFrame(); + + // No teardown/stop here: bench-option hooks fire per measured iteration, + // and stopping the TUI would turn every subsequent frame into a no-op. + bench(`${TRANSCRIPT_LINES}-line transcript, one spinner line change per frame`, () => { + renderFrame(); + }); +}); diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 500e9627b7..c25bd9efe3 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -8,8 +8,9 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 1. **`src/components/editor.ts` — `wordWrapLine` single-grapheme recursion guard**: when a segment cannot be split further (a single grapheme) and is wider than `maxWidth`, do not recurse (upstream recurses infinitely and overflows the stack at maxWidth=1 with CJK). The guard must be based on grapheme count (`graphemeSegmenter.segment(...)`), not code-unit length — `grapheme.length` misjudges ZWJ emoji. Guarding tests: "wordWrapLine narrow width" and "Editor narrow width rendering" in `test/editor.test.ts`. 2. **`src/tui.ts` — `Container.render` width clamp**: `width = Math.max(1, width)` at the entry point. Guarding test: "Container width clamping" in `test/tui-render.test.ts`. -3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` before `applyLineResets`; the upstream "write crash log + throw" block in the differential render path has been removed — do not bring it back when syncing. Performance constraint: the truncation check scans every line every frame, so it must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Known boundary: with more than 4096 distinct non-ASCII lines the width cache FIFO thrashes (~30ms/frame); the real fix is a prepared-frame per-row cache, tracked as follow-up work. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`. +3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` while building the per-line processed output (upstream had a "write crash log + throw" block in the differential render path — do not bring it back when syncing). Performance constraint: the width check must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Since divergence 5 below, this check only runs for lines whose raw string reference changed since the previous frame, so the every-line-every-frame scan (and its width-cache FIFO thrash beyond 4096 distinct non-ASCII lines) no longer occurs. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`. 4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — negative-width `repeat` guards**: the `repeat` counts for blank lines, horizontal rules, and the editor's top/bottom borders are clamped to ≥ 0 (two editor border sites; markdown's emptyLine and hr — the hr site is currently unreachable from the render entry and is purely defensive). Guarding tests: the "negative width safety" cases — Text's lives in `test/tui-render.test.ts` (Text has no dedicated test file), Markdown's and TruncatedText's live in their own test files; the editor's is "does not throw at zero or negative widths" inside the "Editor narrow width rendering" group in `test/editor.test.ts`. +5. **`src/tui.ts` — per-frame processed-line reuse**: `doRender` keeps the previous frame's raw lines (`previousRawLines`), their processed output (`previousLines`), and per-line kitty image ids (`previousLineImageIds`). A line whose raw string is reference-identical to the previous frame's reuses its processed output (truncation + `normalizeTerminalOutput` + trailing `SEGMENT_RESET`) verbatim, so a steady-state frame costs O(total lines) pointer comparisons plus O(changed lines) real work instead of re-normalizing every line. Image-id consumers (`expandChangedRangeForKittyImages`, `deleteChangedKittyImages`, the frame-end id union) read the cached per-line ids rather than re-scanning line text; upstream has no such cache and re-processes every line every frame (upstream also has `applyLineResets`, which this divergence inlines into the processed-line build). Reuse is only valid when the terminal width is unchanged; width changes re-render everything at the new width so references never match. Guarding tests: "TUI steady-frame processed-line reuse" in `test/tui-render.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index c578c7d93e..e4556a8f47 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -27,6 +27,9 @@ import { const KITTY_SEQUENCE_PREFIX = "\x1b_G"; +/** Shared empty id list for non-image lines in the per-line image-id cache. */ +const EMPTY_IMAGE_IDS: readonly number[] = []; + interface KittyImageHeader { ids: number[]; rows: number; @@ -305,6 +308,16 @@ export class Container implements Component { export class TUI extends Container { public terminal: Terminal; private previousLines: string[] = []; + /** + * Raw (pre-processing) lines of the previous frame, aligned with + * {@link previousLines}. Component render caches return identical string + * references for unchanged content, which lets each frame reuse the + * processed output for every untouched line instead of re-normalizing and + * re-comparing the whole transcript (see doRender). + */ + private previousRawLines: string[] = []; + /** Per-line kitty image ids of the previous frame, aligned with previousRawLines. */ + private previousLineImageIds: ReadonlyArray[] = []; private previousKittyImageIds = new Set(); private previousWidth = 0; private previousHeight = 0; @@ -1102,21 +1115,10 @@ export class TUI extends Container { private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - private applyLineResets(lines: string[]): string[] { - const reset = TUI.SEGMENT_RESET; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!; - if (!isImageLine(line)) { - lines[i] = normalizeTerminalOutput(line) + reset; - } - } - return lines; - } - - private collectKittyImageIds(lines: string[]): Set { + private unionKittyImageIds(lineImageIds: ReadonlyArray[]): Set { const ids = new Set(); - for (const line of lines) { - for (const id of extractKittyImageIds(line)) { + for (const lineIds of lineImageIds) { + for (const id of lineIds) { ids.add(id); } } @@ -1149,12 +1151,13 @@ export class TUI extends Container { firstChanged: number, lastChanged: number, newLines: string[], + newLineImageIds: ReadonlyArray[], ): { firstChanged: number; lastChanged: number } { let expandedFirstChanged = firstChanged; let expandedLastChanged = lastChanged; - const expandForLines = (lines: string[]): void => { + const expandForLines = (lines: string[], lineImageIds: ReadonlyArray[]): void => { for (let i = 0; i < lines.length; i++) { - if (extractKittyImageIds(lines[i]!).length === 0) continue; + if ((lineImageIds[i] ?? EMPTY_IMAGE_IDS).length === 0) continue; const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { expandedFirstChanged = Math.min(expandedFirstChanged, i); @@ -1163,8 +1166,8 @@ export class TUI extends Container { } }; - expandForLines(this.previousLines); - expandForLines(newLines); + expandForLines(this.previousLines, this.previousLineImageIds); + expandForLines(newLines, newLineImageIds); return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; } @@ -1174,7 +1177,7 @@ export class TUI extends Container { const ids = new Set(); const maxLine = Math.min(lastChanged, this.previousLines.length - 1); for (let i = firstChanged; i <= maxLine; i++) { - for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { + for (const id of this.previousLineImageIds[i] ?? EMPTY_IMAGE_IDS) { ids.add(id); } } @@ -1288,21 +1291,44 @@ export class TUI extends Container { // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); - // Never write a line wider than the terminal: truncate defensively - // instead of crashing. Extremely narrow terminals can make - // components overflow by a column (e.g. wide graphemes at width 1). - // applyLineResets() runs afterwards, so truncated lines still get - // their trailing reset and cannot leak styles. - for (let i = 0; i < newLines.length; i++) { - const line = newLines[i]!; - if (isImageLine(line)) continue; - const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); - if (lineWidth > width) { - newLines[i] = sliceByColumn(line, 0, width, true); + // Process raw lines for output. Never write a line wider than the + // terminal: truncate defensively instead of crashing. Extremely narrow + // terminals can make components overflow by a column (e.g. wide + // graphemes at width 1). The trailing segment reset is appended after + // truncation, so truncated lines still get their reset and cannot leak + // styles. + // + // Lines whose raw string is reference-identical to the previous frame's + // reuse their processed output verbatim: component render caches return + // the same string references for unchanged content, so a steady frame + // only pays for the lines that actually changed instead of + // re-normalizing the whole transcript. + const rawLines = newLines; + const reuseProcessed = !widthChanged && this.previousRawLines.length > 0; + const processedLines: string[] = new Array(rawLines.length); + const lineImageIds: ReadonlyArray[] = new Array(rawLines.length); + for (let i = 0; i < rawLines.length; i++) { + const rawLine = rawLines[i]!; + if (reuseProcessed && rawLine === this.previousRawLines[i]) { + processedLines[i] = this.previousLines[i]!; + lineImageIds[i] = this.previousLineImageIds[i]!; + continue; } + let line = rawLine; + let imageIds: readonly number[] = EMPTY_IMAGE_IDS; + if (isImageLine(line)) { + imageIds = extractKittyImageIds(line); + } else { + const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); + if (lineWidth > width) { + line = sliceByColumn(line, 0, width, true); + } + line = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET; + } + processedLines[i] = line; + lineImageIds[i] = imageIds; } - - newLines = this.applyLineResets(newLines); + newLines = processedLines; // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { @@ -1343,7 +1369,9 @@ export class TUI extends Container { this.previousViewportTop = Math.max(0, bufferLength - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; }; @@ -1411,7 +1439,12 @@ export class TUI extends Container { lastChanged = newLines.length - 1; } if (firstChanged !== -1) { - const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + const expandedRange = this.expandChangedRangeForKittyImages( + firstChanged, + lastChanged, + newLines, + lineImageIds, + ); firstChanged = expandedRange.firstChanged; lastChanged = expandedRange.lastChanged; } @@ -1422,6 +1455,11 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousViewportTop = prevViewportTop; this.previousHeight = height; + // Processed output is unchanged, but keep the raw/image-id caches in + // sync so future frames keep hitting the reuse fast path (e.g. the + // cursor-marker line gets a fresh string every frame). + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; return; } @@ -1467,7 +1505,9 @@ export class TUI extends Container { } this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; this.previousViewportTop = prevViewportTop; @@ -1610,7 +1650,9 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; } diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index f928f7015d..d40811d0e8 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -829,3 +829,164 @@ describe("Text negative width safety", () => { assert.doesNotThrow(() => text.render(-1)); }); }); + +describe("TUI steady-frame processed-line reuse", () => { + it("writes only the changed line when one component updates in a long transcript", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const staticComponent = new TestComponent(); + staticComponent.lines = Array.from({ length: 30 }, (_, i) => `static-${String(i).padStart(2, "0")}`); + const spinner = new TestComponent(); + spinner.lines = ["frame-a"]; + tui.addChild(staticComponent); + tui.addChild(spinner); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + spinner.lines = ["frame-b"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("frame-b"), "changed line should be written"); + assert.ok(!writes.includes("static-"), "unchanged transcript lines must not be rewritten"); + assert.ok(!writes.includes("\x1b[2J"), "no full clear for an in-viewport change"); + + tui.stop(); + }); + + it("writes nothing but the cursor hide for an identical frame", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["alpha", "beta", "gamma"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.strictEqual(writes.replaceAll("\x1b[?25l", ""), ""); + tui.stop(); + }); + + it("does not serve stale processed lines when a line toggles between two values", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["head", "value-a", "tail"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + component.lines = ["head", "value-b", "tail"]; + tui.requestRender(); + await terminal.waitForRender(); + component.lines = ["head", "value-a", "tail"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = await terminal.flushAndGetViewport(); + assert.strictEqual(viewport[0], "head"); + assert.strictEqual(viewport[1], "value-a"); + assert.strictEqual(viewport[2], "tail"); + tui.stop(); + }); + + it("fully redraws when the terminal width changes", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["one", "two", "three"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + terminal.resize(30, 10); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("\x1b[2J"), "width change should clear and redraw"); + assert.ok(writes.includes("one") && writes.includes("two") && writes.includes("three")); + tui.stop(); + }); + + it("redraws a kitty image block when a line above it changes", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]!; + component.lines = ["header", ...imageLines, "footer"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = ["header2", ...imageLines, "footer"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("header2"), "changed line should be written"); + assert.ok( + writes.includes(imageSequence), + "image block should be redrawn when a line above it changes", + ); + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("does not rewrite a kitty image on an identical frame", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]!; + component.lines = ["header", ...imageLines, "footer"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(!writes.includes(imageSequence), "identical frame must not rewrite the image"); + assert.strictEqual(writes.replaceAll("\x1b[?25l", ""), ""); + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); +}); From f13211740cd51c13f740f804f324fdcbaa1cab19 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 00:11:31 +0800 Subject: [PATCH 2/7] fix(tui): stop tree-wide transcript invalidation on structural updates Grouping a second Read/Agent call, removing swarm progress, finalizing an MCP status row, replay tool-call removal, and /undo each invalidated the entire transcript tree, forcing every mounted message to re-render (markdown lexing + code highlighting) on the next frame. The container's render cache already validates per child by reference, so structural child-list changes are picked up without a tree-wide invalidate; reserve it for global style changes such as theme switches. --- .changeset/tui-long-session-render-lag.md | 5 +++++ apps/kimi-code/src/tui/commands/undo.ts | 3 ++- .../kimi-code/src/tui/components/chrome/gutter-container.ts | 6 ++++++ apps/kimi-code/src/tui/controllers/session-event-handler.ts | 3 ++- apps/kimi-code/src/tui/controllers/session-replay.ts | 3 ++- apps/kimi-code/src/tui/controllers/streaming-ui.ts | 6 ++++-- .../kimi-code/src/tui/controllers/subagent-event-handler.ts | 3 ++- 7 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 .changeset/tui-long-session-render-lag.md diff --git a/.changeset/tui-long-session-render-lag.md b/.changeset/tui-long-session-render-lag.md new file mode 100644 index 0000000000..83939ba1e5 --- /dev/null +++ b/.changeset/tui-long-session-render-lag.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the TUI getting laggy and CPU-bound in long sessions: frames now skip unchanged transcript lines, and grouping or removing tool entries no longer forces a full transcript re-render. diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index bd427277d4..5db5871744 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -107,8 +107,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise= 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); } diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a20e8a513a..17d11ca891 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -647,8 +647,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(); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 484b9f02ad..60216f59ff 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -835,8 +835,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); } @@ -892,8 +893,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); } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index deb407bfc2..f2281ea54c 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -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(); } From 0fe4486ee52cad9df4fd1ad365bd402c5448f1ee Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 00:12:48 +0800 Subject: [PATCH 3/7] fix(tui): fold older assistant messages into the turn step summary Step merging only collapsed thinking/tool steps, so assistant text blocks accumulated without bound inside a turn (hundreds of markdown components in a single long turn, all re-processed every frame). A running turn now keeps its last 20 assistant messages (KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT), and a finished turn folds down to its conclusion tail of 2 (KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED); older ones collapse into the step summary line with a message count. Entries are kept, so expand behavior is unchanged. --- .changeset/tui-assistant-message-folding.md | 5 + .../tui/components/messages/step-summary.ts | 14 +- .../src/tui/controllers/streaming-ui.ts | 4 + apps/kimi-code/src/tui/kimi-tui.ts | 78 ++++++++--- .../src/tui/utils/transcript-window.ts | 16 +++ .../components/messages/step-summary.test.ts | 35 +++++ .../test/tui/kimi-tui-message-flow.test.ts | 128 ++++++++++++++++++ 7 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 .changeset/tui-assistant-message-folding.md create mode 100644 apps/kimi-code/test/tui/components/messages/step-summary.test.ts diff --git a/.changeset/tui-assistant-message-folding.md b/.changeset/tui-assistant-message-folding.md new file mode 100644 index 0000000000..a4208fc539 --- /dev/null +++ b/.changeset/tui-assistant-message-folding.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Collapse older assistant messages into the turn's step summary line so long turns stay bounded: a running turn keeps its last 20 messages, and a finished turn keeps only its conclusion tail (last 2). Set KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT and KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED to tune (0 disables folding). diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts index 325ae6eb24..62d5add2f7 100644 --- a/apps/kimi-code/src/tui/components/messages/step-summary.ts +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -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 {} @@ -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(', ')}`)]; } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 60216f59ff..5b6a35d7f5 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -36,6 +36,7 @@ export interface StreamingUIHost { shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; mergeCurrentTurnSteps(): void; + mergeCompletedTurnAssistants(): void; } export class StreamingUIController { @@ -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; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9d03f9395b..5ba28f76b8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -150,6 +150,8 @@ import { nextTranscriptId } from './utils/transcript-id'; import { TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_WINDOW_ENABLED, @@ -2100,7 +2102,25 @@ export class KimiTUI { } mergeCurrentTurnSteps(): boolean { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false; + return this.foldCurrentTurnContent(TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_KEEP_RECENT_ASSISTANT); + } + + /** + * Fold the just-finished turn's assistant messages down to the completed-turn + * cap: while a turn is live it may keep TRANSCRIPT_KEEP_RECENT_ASSISTANT + * messages mounted, but once it ends only the conclusion-bearing tail stays. + * Called when a turn finishes; the finished turn is still the current one at + * that point (no newer boundary exists yet). + */ + mergeCompletedTurnAssistants(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + ); + } + + private foldCurrentTurnContent(keepSteps: number, keepAssistants: number): boolean { + if (keepSteps <= 0 && keepAssistants <= 0) return false; const children = this.state.transcriptContainer.children; // Find the start of the current turn (last turn-starting user message). @@ -2113,22 +2133,34 @@ export class KimiTUI { } if (turnStart < 0) return false; - // Locate an existing summary, the assistant message, and the mergeable steps. + // Locate an existing summary, the assistant messages, and the mergeable steps. let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: 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; + if (child instanceof AssistantMessageComponent) { + assistantIndices.push(i); + 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); + // Fold the oldest steps / assistant messages beyond their respective caps; + // the most recent ones stay mounted. Children are chronological, so the + // oldest of each kind sit at the front of their index lists. + const stepMergeCount = keepSteps > 0 ? Math.max(0, stepIndices.length - keepSteps) : 0; + const assistantMergeCount = + keepAssistants > 0 ? Math.max(0, assistantIndices.length - keepAssistants) : 0; + if (stepMergeCount === 0 && assistantMergeCount === 0) return false; + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; @@ -2137,15 +2169,15 @@ export class KimiTUI { if (child instanceof ThinkingComponent) thinkingCount++; else if (child instanceof ToolCallComponent) toolCount++; } - if (thinkingCount === 0 && toolCount === 0) return false; + if (thinkingCount === 0 && toolCount === 0 && assistantMergeCount === 0) return false; let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } // Rebuild children: keep everything except the merged steps, with the summary @@ -2170,7 +2202,8 @@ export class KimiTUI { } mergeAllTurnSteps(): void { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return; + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) + return; const children = this.state.transcriptContainer.children; const boundaries: number[] = []; @@ -2190,16 +2223,29 @@ export class KimiTUI { let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: 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 if (child instanceof AssistantMessageComponent) assistantIndices.push(i); 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); + const stepMergeCount = + TRANSCRIPT_KEEP_RECENT_STEPS > 0 + ? Math.max(0, stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS) + : 0; + // Replayed turns are all completed turns, so the stricter completed-turn + // assistant cap applies (matching what live turns fold to on turn end). + const assistantMergeCount = + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED > 0 + ? Math.max(0, assistantIndices.length - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED) + : 0; + if (stepMergeCount > 0 || assistantMergeCount > 0) { + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; for (const idx of toMergeIndices) { @@ -2210,10 +2256,10 @@ export class KimiTUI { let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } newChildren.push(summary); for (const idx of toMergeIndices) toDispose.push(children[idx]!); diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts index ed0083b6fc..7f53fe6568 100644 --- a/apps/kimi-code/src/tui/utils/transcript-window.ts +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -5,6 +5,9 @@ * 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). + * Within a kept turn, older steps — and assistant messages beyond a cap — are + * folded into a collapsed summary line so a single long turn cannot grow the + * mounted component tree without bound. * * 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. @@ -40,6 +43,19 @@ export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 5); /** 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); +/** Keep this many recent assistant messages mounted inside the active turn; older ones fold into the step summary. `0` disables folding. */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT', 20); + +/** + * Once a turn ends, fold all but its last few assistant messages into the + * step summary — intermediate chatter is rarely re-read, while the tail + * usually holds the conclusion. `0` disables folding. + */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED = readEnvInt( + 'KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED', + 2, +); + export interface TranscriptTurn { readonly turnId: string | undefined; readonly entries: TranscriptEntry[]; diff --git a/apps/kimi-code/test/tui/components/messages/step-summary.test.ts b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts new file mode 100644 index 0000000000..85626c8ae6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('StepSummaryComponent', () => { + it('renders nothing when empty', () => { + const component = new StepSummaryComponent(); + expect(component.isEmpty).toBe(true); + expect(component.render(80)).toEqual([]); + }); + + it('renders thinking and tool counts without a message part', () => { + const component = new StepSummaryComponent(); + component.addCounts(5, 50); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('thinking 5 times'); + expect(out).toContain('call 50 tools'); + expect(out).not.toContain('messages'); + }); + + it('renders folded assistant message counts and accumulates', () => { + const component = new StepSummaryComponent(); + component.addCounts(0, 0, 3); + component.addCounts(2, 4, 5); + const out = strip(component.render(80).join('\n')); + expect(component.isEmpty).toBe(false); + expect(out).toContain('thinking 2 times'); + expect(out).toContain('call 4 tools'); + expect(out).toContain('8 messages'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 9e9804e262..9fe501d9e0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -19,6 +19,14 @@ import { AgentSwarmProgressComponent, agentSwarmGridHeightForTerminalRows, } from '#/tui/components/messages/agent-swarm-progress'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; @@ -5483,3 +5491,123 @@ describe('/effort support_efforts override', () => { expect(session.setThinking).not.toHaveBeenCalled(); }); }); + +describe('transcript step and assistant folding', () => { + function driveSteps(driver: MessageDriver, cycles: number): void { + for (let i = 0; i < cycles; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: `msg-${i} `, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + name: 'Bash', + args: { command: 'ls' }, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + output: 'ok', + isError: undefined, + } as Event, + vi.fn(), + ); + } + } + + it('folds the oldest assistant messages and steps beyond their per-turn caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('fold me'); + + const cycles = Math.max(TRANSCRIPT_KEEP_RECENT_ASSISTANT, TRANSCRIPT_KEEP_RECENT_STEPS) + 7; + driveSteps(driver, cycles); + + const children = driver.state.transcriptContainer.children; + const assistantCount = children.filter( + (child) => child instanceof AssistantMessageComponent, + ).length; + const toolCount = children.filter((child) => child instanceof ToolCallComponent).length; + expect(assistantCount).toBe(TRANSCRIPT_KEEP_RECENT_ASSISTANT); + expect(toolCount).toBe(TRANSCRIPT_KEEP_RECENT_STEPS); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${cycles - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT} messages`); + + // Folding drops mounted components only; every transcript entry is kept. + const assistantEntries = driver.state.transcriptEntries.filter( + (entry) => entry.kind === 'assistant', + ); + expect(assistantEntries).toHaveLength(cycles); + }); + + it('does not fold a turn within the caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('small turn'); + driveSteps(driver, 3); + + const children = driver.state.transcriptContainer.children; + expect(children.filter((child) => child instanceof AssistantMessageComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof StepSummaryComponent)).toHaveLength(0); + }); + + it('folds a completed turn down to its conclusion tail on turn end', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('round one'); + const cycles = 10; + driveSteps(driver, cycles); + + // Below the active-turn caps, nothing folds while the turn is live. + let children = driver.state.transcriptContainer.children; + expect( + children.filter((child) => child instanceof AssistantMessageComponent), + ).toHaveLength(cycles); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + + children = driver.state.transcriptContainer.children; + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); + + // Steps below the step cap are untouched by the completed-turn fold. + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(cycles); + + // The conclusion stays mounted. + const lastAssistant = assistants.at(-1)!; + expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); + }); +}); From 945f5672c59307cac60df84b640885c7976f0207 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 00:13:36 +0800 Subject: [PATCH 4/7] fix(session): bound resumed history to the most recent user turns Resume used to return every agent's full replay over the RPC boundary (a long session can reach ~100MB, serialized and parsed several times on an in-process call), while the TUI only renders the last 10 user turns. The resume payload now accepts an optional replayTurnLimit, the turn-boundary predicate moves into agent-core as the single source of truth (limitAgentReplayByTurns, re-exported through the SDK), and the CLI passes its existing 10-turn limit so resume transfers just the tail. --- .changeset/resume-replay-turn-limit.md | 7 +++ apps/kimi-code/src/tui/kimi-tui.ts | 8 ++- .../kimi-code/src/tui/utils/message-replay.ts | 38 ++---------- .../test/tui/kimi-tui-startup.test.ts | 21 +++++-- .../src/composables/messagesToTurns.ts | 13 +++-- packages/agent-core/src/agent/replay/turns.ts | 58 +++++++++++++++++++ packages/agent-core/src/index.ts | 1 + packages/agent-core/src/rpc/core-api.ts | 6 ++ packages/agent-core/src/rpc/core-impl.ts | 20 ++++++- .../src/services/message/message.ts | 5 +- packages/agent-core/test/agent/resume.test.ts | 54 +++++++++++++++++ packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/types.ts | 6 ++ 13 files changed, 189 insertions(+), 49 deletions(-) create mode 100644 .changeset/resume-replay-turn-limit.md create mode 100644 packages/agent-core/src/agent/replay/turns.ts diff --git a/.changeset/resume-replay-turn-limit.md b/.changeset/resume-replay-turn-limit.md new file mode 100644 index 0000000000..c57ba2748f --- /dev/null +++ b/.changeset/resume-replay-turn-limit.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +--- + +Speed up resuming long sessions by letting callers bound the resumed history to the most recent user turns; the CLI now resumes with only the tail of long histories instead of transferring the full history. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5ba28f76b8..db54e9a54c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -134,6 +134,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -776,6 +777,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: startup.sessionFlag, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -785,6 +787,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: target.id, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -1702,7 +1705,10 @@ export class KimiTUI { let session: Session; try { - session = await this.harness.resumeSession({ id: targetSessionId }); + session = await this.harness.resumeSession({ + id: targetSessionId, + replayTurnLimit: REPLAY_TURN_LIMIT, + }); } catch (error) { const msg = formatErrorMessage(error); this.showError(`Failed to resume session ${targetSessionId}: ${msg}`); diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 99441472b9..d778b9a472 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -7,6 +7,7 @@ import type { ResumedAgentState, ToolCall, } from '@moonshot-ai/kimi-code-sdk'; +import { limitAgentReplayByTurns } from '@moonshot-ai/kimi-code-sdk'; import type { AppState, @@ -132,12 +133,10 @@ export function limitReplayRecordsByTurn( records: readonly AgentReplayRecord[], maxTurns: number, ): readonly AgentReplayRecord[] { - if (maxTurns <= 0) return []; - const turnStarts = records.flatMap((record, index) => - isReplayUserTurnRecord(record) ? [index] : [], - ); - if (turnStarts.length <= maxTurns) return records; - return records.slice(turnStarts[turnStarts.length - maxTurns]); + // Defensive slice — the core already trims the replay when the caller passes + // `replayTurnLimit` on resume; the boundary predicate lives in agent-core + // (`limitAgentReplayByTurns`) and is re-exported through the SDK. + return limitAgentReplayByTurns(records, maxTurns); } export function replayEntry( @@ -264,33 +263,6 @@ export function formatHookResultMessageForTranscript( return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n'); } -function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { - if (record.type !== 'message') return false; - const { message } = record; - if (message.role !== 'user') return false; - switch (message.origin?.kind) { - case undefined: - case 'user': - return true; - case 'skill_activation': - return message.origin.trigger === 'user-slash'; - case 'plugin_command': - return message.origin.trigger === 'user-slash'; - case 'shell_command': - // A `!` command's input is a user-turn anchor; its output is not. - return message.origin.phase === 'input'; - case 'background_task': - case 'compaction_summary': - case 'cron_job': - case 'cron_missed': - case 'hook_result': - case 'injection': - case 'retry': - case 'system_trigger': - return false; - } -} - function parseReplayToolArguments(value: string | null): Record { if (value === null || value.length === 0) return {}; try { diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index a914ff5234..79f8cfb552 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -13,6 +13,7 @@ import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/co import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { quoteShellArg } from '#/utils/shell-quote'; import { @@ -288,7 +289,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-latest', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe('ses-latest'); @@ -1476,7 +1480,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-latest', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe(''); @@ -1493,7 +1500,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-target', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe(''); }); @@ -1747,7 +1757,10 @@ describe('KimiTUI startup', () => { sessionId: 'ses-target', workDir: String.raw`C:\Users\kimi\project`, }); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-target', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(driver.state.appState.sessionId).toBe('ses-target'); }); }); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index aaa2a67a9a..82b170a2c8 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -471,12 +471,13 @@ function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_mis /** - * Whether a USER-role message should be shown. Mirrors the TUI's - * isReplayUserTurnRecord: only real user input (origin `user`/absent, or a - * user-typed slash command) is displayed; system-injected user turns - * (compaction summaries, injections, hook results, retries, system triggers, - * background tasks, cron) are hidden. The origin arrives via message metadata - * (see toProtocolMessage in @moonshot-ai/agent-core). + * Whether a USER-role message should be shown. Mirrors agent-core's + * isAgentReplayUserTurnRecord (agent/replay/turns.ts): only real user input + * (origin `user`/absent, or a user-typed slash command) is displayed; + * system-injected user turns (compaction summaries, injections, hook results, + * retries, system triggers, background tasks, cron) are hidden. The origin + * arrives via message metadata (see toProtocolMessage in + * @moonshot-ai/agent-core). */ function isDisplayableUserMessage(msg: AppMessage): boolean { const origin = msg.metadata?.['origin'] as { kind?: string; trigger?: string } | undefined; diff --git a/packages/agent-core/src/agent/replay/turns.ts b/packages/agent-core/src/agent/replay/turns.ts new file mode 100644 index 0000000000..dfb3d5bf05 --- /dev/null +++ b/packages/agent-core/src/agent/replay/turns.ts @@ -0,0 +1,58 @@ +import type { AgentReplayRecord } from '../../rpc/resumed'; + +/** + * User-turn boundary detection over an agent's replay records. + * + * A record starts a new user turn when it is a user-role message that came + * from an actual user action — a typed prompt, a user-invoked skill/plugin + * slash command, or a `!` shell command's input line. System-originated user + * messages (compaction summaries, cron fires, hook results, retries, goal + * continuation triggers, background-task results, injections) continue the + * current turn instead. + * + * Source of truth for turn-boundary detection; the TUI mirrors this through + * the SDK re-export instead of keeping its own predicate. + */ +export function isAgentReplayUserTurnRecord(record: AgentReplayRecord): boolean { + if (record.type !== 'message') return false; + const { message } = record; + if (message.role !== 'user') return false; + switch (message.origin?.kind) { + case undefined: + case 'user': + return true; + case 'skill_activation': + return message.origin.trigger === 'user-slash'; + case 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; + case 'background_task': + case 'compaction_summary': + case 'cron_job': + case 'cron_missed': + case 'hook_result': + case 'injection': + case 'retry': + case 'system_trigger': + return false; + } +} + +/** + * Keep only the most recent `maxTurns` user turns of a replay. `undefined` + * keeps the full replay; `0` or negative returns an empty replay. + */ +export function limitAgentReplayByTurns( + records: readonly AgentReplayRecord[], + maxTurns?: number, +): readonly AgentReplayRecord[] { + if (maxTurns === undefined) return records; + if (maxTurns <= 0) return []; + const turnStarts = records.flatMap((record, index) => + isAgentReplayUserTurnRecord(record) ? [index] : [], + ); + if (turnStarts.length <= maxTurns) return records; + return records.slice(turnStarts[turnStarts.length - maxTurns]); +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 25236a7d2b..46b97172a5 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -8,6 +8,7 @@ export * from './telemetry'; export * from './errors'; export * from './plugin'; export { buildReplay } from './agent/replay/build'; +export { isAgentReplayUserTurnRecord, limitAgentReplayByTurns } from './agent/replay/turns'; export { flushDiagnosticLogs, flushDiagnosticLogsSync, diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 5ae3f283b2..faa44f538f 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -83,6 +83,12 @@ export interface ResumeSessionPayload { readonly additionalDirs?: readonly string[]; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; + /** + * Limit each returned agent replay to the most recent N user turns. Omit to + * return the full replay. Lets UI callers that only render the tail avoid + * serializing the entire history over the RPC boundary. + */ + readonly replayTurnLimit?: number; } export interface ReloadSessionPayload { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 09cf2965ca..4b34590d38 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -12,6 +12,7 @@ import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; +import { limitAgentReplayByTurns } from '../agent/replay/turns'; import { applyPrintModeConfigDefaults, ensureKimiHome, @@ -483,7 +484,13 @@ export class KimiCore implements PromisableMethods { } await active.setBaseAdditionalDirs(additionalDirs); return withAdditionalDirs( - await resumeSessionResult(summary, active, undefined, input.includeSubagents), + await resumeSessionResult( + summary, + active, + undefined, + input.includeSubagents, + input.replayTurnLimit, + ), active, ); } @@ -544,7 +551,13 @@ export class KimiCore implements PromisableMethods { // (and any SDK caller's resumeState) reflects the refreshed plugin context. await session.appendPluginSessionStartReminder(); } - return resumeSessionResult(summary, session, warning, input.includeSubagents); + return resumeSessionResult( + summary, + session, + warning, + input.includeSubagents, + input.replayTurnLimit, + ); } async reloadSession(input: ReloadSessionPayload): Promise { @@ -1482,6 +1495,7 @@ async function resumeSessionResult( session: Session, warning?: string, includeSubagents = false, + replayTurnLimit?: number, ): Promise { if (includeSubagents) { const persistedAgentIds = Object.keys(session.metadata.agents).filter( @@ -1513,7 +1527,7 @@ async function resumeSessionResult( type: agent.type, config, context, - replay: agent.replayBuilder.buildResult(), + replay: limitAgentReplayByTurns(agent.replayBuilder.buildResult(), replayTurnLimit), permission, plan, swarmMode, diff --git a/packages/agent-core/src/services/message/message.ts b/packages/agent-core/src/services/message/message.ts index 3b6ffb197c..1d25ec626f 100644 --- a/packages/agent-core/src/services/message/message.ts +++ b/packages/agent-core/src/services/message/message.ts @@ -269,8 +269,9 @@ export function toProtocolMessage( // Expose the message origin (kosong/agent-core `origin`) via metadata so REST // clients (e.g. the web UI) can hide injected/system user turns — compaction // summaries, injections, hook results, retries, system triggers, cron, etc. — - // the same way the TUI does (see isReplayUserTurnRecord). Absent for plain - // user/assistant/tool messages with no origin. + // the same way the TUI does (see isAgentReplayUserTurnRecord in + // agent/replay/turns.ts). Absent for plain user/assistant/tool messages with + // no origin. const metadata = msg.origin !== undefined ? { origin: msg.origin } : undefined; return { id, diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 24b14a8ae4..1236caed61 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -5,10 +5,13 @@ import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; import type { AgentRecord } from '../../src/agent'; +import type { PromptOrigin } from '../../src/agent/context'; import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, } from '../../src/agent/records'; +import { limitAgentReplayByTurns } from '../../src/agent/replay/turns'; +import type { AgentReplayRecord } from '../../src/rpc/resumed'; import { BackgroundTaskPersistence } from '../../src/agent/background'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; @@ -1688,3 +1691,54 @@ function findRpcEvent( ) { return ctxEvents.find((entry) => entry.type === '[rpc]' && entry.event === event); } + +describe('limitAgentReplayByTurns', () => { + const replayMessage = ( + role: 'user' | 'assistant', + text: string, + origin?: PromptOrigin, + ): AgentReplayRecord => + ({ + time: 0, + type: 'message', + message: { role, content: [{ type: 'text', text }], ...(origin ? { origin } : {}) }, + }) as AgentReplayRecord; + + it('returns the full replay when maxTurns is undefined', () => { + const records = [replayMessage('user', 'a'), replayMessage('assistant', 'b')]; + expect(limitAgentReplayByTurns(records, undefined)).toBe(records); + }); + + it('returns an empty replay when maxTurns is zero', () => { + expect(limitAgentReplayByTurns([replayMessage('user', 'a')], 0)).toEqual([]); + }); + + it('keeps the most recent user turns, treating system-triggered user messages as continuations', () => { + const records = [ + replayMessage('user', 'first', { kind: 'user' }), + replayMessage('assistant', 'one'), + replayMessage('user', 'second', { kind: 'user' }), + replayMessage('user', 'goal continuation', { kind: 'system_trigger', name: 'goal' }), + replayMessage('assistant', 'two'), + replayMessage('user', 'third', { kind: 'user' }), + replayMessage('assistant', 'three'), + ]; + expect(limitAgentReplayByTurns(records, 2)).toEqual(records.slice(2)); + }); + + it('treats user-slash activations and shell command inputs as boundaries, but not their outputs', () => { + const records = [ + replayMessage('user', 't1', { kind: 'user' }), + replayMessage('user', '/skill', { + kind: 'skill_activation', + activationId: 'act-1', + skillName: 'demo', + trigger: 'user-slash', + }), + replayMessage('user', '!ls', { kind: 'shell_command', phase: 'input' }), + replayMessage('user', 'ls output', { kind: 'shell_command', phase: 'output' }), + replayMessage('user', 't2', { kind: 'user' }), + ]; + expect(limitAgentReplayByTurns(records, 2)).toEqual(records.slice(2)); + }); +}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 6b731391ee..84992c2631 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -64,6 +64,7 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // by hosts (e.g. the CLI's server telemetry bootstrap) that need to inspect // config without spinning up a full KimiCore. export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core'; +export { limitAgentReplayByTurns } from '@moonshot-ai/agent-core'; // Process-wide HTTP proxy bootstrap — installed once at CLI startup so all // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c72419e3f4..0968d96a18 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -130,6 +130,12 @@ export interface ResumeSessionInput { readonly additionalDirs?: readonly string[]; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; + /** + * Limit each returned agent replay to the most recent N user turns. Omit to + * return the full replay. Lets UI callers that only render the tail avoid + * transferring the entire history over the RPC boundary. + */ + readonly replayTurnLimit?: number; readonly sessionStartedProperties?: TelemetryProperties; } From fa670efac3840741440d9396b29fdc90aeab9346 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 00:13:46 +0800 Subject: [PATCH 5/7] fix(session): count goal continuation rounds as replay turns Replay turn boundaries only matched real user input, so a 100-round goal (a handful of user prompts plus 100 system-trigger continuations) fell entirely inside the 10-turn replay window and resumed by rendering the whole run from the start. The goal driver already fires one synthetic prompt per goal turn and counts those as turns itself, so replay trimming now treats goal_continuation prompts as turn boundaries and keeps the most recent 10 rounds. The continuation prompt is model-facing and hidden live; replay no longer renders it as a user bubble either, while still advancing the replay turn so each round groups separately. --- .changeset/goal-resume-turn-trim.md | 6 ++++ .../src/tui/controllers/session-replay.ts | 12 +++++++ .../kimi-code/test/tui/message-replay.test.ts | 32 +++++++++++++++++++ packages/agent-core/src/agent/replay/turns.ts | 15 +++++++-- packages/agent-core/test/agent/resume.test.ts | 23 +++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .changeset/goal-resume-turn-trim.md diff --git a/.changeset/goal-resume-turn-trim.md b/.changeset/goal-resume-turn-trim.md new file mode 100644 index 0000000000..fef43eeedb --- /dev/null +++ b/.changeset/goal-resume-turn-trim.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/agent-core": patch +--- + +Fix resume replaying an entire goal session from the start: goal continuation rounds now count as turns when trimming resumed history (the most recent 10 are kept), and the goal driver's synthetic continuation prompts no longer render as user messages on replay. diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 17d11ca891..b7244ba646 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -307,6 +307,18 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'system_trigger') { return; } + if ( + message.origin?.kind === 'system_trigger' && + message.origin.name === 'goal_continuation' + ) { + // The goal driver's synthetic "continue" prompt is model-facing (the + // autonomous stand-in for the user typing continue) and is never shown + // live, so don't render it as a user bubble on replay either. Still + // advance the replay turn: each goal round groups under its own turn, + // matching how replay trimming counts goal rounds as turns. + this.advanceTurn(context); + return; + } this.flushAssistant(context); const skill = skillActivationFromOrigin(message.origin); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index d13b8595b2..8c2e0c829d 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -17,6 +17,7 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -1220,4 +1221,35 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Plan rejected by user.'); expect(transcript).not.toContain('Plan mode: OFF'); }); + + it('trims goal sessions to the most recent goal turns and hides continuation prompts', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + for (let i = 0; i < 25; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + // Continuation prompts are model-facing and never render as user bubbles. + expect(transcript).not.toContain('Continue working toward the active goal.'); + // Only the most recent REPLAY_TURN_LIMIT goal turns are replayed. + expect(transcript).not.toContain('round 0 summary'); + expect(transcript).not.toContain('round 14 summary'); + expect(transcript).toContain('round 15 summary'); + expect(transcript).toContain('round 24 summary'); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ToolCallComponent, + ), + ).toHaveLength(10); + }); }); diff --git a/packages/agent-core/src/agent/replay/turns.ts b/packages/agent-core/src/agent/replay/turns.ts index dfb3d5bf05..fef7f941ee 100644 --- a/packages/agent-core/src/agent/replay/turns.ts +++ b/packages/agent-core/src/agent/replay/turns.ts @@ -7,8 +7,12 @@ import type { AgentReplayRecord } from '../../rpc/resumed'; * from an actual user action — a typed prompt, a user-invoked skill/plugin * slash command, or a `!` shell command's input line. System-originated user * messages (compaction summaries, cron fires, hook results, retries, goal - * continuation triggers, background-task results, injections) continue the - * current turn instead. + * reminders, background-task results, injections) continue the current turn + * instead — with one exception: `goal_continuation` prompts. The goal driver + * fires one synthetic continuation prompt per goal turn (see + * agent/turn/index.ts), and the goal system itself counts those as turns, so + * replay trimming treats them as turn boundaries; otherwise a 100-round goal + * would count as a single user turn and resume would replay the entire run. * * Source of truth for turn-boundary detection; the TUI mirrors this through * the SDK re-export instead of keeping its own predicate. @@ -35,8 +39,13 @@ export function isAgentReplayUserTurnRecord(record: AgentReplayRecord): boolean case 'hook_result': case 'injection': case 'retry': - case 'system_trigger': return false; + case 'system_trigger': + // The goal driver fires one synthetic continuation prompt per goal turn + // (agent/turn/index.ts GOAL_CONTINUATION_ORIGIN) — real rounds of work + // the goal system itself counts as turns. All other system triggers are + // reminders that continue the current turn. + return message.origin.name === 'goal_continuation'; } } diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 1236caed61..0bb76debe9 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -1741,4 +1741,27 @@ describe('limitAgentReplayByTurns', () => { ]; expect(limitAgentReplayByTurns(records, 2)).toEqual(records.slice(2)); }); + + it('treats goal continuation prompts as turn boundaries, but not other system triggers', () => { + const rounds = (n: number): AgentReplayRecord[] => + Array.from({ length: n }, (_, i) => [ + replayMessage('user', 'Resume the active goal.', { + kind: 'system_trigger', + name: 'goal_continuation', + }), + replayMessage('assistant', `round ${i}`), + ]).flat(); + const records = [ + replayMessage('user', '/goal ship it', { kind: 'user' }), + ...rounds(15), + replayMessage('user', 'cancelled reminder', { + kind: 'system_trigger', + name: 'goal_cancelled', + }), + ]; + const limited = limitAgentReplayByTurns(records, 10); + // The /goal prompt and the first five continuation rounds fall away; the + // trailing reminder stays attached to the last kept turn. + expect(limited).toEqual(records.slice(11)); + }); }); From 779e28ca29f0845e4e061b0d1ec77f0022245891 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 11:15:04 +0800 Subject: [PATCH 6/7] fix(tui): keep replay turn folding when goal continuations are hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing goal continuation bubbles removed every turn-boundary component from goal-session replays, so mergeAllTurnSteps found no turn edges and skipped step/assistant folding entirely — a single oversized goal round still mounted all of its tool cards and assistant messages. Mount an invisible ReplayTurnBoundaryComponent for each hidden continuation: it renders zero lines but keeps the turn edges discoverable to folding and window trimming, matching replay trimming's per-round turn semantics. --- .../tui/components/messages/user-message.ts | 14 +++++ .../src/tui/controllers/session-replay.ts | 24 ++++---- apps/kimi-code/src/tui/kimi-tui.ts | 13 ++++- .../kimi-code/test/tui/message-replay.test.ts | 58 +++++++++++++++++++ 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index cec7fbd133..e7241e963a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -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 []; + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index b7244ba646..f1a027a021 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -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 { @@ -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, @@ -305,18 +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') { - return; - } - if ( - message.origin?.kind === 'system_trigger' && - message.origin.name === 'goal_continuation' - ) { - // The goal driver's synthetic "continue" prompt is model-facing (the - // autonomous stand-in for the user typing continue) and is never shown - // live, so don't render it as a user bubble on replay either. Still - // advance the replay turn: each goal round groups under its own turn, - // matching how replay trimming counts goal rounds as turns. - this.advanceTurn(context); + 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; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index db54e9a54c..464c7237aa 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -85,7 +85,10 @@ import { import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import { UserMessageComponent } from './components/messages/user-message'; +import { + ReplayTurnBoundaryComponent, + UserMessageComponent, +} from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; @@ -2020,7 +2023,8 @@ export class KimiTUI { if ( !(child instanceof UserMessageComponent) && !(child instanceof SkillActivationComponent) && - !(child instanceof PluginCommandComponent) + !(child instanceof PluginCommandComponent) && + !(child instanceof ReplayTurnBoundaryComponent) ) { return false; } @@ -2108,7 +2112,10 @@ export class KimiTUI { } mergeCurrentTurnSteps(): boolean { - return this.foldCurrentTurnContent(TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_KEEP_RECENT_ASSISTANT); + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + ); } /** diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 8c2e0c829d..20d825ce04 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -17,6 +17,12 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; @@ -1252,4 +1258,56 @@ describe('KimiTUI resume message replay', () => { ), ).toHaveLength(10); }); + + it('folds oversized goal rounds even though continuation boundaries are hidden', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + // Ten continuation rounds — exactly at the replay turn limit, so nothing + // is trimmed and only folding can bound the oversized final round. + for (let i = 0; i < 9; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + // Final round: 40 tool calls and 5 assistant texts in one continuation turn. + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + for (let t = 0; t < 40; t++) { + replay.push( + message('assistant', t < 5 ? [{ type: 'text', text: `final text ${t}` }] : [], { + toolCalls: [toolCall(`final_${t}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `final_${t}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const children = driver.state.transcriptContainer.children; + + // The oversized round folds to the per-turn caps even with no visible + // boundary component mounted for the continuation prompt. + const tools = children.filter((child) => child instanceof ToolCallComponent); + expect(tools).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_STEPS); + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripAnsi(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${40 - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${5 - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); + + // The folded content is gone from view; the latest work stays. + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('final text 0'); + expect(transcript).toContain('final text 4'); + }); }); From cf511ffdb018ceadb0e0964ad29d126a985bef66 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 21 Jul 2026 13:55:38 +0800 Subject: [PATCH 7/7] chore: consolidate and simplify changesets --- .changeset/goal-resume-turn-trim.md | 6 ------ .changeset/long-session-tui-performance.md | 7 +++++++ .changeset/pi-tui-frame-line-reuse.md | 2 +- .changeset/resume-replay-turn-limit.md | 7 ------- .changeset/tui-assistant-message-folding.md | 5 ----- .changeset/tui-long-session-render-lag.md | 5 ----- 6 files changed, 8 insertions(+), 24 deletions(-) delete mode 100644 .changeset/goal-resume-turn-trim.md create mode 100644 .changeset/long-session-tui-performance.md delete mode 100644 .changeset/resume-replay-turn-limit.md delete mode 100644 .changeset/tui-assistant-message-folding.md delete mode 100644 .changeset/tui-long-session-render-lag.md diff --git a/.changeset/goal-resume-turn-trim.md b/.changeset/goal-resume-turn-trim.md deleted file mode 100644 index fef43eeedb..0000000000 --- a/.changeset/goal-resume-turn-trim.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/agent-core": patch ---- - -Fix resume replaying an entire goal session from the start: goal continuation rounds now count as turns when trimming resumed history (the most recent 10 are kept), and the goal driver's synthetic continuation prompts no longer render as user messages on replay. diff --git a/.changeset/long-session-tui-performance.md b/.changeset/long-session-tui-performance.md new file mode 100644 index 0000000000..881873520b --- /dev/null +++ b/.changeset/long-session-tui-performance.md @@ -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. diff --git a/.changeset/pi-tui-frame-line-reuse.md b/.changeset/pi-tui-frame-line-reuse.md index 8bf331fc3a..b75646a037 100644 --- a/.changeset/pi-tui-frame-line-reuse.md +++ b/.changeset/pi-tui-frame-line-reuse.md @@ -2,4 +2,4 @@ "@moonshot-ai/pi-tui": patch --- -Reuse the processed output of unchanged lines across frames so a steady-state frame only pays for the lines that actually changed, instead of re-processing the whole transcript on every spinner tick and streaming flush. +Speed up the terminal renderer in long sessions by reusing processed lines across frames. diff --git a/.changeset/resume-replay-turn-limit.md b/.changeset/resume-replay-turn-limit.md deleted file mode 100644 index c57ba2748f..0000000000 --- a/.changeset/resume-replay-turn-limit.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/kimi-code-sdk": patch -"@moonshot-ai/agent-core": patch ---- - -Speed up resuming long sessions by letting callers bound the resumed history to the most recent user turns; the CLI now resumes with only the tail of long histories instead of transferring the full history. diff --git a/.changeset/tui-assistant-message-folding.md b/.changeset/tui-assistant-message-folding.md deleted file mode 100644 index a4208fc539..0000000000 --- a/.changeset/tui-assistant-message-folding.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Collapse older assistant messages into the turn's step summary line so long turns stay bounded: a running turn keeps its last 20 messages, and a finished turn keeps only its conclusion tail (last 2). Set KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT and KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED to tune (0 disables folding). diff --git a/.changeset/tui-long-session-render-lag.md b/.changeset/tui-long-session-render-lag.md deleted file mode 100644 index 83939ba1e5..0000000000 --- a/.changeset/tui-long-session-render-lag.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the TUI getting laggy and CPU-bound in long sessions: frames now skip unchanged transcript lines, and grouping or removing tool entries no longer forces a full transcript re-render.