From d46914a41578566db428b8195e86febeb687cf02 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:06:12 +0200 Subject: [PATCH 1/5] add support for running bash commands via '!' in the tui editor --- packages/coding-agent/src/theme/dark.json | 4 +- packages/coding-agent/src/theme/light.json | 4 +- .../coding-agent/src/theme/theme-schema.json | 24 +++ packages/coding-agent/src/theme/theme.ts | 9 +- packages/coding-agent/src/tui/tui-renderer.ts | 144 +++++++++++++++++- 5 files changed, 179 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/theme/dark.json b/packages/coding-agent/src/theme/dark.json index 20d0c972..ea40f6fa 100644 --- a/packages/coding-agent/src/theme/dark.json +++ b/packages/coding-agent/src/theme/dark.json @@ -65,6 +65,8 @@ "thinkingMinimal": "#6e6e6e", "thinkingLow": "#5f87af", "thinkingMedium": "#81a2be", - "thinkingHigh": "#b294bb" + "thinkingHigh": "#b294bb", + + "bashMode": "green" } } diff --git a/packages/coding-agent/src/theme/light.json b/packages/coding-agent/src/theme/light.json index 25482376..193c5160 100644 --- a/packages/coding-agent/src/theme/light.json +++ b/packages/coding-agent/src/theme/light.json @@ -64,6 +64,8 @@ "thinkingMinimal": "#9e9e9e", "thinkingLow": "#5f87af", "thinkingMedium": "#5f8787", - "thinkingHigh": "#875f87" + "thinkingHigh": "#875f87", + + "bashMode": "green" } } diff --git a/packages/coding-agent/src/theme/theme-schema.json b/packages/coding-agent/src/theme/theme-schema.json index 8507a94c..44c2ed2c 100644 --- a/packages/coding-agent/src/theme/theme-schema.json +++ b/packages/coding-agent/src/theme/theme-schema.json @@ -221,6 +221,30 @@ "syntaxPunctuation": { "$ref": "#/$defs/colorValue", "description": "Syntax highlighting: punctuation" + }, + "thinkingOff": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: off" + }, + "thinkingMinimal": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: minimal" + }, + "thinkingLow": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: low" + }, + "thinkingMedium": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: medium" + }, + "thinkingHigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: high" + }, + "bashMode": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color in bash mode" } }, "additionalProperties": false diff --git a/packages/coding-agent/src/theme/theme.ts b/packages/coding-agent/src/theme/theme.ts index 287eb522..054b6b24 100644 --- a/packages/coding-agent/src/theme/theme.ts +++ b/packages/coding-agent/src/theme/theme.ts @@ -72,6 +72,8 @@ const ThemeJsonSchema = Type.Object({ thinkingLow: ColorValueSchema, thinkingMedium: ColorValueSchema, thinkingHigh: ColorValueSchema, + // Bash Mode (1 color) + bashMode: ColorValueSchema, }), }); @@ -119,7 +121,8 @@ export type ThemeColor = | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" - | "thinkingHigh"; + | "thinkingHigh" + | "bashMode"; export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; @@ -312,6 +315,10 @@ export class Theme { return (str: string) => this.fg("thinkingOff", str); } } + + getBashModeBorderColor(): (str: string) => string { + return (str: string) => this.fg("bashMode", str); + } } // ============================================================================ diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5cba73e7..abf7e4c2 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -16,7 +16,7 @@ import { TUI, visibleWidth, } from "@mariozechner/pi-tui"; -import { exec } from "child_process"; +import { exec, spawn } from "child_process"; import { getChangelogPath, parseChangelog } from "../changelog.js"; import { copyToClipboard } from "../clipboard.js"; import { calculateContextTokens, compact, shouldCompact } from "../compaction.js"; @@ -113,6 +113,9 @@ export class TuiRenderer { // File-based slash commands private fileCommands: FileSlashCommand[] = []; + // Track if editor is in bash mode (text starts with !) + private isBashMode = false; + constructor( agent: Agent, sessionManager: SessionManager, @@ -275,6 +278,9 @@ export class TuiRenderer { theme.fg("dim", "/") + theme.fg("muted", " for commands") + "\n" + + theme.fg("dim", "!") + + theme.fg("muted", " to run bash") + + "\n" + theme.fg("dim", "drop files") + theme.fg("muted", " to attach"); const header = new Text(logo + "\n" + instructions, 1, 0); @@ -362,6 +368,15 @@ export class TuiRenderer { this.toggleToolOutputExpansion(); }; + // Handle editor text changes for bash mode detection + this.editor.onChange = (text: string) => { + const wasBashMode = this.isBashMode; + this.isBashMode = text.trimStart().startsWith("!"); + if (wasBashMode !== this.isBashMode) { + this.updateEditorBorderColor(); + } + }; + // Handle editor submission this.editor.onSubmit = async (text: string) => { text = text.trim(); @@ -475,6 +490,19 @@ export class TuiRenderer { return; } + // Check for bash command (!) + if (text.startsWith("!")) { + const command = text.slice(1).trim(); + if (command) { + this.handleBashCommand(command); + this.editor.setText(""); + // Reset bash mode since editor is now empty + this.isBashMode = false; + this.updateEditorBorderColor(); + return; + } + } + // Check for file-based slash commands text = expandSlashCommand(text, this.fileCommands); @@ -962,8 +990,12 @@ export class TuiRenderer { } private updateEditorBorderColor(): void { - const level = this.agent.state.thinkingLevel || "off"; - this.editor.borderColor = theme.getThinkingBorderColor(level); + if (this.isBashMode) { + this.editor.borderColor = theme.getBashModeBorderColor(); + } else { + const level = this.agent.state.thinkingLevel || "off"; + this.editor.borderColor = theme.getThinkingBorderColor(level); + } this.ui.requestRender(); } @@ -1793,6 +1825,112 @@ export class TuiRenderer { this.ui.requestRender(); } + private async handleBashCommand(command: string): Promise { + try { + // Execute bash command + const { stdout, stderr } = await this.executeBashCommand(command); + + // Build the message text + let messageText = `Ran \`${command}\``; + if (stdout) { + messageText += `\n\n${stdout}\n`; + } + if (stderr) { + messageText += `\n\n${stderr}\n`; + } + if (!stdout && !stderr) { + messageText += "\n(no output)"; + } + + // 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) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + this.showError(`Failed to execute bash command: ${errorMessage}`); + } + } + + private executeBashCommand(command: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const shell = process.platform === "win32" ? "bash.exe" : "sh"; + const child = spawn(shell, ["-c", command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + if (child.stdout) { + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + // Limit buffer size to 10MB + if (stdout.length > 10 * 1024 * 1024) { + stdout = stdout.slice(0, 10 * 1024 * 1024); + } + }); + } + + if (child.stderr) { + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + // Limit buffer size to 10MB + if (stderr.length > 10 * 1024 * 1024) { + stderr = stderr.slice(0, 10 * 1024 * 1024); + } + }); + } + + // 30 second timeout + const timeoutHandle = setTimeout(() => { + if (child.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Process may already be dead + } + } + reject(new Error("Command execution timeout (30s)")); + }, 30000); + + child.on("close", (code: number | null) => { + clearTimeout(timeoutHandle); + + // Trim trailing newlines from output + stdout = stdout.replace(/\n+$/, ""); + stderr = stderr.replace(/\n+$/, ""); + + // Don't reject on non-zero exit - we want to show the error in stderr + if (code !== 0 && code !== null && !stderr) { + stderr = `Command exited with code ${code}`; + } + + resolve({ stdout, stderr }); + }); + + child.on("error", (err) => { + clearTimeout(timeoutHandle); + reject(err); + }); + }); + } + private compactionAbortController: AbortController | null = null; /** From f9fd620b8bded7c698e65bd5b1202e71c6e057dc Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:24:26 +0200 Subject: [PATCH 2/5] extract shell config logic and add bash command cancellation in tui --- packages/coding-agent/src/shell-config.ts | 30 +++++++ packages/coding-agent/src/tools/bash.ts | 31 +------ packages/coding-agent/src/tui/tui-renderer.ts | 86 +++++++++++++++---- 3 files changed, 98 insertions(+), 49 deletions(-) create mode 100644 packages/coding-agent/src/shell-config.ts diff --git a/packages/coding-agent/src/shell-config.ts b/packages/coding-agent/src/shell-config.ts new file mode 100644 index 00000000..23cec0f6 --- /dev/null +++ b/packages/coding-agent/src/shell-config.ts @@ -0,0 +1,30 @@ +import { existsSync } from "fs"; + +/** + * Get shell configuration based on platform + */ +export function getShellConfig(): { shell: string; args: string[] } { + if (process.platform === "win32") { + const paths: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + paths.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + + for (const path of paths) { + if (existsSync(path)) { + return { shell: path, args: ["-c"] }; + } + } + + throw new Error( + `Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` + + `Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`, + ); + } + return { shell: "sh", args: ["-c"] }; +} diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 4171f95c..de785287 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,36 +1,7 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; -import { existsSync } from "fs"; - -/** - * Get shell configuration based on platform - */ -function getShellConfig(): { shell: string; args: string[] } { - if (process.platform === "win32") { - const paths: string[] = []; - const programFiles = process.env.ProgramFiles; - if (programFiles) { - paths.push(`${programFiles}\\Git\\bin\\bash.exe`); - } - const programFilesX86 = process.env["ProgramFiles(x86)"]; - if (programFilesX86) { - paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); - } - - for (const path of paths) { - if (existsSync(path)) { - return { shell: path, args: ["-c"] }; - } - } - - throw new Error( - `Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` + - `Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`, - ); - } - return { shell: "sh", args: ["-c"] }; -} +import { getShellConfig } from "../shell-config.js"; /** * Kill a process and all its children diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index abf7e4c2..73c41893 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -32,6 +32,7 @@ import { SUMMARY_SUFFIX, } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; +import { getShellConfig } from "../shell-config.js"; import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js"; import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; @@ -116,6 +117,9 @@ export class TuiRenderer { // Track if editor is in bash mode (text starts with !) private isBashMode = false; + // Track running bash command process for cancellation + private bashProcess: ReturnType | null = null; + constructor( agent: Agent, sessionManager: SessionManager, @@ -349,6 +353,17 @@ export class TuiRenderer { // Abort this.agent.abort(); + } else if (this.bashProcess) { + // Kill running bash command + if (this.bashProcess.pid) { + killProcessTree(this.bashProcess.pid); + } + this.bashProcess = null; + } else if (this.isBashMode) { + // Cancel bash mode and clear editor + this.editor.setText(""); + this.isBashMode = false; + this.updateEditorBorderColor(); } }; @@ -1830,16 +1845,13 @@ export class TuiRenderer { // Execute bash command const { stdout, stderr } = await this.executeBashCommand(command); - // Build the message text - let messageText = `Ran \`${command}\``; - if (stdout) { - messageText += `\n\n${stdout}\n`; - } - if (stderr) { - messageText += `\n\n${stderr}\n`; - } - if (!stdout && !stderr) { - messageText += "\n(no output)"; + // 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)"; } // Create user message @@ -1868,12 +1880,15 @@ export class TuiRenderer { private executeBashCommand(command: string): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - const shell = process.platform === "win32" ? "bash.exe" : "sh"; - const child = spawn(shell, ["-c", command], { + const { shell, args } = getShellConfig(); + const child = spawn(shell, [...args, command], { detached: true, stdio: ["ignore", "pipe", "pipe"], }); + // Track process for cancellation + this.bashProcess = child; + let stdout = ""; let stderr = ""; @@ -1900,24 +1915,27 @@ export class TuiRenderer { // 30 second timeout const timeoutHandle = setTimeout(() => { if (child.pid) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - // Process may already be dead - } + killProcessTree(child.pid); } reject(new Error("Command execution timeout (30s)")); }, 30000); child.on("close", (code: number | null) => { clearTimeout(timeoutHandle); + this.bashProcess = null; + + // Check if killed (code is null when process is killed) + if (code === null) { + reject(new Error("Command cancelled")); + return; + } // Trim trailing newlines from output stdout = stdout.replace(/\n+$/, ""); stderr = stderr.replace(/\n+$/, ""); - // Don't reject on non-zero exit - we want to show the error in stderr - if (code !== 0 && code !== null && !stderr) { + // 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}`; } @@ -1926,6 +1944,7 @@ export class TuiRenderer { child.on("error", (err) => { clearTimeout(timeoutHandle); + this.bashProcess = null; reject(err); }); }); @@ -2090,3 +2109,32 @@ export class TuiRenderer { } } } + +/** + * Kill a process and all its children (cross-platform) + */ +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + // Use taskkill on Windows to kill process tree + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + }); + } catch { + // Ignore errors if taskkill fails + } + } else { + // Use SIGKILL on Unix/Linux/Mac + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Fallback to killing just the child if process group kill fails + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead + } + } + } +} From 69ff9c364c35119375919df6cffd93fa7ea357ed Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:29:29 +0200 Subject: [PATCH 3/5] reduce child process stdout and stderr buffer limits in tui renderer --- packages/coding-agent/src/tui/tui-renderer.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 73c41893..b0706c31 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1895,9 +1895,9 @@ export class TuiRenderer { if (child.stdout) { child.stdout.on("data", (data: Buffer) => { stdout += data.toString(); - // Limit buffer size to 10MB - if (stdout.length > 10 * 1024 * 1024) { - stdout = stdout.slice(0, 10 * 1024 * 1024); + // Limit buffer size to 2MB + if (stdout.length > 2 * 1024 * 1024) { + stdout = stdout.slice(0, 2 * 1024 * 1024); } }); } @@ -1905,9 +1905,9 @@ export class TuiRenderer { if (child.stderr) { child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); - // Limit buffer size to 10MB - if (stderr.length > 10 * 1024 * 1024) { - stderr = stderr.slice(0, 10 * 1024 * 1024); + // Limit buffer size to 1MB + if (stderr.length > 1 * 1024 * 1024) { + stderr = stderr.slice(0, 1 * 1024 * 1024); } }); } From bd0d0676d4915265c85d03b61d00934b08f0fa98 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 22:40:32 +0100 Subject: [PATCH 4/5] 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 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 21 ++ packages/coding-agent/docs/RPC.md | 83 +++++- packages/coding-agent/docs/session.md | 3 + packages/coding-agent/src/compaction.ts | 8 +- packages/coding-agent/src/export-html.ts | 88 +++++-- packages/coding-agent/src/main.ts | 123 ++++++++- packages/coding-agent/src/messages.ts | 102 ++++++++ .../coding-agent/src/tui/bash-execution.ts | 161 ++++++++++++ packages/coding-agent/src/tui/tui-renderer.ts | 246 ++++++++++++------ packages/coding-agent/test/compaction.test.ts | 10 +- .../coding-agent/{src => test}/fuzzy.test.ts | 0 packages/coding-agent/test/rpc.test.ts | 196 ++++++++++++++ 13 files changed, 917 insertions(+), 126 deletions(-) create mode 100644 packages/coding-agent/src/messages.ts create mode 100644 packages/coding-agent/src/tui/bash-execution.ts rename packages/coding-agent/{src => test}/fuzzy.test.ts (100%) 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); }); From d5200b4f1c48ded80abca382735d965561f6e23b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 22:45:05 +0100 Subject: [PATCH 5/5] Add green borders around bash execution component --- .../coding-agent/src/tui/bash-execution.ts | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/src/tui/bash-execution.ts b/packages/coding-agent/src/tui/bash-execution.ts index 2d3e24de..b9ef3134 100644 --- a/packages/coding-agent/src/tui/bash-execution.ts +++ b/packages/coding-agent/src/tui/bash-execution.ts @@ -6,6 +6,7 @@ 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"; +import { DynamicBorder } from "./dynamic-border.js"; // Preview line limit when not expanded (matches tool execution behavior) const PREVIEW_LINES = 20; @@ -18,24 +19,28 @@ export class BashExecutionComponent extends Container { private loader: Loader; private truncationResult?: TruncationResult; private fullOutputPath?: string; - private contentText: Text; - private statusText: Text | null = null; private expanded = false; + private contentContainer: Container; constructor(command: string, ui: TUI) { super(); this.command = command; + const borderColor = (str: string) => theme.fg("bashMode", 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("bashMode", theme.bold(`$ ${command}`)), 1, 0); - this.addChild(header); - - // Output area (will be updated) - this.contentText = new Text("", 1, 0); - this.addChild(this.contentText); + this.contentContainer.addChild(header); // Loader this.loader = new Loader( @@ -44,7 +49,10 @@ export class BashExecutionComponent extends Container { (text) => theme.fg("muted", text), "Running... (esc to cancel)", ); - this.addChild(this.loader); + this.contentContainer.addChild(this.loader); + + // Bottom border + this.addChild(new DynamicBorder(borderColor)); } /** @@ -83,9 +91,8 @@ export class BashExecutionComponent extends Container { this.truncationResult = truncationResult; this.fullOutputPath = fullOutputPath; - // Stop and remove loader + // Stop loader this.loader.stop(); - this.removeChild(this.loader); this.updateDisplay(); } @@ -106,19 +113,23 @@ export class BashExecutionComponent extends Container { const displayLines = availableLines.slice(-maxDisplayLines); // Show last N lines (tail) const hiddenLineCount = availableLines.length - displayLines.length; - let displayText = ""; + // 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 (displayLines.length > 0) { - displayText = displayLines.map((line) => theme.fg("muted", line)).join("\n"); + const displayText = displayLines.map((line) => theme.fg("muted", line)).join("\n"); + this.contentContainer.addChild(new Text("\n" + displayText, 1, 0)); } - this.contentText.setText(displayText ? "\n" + displayText : ""); - - // Update/add status text if complete - if (this.status !== "running") { - if (this.statusText) { - this.removeChild(this.statusText); - } - + // Loader or status + if (this.status === "running") { + this.contentContainer.addChild(this.loader); + } else { const statusParts: string[] = []; // Show how many lines are hidden (collapsed preview) @@ -139,8 +150,7 @@ export class BashExecutionComponent extends Container { } if (statusParts.length > 0) { - this.statusText = new Text("\n" + statusParts.join("\n"), 1, 0); - this.addChild(this.statusText); + this.contentContainer.addChild(new Text("\n" + statusParts.join("\n"), 1, 0)); } } }