From d46914a41578566db428b8195e86febeb687cf02 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:06:12 +0200 Subject: [PATCH] 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; /**