Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TranscriptEntry>();
for (const component of components) {
const entry = getTranscriptComponentEntry(component);
if (entry !== undefined) dropped.add(entry);
Comment on lines +2516 to +2517

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim entries created by the streaming controller

When assistant blocks are created through the normal live or replay path, StreamingUIController.onStreamingTextStart() pushes the entry and component separately without calling markTranscriptComponent (streaming-ui.ts:593-608), so this lookup returns undefined for the assistant components being folded. Once a turn exceeds the assistant cap, the components disappear but their potentially large text entries remain in transcriptEntries; the new test misses this because it uses appendTranscriptEntry(), which does establish the mapping. Associate the entry when the streaming controller creates the component, or reclaim it through another reliable association.

AGENTS.md reference: apps/kimi-code/AGENTS.md:L38-L39

Useful? React with 👍 / 👎.

}
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;
Expand Down Expand Up @@ -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);
}

Expand Down
111 changes: 111 additions & 0 deletions apps/kimi-code/test/tui/transcript-fold-reclaim.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading