mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
extract shell config logic and add bash command cancellation in tui
This commit is contained in:
parent
d46914a415
commit
f9fd620b8b
3 changed files with 98 additions and 49 deletions
30
packages/coding-agent/src/shell-config.ts
Normal file
30
packages/coding-agent/src/shell-config.ts
Normal 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"] };
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,7 @@
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { existsSync } from "fs";
|
import { getShellConfig } from "../shell-config.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a process and all its children
|
* Kill a process and all its children
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
SUMMARY_SUFFIX,
|
SUMMARY_SUFFIX,
|
||||||
} from "../session-manager.js";
|
} from "../session-manager.js";
|
||||||
import type { SettingsManager } from "../settings-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 { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
|
||||||
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
||||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
|
|
@ -116,6 +117,9 @@ export class TuiRenderer {
|
||||||
// Track if editor is in bash mode (text starts with !)
|
// Track if editor is in bash mode (text starts with !)
|
||||||
private isBashMode = false;
|
private isBashMode = false;
|
||||||
|
|
||||||
|
// Track running bash command process for cancellation
|
||||||
|
private bashProcess: ReturnType<typeof spawn> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
|
@ -349,6 +353,17 @@ export class TuiRenderer {
|
||||||
|
|
||||||
// Abort
|
// Abort
|
||||||
this.agent.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
|
// Execute bash command
|
||||||
const { stdout, stderr } = await this.executeBashCommand(command);
|
const { stdout, stderr } = await this.executeBashCommand(command);
|
||||||
|
|
||||||
// Build the message text
|
// Build the message text, format like a user would naturally share command output
|
||||||
let messageText = `Ran \`${command}\``;
|
let messageText = `Ran \`${command}\`\n`;
|
||||||
if (stdout) {
|
const output = [stdout, stderr].filter(Boolean).join("\n");
|
||||||
messageText += `\n<stdout>\n${stdout}\n</stdout>`;
|
if (output) {
|
||||||
}
|
messageText += "```\n" + output + "\n```";
|
||||||
if (stderr) {
|
} else {
|
||||||
messageText += `\n<stderr>\n${stderr}\n</stderr>`;
|
messageText += "(no output)";
|
||||||
}
|
|
||||||
if (!stdout && !stderr) {
|
|
||||||
messageText += "\n(no output)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user message
|
// Create user message
|
||||||
|
|
@ -1868,12 +1880,15 @@ export class TuiRenderer {
|
||||||
|
|
||||||
private executeBashCommand(command: string): Promise<{ stdout: string; stderr: string }> {
|
private executeBashCommand(command: string): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const shell = process.platform === "win32" ? "bash.exe" : "sh";
|
const { shell, args } = getShellConfig();
|
||||||
const child = spawn(shell, ["-c", command], {
|
const child = spawn(shell, [...args, command], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track process for cancellation
|
||||||
|
this.bashProcess = child;
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
|
|
@ -1900,24 +1915,27 @@ export class TuiRenderer {
|
||||||
// 30 second timeout
|
// 30 second timeout
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
if (child.pid) {
|
if (child.pid) {
|
||||||
try {
|
killProcessTree(child.pid);
|
||||||
process.kill(-child.pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
// Process may already be dead
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
reject(new Error("Command execution timeout (30s)"));
|
reject(new Error("Command execution timeout (30s)"));
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
child.on("close", (code: number | null) => {
|
child.on("close", (code: number | null) => {
|
||||||
clearTimeout(timeoutHandle);
|
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
|
// Trim trailing newlines from output
|
||||||
stdout = stdout.replace(/\n+$/, "");
|
stdout = stdout.replace(/\n+$/, "");
|
||||||
stderr = stderr.replace(/\n+$/, "");
|
stderr = stderr.replace(/\n+$/, "");
|
||||||
|
|
||||||
// Don't reject on non-zero exit - we want to show the error in stderr
|
// Don't reject on non-zero exit as we want to show the error in stderr
|
||||||
if (code !== 0 && code !== null && !stderr) {
|
if (code !== 0 && !stderr) {
|
||||||
stderr = `Command exited with code ${code}`;
|
stderr = `Command exited with code ${code}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1926,6 +1944,7 @@ export class TuiRenderer {
|
||||||
|
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
|
this.bashProcess = null;
|
||||||
reject(err);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue