/** * Multi-Command Format Extension — makes bash output easier to read. * * - Splits multi-command chains (&&, ;, ||, |) into separate lines in the call display * - Shows exit code and truncated output size at a glance * - Clean expanded view with the full command output * * Usage: drop this in .pi/extensions/ and restart pi (or /reload). */ import type { BashToolDetails, ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createBashTool } from "@earendil-works/pi-coding-agent"; import { Container, Text } from "@earendil-works/pi-tui"; // --------------------------------------------------------------------------- // Simple command-chain splitter — handles basic quoting so we don't cut // inside 'single-quoted strings' or "double-quoted strings". // --------------------------------------------------------------------------- type SplitResult = { segments: string[]; separator: string | null; // the separator that was used between segments }; function splitCommandChain(command: string): SplitResult { // Try each separator in order of precedence. // We pick the first one that actually produces multiple non-empty pieces. for (const sep of ["&&", "||", "|", ";"]) { const pieces = splitRespectingQuotes(command, sep); const trimmed = pieces.map((p) => p.trim()).filter(Boolean); if (trimmed.length > 1) return { segments: trimmed, separator: sep }; } // Single command or couldn't split meaningfully. return { segments: [command.trim()].filter(Boolean), separator: null }; } function splitRespectingQuotes(str: string, sep: string): string[] { const result: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (let i = 0; i < str.length; i++) { const ch = str[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } if (!inSingle && !inDouble && str.slice(i, i + sep.length) === sep) { result.push(current); current = ""; i += sep.length - 1; // skip over separator continue; } current += ch; } result.push(current); return result; } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { const cwd = process.cwd(); const originalBash = createBashTool(cwd); pi.registerTool({ name: "bash", label: "bash", description: originalBash.description, parameters: originalBash.parameters, // Delegate execution to the built-in bash tool. async execute(toolCallId, params, signal, onUpdate, ctx) { return originalBash.execute(toolCallId, params, signal, onUpdate); }, // ── Call display: split multi-commands into separate lines ────────── renderCall(args, theme, _context) { const command: string = args.command || ""; const timeout = args.timeout as number | undefined; const { segments, separator } = splitCommandChain(command); // Multi-command: show each segment on its own line with a connector. if (segments.length > 1 && separator) { const container = new Container(); for (let i = 0; i < segments.length; i++) { const prefix = i === 0 ? theme.fg("toolTitle", theme.bold("$ ")) : theme.fg("dim", ` ${separator} `); container.addChild( new Text(prefix + theme.fg("accent", segments[i]), 0, 0), ); } if (timeout) { container.addChild( new Text(theme.fg("muted", ` (timeout ${timeout}s)`), 0, 0), ); } return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: () => {}, }; } // Single command: compact one-liner. let text = theme.fg("toolTitle", theme.bold("$ ")); const cmd = command.length > 120 ? command.slice(0, 117) + "..." : command; text += theme.fg("accent", cmd); if (timeout) text += theme.fg("dim", ` (${timeout}s)`); return new Text(text, 0, 0); }, // ── Result display: clean summary + expandable output ─────────────── renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "… running"), 0, 0); } const details = result.details as BashToolDetails | undefined; const content = result.content[0]; const output = content?.type === "text" ? content.text : ""; const lines = output.split("\n"); const nonEmpty = lines.filter((l) => l.trim()).length; // Extract exit code from the built-in footer: "exit code: X" const exitMatch = output.match(/exit code: (\d+)/); const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null; const truncated = details?.truncation?.truncated ?? false; // ── Status line (always visible) ── let status: string; let statusColor: "success" | "error" = "success"; if (exitCode === null) { status = "done"; } else if (exitCode === 0) { status = "ok"; } else { status = `exit ${exitCode}`; statusColor = "error"; } let statusLine = theme.fg(statusColor, theme.bold(status)); statusLine += theme.fg("dim", ` · ${nonEmpty} lines`); if (truncated) { statusLine += theme.fg("warning", " · truncated"); } const container = new Container(); container.addChild(new Text(statusLine, 0, 0)); // ── Expanded output ── if (expanded && output.trim()) { container.addChild(new Text("", 0, 0)); // spacer const maxShow = 100; const showLines = output.trim().split("\n"); const displayLines = showLines.slice(0, maxShow); for (const line of displayLines) { container.addChild(new Text(theme.fg("toolOutput", line), 0, 0)); } if (showLines.length > maxShow) { container.addChild( new Text( theme.fg("dim", `… ${showLines.length - maxShow} more lines`), 0, 0, ), ); } } return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: () => {}, }; }, }); }