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

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