tui: add local shell execution for !-prefixed lines
This commit is contained in:
committed by
Peter Steinberger
parent
c1e50b7184
commit
5fd699d0bf
@@ -13,6 +13,7 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
editor,
|
editor,
|
||||||
handleCommand: vi.fn(),
|
handleCommand: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
handleBangLine: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler("hello world");
|
handler("hello world");
|
||||||
@@ -21,7 +22,7 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
|
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims input before adding to history", () => {
|
it("does not trim input before adding to history", () => {
|
||||||
const editor = {
|
const editor = {
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
addToHistory: vi.fn(),
|
addToHistory: vi.fn(),
|
||||||
@@ -31,14 +32,15 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
editor,
|
editor,
|
||||||
handleCommand: vi.fn(),
|
handleCommand: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
handleBangLine: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler(" hi ");
|
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 = {
|
const editor = {
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
addToHistory: vi.fn(),
|
addToHistory: vi.fn(),
|
||||||
@@ -48,9 +50,10 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
editor,
|
editor,
|
||||||
handleCommand: vi.fn(),
|
handleCommand: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
handleBangLine: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler(" ");
|
handler("");
|
||||||
|
|
||||||
expect(editor.addToHistory).not.toHaveBeenCalled();
|
expect(editor.addToHistory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -67,6 +70,7 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
editor,
|
editor,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
handleBangLine: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler("/models");
|
handler("/models");
|
||||||
@@ -88,6 +92,7 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
editor,
|
editor,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
handleBangLine: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler("hello");
|
handler("hello");
|
||||||
@@ -96,4 +101,42 @@ describe("createEditorSubmitHandler", () => {
|
|||||||
expect(sendMessage).toHaveBeenCalledWith("hello");
|
expect(sendMessage).toHaveBeenCalledWith("hello");
|
||||||
expect(handleCommand).not.toHaveBeenCalled();
|
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("!");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
74
src/tui/tui.submit-handler.test.ts
Normal file
74
src/tui/tui.submit-handler.test.ts
Normal 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 ");
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/tui/tui.ts
115
src/tui/tui.ts
@@ -6,6 +6,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TUI,
|
TUI,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import { GatewayChatClient } from "./gateway-chat.js";
|
|||||||
import { editorTheme, theme } from "./theme/theme.js";
|
import { editorTheme, theme } from "./theme/theme.js";
|
||||||
import { createCommandHandlers } from "./tui-command-handlers.js";
|
import { createCommandHandlers } from "./tui-command-handlers.js";
|
||||||
import { createEventHandlers } from "./tui-event-handlers.js";
|
import { createEventHandlers } from "./tui-event-handlers.js";
|
||||||
|
import { createSearchableSelectList } from "./components/selectors.js";
|
||||||
import { formatTokens } from "./tui-formatters.js";
|
import { formatTokens } from "./tui-formatters.js";
|
||||||
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
|
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
|
||||||
import { createOverlayHandlers } from "./tui-overlays.js";
|
import { createOverlayHandlers } from "./tui-overlays.js";
|
||||||
@@ -43,19 +45,30 @@ export function createEditorSubmitHandler(params: {
|
|||||||
};
|
};
|
||||||
handleCommand: (value: string) => Promise<void> | void;
|
handleCommand: (value: string) => Promise<void> | void;
|
||||||
sendMessage: (value: string) => Promise<void> | void;
|
sendMessage: (value: string) => Promise<void> | void;
|
||||||
|
handleBangLine: (value: string) => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
return (text: string) => {
|
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("");
|
params.editor.setText("");
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
// Enable built-in editor prompt history navigation (up/down).
|
// Enable built-in editor prompt history navigation (up/down).
|
||||||
params.editor.addToHistory(value);
|
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("/")) {
|
if (value.startsWith("/")) {
|
||||||
void params.handleCommand(value);
|
void params.handleCommand(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void params.sendMessage(value);
|
void params.sendMessage(value);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,6 +90,12 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
let isConnected = false;
|
let isConnected = false;
|
||||||
let toolsExpanded = false;
|
let toolsExpanded = false;
|
||||||
let showThinking = 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 deliverDefault = opts.deliver ?? false;
|
||||||
const autoMessage = opts.message?.trim();
|
const autoMessage = opts.message?.trim();
|
||||||
let autoMessageSent = false;
|
let autoMessageSent = false;
|
||||||
@@ -496,11 +515,105 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
formatSessionKey,
|
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();
|
updateAutocompleteProvider();
|
||||||
editor.onSubmit = createEditorSubmitHandler({
|
editor.onSubmit = createEditorSubmitHandler({
|
||||||
editor,
|
editor,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
handleBangLine: runLocalShellLine,
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.onEscape = () => {
|
editor.onEscape = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user