Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
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/collapse-shell-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"echadron": patch
---

Long `!` shell command output is collapsed in the transcript instead of flooding it, with the full text still available on expand.
87 changes: 68 additions & 19 deletions apps/kimi-code/src/tui/components/messages/shell-run.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,50 @@
import { Container, Text } from '@moonshot-ai/pi-tui';

import { SHELL_OUTPUT_PREVIEW_LINES } from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';

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

import { TruncatedOutputComponent } from './tool-renderers/truncated';

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().
// captured stdout/stderr passed to finish(). When the cap drops older output,
// the expanded running view says so via TRUNCATED_RUNNING_NOTICE.
const MAX_COMBINED_CHARS = 256 * 1024;
const KEEP_COMBINED_CHARS = 64 * 1024;

const TRUNCATED_RUNNING_NOTICE = '... (output truncated)';

/**
* 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.
* - running: dim, ANSI-stripped tail of the combined output (the last
* RUNNING_TAIL_LINES lines, or the whole buffer when expanded via
* ctrl+o), 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.
* on failure) through the shared TruncatedOutputComponent — collapsed to
* the first SHELL_OUTPUT_PREVIEW_LINES visual rows, expanded to the full
* output by the global ctrl+o toggle.
*
* Hardened so a misbehaving command can never crash the TUI: the running
* buffer is capped, and every render/render-request path swallows errors.
*/
export class ShellRunComponent extends Container {
private readonly textComponent: Text;
private finalOutput = '';
private combined = '';
private combinedTruncated = false;
private running = true;
private backgrounded = false;
private disposed = false;
private finalStdout = '';
private finalStderr = '';
private finalIsError?: boolean;
private expanded = false;
private readonly startedAt = Date.now();
private timer: ReturnType<typeof setInterval> | undefined;

Expand All @@ -50,17 +60,17 @@ export class ShellRunComponent extends Container {
this.combined += text;
if (this.combined.length > MAX_COMBINED_CHARS) {
this.combined = this.combined.slice(-KEEP_COMBINED_CHARS);
this.combinedTruncated = true;
}
this.flush();
}

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;
this.clearTimer();
this.finalOutput = formatBashOutputForDisplay(stdout, stderr, isError);
this.rebuildResult();
this.flush();
}

Expand All @@ -77,6 +87,41 @@ export class ShellRunComponent extends Container {
this.clearTimer();
}

setExpanded(expanded: boolean): void {
if (this.disposed || this.expanded === expanded) return;
this.expanded = expanded;
// Running and backgrounded views re-render in place; only a finished
// card rebuilds its result component with the new state.
if (this.running || this.backgrounded) {
this.flush();
return;
}
this.rebuildResult();
this.flush();
}

// Rebuild-on-toggle, mirroring ToolCallComponent: the result component is
// immutable, so a new expansion state means a new component instance.
private rebuildResult(): void {
try {
// Build before clearing: if the constructor throws, the old view stays.
const next = new TruncatedOutputComponent(this.finalOutput, {
expanded: this.expanded,
// The stream colours are already baked into the formatted text, so
// the component must not re-colour the whole block as an error.
isError: false,
maxLines: SHELL_OUTPUT_PREVIEW_LINES,
expandHint: true,
});
this.clear();
this.addChild(next);
} catch {
// finish() runs in a promise continuation and setExpanded() in a key
// handler — an escaping error would surface as an unhandled rejection
// or take down the TUI.
}
}

