flake-update-20260505
  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
 23import { spawnSync } from "node:child_process";
 24import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 25
 26// Default interactive commands - editors, pagers, git ops, TUIs
 27const 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
100function 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
109function 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
131export 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}