Files
oh-my-pi/packages/coding-agent/src/modes/components/bash-execution.ts
T
luke 47dc03b835 fix: prevent TUI freeze on massive bash output and fix spinner rendering (#500)
- Sync OutputSink.push(): eliminate promise chain per chunk, buffer
  management and onChunk run inline, file writes deferred via queue
- 64KB native read buffer (was 4KB): reduces chunk count ~16x
- chunkThrottleMs in OutputSink: gate onChunk to every 50ms
- BashExecutionComponent streaming throttle: gate + 100-line cap
- Remove requestRender from chunk callbacks: spinner drives renders
- Remove double sanitization in appendOutput (already done by OutputSink)
- Inline SEGMENT_RESET in TUI doRender buffer writes: eliminates O(N)
  string allocations per frame from #applyLineResets
- Cache header Text in BashExecutionComponent (created once, reused)
- Gate sixel mask computation behind protocol + passthrough check
- Fix spinner: #spinnerFrame made optional, interval calls #updateDisplay
- Remove pendingChunks promise chains from bash-executor and bash-interactive
2026-03-21 16:05:42 +01:00

248 lines
7.7 KiB
TypeScript

/**
* Component for displaying bash command execution with streaming output.
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
const MAX_DISPLAY_LINE_CHARS = 4000;
// Minimum interval between processing incoming chunks for display (ms).
// Chunks arriving faster than this are accumulated and processed in one batch.
const CHUNK_THROTTLE_MS = 50;
export class BashExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#exitCode: number | undefined = undefined;
#loader: Loader;
#truncation?: TruncationMeta;
#expanded = false;
#displayDirty = false;
#chunkGate = false;
#contentContainer: Container;
#headerText: Text;
constructor(
private readonly command: string,
ui: TUI,
excludeFromContext = false,
) {
super();
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
// Add spacer
this.addChild(new Spacer(1));
// Top border
this.addChild(new DynamicBorder(borderColor));
// Content container (holds dynamic content between borders)
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
// Command header
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
this.#contentContainer.addChild(this.#headerText);
// Loader
this.#loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
this.#contentContainer.addChild(this.#loader);
// Bottom border
this.addChild(new DynamicBorder(borderColor));
}
/**
* Set whether the output is expanded (shows full output) or collapsed (preview only).
*/
setExpanded(expanded: boolean): void {
this.#expanded = expanded;
this.#updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.#displayDirty = false;
this.#updateDisplay();
}
appendOutput(chunk: string): void {
// During high-throughput output (e.g. seq 1 500M), processing every
// chunk would saturate the event loop. Instead, accept one chunk per
// throttle window and drop the rest — the OutputSink captures everything
// for the artifact, and setComplete() replaces with the final output.
if (this.#chunkGate) return;
this.#chunkGate = true;
setTimeout(() => {
this.#chunkGate = false;
}, CHUNK_THROTTLE_MS);
const incomingLines = chunk.split("\n");
if (this.#outputLines.length > 0 && incomingLines.length > 0) {
const lastIndex = this.#outputLines.length - 1;
const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
const clampedMergedLines = this.#clampLinesPreservingSixel(mergedLines);
this.#outputLines[lastIndex] = clampedMergedLines[0] ?? "";
this.#outputLines.push(...clampedMergedLines.slice(1));
} else {
this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
}
// Cap stored lines during streaming to avoid unbounded memory growth
if (this.#outputLines.length > STREAMING_LINE_CAP) {
this.#outputLines = this.#outputLines.slice(-STREAMING_LINE_CAP);
}
this.#displayDirty = true;
}
setComplete(
exitCode: number | undefined,
cancelled: boolean,
options?: { output?: string; truncation?: TruncationMeta },
): void {
this.#exitCode = exitCode;
this.#status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#truncation = options?.truncation;
if (options?.output !== undefined) {
this.#setOutput(options.output);
}
// Stop loader
this.#loader.stop();
this.#updateDisplay();
}
override render(width: number): string[] {
if (this.#displayDirty) {
this.#displayDirty = false;
this.#updateDisplay();
}
return super.render(width);
}
#updateDisplay(): void {
const availableLines = this.#outputLines;
// Apply preview truncation based on expanded state
const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
const hiddenLineCount = availableLines.length - previewLogicalLines.length;
const sixelLineMask =
TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
? getSixelLineMask(availableLines)
: undefined;
const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
// Rebuild content container
this.#contentContainer.clear();
// Command header
this.#contentContainer.addChild(this.#headerText);
// Output
if (availableLines.length > 0) {
if (this.#expanded || hasSixelOutput) {
const displayText = availableLines
.map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
.join("\n");
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else {
// Use shared visual truncation utility, recomputed per render width
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`;
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
}
}
// Loader or status
if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader);
} else {
const statusParts: string[] = [];
// Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0 && !hasSixelOutput) {
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (this.#status === "cancelled") {
statusParts.push(theme.fg("warning", "(cancelled)"));
} else if (this.#status === "error") {
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
}
if (this.#truncation) {
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
}
if (statusParts.length > 0) {
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
}
}
}
#clampDisplayLine(line: string): string {
if (line.length <= MAX_DISPLAY_LINE_CHARS) {
return line;
}
const omitted = line.length - MAX_DISPLAY_LINE_CHARS;
return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
}
#clampLinesPreservingSixel(lines: string[]): string[] {
if (lines.length === 0) return [];
const sixelLineMask = getSixelLineMask(lines);
if (!sixelLineMask.some(Boolean)) {
return lines.map(line => this.#clampDisplayLine(line));
}
return lines.map((line, index) => (sixelLineMask[index] ? line : this.#clampDisplayLine(line)));
}
#setOutput(output: string): void {
const clean = sanitizeWithOptionalSixelPassthrough(output, sanitizeText);
this.#outputLines = clean ? this.#clampLinesPreservingSixel(clean.split("\n")) : [];
}
/**
* Get the raw output for creating BashExecutionMessage.
*/
getOutput(): string {
return this.#outputLines.join("\n");
}
/**
* Get the command that was executed.
*/
getCommand(): string {
return this.command;
}
}