diff --git a/.changeset/trim-shell-transcript-turns.md b/.changeset/trim-shell-transcript-turns.md new file mode 100644 index 0000000000..242fd8698b --- /dev/null +++ b/.changeset/trim-shell-transcript-turns.md @@ -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. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a979de7219..f569ff1fa1 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -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. diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts index 3a482feb73..3a8487d07a 100644 --- a/apps/kimi-code/src/tui/utils/shell-output.ts +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -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}`; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts index 7f53fe6568..aab66d2c3e 100644 --- a/apps/kimi-code/src/tui/utils/transcript-window.ts +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -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[] = []; @@ -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 = []; + } pendingUndefined.push(entry); continue; } diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts index e7a724b43a..1b2400c1e6 100644 --- a/apps/kimi-code/test/tui/utils/shell-output.test.ts +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -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'; @@ -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)); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts index 4fbc23fec6..cc1061d223 100644 --- a/apps/kimi-code/test/tui/utils/transcript-window.test.ts +++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts @@ -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', () => { @@ -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', () => {