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