diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e93ba8ef..1190082d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - `setActiveTools()` in ExtensionAPI for dynamic tool management - Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult` - `ssh.ts` example: remote tool execution via `--ssh user@host:/path` +- `interactive-shell.ts` example: run interactive commands (vim, git rebase, htop) with full terminal access via `!i` prefix or auto-detection - Wayland clipboard support for `/copy` command using wl-copy with xclip/xsel fallback ([#570](https://github.com/badlogic/pi-mono/pull/570) by [@OgulcanCelik](https://github.com/OgulcanCelik)) - **Experimental:** `ctx.ui.custom()` now accepts `{ overlay: true }` option for floating modal components that composite over existing content without clearing the screen ([#558](https://github.com/badlogic/pi-mono/pull/558) by [@nicobailon](https://github.com/nicobailon)) - `AgentSession.skills` and `AgentSession.skillWarnings` properties to access loaded skills without rediscovery ([#577](https://github.com/badlogic/pi-mono/pull/577) by [@cv](https://github.com/cv)) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 12905855..df7a718e 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -543,7 +543,7 @@ pi.on("user_bash", (event, ctx) => { }); ``` -**Examples:** [ssh.ts](../examples/extensions/ssh.ts) +**Examples:** [ssh.ts](../examples/extensions/ssh.ts), [interactive-shell.ts](../examples/extensions/interactive-shell.ts) ## ExtensionContext diff --git a/packages/coding-agent/examples/extensions/interactive-shell.ts b/packages/coding-agent/examples/extensions/interactive-shell.ts new file mode 100644 index 00000000..87ed8d46 --- /dev/null +++ b/packages/coding-agent/examples/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((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, + }, + }; + }); +}