/ 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  }