mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
add support for running bash commands via '!' in the tui editor
This commit is contained in:
parent
47bb302155
commit
d46914a415
5 changed files with 179 additions and 6 deletions
|
|
@ -65,6 +65,8 @@
|
|||
"thinkingMinimal": "#6e6e6e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#81a2be",
|
||||
"thinkingHigh": "#b294bb"
|
||||
"thinkingHigh": "#b294bb",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@
|
|||
"thinkingMinimal": "#9e9e9e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#5f8787",
|
||||
"thinkingHigh": "#875f87"
|
||||
"thinkingHigh": "#875f87",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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 (!<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<void> {
|
||||
try {
|
||||
// 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)";
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue