/ extensions / interactive-shell.ts
interactive-shell.ts
1 /** 2 * Interactive Shell Commands Extension 3 * 4 * Enables running interactive commands (vim, git rebase -i, htop, etc.) 5 * with full terminal access. The TUI suspends while they run. 6 * 7 * Usage: 8 * pi -e examples/extensions/interactive-shell.ts 9 * 10 * !vim file.txt # Auto-detected as interactive 11 * !i any-command # Force interactive mode with !i prefix 12 * !git rebase -i HEAD~3 13 * !htop 14 * 15 * Configuration via environment variables: 16 * INTERACTIVE_COMMANDS - Additional commands (comma-separated) 17 * INTERACTIVE_EXCLUDE - Commands to exclude (comma-separated) 18 * 19 * Note: This only intercepts user `!` commands, not agent bash tool calls. 20 * If the agent runs an interactive command, it will fail (which is fine). 21 */ 22 23 import { spawnSync } from "node:child_process"; 24 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; 25 26 // Default interactive commands - editors, pagers, git ops, TUIs 27 const DEFAULT_INTERACTIVE_COMMANDS = [ 28 // Editors 29 "vim", 30 "nvim", 31 "vi", 32 "nano", 33 "emacs", 34 "pico", 35 "micro", 36 "helix", 37 "hx", 38 "kak", 39 // Pagers 40 "less", 41 "more", 42 "most", 43 // Git interactive 44 "git commit", 45 "git rebase", 46 "git merge", 47 "git cherry-pick", 48 "git revert", 49 "git add -p", 50 "git add --patch", 51 "git add -i", 52 "git add --interactive", 53 "git stash -p", 54 "git stash --patch", 55 "git reset -p", 56 "git reset --patch", 57 "git checkout -p", 58 "git checkout --patch", 59 "git difftool", 60 "git mergetool", 61 // System monitors 62 "htop", 63 "top", 64 "btop", 65 "glances", 66 // File managers 67 "ranger", 68 "nnn", 69 "lf", 70 "mc", 71 "vifm", 72 // Git TUIs 73 "tig", 74 "lazygit", 75 "gitui", 76 // Fuzzy finders 77 "fzf", 78 "sk", 79 // Remote sessions 80 "ssh", 81 "telnet", 82 "mosh", 83 // Database clients 84 "psql", 85 "mysql", 86 "sqlite3", 87 "mongosh", 88 "redis-cli", 89 // Kubernetes/Docker 90 "kubectl edit", 91 "kubectl exec -it", 92 "docker exec -it", 93 "docker run -it", 94 // Other 95 "tmux", 96 "screen", 97 "ncdu", 98 ]; 99 100 function getInteractiveCommands(): string[] { 101 const additional = 102 process.env.INTERACTIVE_COMMANDS?.split(",") 103 .map((s) => s.trim()) 104 .filter(Boolean) ?? []; 105 const excluded = new Set(process.env.INTERACTIVE_EXCLUDE?.split(",").map((s) => s.trim().toLowerCase()) ?? []); 106 return [...DEFAULT_INTERACTIVE_COMMANDS, ...additional].filter((cmd) => !excluded.has(cmd.toLowerCase())); 107 } 108 109 function isInteractiveCommand(command: string): boolean { 110 const trimmed = command.trim().toLowerCase(); 111 const commands = getInteractiveCommands(); 112 113 for (const cmd of commands) { 114 const cmdLower = cmd.toLowerCase(); 115 // Match at start 116 if (trimmed === cmdLower || trimmed.startsWith(`${cmdLower} `) || trimmed.startsWith(`${cmdLower}\t`)) { 117 return true; 118 } 119 // Match after pipe: "cat file | less" 120 const pipeIdx = trimmed.lastIndexOf("|"); 121 if (pipeIdx !== -1) { 122 const afterPipe = trimmed.slice(pipeIdx + 1).trim(); 123 if (afterPipe === cmdLower || afterPipe.startsWith(`${cmdLower} `)) { 124 return true; 125 } 126 } 127 } 128 return false; 129 } 130 131 export default function (pi: ExtensionAPI) { 132 pi.on("user_bash", async (event, ctx) => { 133 let command = event.command; 134 let forceInteractive = false; 135 136 // Check for !i prefix (command comes without the leading !) 137 // The prefix parsing happens before this event, so we check if command starts with "i " 138 if (command.startsWith("i ") || command.startsWith("i\t")) { 139 forceInteractive = true; 140 command = command.slice(2).trim(); 141 } 142 143 const shouldBeInteractive = forceInteractive || isInteractiveCommand(command); 144 if (!shouldBeInteractive) { 145 return; // Let normal handling proceed 146 } 147 148 // No UI available (print mode, RPC, etc.) 149 if (!ctx.hasUI) { 150 return { 151 result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false }, 152 }; 153 } 154 155 // Use ctx.ui.custom() to get TUI access, then run the command 156 const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => { 157 // Stop TUI to release terminal 158 tui.stop(); 159 160 // Clear screen 161 process.stdout.write("\x1b[2J\x1b[H"); 162 163 // Run command with full terminal access 164 const shell = process.env.SHELL || "/bin/sh"; 165 const result = spawnSync(shell, ["-c", command], { 166 stdio: "inherit", 167 env: process.env, 168 }); 169 170 // Restart TUI 171 tui.start(); 172 tui.requestRender(true); 173 174 // Signal completion 175 done(result.status); 176 177 // Return empty component (immediately disposed since done() was called) 178 return { render: () => [], invalidate: () => {} }; 179 }); 180 181 // Return result to prevent default bash handling 182 const output = 183 exitCode === 0 184 ? "(interactive command completed successfully)" 185 : `(interactive command exited with code ${exitCode})`; 186 187 return { 188 result: { 189 output, 190 exitCode: exitCode ?? 1, 191 cancelled: false, 192 truncated: false, 193 }, 194 }; 195 }); 196 }