feat: add bash mode for running local shell commands (#344)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
112
src/cli/App.tsx
112
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<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}
|
||||
|
||||
73
src/cli/components/BashCommandMessage.tsx
Normal file
73
src/cli/components/BashCommandMessage.tsx
Normal 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";
|
||||
@@ -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>;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user