feat: add bash mode for running local shell commands (#344)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-22 10:12:39 -08:00
committed by GitHub
parent e9a8054aba
commit 7c7daae4fd
8 changed files with 323 additions and 11 deletions

View File

@@ -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<Array<{ input: string; output: string }>>(
[],
);
// 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 = `<system-reminder>
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.
</system-reminder>
`;
for (const cmd of bashCommandCacheRef.current) {
bashCommandPrefix += `<bash-input>${cmd.input}</bash-input>\n<bash-output>${cmd.output}</bash-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}`;
<Text dimColor>{"─".repeat(columns)}</Text>
) : item.kind === "command" ? (
<CommandMessage line={item} />
) : item.kind === "bash_command" ? (
<BashCommandMessage line={item} />
) : null}
</Box>
)}
@@ -4688,6 +4789,8 @@ Plan file path: ${planFilePath}`;
<StatusMessage line={ln} />
) : ln.kind === "command" ? (
<CommandMessage line={ln} />
) : ln.kind === "bash_command" ? (
<BashCommandMessage line={ln} />
) : null}
</Box>
))}
@@ -4726,6 +4829,7 @@ Plan file path: ${planFilePath}`;
tokenCount={tokenCount}
thinkingMessage={thinkingMessage}
onSubmit={onSubmit}
onBashSubmit={handleBashSubmit}
permissionMode={uiPermissionMode}
onPermissionModeChange={setUiPermissionMode}
onExit={handleExit}

View File

@@ -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 <Text color={colors.bash.dot}>!</Text>;
}
if (line.phase === "running") {
return <BlinkDot color={colors.bash.dot} symbol="!" />;
}
return <Text color={colors.bash.dot}>!</Text>;
};
return (
<Box flexDirection="column">
{/* Command input */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
{getIndicatorElement()}
<Text> </Text>
</Box>
<Box flexGrow={1} width={rightWidth}>
<Text>{line.input}</Text>
</Box>
</Box>
{/* Command output (if present) */}
{line.output && (
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text>{" ⎿ "}</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
<MarkdownDisplay text={line.output.replace(/\n+$/, "")} />
</Box>
</Box>
)}
</Box>
);
},
);
BashCommandMessage.displayName = "BashCommandMessage";

View File

@@ -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 <Text color={color}>{on ? "●" : " "}</Text>;
return <Text color={color}>{on ? symbol : " "}</Text>;
},
);

View File

@@ -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<void>;
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({
<Box flexDirection="column">
{/* Top horizontal divider */}
<Text dimColor>{horizontalLine}</Text>
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
{/* Two-column layout for input, matching message components */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={colors.input.prompt}>{">"}</Text>
<Text color={isBashMode ? colors.bash.prompt : colors.input.prompt}>
{isBashMode ? "!" : ">"}
</Text>
<Text> </Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
@@ -606,12 +656,19 @@ export function Input({
cursorPosition={cursorPos}
onCursorMove={setCurrentCursorPosition}
focus={!onEscapeCancel}
onBangAtEmpty={handleBangAtEmpty}
onBackspaceAtEmpty={handleBackspaceAtEmpty}
/>
</Box>
</Box>
{/* Bottom horizontal divider */}
<Text dimColor>{horizontalLine}</Text>
<Text
dimColor={!isBashMode}
color={isBashMode ? colors.bash.border : undefined}
>
{horizontalLine}
</Text>
<InputAssist
currentInput={value}
@@ -631,6 +688,14 @@ export function Input({
<Text dimColor>Press CTRL-C again to exit</Text>
) : escapePressed ? (
<Text dimColor>Press Esc again to clear</Text>
) : isBashMode ? (
<Text>
<Text color={colors.bash.prompt}> bash mode</Text>
<Text color={colors.bash.prompt} dimColor>
{" "}
(backspace to exit)
</Text>
</Text>
) : modeInfo ? (
<Text>
<Text color={modeInfo.color}> {modeInfo.name}</Text>

View File

@@ -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.

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;