extract shell config logic and add bash command cancellation in tui

This commit is contained in:
Markus Ylisiurunen 2025-12-04 20:24:26 +02:00
parent d46914a415
commit f9fd620b8b
3 changed files with 98 additions and 49 deletions

View file

@ -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"] };
}

View file

@ -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

View file

@ -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<typeof spawn> | 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<stdout>\n${stdout}\n</stdout>`;
}
if (stderr) {
messageText += `\n<stderr>\n${stderr}\n</stderr>`;
}
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
}
}
}
}