/** * 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, 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 MAX_DISPLAY_LINE_CHARS = 4000; export class BashExecutionComponent extends Container { #outputLines: string[] = []; #status: "running" | "complete" | "cancelled" | "error" = "running"; #exitCode: number | undefined = undefined; #loader: Loader; #truncation?: TruncationMeta; #expanded = false; #contentContainer: Container; 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 const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.#contentContainer.addChild(header); // 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.#updateDisplay(); } appendOutput(chunk: string): void { const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText); // Append to output lines const incomingLines = clean.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)); } this.#updateDisplay(); } 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(); } #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 ? getSixelLineMask(availableLines) : undefined; const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false; // Rebuild content container this.#contentContainer.clear(); // Command header const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0); this.#contentContainer.addChild(header); // 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; } }