feat: make interactive commands immediate (#634)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
399
src/cli/App.tsx
399
src/cli/App.tsx
@@ -219,6 +219,64 @@ const INTERRUPT_MESSAGE =
|
||||
const ERROR_FEEDBACK_HINT =
|
||||
"Something went wrong? Use /feedback to report issues.";
|
||||
|
||||
// Interactive slash commands that open overlays immediately (bypass queueing)
|
||||
// These commands let users browse/view while the agent is working
|
||||
// Any changes made in the overlay will be queued until end_turn
|
||||
const INTERACTIVE_SLASH_COMMANDS = new Set([
|
||||
"/model",
|
||||
"/toolset",
|
||||
"/system",
|
||||
"/subagents",
|
||||
"/memory",
|
||||
"/mcp",
|
||||
"/help",
|
||||
"/agents",
|
||||
"/resume",
|
||||
"/pinned",
|
||||
"/profiles",
|
||||
"/search",
|
||||
"/feedback",
|
||||
"/pin",
|
||||
"/pin-local",
|
||||
"/conversations",
|
||||
"/profile",
|
||||
]);
|
||||
|
||||
// Non-state commands that should run immediately while the agent is busy
|
||||
// These don't modify agent state, so they should bypass queueing
|
||||
const NON_STATE_COMMANDS = new Set([
|
||||
"/ade",
|
||||
"/bg",
|
||||
"/usage",
|
||||
"/help",
|
||||
"/hooks",
|
||||
"/search",
|
||||
"/memory",
|
||||
"/feedback",
|
||||
"/download",
|
||||
]);
|
||||
|
||||
// Check if a command is interactive (opens overlay, should not be queued)
|
||||
function isInteractiveCommand(msg: string): boolean {
|
||||
const trimmed = msg.trim().toLowerCase();
|
||||
// Check exact matches first
|
||||
if (INTERACTIVE_SLASH_COMMANDS.has(trimmed)) return true;
|
||||
// Check prefix matches for commands with arguments
|
||||
for (const cmd of INTERACTIVE_SLASH_COMMANDS) {
|
||||
if (trimmed.startsWith(`${cmd} `)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNonStateCommand(msg: string): boolean {
|
||||
const trimmed = msg.trim().toLowerCase();
|
||||
if (NON_STATE_COMMANDS.has(trimmed)) return true;
|
||||
for (const cmd of NON_STATE_COMMANDS) {
|
||||
if (trimmed.startsWith(`${cmd} `)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// tiny helper for unique ids (avoid overwriting prior user lines)
|
||||
function uid(prefix: string) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -930,6 +988,27 @@ export default function App({
|
||||
setModelSelectorOptions({});
|
||||
}, []);
|
||||
|
||||
// Queued overlay action - executed after end_turn when user makes a selection
|
||||
// while agent is busy (streaming/executing tools)
|
||||
type QueuedOverlayAction =
|
||||
| { type: "switch_agent"; agentId: string }
|
||||
| { type: "switch_model"; modelId: string }
|
||||
| { type: "switch_conversation"; conversationId: string }
|
||||
| {
|
||||
type: "switch_toolset";
|
||||
toolsetId:
|
||||
| "codex"
|
||||
| "codex_snake"
|
||||
| "default"
|
||||
| "gemini"
|
||||
| "gemini_snake"
|
||||
| "none";
|
||||
}
|
||||
| { type: "switch_system"; promptId: string }
|
||||
| null;
|
||||
const [queuedOverlayAction, setQueuedOverlayAction] =
|
||||
useState<QueuedOverlayAction>(null);
|
||||
|
||||
// Pin dialog state
|
||||
const [pinDialogLocal, setPinDialogLocal] = useState(false);
|
||||
|
||||
@@ -3634,6 +3713,26 @@ export default function App({
|
||||
return;
|
||||
}
|
||||
|
||||
// If agent is busy, queue the switch for after end_turn
|
||||
if (isAgentBusy()) {
|
||||
setQueuedOverlayAction({
|
||||
type: "switch_agent",
|
||||
agentId: targetAgentId,
|
||||
});
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/agents",
|
||||
output: `Agent switch queued – will switch after current task completes`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock input for async operation (set before any await to prevent queue processing)
|
||||
setCommandRunning(true);
|
||||
|
||||
@@ -3731,7 +3830,7 @@ export default function App({
|
||||
setCommandRunning(false);
|
||||
}
|
||||
},
|
||||
[refreshDerived, agentId, agentName, setCommandRunning],
|
||||
[refreshDerived, agentId, agentName, setCommandRunning, isAgentBusy],
|
||||
);
|
||||
|
||||
// Handle creating a new agent and switching to it
|
||||
@@ -4248,7 +4347,13 @@ export default function App({
|
||||
setDequeueEpoch((e) => e + 1);
|
||||
}
|
||||
|
||||
if (isAgentBusy()) {
|
||||
// Interactive slash commands (like /memory, /model, /agents) bypass queueing
|
||||
// so users can browse/view while the agent is working.
|
||||
// Changes made in these overlays will be queued until end_turn.
|
||||
const shouldBypassQueue =
|
||||
isInteractiveCommand(msg) || isNonStateCommand(msg);
|
||||
|
||||
if (isAgentBusy() && !shouldBypassQueue) {
|
||||
setMessageQueue((prev) => {
|
||||
const newQueue = [...prev, msg];
|
||||
|
||||
@@ -7375,6 +7480,24 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
async (modelId: string) => {
|
||||
// If agent is busy, queue the model switch for after end_turn
|
||||
if (isAgentBusy()) {
|
||||
setActiveOverlay(null);
|
||||
setQueuedOverlayAction({ type: "switch_model", modelId });
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/model ${modelId}`,
|
||||
output: `Model switch queued – will switch after current task completes`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
await withCommandLock(async () => {
|
||||
// Declare cmdId outside try block so it's accessible in catch
|
||||
let cmdId: string | null = null;
|
||||
@@ -7511,11 +7634,239 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
}
|
||||
});
|
||||
},
|
||||
[agentId, refreshDerived, currentToolset, withCommandLock],
|
||||
[agentId, refreshDerived, currentToolset, withCommandLock, isAgentBusy],
|
||||
);
|
||||
|
||||
// Process queued overlay actions when streaming ends
|
||||
// These are actions from interactive commands (like /agents, /model) that were
|
||||
// used while the agent was busy. The change is applied after end_turn.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!streaming &&
|
||||
!commandRunning &&
|
||||
!isExecutingTool &&
|
||||
pendingApprovals.length === 0 &&
|
||||
queuedOverlayAction !== null
|
||||
) {
|
||||
const action = queuedOverlayAction;
|
||||
setQueuedOverlayAction(null); // Clear immediately to prevent re-runs
|
||||
|
||||
// Process the queued action
|
||||
if (action.type === "switch_agent") {
|
||||
// Call handleAgentSelect - it will see isAgentBusy() as false now
|
||||
handleAgentSelect(action.agentId);
|
||||
} else if (action.type === "switch_model") {
|
||||
// Call handleModelSelect - it will see isAgentBusy() as false now
|
||||
handleModelSelect(action.modelId);
|
||||
} else if (action.type === "switch_conversation") {
|
||||
// For conversation switch, we need to handle it inline since the handler
|
||||
// is defined in JSX. We'll dispatch a synthetic event or handle directly.
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: `Processing queued conversation switch...`,
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
// Execute the conversation switch asynchronously
|
||||
(async () => {
|
||||
setCommandRunning(true);
|
||||
try {
|
||||
if (action.conversationId === conversationId) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: "Already on this conversation",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
const client = await getClient();
|
||||
if (agentState) {
|
||||
const resumeData = await getResumeData(
|
||||
client,
|
||||
agentState,
|
||||
action.conversationId,
|
||||
);
|
||||
|
||||
setConversationId(action.conversationId);
|
||||
settingsManager.setLocalLastSession(
|
||||
{ agentId, conversationId: action.conversationId },
|
||||
process.cwd(),
|
||||
);
|
||||
settingsManager.setGlobalLastSession({
|
||||
agentId,
|
||||
conversationId: action.conversationId,
|
||||
});
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: `Switched to conversation (${resumeData.messageHistory.length} messages)`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
refreshDerived();
|
||||
}
|
||||
})();
|
||||
} else if (action.type === "switch_toolset") {
|
||||
// Execute toolset switch inline (handler defined later, can't call directly)
|
||||
(async () => {
|
||||
setCommandRunning(true);
|
||||
const cmdId = uid("cmd");
|
||||
try {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/toolset ${action.toolsetId}`,
|
||||
output: `Switching toolset to ${action.toolsetId}...`,
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
const { forceToolsetSwitch } = await import("../tools/toolset");
|
||||
await forceToolsetSwitch(action.toolsetId, agentId);
|
||||
setCurrentToolset(action.toolsetId);
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/toolset ${action.toolsetId}`,
|
||||
output: `Switched toolset to ${action.toolsetId}`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/toolset ${action.toolsetId}`,
|
||||
output: `Failed to switch toolset: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
refreshDerived();
|
||||
}
|
||||
})();
|
||||
} else if (action.type === "switch_system") {
|
||||
// Execute system prompt switch inline (handler defined later, can't call directly)
|
||||
(async () => {
|
||||
setCommandRunning(true);
|
||||
const cmdId = uid("cmd");
|
||||
try {
|
||||
const { SYSTEM_PROMPTS } = await import("../agent/promptAssets");
|
||||
const selectedPrompt = SYSTEM_PROMPTS.find(
|
||||
(p) => p.id === action.promptId,
|
||||
);
|
||||
|
||||
if (!selectedPrompt) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/system ${action.promptId}`,
|
||||
output: `System prompt not found: ${action.promptId}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
return;
|
||||
}
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/system ${action.promptId}`,
|
||||
output: `Switching system prompt to ${selectedPrompt.label}...`,
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
const { updateAgentSystemPrompt } = await import("../agent/modify");
|
||||
await updateAgentSystemPrompt(agentId, selectedPrompt.content);
|
||||
setCurrentSystemPromptId(action.promptId);
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/system ${action.promptId}`,
|
||||
output: `Switched system prompt to ${selectedPrompt.label}`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/system ${action.promptId}`,
|
||||
output: `Failed to switch system prompt: ${error instanceof Error ? error.message : String(error)}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
refreshDerived();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
streaming,
|
||||
commandRunning,
|
||||
isExecutingTool,
|
||||
pendingApprovals,
|
||||
queuedOverlayAction,
|
||||
handleAgentSelect,
|
||||
handleModelSelect,
|
||||
agentId,
|
||||
agentState,
|
||||
conversationId,
|
||||
refreshDerived,
|
||||
setCommandRunning,
|
||||
]);
|
||||
|
||||
const handleSystemPromptSelect = useCallback(
|
||||
async (promptId: string) => {
|
||||
// If agent is busy, queue the system prompt switch for after end_turn
|
||||
if (isAgentBusy()) {
|
||||
setActiveOverlay(null);
|
||||
setQueuedOverlayAction({ type: "switch_system", promptId });
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/system ${promptId}`,
|
||||
output: `System prompt switch queued – will switch after current task completes`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
await withCommandLock(async () => {
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
@@ -7593,7 +7944,7 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
}
|
||||
});
|
||||
},
|
||||
[agentId, refreshDerived, withCommandLock],
|
||||
[agentId, refreshDerived, withCommandLock, isAgentBusy],
|
||||
);
|
||||
|
||||
const handleToolsetSelect = useCallback(
|
||||
@@ -7606,6 +7957,24 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
| "gemini_snake"
|
||||
| "none",
|
||||
) => {
|
||||
// If agent is busy, queue the toolset switch for after end_turn
|
||||
if (isAgentBusy()) {
|
||||
setActiveOverlay(null);
|
||||
setQueuedOverlayAction({ type: "switch_toolset", toolsetId });
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: `/toolset ${toolsetId}`,
|
||||
output: `Toolset switch queued – will switch after current task completes`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
await withCommandLock(async () => {
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
@@ -7650,7 +8019,7 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
}
|
||||
});
|
||||
},
|
||||
[agentId, refreshDerived, withCommandLock],
|
||||
[agentId, refreshDerived, withCommandLock, isAgentBusy],
|
||||
);
|
||||
|
||||
// Handle escape when profile confirmation is pending
|
||||
@@ -8635,6 +9004,26 @@ Plan file path: ${planFilePath}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// If agent is busy, queue the switch for after end_turn
|
||||
if (isAgentBusy()) {
|
||||
setQueuedOverlayAction({
|
||||
type: "switch_conversation",
|
||||
conversationId: convId,
|
||||
});
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/resume",
|
||||
output: `Conversation switch queued – will switch after current task completes`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock input for async operation
|
||||
setCommandRunning(true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user