Add bash mode for executing shell commands

- Add ! prefix in TUI editor to execute shell commands directly
- Output streams in real-time and is added to LLM context
- Supports multiline commands, cancellation (Escape), truncation
- Preview mode shows last 20 lines, Ctrl+O expands full output
- Commands persist in session history as bashExecution messages
- Add bash command to RPC mode via {type:'bash',command:'...'}
- Add RPC tests for bash command execution and context inclusion
- Update docs: rpc.md, session.md, README.md, CHANGELOG.md

Closes #112

Co-authored-by: Markus Ylisiurunen <markus.ylisiurunen@gmail.com>
This commit is contained in:
Mario Zechner 2025-12-08 22:40:32 +01:00
parent 1608da8770
commit bd0d0676d4
13 changed files with 917 additions and 126 deletions

View file

@ -8,6 +8,7 @@
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } from "./messages.js";
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
// ============================================================================
@ -184,11 +185,14 @@ export async function generateSummary(
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
: SUMMARIZATION_PROMPT;
// Transform custom messages (like bashExecution) to LLM-compatible messages
const transformedMessages = messageTransformer(currentMessages);
const summarizationMessages = [
...currentMessages,
...transformedMessages,
{
role: "user" as const,
content: prompt,
content: [{ type: "text" as const, text: prompt }],
timestamp: Date.now(),
},
];

View file

@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename } from "path";
import { APP_NAME, VERSION } from "./config.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
import type { SessionManager } from "./session-manager.js";
// ============================================================================
@ -56,6 +57,8 @@ const COLORS = {
toolPendingBg: "rgb(40, 40, 50)",
toolSuccessBg: "rgb(40, 50, 40)",
toolErrorBg: "rgb(60, 40, 40)",
userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash
userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors
bodyBg: "rgb(24, 24, 30)",
containerBg: "rgb(30, 30, 36)",
text: "rgb(229, 229, 231)",
@ -94,6 +97,34 @@ function formatTimestamp(timestamp: number | string | undefined): string {
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatExpandableOutput(lines: string[], maxLines: number): string {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
if (remaining > 0) {
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
out += '<div class="output-preview">';
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
out += "</div>";
out += '<div class="output-full">';
for (const line of lines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div></div>";
return out;
}
let out = '<div class="tool-output">';
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div>";
return out;
}
// ============================================================================
// Parsing functions
// ============================================================================
@ -304,34 +335,6 @@ function formatToolExecution(
return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n");
};
const formatExpandableOutput = (lines: string[], maxLines: number): string => {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
if (remaining > 0) {
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
out += '<div class="output-preview">';
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
out += "</div>";
out += '<div class="output-full">';
for (const line of lines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div></div>";
return out;
}
let out = '<div class="tool-output">';
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div>";
return out;
};
switch (toolName) {
case "bash": {
const command = (args?.command as string) || "";
@ -427,6 +430,35 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
const timestamp = (message as { timestamp?: number }).timestamp;
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
// Handle bash execution messages (user-executed via ! command)
if (isBashExecutionMessage(message)) {
const bashMsg = message as unknown as BashExecutionMessage;
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;
html += `<div class="tool-execution" style="background-color: ${bgColor}">`;
html += timestampHtml;
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
if (bashMsg.output) {
const lines = bashMsg.output.split("\n");
html += formatExpandableOutput(lines, 10);
}
if (bashMsg.cancelled) {
html += `<div class="bash-status" style="color: ${COLORS.yellow}">(cancelled)</div>`;
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
html += `<div class="bash-status" style="color: ${COLORS.red}">(exit ${bashMsg.exitCode})</div>`;
}
if (bashMsg.truncated && bashMsg.fullOutputPath) {
html += `<div class="bash-truncation" style="color: ${COLORS.yellow}">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
}
html += `</div>`;
return html;
}
if (message.role === "user") {
const userMsg = message as UserMessage;
let textContent = "";

View file

@ -1,92 +0,0 @@
import { describe, expect, test } from "vitest";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
test("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
expect(result.matches).toBe(true);
expect(result.score).toBe(0);
});
test("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
expect(result.matches).toBe(false);
});
test("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
expect(result.matches).toBe(true);
expect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses
});
test("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
expect(matchInOrder.matches).toBe(true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
expect(matchOutOfOrder.matches).toBe(false);
});
test("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
expect(result.matches).toBe(true);
const result2 = fuzzyMatch("abc", "ABC");
expect(result2.matches).toBe(true);
});
test("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
expect(consecutive.matches).toBe(true);
expect(scattered.matches).toBe(true);
expect(consecutive.score).toBeLessThan(scattered.score);
});
test("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
expect(atBoundary.matches).toBe(true);
expect(notAtBoundary.matches).toBe(true);
expect(atBoundary.score).toBeLessThan(notAtBoundary.score);
});
});
describe("fuzzyFilter", () => {
test("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x) => x);
expect(result).toEqual(items);
});
test("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x) => x);
expect(result).toContain("banana");
expect(result).not.toContain("apple");
expect(result).not.toContain("cherry");
});
test("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x) => x);
// "app" should be first (exact consecutive match at start)
expect(result[0]).toBe("app");
});
test("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(items, "foo", (item) => item.name);
expect(result.length).toBe(2);
expect(result.map((r) => r.name)).toContain("foo");
expect(result.map((r) => r.name)).toContain("foobar");
});
});

View file

@ -2,9 +2,12 @@ import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@
import type { Api, AssistantMessage, KnownProvider, Model } from "@mariozechner/pi-ai";
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { existsSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { spawn } from "child_process";
import { randomBytes } from "crypto";
import { createWriteStream, existsSync, readFileSync, statSync } from "fs";
import { homedir, tmpdir } from "os";
import { extname, join, resolve } from "path";
import stripAnsi from "strip-ansi";
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
import {
@ -17,12 +20,15 @@ import {
VERSION,
} from "./config.js";
import { exportFromFile } from "./export-html.js";
import { type BashExecutionMessage, messageTransformer } from "./messages.js";
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { loadSessionFromEntries, SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { getShellConfig } from "./shell.js";
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
import { initTheme } from "./theme/theme.js";
import { allTools, codingTools, type ToolName } from "./tools/index.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
import { ensureTool } from "./tools-manager.js";
import { SessionSelectorComponent } from "./tui/session-selector.js";
import { TuiRenderer } from "./tui/tui-renderer.js";
@ -856,6 +862,87 @@ async function runSingleShotMode(
}
}
/**
* Execute a bash command for RPC mode.
* Similar to tui-renderer's executeBashCommand but without streaming callbacks.
*/
async function executeRpcBashCommand(command: string): Promise<{
output: string;
exitCode: number | null;
truncationResult?: ReturnType<typeof truncateTail>;
fullOutputPath?: string;
}> {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
let chunksBytes = 0;
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to temp file if exceeds threshold
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
const id = randomBytes(8).toString("hex");
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
}
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer
chunks.push(data);
chunksBytes += data.length;
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
};
child.stdout?.on("data", handleData);
child.stderr?.on("data", handleData);
child.on("close", (code) => {
if (tempFileStream) {
tempFileStream.end();
}
// Combine buffered chunks
const fullBuffer = Buffer.concat(chunks);
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
const truncationResult = truncateTail(fullOutput);
resolve({
output: fullOutput,
exitCode: code,
truncationResult: truncationResult.truncated ? truncationResult : undefined,
fullOutputPath: tempFilePath,
});
});
child.on("error", (err) => {
if (tempFileStream) {
tempFileStream.end();
}
reject(err);
});
});
}
async function runRpcMode(
agent: Agent,
sessionManager: SessionManager,
@ -986,6 +1073,37 @@ async function runRpcMode(
} catch (error: any) {
console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
}
} else if (input.type === "bash" && input.command) {
// Execute bash command and add to context
try {
const result = await executeRpcBashCommand(input.command);
// Create bash execution message
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
command: input.command,
output: result.truncationResult?.content || result.output,
exitCode: result.exitCode,
cancelled: false,
truncated: result.truncationResult?.truncated || false,
fullOutputPath: result.fullOutputPath,
timestamp: Date.now(),
};
// Add to agent state and save to session
agent.appendMessage(bashMessage);
sessionManager.saveMessage(bashMessage);
// Initialize session if needed (same logic as message_end handler)
if (sessionManager.shouldInitializeSession(agent.state.messages)) {
sessionManager.startSession(agent.state);
}
// Emit bash_end event with the message
console.log(JSON.stringify({ type: "bash_end", message: bashMessage }));
} catch (error: any) {
console.log(JSON.stringify({ type: "error", error: `Bash command failed: ${error.message}` }));
}
}
} catch (error: any) {
// Output error as JSON
@ -1273,6 +1391,7 @@ export async function main(args: string[]) {
thinkingLevel: initialThinking,
tools: selectedTools,
},
messageTransformer,
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({
// Dynamic API key lookup based on current model's provider

View file

@ -0,0 +1,102 @@
/**
* Custom message types and transformers for the coding agent.
*
* Extends the base AppMessage type with coding-agent specific message types,
* and provides a transformer to convert them to LLM-compatible messages.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
// ============================================================================
// Custom Message Types
// ============================================================================
/**
* Message type for bash executions via the ! command.
*/
export interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
exitCode: number | null;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
timestamp: number;
}
// Extend CustomMessages via declaration merging
declare module "@mariozechner/pi-agent-core" {
interface CustomMessages {
bashExecution: BashExecutionMessage;
}
}
// ============================================================================
// Type Guards
// ============================================================================
/**
* Type guard for BashExecutionMessage.
*/
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
return (msg as BashExecutionMessage).role === "bashExecution";
}
// ============================================================================
// Message Formatting
// ============================================================================
/**
* Convert a BashExecutionMessage to user message text for LLM context.
*/
export function bashExecutionToText(msg: BashExecutionMessage): string {
let text = `Ran \`${msg.command}\`\n`;
if (msg.output) {
text += "```\n" + msg.output + "\n```";
} else {
text += "(no output)";
}
if (msg.cancelled) {
text += "\n\n(command cancelled)";
} else if (msg.exitCode !== null && msg.exitCode !== 0) {
text += `\n\nCommand exited with code ${msg.exitCode}`;
}
if (msg.truncated && msg.fullOutputPath) {
text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
}
return text;
}
// ============================================================================
// Message Transformer
// ============================================================================
/**
* Transform AppMessages (including custom types) to LLM-compatible Messages.
*
* This is used by:
* - Agent's messageTransformer option (for prompt calls)
* - Compaction's generateSummary (for summarization)
*/
export function messageTransformer(messages: AppMessage[]): Message[] {
return messages
.map((m): Message | null => {
if (isBashExecutionMessage(m)) {
// Convert bash execution to user message
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
}
// Pass through standard LLM roles
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
return m as Message;
}
// Filter out unknown message types
return null;
})
.filter((m): m is Message => m !== null);
}

View file

@ -0,0 +1,161 @@
/**
* Component for displaying bash command execution with streaming output.
*/
import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { theme } from "../theme/theme.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "../tools/truncate.js";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
export class BashExecutionComponent extends Container {
private command: string;
private outputLines: string[] = [];
private status: "running" | "complete" | "cancelled" | "error" = "running";
private exitCode: number | null = null;
private loader: Loader;
private truncationResult?: TruncationResult;
private fullOutputPath?: string;
private contentText: Text;
private statusText: Text | null = null;
private expanded = false;
constructor(command: string, ui: TUI) {
super();
this.command = command;
// Add spacer
this.addChild(new Spacer(1));
// Command header
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0);
this.addChild(header);
// Output area (will be updated)
this.contentText = new Text("", 1, 0);
this.addChild(this.contentText);
// Loader
this.loader = new Loader(
ui,
(spinner) => theme.fg("bashMode", spinner),
(text) => theme.fg("muted", text),
"Running... (esc to cancel)",
);
this.addChild(this.loader);
}
/**
* Set whether the output is expanded (shows full output) or collapsed (preview only).
*/
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
appendOutput(chunk: string): void {
// Strip ANSI codes and normalize line endings
const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Append to output lines
const newLines = clean.split("\n");
if (this.outputLines.length > 0 && newLines.length > 0) {
// Append first chunk to last line (incomplete line continuation)
this.outputLines[this.outputLines.length - 1] += newLines[0];
this.outputLines.push(...newLines.slice(1));
} else {
this.outputLines.push(...newLines);
}
this.updateDisplay();
}
setComplete(
exitCode: number | null,
cancelled: boolean,
truncationResult?: TruncationResult,
fullOutputPath?: string,
): void {
this.exitCode = exitCode;
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
this.truncationResult = truncationResult;
this.fullOutputPath = fullOutputPath;
// Stop and remove loader
this.loader.stop();
this.removeChild(this.loader);
this.updateDisplay();
}
private updateDisplay(): void {
// Apply truncation for LLM context limits (same limits as bash tool)
const fullOutput = this.outputLines.join("\n");
const contextTruncation = truncateTail(fullOutput, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
// Get the lines to potentially display (after context truncation)
const availableLines = contextTruncation.content ? contextTruncation.content.split("\n") : [];
// Apply preview truncation based on expanded state
const maxDisplayLines = this.expanded ? availableLines.length : PREVIEW_LINES;
const displayLines = availableLines.slice(-maxDisplayLines); // Show last N lines (tail)
const hiddenLineCount = availableLines.length - displayLines.length;
let displayText = "";
if (displayLines.length > 0) {
displayText = displayLines.map((line) => theme.fg("muted", line)).join("\n");
}
this.contentText.setText(displayText ? "\n" + displayText : "");
// Update/add status text if complete
if (this.status !== "running") {
if (this.statusText) {
this.removeChild(this.statusText);
}
const statusParts: string[] = [];
// Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0) {
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})`));
}
// Add truncation warning (context truncation, not preview truncation)
const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;
if (wasTruncated && this.fullOutputPath) {
statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`));
}
if (statusParts.length > 0) {
this.statusText = new Text("\n" + statusParts.join("\n"), 1, 0);
this.addChild(this.statusText);
}
}
}
/**
* 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;
}
}

View file

@ -1,6 +1,10 @@
import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
import { join } from "node:path";
import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
@ -17,11 +21,13 @@ import {
visibleWidth,
} from "@mariozechner/pi-tui";
import { exec, spawn } from "child_process";
import stripAnsi from "strip-ansi";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { copyToClipboard } from "../clipboard.js";
import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
import { exportSessionToHtml } from "../export-html.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "../messages.js";
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
import {
@ -35,7 +41,9 @@ import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig, killProcessTree } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from "../tools/truncate.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { BashExecutionComponent } from "./bash-execution.js";
import { CompactionComponent } from "./compaction.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -128,6 +136,9 @@ export class TuiRenderer {
// Track running bash command process for cancellation
private bashProcess: ReturnType<typeof spawn> | null = null;
// Track current bash execution component
private bashComponent: BashExecutionComponent | null = null;
constructor(
agent: Agent,
sessionManager: SessionManager,
@ -541,8 +552,16 @@ export class TuiRenderer {
if (text.startsWith("!")) {
const command = text.slice(1).trim();
if (command) {
// Block if bash already running
if (this.bashProcess) {
this.showWarning("A bash command is already running. Press Esc to cancel it first.");
// Restore text since editor clears on submit
this.editor.setText(text);
return;
}
// Add to history for up/down arrow navigation
this.editor.addToHistory(text);
this.handleBashCommand(command);
this.editor.setText("");
// Reset bash mode since editor is now empty
this.isBashMode = false;
this.updateEditorBorderColor();
@ -851,7 +870,24 @@ export class TuiRenderer {
}
}
private addMessageToChat(message: Message): void {
private addMessageToChat(message: Message | AppMessage): void {
// Handle bash execution messages
if (isBashExecutionMessage(message)) {
const bashMsg = message as BashExecutionMessage;
const component = new BashExecutionComponent(bashMsg.command, this.ui);
if (bashMsg.output) {
component.appendOutput(bashMsg.output);
}
component.setComplete(
bashMsg.exitCode,
bashMsg.cancelled,
bashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,
bashMsg.fullOutputPath,
);
this.chatContainer.addChild(component);
return;
}
if (message.role === "user") {
const userMsg = message;
// Extract text content from content blocks
@ -893,6 +929,12 @@ export class TuiRenderer {
for (let i = 0; i < state.messages.length; i++) {
const message = state.messages[i];
// Handle bash execution messages
if (isBashExecutionMessage(message)) {
this.addMessageToChat(message);
continue;
}
if (message.role === "user") {
const userMsg = message;
const textBlocks =
@ -993,6 +1035,12 @@ export class TuiRenderer {
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
for (const message of this.agent.state.messages) {
// Handle bash execution messages
if (isBashExecutionMessage(message)) {
this.addMessageToChat(message);
continue;
}
if (message.role === "user") {
const userMsg = message;
const textBlocks =
@ -1215,12 +1263,14 @@ export class TuiRenderer {
private toggleToolOutputExpansion(): void {
this.toolOutputExpanded = !this.toolOutputExpanded;
// Update all tool execution and compaction components
// Update all tool execution, compaction, and bash execution components
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof CompactionComponent) {
child.setExpanded(this.toolOutputExpanded);
} else if (child instanceof BashExecutionComponent) {
child.setExpanded(this.toolOutputExpanded);
}
}
@ -2020,44 +2070,66 @@ export class TuiRenderer {
}
private async handleBashCommand(command: string): Promise<void> {
// Create component and add to chat
this.bashComponent = new BashExecutionComponent(command, this.ui);
this.chatContainer.addChild(this.bashComponent);
this.ui.requestRender();
try {
// Execute bash command
const { stdout, stderr } = await this.executeBashCommand(command);
const result = await this.executeBashCommand(command, (chunk) => {
if (this.bashComponent) {
this.bashComponent.appendOutput(chunk);
this.ui.requestRender();
}
});
// Build the message text, format like a user would naturally share command output
let messageText = `Ran \`${command}\`\n`;
const output = [stdout, stderr].filter(Boolean).join("\n");
if (output) {
messageText += "```\n" + output + "\n```";
} else {
messageText += "(no output)";
if (this.bashComponent) {
this.bashComponent.setComplete(
result.exitCode,
result.cancelled,
result.truncationResult,
result.fullOutputPath,
);
// Create and save message (even if cancelled, for consistency with LLM aborts)
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
command,
output: result.truncationResult?.content || this.bashComponent.getOutput(),
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncationResult?.truncated || false,
fullOutputPath: result.fullOutputPath,
timestamp: Date.now(),
};
// Add to agent state
this.agent.appendMessage(bashMessage);
// Save to session
this.sessionManager.saveMessage(bashMessage);
}
// Create user message
const userMessage = {
role: "user" as const,
content: [{ type: "text" as const, text: messageText }],
timestamp: Date.now(),
};
// Add to agent state (don't trigger LLM call)
this.agent.appendMessage(userMessage);
// Save to session
this.sessionManager.saveMessage(userMessage);
// Render in chat
this.addMessageToChat(userMessage);
// Update UI
this.ui.requestRender();
} catch (error: unknown) {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
this.showError(`Failed to execute bash command: ${errorMessage}`);
if (this.bashComponent) {
this.bashComponent.setComplete(null, false);
}
this.showError(`Bash command failed: ${errorMessage}`);
}
this.bashComponent = null;
this.ui.requestRender();
}
private executeBashCommand(command: string): Promise<{ stdout: string; stderr: string }> {
private executeBashCommand(
command: string,
onChunk: (chunk: string) => void,
): Promise<{
exitCode: number | null;
cancelled: boolean;
truncationResult?: TruncationResult;
fullOutputPath?: string;
}> {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
@ -2065,64 +2137,78 @@ export class TuiRenderer {
stdio: ["ignore", "pipe", "pipe"],
});
// Track process for cancellation
this.bashProcess = child;
let stdout = "";
let stderr = "";
// Track output for truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
// Limit buffer size to 2MB
if (stdout.length > 2 * 1024 * 1024) {
stdout = stdout.slice(0, 2 * 1024 * 1024);
// Temp file for large output
let tempFilePath: string | undefined;
let tempFileStream: WriteStream | undefined;
let totalBytes = 0;
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to temp file if exceeds threshold
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
const id = randomBytes(8).toString("hex");
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
});
}
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
// Limit buffer size to 1MB
if (stderr.length > 1 * 1024 * 1024) {
stderr = stderr.slice(0, 1 * 1024 * 1024);
}
});
}
// 30 second timeout
const timeoutHandle = setTimeout(() => {
if (child.pid) {
killProcessTree(child.pid);
}
reject(new Error("Command execution timeout (30s)"));
}, 30000);
child.on("close", (code: number | null) => {
clearTimeout(timeoutHandle);
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer
chunks.push(data);
chunksBytes += data.length;
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream to component (strip ANSI)
const text = stripAnsi(data.toString()).replace(/\r/g, "");
onChunk(text);
};
child.stdout?.on("data", handleData);
child.stderr?.on("data", handleData);
child.on("close", (code) => {
if (tempFileStream) {
tempFileStream.end();
}
this.bashProcess = null;
// Check if killed (code is null when process is killed)
if (code === null) {
reject(new Error("Command cancelled"));
return;
}
// Combine buffered chunks for truncation
const fullBuffer = Buffer.concat(chunks);
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
const truncationResult = truncateTail(fullOutput);
// Trim trailing newlines from output
stdout = stdout.replace(/\n+$/, "");
stderr = stderr.replace(/\n+$/, "");
// code === null means killed (cancelled)
const cancelled = code === null;
// Don't reject on non-zero exit as we want to show the error in stderr
if (code !== 0 && !stderr) {
stderr = `Command exited with code ${code}`;
}
resolve({ stdout, stderr });
resolve({
exitCode: code,
cancelled,
truncationResult: truncationResult.truncated ? truncationResult : undefined,
fullOutputPath: tempFilePath,
});
});
child.on("error", (err) => {
clearTimeout(timeoutHandle);
if (tempFileStream) {
tempFileStream.end();
}
this.bashProcess = null;
reject(err);
});