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/bound-completed-shell-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep completed shell-command frames bounded in the terminal UI while preserving the full command output in session transcripts and exports.
98 changes: 85 additions & 13 deletions apps/kimi-code/src/tui/components/messages/shell-run.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,81 @@
import { Container, Text } from '@moonshot-ai/pi-tui';

import { currentTheme } from '#/tui/theme';
import type { TranscriptEntry } from '#/tui/types';

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

const RUNNING_TAIL_LINES = 5;
const TIMER_INTERVAL_MS = 1000;
// Cap the live running buffer so a command that spews output for minutes can't
// grow memory without bound or make every render re-strip a multi-MB string.
// Only affects the transient running tail; the final view uses the full
// captured stdout/stderr passed to finish().
const MAX_COMBINED_CHARS = 256 * 1024;
// Also caps each stream in the completed tail so one long line cannot expand
// into an unbounded number of wrapped rows.
const KEEP_COMBINED_CHARS = 64 * 1024;

function outputTail(text: string): string {
const cleaned = sanitizeShellOutput(text).trimEnd();
if (cleaned.length === 0) return '';

let start = cleaned.length;
let cursor = cleaned.length;
for (let lines = 0; lines < RUNNING_TAIL_LINES && cursor > 0; lines++) {
const newline = cleaned.lastIndexOf('\n', cursor - 1);
start = newline + 1;
if (newline < 0) break;
cursor = newline;
}

start = Math.max(start, cleaned.length - KEEP_COMBINED_CHARS);
const firstCodePoint = cleaned.codePointAt(start);
if (firstCodePoint !== undefined && firstCodePoint >= 0xdc00 && firstCodePoint <= 0xdfff) start++;

let omittedLines = 0;
for (let i = 0; i < start; i++) {
if (cleaned[i] === '\n') omittedLines++;
}

if (start === 0) return cleaned;
const hint =
omittedLines > 0
? `... (${String(omittedLines)} earlier lines)`
: '... (earlier output truncated)';
return `${hint}\n${cleaned.slice(start)}`;
}

type ShellOutputDisplay = NonNullable<TranscriptEntry['shellOutputDisplay']>;

export function shellOutputDisplay(
stdout: string,
stderr: string,
isError?: boolean,
): ShellOutputDisplay {
return {
stdoutTail: outputTail(stdout),
stderrTail: outputTail(stderr),
isError,
};
}

function formatFinalOutput(stdoutTail: string, stderrTail: string, isError?: boolean): string {
try {
const formatted = formatBashOutputForDisplay(stdoutTail, stderrTail, isError);
return ` ${formatted.replaceAll('\n', '\n ')}`;
} catch {
return ' (output unavailable)';
}
}

