add support for running bash commands via '!' in the tui editor

This commit is contained in:
Markus Ylisiurunen 2025-12-04 20:06:12 +02:00
parent 47bb302155
commit d46914a415
5 changed files with 179 additions and 6 deletions

View file

@ -65,6 +65,8 @@
"thinkingMinimal": "#6e6e6e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb"
"thinkingHigh": "#b294bb",
"bashMode": "green"
}
}

View file

@ -64,6 +64,8 @@
"thinkingMinimal": "#9e9e9e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#5f8787",
"thinkingHigh": "#875f87"
"thinkingHigh": "#875f87",
"bashMode": "green"
}
}

View file

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

View file

@ -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);
}
}
// ============================================================================

View file

@ -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;
/**