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}