/**
* Live view for a user-initiated `!` shell command. Two phases:
*
* - running: dim, ANSI-stripped tail of the combined output, a `+N lines`
* overflow marker, an elapsed `(Xs)` timer that ticks every second, and a
* `(ctrl+b to run in background)` hint — matching claude-code's running card
* so warnings are grey rather than red while the command works.
* - finished: the standard `formatBashOutputForDisplay` view (stderr red only
* on failure), the timer stopped and the running chrome removed.
* - finished: a bounded final tail (stderr red only on failure), with the
* timer stopped and the running chrome removed.
*
* Hardened so a misbehaving command can never crash the TUI: the running
* buffer is capped, and every render/render-request path swallows errors.
Expand All @@ -38,11 +92,24 @@ export class ShellRunComponent extends Container {
private readonly startedAt = Date.now();
private timer: ReturnType<typeof setInterval> | undefined;

constructor(private readonly requestRender: () => void) {
constructor(
private readonly requestRender: () => void,
completed?: ShellOutputDisplay,
) {
super();
if (completed !== undefined) {
this.running = false;
this.finalStdout = completed.stdoutTail;
this.finalStderr = completed.stderrTail;
this.finalIsError = completed.isError;
}
this.textComponent = new Text(this.renderText(), 0, 0);
this.addChild(this.textComponent);
this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS);
if (this.running) {
this.timer = setInterval(() => {
this.tick();
}, TIMER_INTERVAL_MS);
}
}

append(text: string): void {
Expand All @@ -57,9 +124,10 @@ export class ShellRunComponent extends Container {
finish(stdout: string, stderr: string, isError?: boolean): void {
if (this.disposed || !this.running) return;
this.running = false;
this.finalStdout = stdout;
this.finalStderr = stderr;
this.finalIsError = isError;
const completed = shellOutputDisplay(stdout, stderr, isError);
this.finalStdout = completed.stdoutTail;
this.finalStderr = completed.stderrTail;
this.finalIsError = completed.isError;
this.clearTimer();
this.flush();
}
Expand All @@ -77,6 +145,13 @@ export class ShellRunComponent extends Container {
this.clearTimer();
}

override invalidate(): void {
if (!this.disposed) {
this.textComponent.setText(this.renderText());
}
super.invalidate();
}

private tick(): void {
if (!this.running) return;
this.flush();
Expand Down Expand Up @@ -106,10 +181,7 @@ export class ShellRunComponent extends Container {
return ` ${currentTheme.fg('textDim', 'Moved to background.')}`;
}
if (!this.running) {
return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError)
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
return formatFinalOutput(this.finalStdout, this.finalStderr, this.finalIsError);
}
const elapsed = Math.floor((Date.now() - this.startedAt) / 1000);
const dim = (s: string): string => currentTheme.fg('textDim', s);
Expand Down
10 changes: 7 additions & 3 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@moonshot-ai/kimi-code-sdk';

import { ToolCallComponent } from '../components/messages/tool-call';
import { shellOutputDisplay } from '../components/messages/shell-run';
import { ReplayTurnBoundaryComponent } from '../components/messages/user-message';
import { currentTheme } from '../theme';
import type { TodoItem } from '../components/chrome/todo-panel';
Expand Down Expand Up @@ -288,10 +289,13 @@ export class SessionReplayRenderer {
}),
);
} else {
const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim();
const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim();
const stdout = extractBashTag(text, 'bash-stdout') ?? '';
const stderr = extractBashTag(text, 'bash-stderr') ?? '';
const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError);
this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain'));
this.host.appendTranscriptEntry({
...replayEntry(context, 'status', out, 'plain'),
shellOutputDisplay: shellOutputDisplay(stdout, stderr, message.origin.isError),
});
}
return;
}
Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,14 @@ export class KimiTUI {
if (entry.backgroundAgentStatus !== undefined) {
return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus);
}
if (entry.shellOutputDisplay !== undefined) {
return new ShellRunComponent(
() => {
this.state.ui.requestRender();
},
entry.shellOutputDisplay,
);
}
return entry.renderMode === 'notice'
? new NoticeMessageComponent(entry.content, entry.detail)
: new StatusMessageComponent(entry.content, entry.color);
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ export interface TranscriptEntry {
turnId?: string;
renderMode: 'markdown' | 'plain' | 'notice';
content: string;
/** Bounded presentation data for a completed foreground shell command. */
shellOutputDisplay?: {
readonly stdoutTail: string;
readonly stderrTail: string;
readonly isError?: boolean;
};
/**
* True only for entries holding real model-authored text (created by the
* assistant stream). Derived cards — hook results, goal completions, goal
Expand Down
61 changes: 61 additions & 0 deletions apps/kimi-code/test/tui/components/messages/shell-run.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
/**
* Scenario: foreground `!` shell output transitions from its live tail to the completed frame.
* Responsibilities: keep rendering safe and bound the completed frame for high-line-count output.
* Wiring: real ShellRunComponent and pi-tui Text rendering; only the render callback is inert.
* Run: pnpm --filter @moonshot-ai/kimi-code exec vitest run test/tui/components/messages/shell-run.test.ts
*/
import chalk from 'chalk';
import { afterEach, describe, expect, it } from 'vitest';

import { ShellRunComponent } from '#/tui/components/messages/shell-run';
import { currentTheme, darkColors, lightColors } from '#/tui/theme';

