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
5 changes: 5 additions & 0 deletions .changeset/trim-shell-transcript-turns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Bound the transcript in `!`-heavy sessions: each shell command now groups as its own trimmable turn instead of piling into an untrimmable tail turn, and a finished command's stored stdout/stderr is capped to the last 64 KB per stream.
14 changes: 11 additions & 3 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ import { startupTrace } from '#/utils/startup-trace';
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';
import { capStoredShellOutput, formatBashOutputForDisplay } from './utils/shell-output';
import { thinkingEffortFromConfig } from './utils/thinking-config';
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
import { installTerminalFocusTracking } from './utils/terminal-focus';
Expand Down Expand Up @@ -1214,10 +1214,18 @@ export class KimiTUI {
// the UI and the model notification, so there is nothing to render here.
return;
}
stream.component.finish(stdout, stderr, isError);
stream.component.finish(
capStoredShellOutput(stdout),
capStoredShellOutput(stderr),
isError,
);
// Keep the transcript entry's metadata in sync for anything that reads it
// (export / copy). The component renders itself.
stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError);
stream.entry.content = formatBashOutputForDisplay(
capStoredShellOutput(stdout),
capStoredShellOutput(stderr),
isError,
);
this.shellOutputStreams.delete(commandId);
// When the last shell command finishes, leave the shell streaming phase,
// release one queued message (if any), and refresh the activity pane.
Expand Down
19 changes: 19 additions & 0 deletions apps/kimi-code/src/tui/utils/shell-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,22 @@ export function formatBashOutputForDisplay(stdout: string, stderr: string, isErr
return plain.length > 0 ? plain : '(no output)';
}
}

/** Cap on each stored stream once a command finishes; the tail is what matters. */
export const MAX_STORED_STREAM_CHARS = 64 * 1024;

