Commit db136df99acc
Changed files (2)
dots
pi
agent
extensions
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,
+ },
+ };
+ });
+}