function stripTheme(text: string): string {
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
Expand Down Expand Up @@ -39,6 +47,59 @@ describe('ShellRunComponent hardening', () => {
expect(rendered).not.toContain('should be ignored');
});

it('keeps completed stdout and stderr bounded when both contain many short lines', () => {
const c = create();

c.finish(
`${'x\n'.repeat(49_999)}final stdout\n`,
`${'y\n'.repeat(49_999)}final stderr\n`,
true,
);
const rendered = c.render(120);
const text = stripTheme(rendered.join('\n'));

expect(rendered.length).toBeLessThanOrEqual(12);
expect(rendered.join('\n').length).toBeLessThanOrEqual(2_048);
expect(text.match(/\.\.\. \(49995 earlier lines\)/g)).toHaveLength(2);
expect(text).toContain('final stdout');
expect(text).toContain('final stderr');
});

it('caps a completed frame when output is one very long line', () => {
const c = create();

c.finish(`${'x'.repeat(200_000)}final`, '', false);
const rendered = c.render(120);
const text = stripTheme(rendered.join('\n'));

expect(rendered.length).toBeLessThanOrEqual(560);
expect(rendered.join('\n').length).toBeLessThanOrEqual(70_000);
expect(text).toContain('... (earlier output truncated)');
expect(text).toContain('final');
});

it('recolors completed output when the active theme changes', () => {
const c = create();
const previousPalette = currentTheme.palette;
const previousLevel = chalk.level;
try {
chalk.level = 3;
currentTheme.setPalette(darkColors);
c.finish('final output', '', false);
const dark = c.render(100).join('\n');

currentTheme.setPalette(lightColors);
c.invalidate();
const light = c.render(100).join('\n');

expect(light).not.toBe(dark);
expect(light).toContain(currentTheme.fg('textDim', 'final output'));
} finally {
currentTheme.setPalette(previousPalette);
chalk.level = previousLevel;
}
});

it('finishBackgrounded renders the background hint', () => {
const c = create();
c.finishBackgrounded();
Expand Down
41 changes: 41 additions & 0 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* Scenario: resume records are projected into the interactive TUI transcript.
* Responsibilities: restore visible messages, bounded presentation, grouping, and replay state.
* Wiring: real KimiTUI/controllers/components with an in-memory SDK Session boundary.
* Run: pnpm --filter @moonshot-ai/kimi-code exec vitest run test/tui/message-replay.test.ts
*/
import { AsyncLocalStorage } from 'node:async_hooks';

import type {
Expand All @@ -23,6 +29,7 @@ import {
TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED,
TRANSCRIPT_KEEP_RECENT_STEPS,
} from '#/tui/utils/transcript-window';
import { getTranscriptComponentEntry } from '#/tui/utils/transcript-component-metadata';
import { ToolCallComponent } from '#/tui/components/messages/tool-call';
import { ReadGroupComponent } from '#/tui/components/messages/read-group';

Expand Down Expand Up @@ -336,6 +343,40 @@ describe('KimiTUI resume message replay', () => {
expect(transcript).toContain('pre</bash-stdout>post');
});

it('bounds replayed shell frames while retaining the complete transcript content', async () => {
const driver = await replayIntoDriver([
message(
'user',
[
{
type: 'text',
text:
`<bash-stdout>${' x\n'.repeat(49_999)} final line\n</bash-stdout>` +
'<bash-stderr></bash-stderr>',
},
],
{ origin: { kind: 'shell_command', phase: 'output' } },
),
]);

const shellEntry = driver.state.transcriptEntries.find(
(entry) => entry.shellOutputDisplay !== undefined,
);
const shellComponent = driver.state.transcriptContainer.children.find(
(component) => getTranscriptComponentEntry(component)?.id === shellEntry?.id,
);
const rendered = shellComponent?.render(120) ?? [];
const transcript = stripAnsi(rendered.join('\n'));

expect(shellEntry?.content.length).toBeGreaterThan(200_000);
expect(stripAnsi(shellEntry?.content ?? '')).toMatch(/^ x/);
expect(shellEntry?.shellOutputDisplay?.stdoutTail).toContain('\n final line');
expect(rendered.length).toBeLessThanOrEqual(10);
expect(rendered.join('\n').length).toBeLessThanOrEqual(2_048);
expect(transcript).toContain('... (49995 earlier lines)');
expect(transcript).toContain('final line');
});

it('does not render neutral goal completion context reminders as transcript messages', async () => {
const driver = await replayIntoDriver([
message(
Expand Down
Loading