/**
* Bound a finished command's stored output. The running buffer is already
* capped mid-stream (see ShellRunComponent), but the final copies used to be
* stored whole: one inside the component, one formatted into the transcript
* entry, so a `! cat bigfile` habit grew the session without bound. Keep the
* tail and say on the first line how much was dropped.
*/
export function capStoredShellOutput(text: string, maxChars = MAX_STORED_STREAM_CHARS): string {
if (text.length <= maxChars) return text;
let tail = text.slice(-maxChars);
// Do not start the kept text on the low half of a surrogate pair.
const first = tail.codePointAt(0);
if (first !== undefined && first >= 0xdc00 && first <= 0xdfff) tail = tail.slice(1);
return `… (${text.length - tail.length} earlier chars truncated)\n${tail}`;
}
15 changes: 13 additions & 2 deletions apps/kimi-code/src/tui/utils/transcript-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ export interface TranscriptTurn {
* defined turn. This matters because a user message is appended (with
* `turnId: undefined`) before its turn actually starts, so without this
* buffering every user message would become its own single-entry turn at the
* front and get trimmed first. Any undefined entries left at the tail (no
* following turn) become their own turn.
* front and get trimmed first. A buffered run is flushed into its own turn
* when the next user entry arrives: `!` shell echoes are user entries too,
* but no defined turn ever follows them, so each one starts a fresh group.
* Any undefined entries left at the tail (no following turn) become their
* own turn.
*/
export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] {
const turns: TranscriptTurn[] = [];
Expand All @@ -80,6 +83,14 @@ export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[
for (const entry of entries) {
const turnId = entry.turnId;
if (turnId === undefined) {
// A `!` shell echo is a user entry with no turnId, and no defined turn
// ever follows it. Flush the buffered entries at each one so a
// `!`-heavy stretch becomes many small trimmable turns instead of a
// single tail turn that turnsToTrim can never touch.
if (entry.kind === 'user' && pendingUndefined.length > 0) {
turns.push({ turnId: undefined, entries: pendingUndefined });
pendingUndefined = [];
Comment on lines +90 to +92

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 Avoid splitting boundaryless entries into trimmable turns

When an undefined-turn, non-boundary entry is pending (for example the goal-completion assistant card appended without a turnId), this flush runs as soon as the next real user prompt is appended and creates a trimmable turn with no user/skill/plugin boundary. trimTranscriptWindow removes mounted components by counting those boundaries, so if such a boundaryless turn is selected for trimming (e.g. with aggressive KIMI_CODE_TUI_HYSTERESIS=0, or a run of several such entries under the default hysteresis), the entry is dropped from transcriptEntries but its component remains mounted, leaving stale UI and unreclaimed memory. Restrict the split to actual shell-command runs or teach the trim path to remove boundaryless turns too.

Useful? React with 👍 / 👎.

}
pendingUndefined.push(entry);
continue;
}
Expand Down
29 changes: 28 additions & 1 deletion apps/kimi-code/test/tui/utils/shell-output.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';
import { capStoredShellOutput, formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output';

const ESC = '\u001B';
const BEL = '\u0007';
Expand Down Expand Up @@ -106,3 +106,30 @@ describe('formatBashOutputForDisplay', () => {
).not.toThrow();
});
});

describe('capStoredShellOutput', () => {
it('keeps short output untouched', () => {
expect(capStoredShellOutput('hello\nworld')).toBe('hello\nworld');
});

it('keeps exactly maxChars untouched', () => {
const exact = 'x'.repeat(100);
expect(capStoredShellOutput(exact, 100)).toBe(exact);
});

it('keeps the tail and reports the dropped count', () => {
const long = `${'a'.repeat(80)}${'b'.repeat(80)}`;
const capped = capStoredShellOutput(long, 80);
expect(capped).toMatch(/^… \(80 earlier chars truncated\)\n/);
expect(capped.endsWith('b'.repeat(80))).toBe(true);
});

it('never starts the tail on a lone low surrogate', () => {
// 100 BMP chars, an astral char (surrogate pair), then 100 more; a 101-char
// cut lands on the low half of the pair, which must be dropped with it.
const text = `${'x'.repeat(100)}${'\u{1F600}'}${'y'.repeat(100)}`;
const capped = capStoredShellOutput(text, 101);
expect(capped).toMatch(/^… \(102 earlier chars truncated\)\n/);
expect(capped.split('\n')[1]).toBe('y'.repeat(100));
});
});
45 changes: 45 additions & 0 deletions apps/kimi-code/test/tui/utils/transcript-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ describe('groupTurns', () => {
expect(turns[1]!.turnId).toBeUndefined();
expect(turns[1]!.entries).toHaveLength(1);
});

it('splits a `!` shell stretch into one turn per command', () => {
const echo = () => makeEntry(undefined, 'user');
const out = () => makeEntry(undefined, 'status');
const turns = groupTurns([echo(), out(), echo(), out(), echo(), out()]);
expect(turns).toHaveLength(3);
for (const turn of turns) {
expect(turn.turnId).toBeUndefined();
expect(turn.entries.map((e) => e.kind)).toEqual(['user', 'status']);
}
});

it('still attaches a real prompt to the following defined turn', () => {
const prompt = makeEntry(undefined, 'user');
const turns = groupTurns([prompt, tool('7'), msg('7')]);
expect(turns).toHaveLength(1);
expect(turns[0]!.turnId).toBe('7');
expect(turns[0]!.entries[0]).toBe(prompt);
});

it('flushes a buffered `!` run when the next prompt arrives', () => {
const echo = makeEntry(undefined, 'user');
const out = makeEntry(undefined, 'status');
const prompt = makeEntry(undefined, 'user');
const turns = groupTurns([echo, out, prompt, msg('3')]);
expect(turns.map((t) => t.turnId)).toEqual([undefined, '3']);
expect(turns[0]!.entries).toEqual([echo, out]);
expect(turns[1]!.entries).toEqual([prompt, turns[1]!.entries[1]!]);
});
});

describe('turnsToTrim', () => {
Expand Down Expand Up @@ -77,6 +106,22 @@ describe('turnsToTrim', () => {
const removed = turnsToTrim(turns, 2, 0);
expect(removed.size).toBe(0);
});

it('trims old `!` command turns in a shell-heavy session', () => {
const entries: TranscriptEntry[] = [];
for (let i = 0; i < 10; i++) {
entries.push(makeEntry(undefined, 'user'), makeEntry(undefined, 'status'));
}
const turns = groupTurns(entries); // 10 small turns, one per command
expect(turns).toHaveLength(10);
const removed = turnsToTrim(turns, 3, 0);
// 10 > 3, oldest 7 commands trimmed; the newest 3 stay.
expect(removed.size).toBe(14);
expect(removed.has(entries[0]!)).toBe(true);
expect(removed.has(entries[13]!)).toBe(true);
expect(removed.has(entries[14]!)).toBe(false);
expect(removed.has(entries[19]!)).toBe(false);
});
});

describe('readEnvInt', () => {
Expand Down
Loading