Commit db136df99acc

Vincent Demeester <vincent@sbr.pm>
2026-02-08 22:33:25
feat(pi): added inline-bash and interactive-shell extensions
Added two pi extensions for enhanced shell interaction. Inline-bash enables direct command execution without TUI suspension, while interactive-shell provides full terminal access for interactive commands like vim and git rebase.
1 parent 8b2a8fe
Changed files (2)
dots/pi/agent/extensions/inline-bash.ts
@@ -0,0 +1,94 @@
+/**
+ * Inline Bash Extension - expands inline bash commands in user prompts.
+ *
+ * Start pi with this extension:
+ *   pi -e ./examples/extensions/inline-bash.ts
+ *
+ * Then type prompts with inline bash:
+ *   What's in !{pwd}?
+ *   The current branch is !{git branch --show-current} and status: !{git status --short}
+ *   My node version is !{node --version}
+ *
+ * The !{command} patterns are executed and replaced with their output before
+ * the prompt is sent to the agent.
+ *
+ * Note: Regular !command syntax (whole-line bash) is preserved and works as before.
+ */
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+	const PATTERN = /!\{([^}]+)\}/g;
+	const TIMEOUT_MS = 30000;
+
+	pi.on("input", async (event, ctx) => {
+		const text = event.text;
+
+		// Don't process if it's a whole-line bash command (starts with !)
+		// This preserves the existing !command behavior
+		if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
+			return { action: "continue" };
+		}
+
+		// Check if there are any inline bash patterns
+		if (!PATTERN.test(text)) {
+			return { action: "continue" };
+		}
+
+		// Reset regex state after test()
+		PATTERN.lastIndex = 0;
+
+		let result = text;
+		const expansions: Array<{ command: string; output: string; error?: string }> = [];
+
+		// Find all matches first (to avoid issues with replacing while iterating)
+		const matches: Array<{ full: string; command: string }> = [];
+		let match = PATTERN.exec(text);
+		while (match) {
+			matches.push({ full: match[0], command: match[1] });
+			match = PATTERN.exec(text);
+		}
+
+		// Execute each command and collect results
+		for (const { full, command } of matches) {
+			try {
+				const bashResult = await pi.exec("bash", ["-c", command], {
+					timeout: TIMEOUT_MS,
+				});
+
+				const output = bashResult.stdout || bashResult.stderr || "";
+				const trimmed = output.trim();
+
+				if (bashResult.code !== 0 && bashResult.stderr) {
+					expansions.push({
+						command,
+						output: trimmed,
+						error: `exit code ${bashResult.code}`,
+					});
+				} else {
+					expansions.push({ command, output: trimmed });
+				}
+
+				result = result.replace(full, trimmed);
+			} catch (err) {
+				const errorMsg = err instanceof Error ? err.message : String(err);
+				expansions.push({ command, output: "", error: errorMsg });
+				result = result.replace(full, `[error: ${errorMsg}]`);
+			}
+		}
+
+		// Show what was expanded (if UI available)
+		if (ctx.hasUI && expansions.length > 0) {
+			const summary = expansions
+				.map((e) => {
+					const status = e.error ? ` (${e.error})` : "";
+					const preview = e.output.length > 50 ? `${e.output.slice(0, 50)}...` : e.output;
+					return `!{${e.command}}${status} -> "${preview}"`;
+				})
+				.join("\n");
+
+			ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
+		}
+
+		return { action: "transform", text: result, images: event.images };
+	});
+}
dots/pi/agent/extensions/interactive-shell.ts
@@ -0,0 +1,196 @@
+/**
+ * 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,
+			},
+		};
+	});
+}