tui: add local shell execution for !-prefixed lines

This commit is contained in:
Vignesh Natarajan
2026-01-22 11:35:13 -08:00
committed by Peter Steinberger
parent c1e50b7184
commit 5fd699d0bf
3 changed files with 235 additions and 5 deletions

View File

@@ -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("!");
});
});

View File

@@ -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 ");
});
});

View File

@@ -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> | void;
sendMessage: (value: string) => Promise<void> | void;
handleBangLine: (value: string) => Promise<void> | 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<boolean> => {
if (localExecAllowed) return true;
if (localExecAsked) return false;
localExecAsked = true;
return await new Promise<boolean>((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<void>((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 = () => {