From 1dee7cfbc9ad94c0ba413a259bdb40520e893a2f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:52:28 +0800 Subject: [PATCH] fix(tui): reclaim transcript entries when turns fold foldCurrentTurnContent and mergeAllTurnSteps dispose the merged components and splice the child list, but the matching entries stayed in state.transcriptEntries forever, so the entry list grew underneath the folded component tree. The Component-to-entry WeakMap already existed for exactly this mapping; use it to drop folded entries along with their components. Refs #2556 --- apps/kimi-code/src/tui/kimi-tui.ts | 20 +++- .../test/tui/transcript-fold-reclaim.test.ts | 111 ++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 714dd7e4b7..313f74df95 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2498,15 +2498,30 @@ export class KimiTUI { newChildren.push(children[i]!); } - for (const idx of toMergeIndices) { - const child = children[idx]!; + const mergedChildren = toMergeIndices.map((idx) => children[idx]!); + for (const child of mergedChildren) { if (hasDispose(child)) child.dispose(); } + // The merged components are gone; their transcript entries have to go + // too, or the entry list keeps growing underneath the folded tree. + this.dropTranscriptEntriesOf(mergedChildren); children.splice(0, children.length, ...newChildren); return true; } + private dropTranscriptEntriesOf(components: readonly Component[]): void { + const dropped = new Set(); + for (const component of components) { + const entry = getTranscriptComponentEntry(component); + if (entry !== undefined) dropped.add(entry); + } + if (dropped.size === 0) return; + this.state.transcriptEntries = this.state.transcriptEntries.filter( + (entry) => !dropped.has(entry), + ); + } + mergeAllTurnSteps(): void { if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) return; @@ -2583,6 +2598,7 @@ export class KimiTUI { for (const child of toDispose) { if (hasDispose(child)) child.dispose(); } + this.dropTranscriptEntriesOf(toDispose); children.splice(0, children.length, ...newChildren); } diff --git a/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts b/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts new file mode 100644 index 0000000000..ea56e455ba --- /dev/null +++ b/apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { KimiTUI, type KimiTUIStartupInput } from '#/tui/kimi-tui'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; + +function makeHarness() { + return { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession: vi.fn(async () => ({ id: 'ses-1', model: 'k2' })), + resumeSession: vi.fn(async () => ({ id: 'ses-1', model: 'k2' })), + listSessions: vi.fn(async () => []), + close: vi.fn(async () => {}), + track: vi.fn(), + setTelemetryContext: vi.fn(), + getExperimentalFeatures: vi.fn(async () => []), + supportsAtomicSectionReplace: vi.fn(() => false), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => {}), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }; +} + +function makeStartupInput(): KimiTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + tuiConfig: { + theme: 'dark', + disablePasteBurst: false, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, + }, + version: '0.0.0-test', + workDir: '/tmp/proj-a', + }; +} + +function makeDriver() { + const driver = new KimiTUI(makeHarness() as never, makeStartupInput()); + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + return driver; +} + +describe('transcript fold entry reclaim', () => { + it('drops the folded assistant entries when a completed turn folds', () => { + const driver = makeDriver(); + driver.appendTranscriptEntry({ id: 'u1', kind: 'user', renderMode: 'plain', content: 'hello' }); + for (const id of ['a0', 'a1', 'a2', 'a3']) { + driver.appendTranscriptEntry({ + id, + kind: 'assistant', + turnId: 't1', + renderMode: 'markdown', + content: `message ${id}`, + modelText: true, + }); + } + expect(driver.state.transcriptEntries).toHaveLength(5); + + const folded = driver.mergeCompletedTurnAssistants(); + + expect(folded).toBe(true); + // the two oldest assistants merged into the summary; the tail stays + expect(driver.state.transcriptEntries.map((entry) => entry.id)).toEqual(['u1', 'a2', 'a3']); + const summaryCount = driver.state.transcriptContainer.children.filter( + (child) => child instanceof StepSummaryComponent, + ).length; + expect(summaryCount).toBe(1); + }); + + it('keeps every entry when nothing exceeds the fold caps', () => { + const driver = makeDriver(); + driver.appendTranscriptEntry({ id: 'u1', kind: 'user', renderMode: 'plain', content: 'hello' }); + for (const id of ['a0', 'a1']) { + driver.appendTranscriptEntry({ + id, + kind: 'assistant', + turnId: 't1', + renderMode: 'markdown', + content: `message ${id}`, + modelText: true, + }); + } + + const folded = driver.mergeCompletedTurnAssistants(); + + expect(folded).toBe(false); + expect(driver.state.transcriptEntries.map((entry) => entry.id)).toEqual(['u1', 'a0', 'a1']); + }); +});