From f9fd620b8bded7c698e65bd5b1202e71c6e057dc Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:24:26 +0200 Subject: [PATCH] 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 + } + } + } +}