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}
|
||||
|
||||
Reference in New Issue
Block a user