private tick(): void {
if (!this.running) return;
this.flush();
Expand All @@ -85,7 +130,9 @@ export class ShellRunComponent extends Container {
private flush(): void {
if (this.disposed) return;
try {
this.textComponent.setText(this.renderText());
if (this.running || this.backgrounded) {
this.textComponent.setText(this.renderText());
}
this.requestRender();
} catch {
// Never let a render/render-request error escape into a timer or event
Expand All @@ -105,19 +152,21 @@ export class ShellRunComponent extends Container {
if (this.backgrounded) {
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');
}
const elapsed = Math.floor((Date.now() - this.startedAt) / 1000);
const dim = (s: string): string => currentTheme.fg('textDim', s);
const trimmed = sanitizeShellOutput(this.combined).trimEnd();
let body: string;
let extra = 0;
if (trimmed.length === 0) {
body = ` ${dim('Running…')}`;
} else if (this.expanded) {
const notice = this.combinedTruncated ? ` ${dim(TRUNCATED_RUNNING_NOTICE)}\n` : '';
body =
notice +
trimmed
.split('\n')
.map((line) => ` ${dim(line)}`)
.join('\n');
} else {
const lines = trimmed.split('\n');
const tail = lines.slice(-RUNNING_TAIL_LINES);
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const CHROME_GUTTER = 1;

// Shared preview caps used by thinking, tool results, and shell snippets.
export const RESULT_PREVIEW_LINES = 3;
// Collapsed row cap for a finished `!` shell command's output card.
export const SHELL_OUTPUT_PREVIEW_LINES = 10;
export const THINKING_PREVIEW_LINES = 2;
export const COMMAND_PREVIEW_LINES = 10;

Expand Down
20 changes: 18 additions & 2 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from '@moonshot-ai/kimi-code-sdk';

import { ToolCallComponent } from '../components/messages/tool-call';
import { ShellRunComponent } 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 @@ -291,8 +292,23 @@ export class SessionReplayRenderer {
} else {
const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim();
const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim();
const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError);
this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain'));
// Replayed `!` output is a finished card: mount the same component the
// live view uses, already finished, so the ctrl+o toggle reaches it.
const output = new ShellRunComponent(() => this.host.state.ui.requestRender());
output.finish(stdout, stderr, message.origin.isError);
// Inherit the current ctrl+o state, same as the live card — the global
// toggle only reaches components that exist when it fires.
if (this.host.state.toolOutputExpanded) output.setExpanded(true);
markTranscriptComponent(
output,
replayEntry(
context,
'status',
formatBashOutputForDisplay(stdout, stderr, message.origin.isError),
'plain',
),
);
this.host.state.transcriptContainer.addChild(output);
}
return;
}
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,9 @@ export class KimiTUI {
content: '',
};
const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender());
// Inherit the current ctrl+o state, same as freshly mounted tool calls —
// the global toggle only reaches components that exist when it fires.
if (this.state.toolOutputExpanded) outputComponent.setExpanded(true);
this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent });
this.state.transcriptEntries.push(outputEntry);
markTranscriptComponent(outputComponent, outputEntry);
Expand Down
107 changes: 107 additions & 0 deletions apps/kimi-code/test/tui/components/messages/shell-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,110 @@ describe('ShellRunComponent hardening', () => {
}).not.toThrow();
});
});

describe('ShellRunComponent finished collapse', () => {
let component: ShellRunComponent | undefined;

afterEach(() => {
component?.dispose();
component = undefined;
});

function create(): ShellRunComponent {
component = new ShellRunComponent(() => {});
return component;
}

function rows(n: number): string {
return Array.from({ length: n }, (_, i) => `row-${String(i + 1).padStart(2, '0')}`).join('\n');
}

it('collapses finished output to the first 10 visual rows with an expand hint', () => {
const c = create();
c.finish(rows(30), '', false);
const rendered = stripTheme(c.render(80).join('\n'));
expect(rendered).toContain('... (20 more lines, ctrl+o to expand)');
expect(rendered).toContain('row-01');
expect(rendered).toContain('row-10');
expect(rendered).not.toContain('row-11');
});

it('renders short finished output in full without a hint', () => {
const c = create();
c.finish(rows(10), '', false);
const rendered = stripTheme(c.render(80).join('\n'));
expect(rendered).toContain('row-01');
expect(rendered).toContain('row-10');
expect(rendered).not.toContain('more lines');
});

it('setExpanded toggles the finished view', () => {
const c = create();
c.finish(rows(30), '', false);

c.setExpanded(true);
const expanded = stripTheme(c.render(80).join('\n'));
expect(expanded).toContain('row-30');
expect(expanded).not.toContain('more lines');

c.setExpanded(false);
const collapsed = stripTheme(c.render(80).join('\n'));
expect(collapsed).toContain('... (20 more lines, ctrl+o to expand)');
expect(collapsed).not.toContain('row-11');
});

it('expands the running view via setExpanded', () => {
const c = create();
c.append(rows(10));

c.setExpanded(true);
const expanded = stripTheme(c.render(80).join('\n'));
expect(expanded).toContain('row-01');
expect(expanded).toContain('row-10');
expect(expanded).toContain('(ctrl+b to run in background)');
expect(expanded).not.toContain('+5 lines');

c.setExpanded(false);
const collapsed = stripTheme(c.render(80).join('\n'));
expect(collapsed).toContain('+5 lines');
expect(collapsed).not.toContain('row-01');
});

it('carries the expanded state over to the finished view', () => {
const c = create();
c.append(rows(10));
c.setExpanded(true);

c.finish(rows(30), '', false);
const finished = stripTheme(c.render(80).join('\n'));
expect(finished).toContain('row-30');
expect(finished).not.toContain('more lines');
});

it('flags a truncated buffer in the expanded running view', () => {
const c = create();
c.append('x'.repeat(300 * 1024));
c.setExpanded(true);
const rendered = stripTheme(c.render(80).join('\n'));
expect(rendered).toContain('... (output truncated)');
});

it('keeps the backgrounded view when toggled', () => {
const c = create();
c.finishBackgrounded();
c.setExpanded(true);
const rendered = stripTheme(c.render(80).join('\n'));
expect(rendered).toContain('Moved to background.');
});

it('collapses failed output the same way instead of auto-expanding', () => {
const c = create();
c.finish(rows(30), 'boom', true);
const collapsed = stripTheme(c.render(80).join('\n'));
expect(collapsed).toContain('... (21 more lines, ctrl+o to expand)');

c.setExpanded(true);
const expanded = stripTheme(c.render(80).join('\n'));
expect(expanded).toContain('boom');
});
});
Loading
Loading