diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0aa57f10..d13b9606 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,8 @@ - **Collapse changelog setting**: Add `"collapseChangelog": true` to `~/.pi/agent/settings.json` to show a condensed "Updated to vX.Y.Z" message instead of the full changelog after updates. Use `/changelog` to view the full changelog. ([#148](https://github.com/badlogic/pi-mono/issues/148)) +- **Bash mode**: Execute shell commands directly from the editor by prefixing with `!` (e.g., `!ls -la`). Output streams in real-time, is added to the LLM context, and persists in session history. Supports multiline commands, cancellation (Escape), truncation for large outputs, and preview/expand toggle (Ctrl+O). Also available in RPC mode via `{"type":"bash","command":"..."}`. ([#112](https://github.com/badlogic/pi-mono/pull/112), original implementation by [@markusylisiurunen](https://github.com/markusylisiurunen)) + ## [0.13.2] - 2025-12-07 ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1e6a73c6..1f778e8b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -762,6 +762,27 @@ You can submit multiple messages while the agent is processing without waiting f Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settings.json`. +### Bash Mode (`!`) + +Execute shell commands directly and add output to the LLM context by prefixing with `!`: + +``` +!ls -la +!git status +!cat package.json | jq '.dependencies' +``` + +**Features:** +- **Streaming output**: Command output streams in real-time as it executes +- **Multiline commands**: Write complex commands across multiple lines +- **Cancellation**: Press **Escape** to cancel a running command +- **Truncation**: Large outputs are truncated (2000 lines / 50KB) with full output saved to a temp file +- **Preview mode**: Shows last 20 lines by default; press **Ctrl+O** to expand +- **History**: Commands are added to editor history (navigate with Up/Down arrows) +- **Visual feedback**: Editor border turns green in bash mode; cancelled commands show yellow warning + +Output is automatically added to the conversation context, allowing the LLM to see command results without manual copy-paste. + ### Keyboard Shortcuts **Navigation:** diff --git a/packages/coding-agent/docs/RPC.md b/packages/coding-agent/docs/RPC.md index d6d3944a..2f5ef4d2 100644 --- a/packages/coding-agent/docs/RPC.md +++ b/packages/coding-agent/docs/RPC.md @@ -54,6 +54,19 @@ Compact the conversation context to reduce token usage: The `customInstructions` field is optional and allows you to guide what the summary should focus on. +#### Bash Message + +Execute a shell command and add output to the LLM context (without triggering a prompt): + +```json +{ + "type": "bash", + "command": "ls -la" +} +``` + +On success, emits a `bash_end` event with the `BashExecutionMessage`. The command output is automatically added to the conversation context, allowing subsequent prompts to reference it. + ## Output Protocol The agent emits JSON events to stdout, one per line. Events follow the `AgentEvent` type hierarchy. @@ -72,6 +85,7 @@ The agent emits JSON events to stdout, one per line. Events follow the `AgentEve | `tool_execution_start` | Tool execution begins | | `tool_execution_end` | Tool execution completes | | `compaction` | Context was compacted (manual or auto) | +| `bash_end` | User-initiated bash command completed | | `error` | An error occurred | ### Event Schemas @@ -192,6 +206,28 @@ The `result` field contains either: - An `AgentToolResult` object with `content` and `details` fields - A string error message if `isError` is true +#### bash_end + +Emitted when a user-initiated bash command (via `bash` input message) completes. + +```json +{ + "type": "bash_end", + "message": { + "role": "bashExecution", + "command": "ls -la", + "output": "total 48\ndrwxr-xr-x ...", + "exitCode": 0, + "cancelled": false, + "truncated": false, + "fullOutputPath": "/tmp/pi-bash-abc123.log", // Only present if output was truncated + "timestamp": 1733234567890 + } +} +``` + +The `message` is a `BashExecutionMessage` that has been added to the conversation context. See [BashExecutionMessage](#bashexecutionmessage) for the full schema. + #### error Emitted when an error occurs during input processing. @@ -307,6 +343,33 @@ type AppMessage = | CustomMessages[keyof CustomMessages]; ``` +#### BashExecutionMessage + +Defined in [`packages/coding-agent/src/messages.ts`](../src/messages.ts) + +Custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command): + +```typescript +interface BashExecutionMessage { + role: "bashExecution"; + command: string; // The command that was executed + output: string; // Command output (truncated if large) + exitCode: number | null; // Exit code, null if killed + cancelled: boolean; // True if user cancelled with Escape + truncated: boolean; // True if output was truncated + fullOutputPath?: string; // Path to temp file with full output (if truncated) + timestamp: number; // Unix timestamp in milliseconds +} +``` + +When sent to the LLM, this message is transformed into a user message with the format: +``` +Ran `` +\`\`\` + +\`\`\` +``` + ### Content Types #### TextContent @@ -456,7 +519,7 @@ function handleEvent(event: any) { args: event.args }); } - + if (event.type === "tool_execution_end") { const toolCall = pendingTools.get(event.toolCallId); if (toolCall) { @@ -467,7 +530,7 @@ function handleEvent(event: any) { result: event.result, isError: event.isError }; - + // Format for display displayToolExecution(merged); pendingTools.delete(event.toolCallId); @@ -497,16 +560,16 @@ function displayToolExecution(tool: { switch (tool.name) { case "bash": return `$ ${tool.args.command}\n${resultText}`; - + case "read": return `📄 ${tool.args.path}\n${resultText.slice(0, 500)}...`; - + case "write": return `✏️ Wrote ${tool.args.path}`; - + case "edit": return `✏️ Edited ${tool.args.path}`; - + default: return `🔧 ${tool.name}: ${resultText.slice(0, 200)}`; } @@ -520,10 +583,10 @@ The `turn_end` event provides the assistant message and all tool results togethe ```typescript if (event.type === "turn_end") { const { message, toolResults } = event; - + // Extract tool calls from assistant message const toolCalls = message.content.filter(c => c.type === "toolCall"); - + // Match each tool call with its result by toolCallId for (const call of toolCalls) { const result = toolResults.find(r => r.toolCallId === call.id); @@ -586,14 +649,14 @@ const agent = spawn("pi", ["--mode", "rpc", "--no-session"]); // Parse output events readline.createInterface({ input: agent.stdout }).on("line", (line) => { const event = JSON.parse(line); - + if (event.type === "message_update") { const { assistantMessageEvent } = event; if (assistantMessageEvent.type === "text_delta") { process.stdout.write(assistantMessageEvent.delta); } } - + if (event.type === "tool_execution_start") { console.log(`\n[Tool: ${event.toolName}]`); } diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 66499b77..7778eaef 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -40,8 +40,11 @@ A message in the conversation. The `message` field contains an `AppMessage` (see {"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}} {"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}} {"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}} +{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}} ``` +The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema. + ### ModelChangeEntry Emitted when the user switches models mid-session. diff --git a/packages/coding-agent/src/compaction.ts b/packages/coding-agent/src/compaction.ts index 34ec7f1f..6acb3ff7 100644 --- a/packages/coding-agent/src/compaction.ts +++ b/packages/coding-agent/src/compaction.ts @@ -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(), }, ]; diff --git a/packages/coding-agent/src/export-html.ts b/packages/coding-agent/src/export-html.ts index 632e4732..1ac8f2a4 100644 --- a/packages/coding-agent/src/export-html.ts +++ b/packages/coding-agent/src/export-html.ts @@ -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 = '"; + return out; + } + + let out = '
'; + for (const line of displayLines) { + out += `
${escapeHtml(replaceTabs(line))}
`; + } + out += "
"; + 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 = '"; - return out; - } - - let out = '
'; - for (const line of displayLines) { - out += `
${escapeHtml(replaceTabs(line))}
`; - } - out += "
"; - return out; - }; - switch (toolName) { case "bash": { const command = (args?.command as string) || ""; @@ -427,6 +430,35 @@ function formatMessage(message: Message, toolResultsMap: Map${formatTimestamp(timestamp)}` : ""; + // 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 += `
`; + html += timestampHtml; + html += `
$ ${escapeHtml(bashMsg.command)}
`; + + if (bashMsg.output) { + const lines = bashMsg.output.split("\n"); + html += formatExpandableOutput(lines, 10); + } + + if (bashMsg.cancelled) { + html += `
(cancelled)
`; + } else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) { + html += `
(exit ${bashMsg.exitCode})
`; + } + + if (bashMsg.truncated && bashMsg.fullOutputPath) { + html += `
Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}
`; + } + + html += `
`; + return html; + } + if (message.role === "user") { const userMsg = message as UserMessage; let textContent = ""; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d7cd387a..853ee74e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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; + 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 | 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 diff --git a/packages/coding-agent/src/messages.ts b/packages/coding-agent/src/messages.ts new file mode 100644 index 00000000..dfa6aa19 --- /dev/null +++ b/packages/coding-agent/src/messages.ts @@ -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); +} diff --git a/packages/coding-agent/src/tui/bash-execution.ts b/packages/coding-agent/src/tui/bash-execution.ts new file mode 100644 index 00000000..2d3e24de --- /dev/null +++ b/packages/coding-agent/src/tui/bash-execution.ts @@ -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; + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 53ceceff..509743c8 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -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 | 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 { + // 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); }); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index fea8649f..2af878db 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -193,10 +193,12 @@ describe("createSummaryMessage", () => { it("should create user message with prefix", () => { const msg = createSummaryMessage("This is the summary"); expect(msg.role).toBe("user"); - expect(msg.content).toContain( - "The conversation history before this point was compacted into the following summary:", - ); - expect(msg.content).toContain("This is the summary"); + if (msg.role === "user") { + expect(msg.content).toContain( + "The conversation history before this point was compacted into the following summary:", + ); + expect(msg.content).toContain("This is the summary"); + } }); }); diff --git a/packages/coding-agent/src/fuzzy.test.ts b/packages/coding-agent/test/fuzzy.test.ts similarity index 100% rename from packages/coding-agent/src/fuzzy.test.ts rename to packages/coding-agent/test/fuzzy.test.ts diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 377695e6..f3acdc99 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -6,6 +6,7 @@ import * as readline from "node:readline"; import { fileURLToPath } from "node:url"; import type { AgentEvent } from "@mariozechner/pi-agent-core"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { BashExecutionMessage } from "../src/messages.js"; import type { CompactionEntry } from "../src/session-manager.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -230,4 +231,199 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(compactionEntries.length).toBe(1); expect(compactionEntries[0].summary).toBeDefined(); }, 120000); + + test("should execute bash command and add to context", async () => { + // Spawn agent in RPC mode + agent = spawn( + "node", + ["dist/cli.js", "--mode", "rpc", "--provider", "anthropic", "--model", "claude-sonnet-4-5"], + { + cwd: join(__dirname, ".."), + env: { + ...process.env, + PI_CODING_AGENT_DIR: sessionDir, + }, + }, + ); + + const events: ( + | AgentEvent + | { type: "bash_end"; message: BashExecutionMessage } + | { type: "error"; error: string } + )[] = []; + + const rl = readline.createInterface({ input: agent.stdout!, terminal: false }); + + let stderr = ""; + agent.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + // Set up persistent event collector BEFORE sending any commands + // This is critical for fast commands like bash that complete before + // a per-call handler would be registered + rl.on("line", (line: string) => { + try { + const event = JSON.parse(line); + events.push(event); + } catch { + // Ignore non-JSON + } + }); + + // Helper to wait for a specific event type by polling collected events + const waitForEvent = (eventType: string, timeout = 60000) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timeout waiting for ${eventType}. Stderr: ${stderr}`)), + timeout, + ); + const check = () => { + if (events.some((e) => e.type === eventType)) { + clearTimeout(timer); + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + + // Send a bash command + agent.stdin!.write(JSON.stringify({ type: "bash", command: "echo hello" }) + "\n"); + await waitForEvent("bash_end"); + + // Verify bash_end event + const bashEvent = events.find((e) => e.type === "bash_end") as + | { type: "bash_end"; message: BashExecutionMessage } + | undefined; + expect(bashEvent).toBeDefined(); + expect(bashEvent!.message.role).toBe("bashExecution"); + expect(bashEvent!.message.command).toBe("echo hello"); + expect(bashEvent!.message.output.trim()).toBe("hello"); + expect(bashEvent!.message.exitCode).toBe(0); + expect(bashEvent!.message.cancelled).toBe(false); + + // Clear events for next phase + events.length = 0; + + // Session only initializes after user+assistant exchange, so send a prompt + agent.stdin!.write(JSON.stringify({ type: "prompt", message: "Say hi" }) + "\n"); + await waitForEvent("agent_end"); + + // Wait for file writes + await new Promise((resolve) => setTimeout(resolve, 200)); + + agent.kill("SIGTERM"); + + // Verify bash execution was saved to session file + const sessionsPath = join(sessionDir, "sessions"); + const sessionDirs = readdirSync(sessionsPath); + const cwdSessionDir = join(sessionsPath, sessionDirs[0]); + const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl")); + const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8"); + const entries = sessionContent + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + // Should have a bashExecution message + const bashMessages = entries.filter( + (e: { type: string; message?: { role: string } }) => + e.type === "message" && e.message?.role === "bashExecution", + ); + expect(bashMessages.length).toBe(1); + expect(bashMessages[0].message.command).toBe("echo hello"); + expect(bashMessages[0].message.output.trim()).toBe("hello"); + }, 90000); + + test("should include bash output in LLM context", async () => { + // Spawn agent in RPC mode + agent = spawn( + "node", + ["dist/cli.js", "--mode", "rpc", "--provider", "anthropic", "--model", "claude-sonnet-4-5"], + { + cwd: join(__dirname, ".."), + env: { + ...process.env, + PI_CODING_AGENT_DIR: sessionDir, + }, + }, + ); + + const events: ( + | AgentEvent + | { type: "bash_end"; message: BashExecutionMessage } + | { type: "error"; error: string } + )[] = []; + + const rl = readline.createInterface({ input: agent.stdout!, terminal: false }); + + let stderr = ""; + agent.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + // Set up persistent event collector BEFORE sending any commands + rl.on("line", (line: string) => { + try { + const event = JSON.parse(line); + events.push(event); + } catch { + // Ignore non-JSON + } + }); + + // Helper to wait for a specific event type by polling collected events + const waitForEvent = (eventType: string, timeout = 60000) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timeout waiting for ${eventType}. Stderr: ${stderr}`)), + timeout, + ); + const check = () => { + if (events.some((e) => e.type === eventType)) { + clearTimeout(timer); + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + + // Wait for agent to initialize (session manager, etc.) + await new Promise((resolve) => setTimeout(resolve, 500)); + + // First, run a bash command with a unique value + const uniqueValue = `test-${Date.now()}`; + agent.stdin!.write(JSON.stringify({ type: "bash", command: `echo ${uniqueValue}` }) + "\n"); + await waitForEvent("bash_end"); + + // Clear events but keep collecting new ones + events.length = 0; + + // Now ask the LLM what the output was - it should be in context + agent.stdin!.write( + JSON.stringify({ + type: "prompt", + message: `What was the exact output of the echo command I just ran? Reply with just the value, nothing else.`, + }) + "\n", + ); + await waitForEvent("agent_end"); + + // Find the assistant's response + const messageEndEvents = events.filter((e) => e.type === "message_end") as AgentEvent[]; + const assistantMessage = messageEndEvents.find( + (e) => e.type === "message_end" && (e as any).message?.role === "assistant", + ) as any; + + expect(assistantMessage).toBeDefined(); + + // The assistant should mention the unique value from the bash output + const textContent = assistantMessage.message.content.find((c: any) => c.type === "text"); + expect(textContent?.text).toContain(uniqueValue); + + agent.kill("SIGTERM"); + }, 90000); });