From 5fd699d0bfb032b95fa9caf969c397068210298f Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 22 Jan 2026 11:35:13 -0800 Subject: [PATCH] tui: add local shell execution for !-prefixed lines --- src/tui/tui-input-history.test.ts | 51 ++++++++++++- src/tui/tui.submit-handler.test.ts | 74 +++++++++++++++++++ src/tui/tui.ts | 115 ++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/tui/tui.submit-handler.test.ts diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts index 1a6870b99..18333e1fa 100644 --- a/src/tui/tui-input-history.test.ts +++ b/src/tui/tui-input-history.test.ts @@ -13,6 +13,7 @@ describe("createEditorSubmitHandler", () => { editor, handleCommand: vi.fn(), sendMessage: vi.fn(), + handleBangLine: vi.fn(), }); handler("hello world"); @@ -21,7 +22,7 @@ describe("createEditorSubmitHandler", () => { expect(editor.addToHistory).toHaveBeenCalledWith("hello world"); }); - it("trims input before adding to history", () => { + it("does not trim input before adding to history", () => { const editor = { setText: vi.fn(), addToHistory: vi.fn(), @@ -31,14 +32,15 @@ describe("createEditorSubmitHandler", () => { editor, handleCommand: vi.fn(), sendMessage: vi.fn(), + handleBangLine: vi.fn(), }); handler(" hi "); - expect(editor.addToHistory).toHaveBeenCalledWith("hi"); + expect(editor.addToHistory).toHaveBeenCalledWith(" hi "); }); - it("does not add empty submissions to history", () => { + it("does not add empty-string submissions to history", () => { const editor = { setText: vi.fn(), addToHistory: vi.fn(), @@ -48,9 +50,10 @@ describe("createEditorSubmitHandler", () => { editor, handleCommand: vi.fn(), sendMessage: vi.fn(), + handleBangLine: vi.fn(), }); - handler(" "); + handler(""); expect(editor.addToHistory).not.toHaveBeenCalled(); }); @@ -67,6 +70,7 @@ describe("createEditorSubmitHandler", () => { editor, handleCommand, sendMessage, + handleBangLine: vi.fn(), }); handler("/models"); @@ -88,6 +92,7 @@ describe("createEditorSubmitHandler", () => { editor, handleCommand, sendMessage, + handleBangLine: vi.fn(), }); handler("hello"); @@ -96,4 +101,42 @@ describe("createEditorSubmitHandler", () => { expect(sendMessage).toHaveBeenCalledWith("hello"); expect(handleCommand).not.toHaveBeenCalled(); }); + + it("routes bang-prefixed lines to handleBangLine", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const handleBangLine = vi.fn(); + + const handler = createEditorSubmitHandler({ + editor, + handleCommand: vi.fn(), + sendMessage: vi.fn(), + handleBangLine, + }); + + handler("!ls"); + + expect(handleBangLine).toHaveBeenCalledWith("!ls"); + }); + + it("treats a lone ! as a normal message", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const sendMessage = vi.fn(); + + const handler = createEditorSubmitHandler({ + editor, + handleCommand: vi.fn(), + sendMessage, + handleBangLine: vi.fn(), + }); + + handler("!"); + + expect(sendMessage).toHaveBeenCalledWith("!"); + }); }); diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts new file mode 100644 index 000000000..427c1baa4 --- /dev/null +++ b/src/tui/tui.submit-handler.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createEditorSubmitHandler } from "./tui.js"; + +describe("createEditorSubmitHandler", () => { + it("routes lines starting with ! to handleBangLine", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const handleCommand = vi.fn(); + const sendMessage = vi.fn(); + const handleBangLine = vi.fn(); + + const onSubmit = createEditorSubmitHandler({ + editor, + handleCommand, + sendMessage, + handleBangLine, + }); + + onSubmit("!ls"); + + expect(handleBangLine).toHaveBeenCalledTimes(1); + expect(handleBangLine).toHaveBeenCalledWith("!ls"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(handleCommand).not.toHaveBeenCalled(); + }); + + it("treats a lone ! as a normal message", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const handleCommand = vi.fn(); + const sendMessage = vi.fn(); + const handleBangLine = vi.fn(); + + const onSubmit = createEditorSubmitHandler({ + editor, + handleCommand, + sendMessage, + handleBangLine, + }); + + onSubmit("!"); + + expect(handleBangLine).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("!"); + }); + + it("does not trim input", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const handleCommand = vi.fn(); + const sendMessage = vi.fn(); + const handleBangLine = vi.fn(); + + const onSubmit = createEditorSubmitHandler({ + editor, + handleCommand, + sendMessage, + handleBangLine, + }); + + onSubmit(" hello "); + + expect(sendMessage).toHaveBeenCalledWith(" hello "); + expect(editor.addToHistory).toHaveBeenCalledWith(" hello "); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index bf777f133..af5d1499e 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,6 +6,7 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; +import { spawn } from "node:child_process"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import { @@ -21,6 +22,7 @@ import { GatewayChatClient } from "./gateway-chat.js"; import { editorTheme, theme } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; +import { createSearchableSelectList } from "./components/selectors.js"; import { formatTokens } from "./tui-formatters.js"; import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; import { createOverlayHandlers } from "./tui-overlays.js"; @@ -43,19 +45,30 @@ export function createEditorSubmitHandler(params: { }; handleCommand: (value: string) => Promise | void; sendMessage: (value: string) => Promise | void; + handleBangLine: (value: string) => Promise | void; }) { return (text: string) => { - const value = text.trim(); + // NOTE: We intentionally do not trim here. + // The caller decides how to interpret whitespace. + const value = text; params.editor.setText(""); if (!value) return; // Enable built-in editor prompt history navigation (up/down). params.editor.addToHistory(value); + // Bash mode: only if the very first character is '!' and it's not just '!'. + // Per requirement: a lone '!' should be treated as a normal message. + if (value.startsWith("!") && value !== "!") { + void params.handleBangLine(value); + return; + } + if (value.startsWith("/")) { void params.handleCommand(value); return; } + void params.sendMessage(value); }; } @@ -77,6 +90,12 @@ export async function runTui(opts: TuiOptions) { let isConnected = false; let toolsExpanded = false; let showThinking = false; + + // Local "bash mode" (lines starting with '!') state. + // Permission is asked once per TUI session. + let localExecAsked = false; + let localExecAllowed = false; + const deliverDefault = opts.deliver ?? false; const autoMessage = opts.message?.trim(); let autoMessageSent = false; @@ -496,11 +515,105 @@ export async function runTui(opts: TuiOptions) { formatSessionKey, }); + const ensureLocalExecAllowed = async (): Promise => { + if (localExecAllowed) return true; + if (localExecAsked) return false; + localExecAsked = true; + + return await new Promise((resolve) => { + chatLog.addSystem("Allow local shell commands for this session?"); + chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel."); + const selector = createSearchableSelectList( + [ + { value: "no", label: "No" }, + { value: "yes", label: "Yes" }, + ], + 2, + ); + selector.onSelect = (item) => { + closeOverlay(); + if (item.value === "yes") { + localExecAllowed = true; + chatLog.addSystem("local shell: enabled for this session"); + resolve(true); + } else { + chatLog.addSystem("local shell: not enabled"); + resolve(false); + } + tui.requestRender(); + }; + selector.onCancel = () => { + closeOverlay(); + chatLog.addSystem("local shell: cancelled"); + tui.requestRender(); + resolve(false); + }; + openOverlay(selector); + tui.requestRender(); + }); + }; + + const runLocalShellLine = async (line: string) => { + // line starts with '!' + const cmd = line.slice(1); + // NOTE: A lone '!' is handled by the submit handler as a normal message. + // Keep this guard anyway in case this is called directly. + if (cmd === "") return; + + const allowed = await ensureLocalExecAllowed(); + if (!allowed) return; + + chatLog.addSystem(`[local] $ ${cmd}`); + tui.requestRender(); + + await new Promise((resolve) => { + const child = spawn(cmd, { + shell: true, + cwd: process.cwd(), + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (buf) => { + stdout += buf.toString("utf8"); + }); + child.stderr.on("data", (buf) => { + stderr += buf.toString("utf8"); + }); + + child.on("close", (code, signal) => { + const maxChars = 40_000; + const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : "")) + .slice(0, maxChars) + .trimEnd(); + + if (combined) { + for (const line of combined.split("\n")) { + chatLog.addSystem(`[local] ${line}`); + } + } + chatLog.addSystem( + `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`, + ); + tui.requestRender(); + resolve(); + }); + + child.on("error", (err) => { + chatLog.addSystem(`[local] error: ${String(err)}`); + tui.requestRender(); + resolve(); + }); + }); + }; + updateAutocompleteProvider(); editor.onSubmit = createEditorSubmitHandler({ editor, handleCommand, sendMessage, + handleBangLine: runLocalShellLine, }); editor.onEscape = () => {