From 7c7daae4fddce84a697280a29f3276a0d81e1852 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 22 Dec 2025 10:12:39 -0800 Subject: [PATCH] feat: add bash mode for running local shell commands (#344) Co-authored-by: Letta --- src/cli/App.tsx | 112 ++++++++++++++++++++- src/cli/components/BashCommandMessage.tsx | 73 ++++++++++++++ src/cli/components/BlinkDot.tsx | 10 +- src/cli/components/InputRich.tsx | 71 ++++++++++++- src/cli/components/PasteAwareTextInput.tsx | 50 ++++++++- src/cli/components/colors.ts | 7 ++ src/cli/helpers/accumulator.ts | 8 ++ src/tools/impl/Bash.ts | 3 +- 8 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 src/cli/components/BashCommandMessage.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 21b632e..8321817 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -55,6 +55,7 @@ import { import { AgentSelector } from "./components/AgentSelector"; import { ApprovalDialog } from "./components/ApprovalDialogRich"; import { AssistantMessage } from "./components/AssistantMessageRich"; +import { BashCommandMessage } from "./components/BashCommandMessage"; import { CommandMessage } from "./components/CommandMessage"; import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog"; import { ErrorMessage } from "./components/ErrorMessageRich"; @@ -406,6 +407,12 @@ export default function App({ }> >([]); + // Bash mode: cache bash commands to prefix next user message + // Use ref instead of state to avoid stale closure issues in onSubmit + const bashCommandCacheRef = useRef>( + [], + ); + // Derive current approval from pending approvals and results // This is the approval currently being shown to the user const currentApproval = pendingApprovals[approvalResults.length]; @@ -604,7 +611,7 @@ export default function App({ continue; } // Commands with phase should only commit when finished - if (ln.kind === "command") { + if (ln.kind === "command" || ln.kind === "bash_command") { if (!ln.phase || ln.phase === "finished") { emittedIdsRef.current.add(id); newlyCommitted.push({ ...ln }); @@ -1782,6 +1789,80 @@ export default function App({ [refreshDerived, agentId, agentName, setCommandRunning], ); + // Handle bash mode command submission + // Uses the same shell runner as the Bash tool for consistency + const handleBashSubmit = useCallback( + async (command: string) => { + const cmdId = uid("bash"); + + // Add running bash_command line + buffersRef.current.byId.set(cmdId, { + kind: "bash_command", + id: cmdId, + input: command, + output: "", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + try { + // Use the same spawnCommand as the Bash tool for consistent behavior + const { spawnCommand } = await import("../tools/impl/Bash.js"); + const { getShellEnv } = await import("../tools/impl/shellEnv.js"); + + const result = await spawnCommand(command, { + cwd: process.cwd(), + env: getShellEnv(), + timeout: 30000, // 30 second timeout + }); + + // Combine stdout and stderr for output + const output = (result.stdout + result.stderr).trim(); + const success = result.exitCode === 0; + + // Update line with output + buffersRef.current.byId.set(cmdId, { + kind: "bash_command", + id: cmdId, + input: command, + output: output || (success ? "" : `Exit code: ${result.exitCode}`), + phase: "finished", + success, + }); + + // Cache for next user message + bashCommandCacheRef.current.push({ + input: command, + output: output || (success ? "" : `Exit code: ${result.exitCode}`), + }); + } catch (error: unknown) { + // Handle command errors (timeout, abort, etc.) + const errOutput = + error instanceof Error + ? (error as { stderr?: string; stdout?: string }).stderr || + (error as { stdout?: string }).stdout || + error.message + : String(error); + + buffersRef.current.byId.set(cmdId, { + kind: "bash_command", + id: cmdId, + input: command, + output: errOutput, + phase: "finished", + success: false, + }); + + // Still cache for next user message (even failures are visible to agent) + bashCommandCacheRef.current.push({ input: command, output: errOutput }); + } + + refreshDerived(); + }, + [refreshDerived], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: refs read .current dynamically, complex callback with intentional deps const onSubmit = useCallback( async (message?: string): Promise<{ submitted: boolean }> => { @@ -3203,9 +3284,27 @@ ${gitContext} hasSentSessionContextRef.current = true; } - // Combine reminders with content (session context first, then plan mode, then skill unload) + // Build bash command prefix if there are cached commands + let bashCommandPrefix = ""; + if (bashCommandCacheRef.current.length > 0) { + bashCommandPrefix = ` +The messages below were generated by the user while running local commands using "bash mode" in the Letta Code CLI tool. +DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to. + +`; + for (const cmd of bashCommandCacheRef.current) { + bashCommandPrefix += `${cmd.input}\n${cmd.output}\n`; + } + // Clear the cache after building the prefix + bashCommandCacheRef.current = []; + } + + // Combine reminders with content (session context first, then plan mode, then skill unload, then bash commands) const allReminders = - sessionContextReminder + planModeReminder + skillUnloadReminder; + sessionContextReminder + + planModeReminder + + skillUnloadReminder + + bashCommandPrefix; const messageContent = allReminders && typeof contentParts === "string" ? allReminders + contentParts @@ -4534,7 +4633,7 @@ Plan file path: ${planFilePath}`; const liveItems = useMemo(() => { return lines.filter((ln) => { if (!("phase" in ln)) return false; - if (ln.kind === "command") { + if (ln.kind === "command" || ln.kind === "bash_command") { return ln.phase === "running"; } if (ln.kind === "tool_call") { @@ -4652,6 +4751,8 @@ Plan file path: ${planFilePath}`; {"─".repeat(columns)} ) : item.kind === "command" ? ( + ) : item.kind === "bash_command" ? ( + ) : null} )} @@ -4688,6 +4789,8 @@ Plan file path: ${planFilePath}`; ) : ln.kind === "command" ? ( + ) : ln.kind === "bash_command" ? ( + ) : null} ))} @@ -4726,6 +4829,7 @@ Plan file path: ${planFilePath}`; tokenCount={tokenCount} thinkingMessage={thinkingMessage} onSubmit={onSubmit} + onBashSubmit={handleBashSubmit} permissionMode={uiPermissionMode} onPermissionModeChange={setUiPermissionMode} onExit={handleExit} diff --git a/src/cli/components/BashCommandMessage.tsx b/src/cli/components/BashCommandMessage.tsx new file mode 100644 index 0000000..e4ff9a7 --- /dev/null +++ b/src/cli/components/BashCommandMessage.tsx @@ -0,0 +1,73 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { BlinkDot } from "./BlinkDot.js"; +import { colors } from "./colors.js"; +import { MarkdownDisplay } from "./MarkdownDisplay.js"; + +type BashCommandLine = { + kind: "bash_command"; + id: string; + input: string; + output: string; + phase?: "running" | "finished"; + success?: boolean; +}; + +/** + * BashCommandMessage - Renders bash mode command output + * Similar to CommandMessage but with red ! indicator instead of dot + * + * Features: + * - Two-column layout with left gutter (2 chars) and right content area + * - Red ! indicator (blinking when running) + * - Proper terminal width calculation and wrapping + * - Markdown rendering for output + */ +export const BashCommandMessage = memo( + ({ line }: { line: BashCommandLine }) => { + const columns = useTerminalWidth(); + const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols + + // Determine indicator state based on phase and success + const getIndicatorElement = () => { + if (!line.phase || line.phase === "finished") { + // Show red ! for both success and failure (it's user-run, not agent-run) + return !; + } + if (line.phase === "running") { + return ; + } + return !; + }; + + return ( + + {/* Command input */} + + + {getIndicatorElement()} + + + + {line.input} + + + + {/* Command output (if present) */} + {line.output && ( + + + {" ⎿ "} + + + + + + )} + + ); + }, +); + +BashCommandMessage.displayName = "BashCommandMessage"; diff --git a/src/cli/components/BlinkDot.tsx b/src/cli/components/BlinkDot.tsx index db76d62..a1d0071 100644 --- a/src/cli/components/BlinkDot.tsx +++ b/src/cli/components/BlinkDot.tsx @@ -7,13 +7,19 @@ import { colors } from "./colors.js"; * Toggles visibility every 400ms to create a blinking effect. */ export const BlinkDot = memo( - ({ color = colors.tool.pending }: { color?: string }) => { + ({ + color = colors.tool.pending, + symbol = "●", + }: { + color?: string; + symbol?: string; + }) => { const [on, setOn] = useState(true); useEffect(() => { const t = setInterval(() => setOn((v) => !v), 400); return () => clearInterval(t); }, []); - return {on ? "●" : " "}; + return {on ? symbol : " "}; }, ); diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 7f4f9ce..38a712b 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -44,6 +44,7 @@ export function Input({ tokenCount, thinkingMessage, onSubmit, + onBashSubmit, permissionMode: externalMode, onPermissionModeChange, onExit, @@ -61,6 +62,7 @@ export function Input({ tokenCount: number; thinkingMessage: string; onSubmit: (message?: string) => Promise<{ submitted: boolean }>; + onBashSubmit?: (command: string) => Promise; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; onExit?: () => void; @@ -95,6 +97,21 @@ export function Input({ const [atStartBoundary, setAtStartBoundary] = useState(false); const [atEndBoundary, setAtEndBoundary] = useState(false); + // Bash mode state + const [isBashMode, setIsBashMode] = useState(false); + + const handleBangAtEmpty = () => { + if (isBashMode) return false; + setIsBashMode(true); + return true; + }; + + const handleBackspaceAtEmpty = () => { + if (!isBashMode) return false; + setIsBashMode(false); + return true; + }; + // Reset cursor position after it's been applied useEffect(() => { if (cursorPos !== undefined) { @@ -190,12 +207,14 @@ export function Input({ if (!visible) return; // Handle CTRL-C for double-ctrl-c-to-exit + // In bash mode, CTRL-C wipes input but doesn't exit bash mode if (input === "c" && key.ctrl) { if (ctrlCPressed) { // Second CTRL-C - call onExit callback which handles stats and exit if (onExit) onExit(); } else { // First CTRL-C - wipe input and start 1-second timer + // Note: In bash mode, this clears input but keeps bash mode active setValue(""); setCtrlCPressed(true); if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); @@ -206,6 +225,9 @@ export function Input({ } }); + // Note: bash mode entry/exit is implemented inside PasteAwareTextInput so we can + // consume the keystroke before it renders (no flicker). + // Handle Shift+Tab for permission mode cycling useInput((_input, key) => { if (!visible) return; @@ -439,6 +461,27 @@ export function Input({ const previousValue = value; + // Handle bash mode submission + if (isBashMode) { + if (!previousValue.trim()) return; + + // Add to history if not empty and not a duplicate of the last entry + if (previousValue.trim() !== history[history.length - 1]) { + setHistory([...history, previousValue]); + } + + // Reset history navigation + setHistoryIndex(-1); + setTemporaryInput(""); + + setValue(""); // Clear immediately for responsiveness + // Stay in bash mode after submitting (don't exit) + if (onBashSubmit) { + await onBashSubmit(previousValue); + } + return; + } + // Add to history if not empty and not a duplicate of the last entry if (previousValue.trim() && previousValue !== history[history.length - 1]) { setHistory([...history, previousValue]); @@ -590,12 +633,19 @@ export function Input({ {/* Top horizontal divider */} - {horizontalLine} + + {horizontalLine} + {/* Two-column layout for input, matching message components */} - {">"} + + {isBashMode ? "!" : ">"} + @@ -606,12 +656,19 @@ export function Input({ cursorPosition={cursorPos} onCursorMove={setCurrentCursorPosition} focus={!onEscapeCancel} + onBangAtEmpty={handleBangAtEmpty} + onBackspaceAtEmpty={handleBackspaceAtEmpty} /> {/* Bottom horizontal divider */} - {horizontalLine} + + {horizontalLine} + Press CTRL-C again to exit ) : escapePressed ? ( Press Esc again to clear + ) : isBashMode ? ( + + ⏵⏵ bash mode + + {" "} + (backspace to exit) + + ) : modeInfo ? ( ⏵⏵ {modeInfo.name} diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index ed31728..69479db 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -22,6 +22,18 @@ interface PasteAwareTextInputProps { focus?: boolean; cursorPosition?: number; onCursorMove?: (position: number) => void; + + /** + * Called when the user presses `!` while the input is empty. + * Return true to consume the keystroke (it will NOT appear in the input). + */ + onBangAtEmpty?: () => boolean; + + /** + * Called when the user presses Backspace while the input is empty. + * Return true to consume the keystroke. + */ + onBackspaceAtEmpty?: () => boolean; } function countLines(text: string): number { @@ -101,6 +113,8 @@ export function PasteAwareTextInput({ focus = true, cursorPosition, onCursorMove, + onBangAtEmpty, + onBackspaceAtEmpty, }: PasteAwareTextInputProps) { const { internal_eventEmitter } = useStdin(); const [displayValue, setDisplayValue] = useState(value); @@ -145,6 +159,17 @@ export function PasteAwareTextInput({ // Recompute ACTUAL by substituting placeholders via shared registry const resolved = resolvePlaceholders(value); setActualValue(resolved); + + // Keep caret in bounds when parent updates value (e.g. clearing input). + // This also ensures mode-switch hotkeys that depend on caret position behave correctly. + const nextCaret = Math.max( + 0, + Math.min(caretOffsetRef.current, value.length), + ); + if (nextCaret !== caretOffsetRef.current) { + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } }, [value]); // Intercept paste events and macOS fallback for image clipboard imports @@ -224,16 +249,30 @@ export function PasteAwareTextInput({ caretOffsetRef.current = nextCaret; } } + + // Backspace on empty input - handle here since handleChange won't fire + // (value doesn't change when backspacing on empty) + // Use ref to avoid stale closure issues + // Note: On macOS, backspace sends \x7f which Ink parses as "delete", not "backspace" + if ((key.backspace || key.delete) && displayValueRef.current === "") { + onBackspaceAtEmptyRef.current?.(); + return; + } }, { isActive: focus }, ); - // Store onChange in a ref to avoid stale closures in event handlers + // Store callbacks in refs to avoid stale closures in event handlers const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + const onBackspaceAtEmptyRef = useRef(onBackspaceAtEmpty); + useEffect(() => { + onBackspaceAtEmptyRef.current = onBackspaceAtEmpty; + }, [onBackspaceAtEmpty]); + // Consolidated raw stdin handler for Option+Arrow navigation and Option+Delete // Uses internal_eventEmitter (Ink's private API) for escape sequences that useInput doesn't parse correctly. // Falls back gracefully if internal_eventEmitter is unavailable (useInput handler above still works for some cases). @@ -333,6 +372,15 @@ export function PasteAwareTextInput({ }, [internal_eventEmitter]); const handleChange = (newValue: string) => { + // Bash mode entry: intercept "!" typed on empty input BEFORE updating state + // This prevents any flicker since we never commit the "!" to displayValue + if (displayValue === "" && newValue === "!") { + if (onBangAtEmpty?.()) { + // Parent handled it (entered bash mode) - don't update our state + return; + } + } + // Drop lone escape characters that Ink's text input would otherwise insert; // they are used as control keys for double-escape handling and should not // mutate the input value. diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index 2966362..1dd1efa 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -108,6 +108,13 @@ export const colors = { prompt: brandColors.textMain, }, + // Bash mode + bash: { + prompt: brandColors.statusError, // Red ! prompt + border: brandColors.statusError, // Red horizontal bars + dot: brandColors.statusError, // Red dot in output + }, + // Todo list todo: { completed: brandColors.blue, diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 923f64b..8b57e2c 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -47,6 +47,14 @@ export type Line = success?: boolean; dimOutput?: boolean; } + | { + kind: "bash_command"; + id: string; + input: string; + output: string; + phase?: "running" | "finished"; + success?: boolean; + } | { kind: "status"; id: string; diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts index d4353ac..a86ae9a 100644 --- a/src/tools/impl/Bash.ts +++ b/src/tools/impl/Bash.ts @@ -59,8 +59,9 @@ function getShellConfig(): { /** * Execute a command using spawn with explicit shell. * This avoids the double-shell parsing that exec() does. + * Exported for use by bash mode in the CLI. */ -function spawnCommand( +export function spawnCommand( command: string, options: { cwd: string;