diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 2f16877..3be6ff1 100644 --- a/src/cli/App.tsx +++ b/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(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);