mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 16:04:03 +00:00
Demonstrates running interactive commands (vim, git rebase, htop, etc.) with full terminal access using user_bash event and ctx.ui.custom(). - Auto-detects interactive commands from built-in list - !i prefix to force interactive mode - Configurable via INTERACTIVE_COMMANDS/INTERACTIVE_EXCLUDE env vars closes #532
196 lines
4.7 KiB
TypeScript
196 lines
4.7 KiB
TypeScript
/**
|
|
* Interactive Shell Commands Extension
|
|
*
|
|
* Enables running interactive commands (vim, git rebase -i, htop, etc.)
|
|
* with full terminal access. The TUI suspends while they run.
|
|
*
|
|
* Usage:
|
|
* pi -e examples/extensions/interactive-shell.ts
|
|
*
|
|
* !vim file.txt # Auto-detected as interactive
|
|
* !i any-command # Force interactive mode with !i prefix
|
|
* !git rebase -i HEAD~3
|
|
* !htop
|
|
*
|
|
* Configuration via environment variables:
|
|
* INTERACTIVE_COMMANDS - Additional commands (comma-separated)
|
|
* INTERACTIVE_EXCLUDE - Commands to exclude (comma-separated)
|
|
*
|
|
* Note: This only intercepts user `!` commands, not agent bash tool calls.
|
|
* If the agent runs an interactive command, it will fail (which is fine).
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
|
// Default interactive commands - editors, pagers, git ops, TUIs
|
|
const DEFAULT_INTERACTIVE_COMMANDS = [
|
|
// Editors
|
|
"vim",
|
|
"nvim",
|
|
"vi",
|
|
"nano",
|
|
"emacs",
|
|
"pico",
|
|
"micro",
|
|
"helix",
|
|
"hx",
|
|
"kak",
|
|
// Pagers
|
|
"less",
|
|
"more",
|
|
"most",
|
|
// Git interactive
|
|
"git commit",
|
|
"git rebase",
|
|
"git merge",
|
|
"git cherry-pick",
|
|
"git revert",
|
|
"git add -p",
|
|
"git add --patch",
|
|
"git add -i",
|
|
"git add --interactive",
|
|
"git stash -p",
|
|
"git stash --patch",
|
|
"git reset -p",
|
|
"git reset --patch",
|
|
"git checkout -p",
|
|
"git checkout --patch",
|
|
"git difftool",
|
|
"git mergetool",
|
|
// System monitors
|
|
"htop",
|
|
"top",
|
|
"btop",
|
|
"glances",
|
|
// File managers
|
|
"ranger",
|
|
"nnn",
|
|
"lf",
|
|
"mc",
|
|
"vifm",
|
|
// Git TUIs
|
|
"tig",
|
|
"lazygit",
|
|
"gitui",
|
|
// Fuzzy finders
|
|
"fzf",
|
|
"sk",
|
|
// Remote sessions
|
|
"ssh",
|
|
"telnet",
|
|
"mosh",
|
|
// Database clients
|
|
"psql",
|
|
"mysql",
|
|
"sqlite3",
|
|
"mongosh",
|
|
"redis-cli",
|
|
// Kubernetes/Docker
|
|
"kubectl edit",
|
|
"kubectl exec -it",
|
|
"docker exec -it",
|
|
"docker run -it",
|
|
// Other
|
|
"tmux",
|
|
"screen",
|
|
"ncdu",
|
|
];
|
|
|
|
function getInteractiveCommands(): string[] {
|
|
const additional =
|
|
process.env.INTERACTIVE_COMMANDS?.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean) ?? [];
|
|
const excluded = new Set(process.env.INTERACTIVE_EXCLUDE?.split(",").map((s) => s.trim().toLowerCase()) ?? []);
|
|
return [...DEFAULT_INTERACTIVE_COMMANDS, ...additional].filter((cmd) => !excluded.has(cmd.toLowerCase()));
|
|
}
|
|
|
|
function isInteractiveCommand(command: string): boolean {
|
|
const trimmed = command.trim().toLowerCase();
|
|
const commands = getInteractiveCommands();
|
|
|
|
for (const cmd of commands) {
|
|
const cmdLower = cmd.toLowerCase();
|
|
// Match at start
|
|
if (trimmed === cmdLower || trimmed.startsWith(`${cmdLower} `) || trimmed.startsWith(`${cmdLower}\t`)) {
|
|
return true;
|
|
}
|
|
// Match after pipe: "cat file | less"
|
|
const pipeIdx = trimmed.lastIndexOf("|");
|
|
if (pipeIdx !== -1) {
|
|
const afterPipe = trimmed.slice(pipeIdx + 1).trim();
|
|
if (afterPipe === cmdLower || afterPipe.startsWith(`${cmdLower} `)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.on("user_bash", async (event, ctx) => {
|
|
let command = event.command;
|
|
let forceInteractive = false;
|
|
|
|
// Check for !i prefix (command comes without the leading !)
|
|
// The prefix parsing happens before this event, so we check if command starts with "i "
|
|
if (command.startsWith("i ") || command.startsWith("i\t")) {
|
|
forceInteractive = true;
|
|
command = command.slice(2).trim();
|
|
}
|
|
|
|
const shouldBeInteractive = forceInteractive || isInteractiveCommand(command);
|
|
if (!shouldBeInteractive) {
|
|
return; // Let normal handling proceed
|
|
}
|
|
|
|
// No UI available (print mode, RPC, etc.)
|
|
if (!ctx.hasUI) {
|
|
return {
|
|
result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false },
|
|
};
|
|
}
|
|
|
|
// Use ctx.ui.custom() to get TUI access, then run the command
|
|
const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => {
|
|
// Stop TUI to release terminal
|
|
tui.stop();
|
|
|
|
// Clear screen
|
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
|
|
// Run command with full terminal access
|
|
const shell = process.env.SHELL || "/bin/sh";
|
|
const result = spawnSync(shell, ["-c", command], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
});
|
|
|
|
// Restart TUI
|
|
tui.start();
|
|
tui.requestRender(true);
|
|
|
|
// Signal completion
|
|
done(result.status);
|
|
|
|
// Return empty component (immediately disposed since done() was called)
|
|
return { render: () => [], invalidate: () => {} };
|
|
});
|
|
|
|
// Return result to prevent default bash handling
|
|
const output =
|
|
exitCode === 0
|
|
? "(interactive command completed successfully)"
|
|
: `(interactive command exited with code ${exitCode})`;
|
|
|
|
return {
|
|
result: {
|
|
output,
|
|
exitCode: exitCode ?? 1,
|
|
cancelled: false,
|
|
truncated: false,
|
|
},
|
|
};
|
|
});
|
|
}
|