From 592ed66e1be82a64da191e2b6a02686e2a23e0dd Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 23 Nov 2025 20:02:04 -0800 Subject: [PATCH] feat: add toolset switching UI (#115) --- src/cli/App.tsx | 76 +++++++++++++++ src/cli/commands/registry.ts | 7 ++ src/cli/components/ToolsetSelector.tsx | 123 +++++++++++++++++++++++++ src/tools/toolset.ts | 30 ++++++ 4 files changed, 236 insertions(+) create mode 100644 src/cli/components/ToolsetSelector.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3862e79..8a0d703 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -45,6 +45,7 @@ import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { SessionStats as SessionStatsComponent } from "./components/SessionStats"; // import { ToolCallMessage } from "./components/ToolCallMessage"; import { ToolCallMessage } from "./components/ToolCallMessageRich"; +import { ToolsetSelector } from "./components/ToolsetSelector"; // import { UserMessage } from "./components/UserMessage"; import { UserMessage } from "./components/UserMessageRich"; import { WelcomeScreen } from "./components/WelcomeScreen"; @@ -204,6 +205,10 @@ export default function App({ // Model selector state const [modelSelectorOpen, setModelSelectorOpen] = useState(false); + const [toolsetSelectorOpen, setToolsetSelectorOpen] = useState(false); + const [currentToolset, setCurrentToolset] = useState< + "codex" | "default" | null + >(null); const [llmConfig, setLlmConfig] = useState(null); const [agentName, setAgentName] = useState(null); @@ -788,6 +793,12 @@ export default function App({ return { submitted: true }; } + // Special handling for /toolset command - opens selector + if (msg.trim() === "/toolset") { + setToolsetSelectorOpen(true); + return { submitted: true }; + } + // Special handling for /agent command - show agent link if (msg.trim() === "/agent") { const cmdId = uid("cmd"); @@ -1725,6 +1736,7 @@ export default function App({ selectedModel.handle ?? "", agentId, ); + setCurrentToolset(toolsetName); // Update the same command with final result (include toolset info) const autoToolsetLine = toolsetName @@ -1765,6 +1777,60 @@ export default function App({ [agentId, refreshDerived], ); + const handleToolsetSelect = useCallback( + async (toolsetId: "codex" | "default") => { + setToolsetSelectorOpen(false); + + const cmdId = uid("cmd"); + + try { + // Immediately add command to transcript with "running" phase + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Switching toolset to ${toolsetId}...`, + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Lock input during async operation + setCommandRunning(true); + + // Force switch to the selected toolset + const { forceToolsetSwitch } = await import("../tools/toolset"); + await forceToolsetSwitch(toolsetId, agentId); + setCurrentToolset(toolsetId); + + // Update the command with final result + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Switched toolset to ${toolsetId}`, + phase: "finished", + success: true, + }); + refreshDerived(); + } catch (error) { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: `/toolset ${toolsetId}`, + output: `Failed to switch toolset: ${error instanceof Error ? error.message : String(error)}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } finally { + // Unlock input + setCommandRunning(false); + } + }, + [agentId, refreshDerived], + ); + const handleAgentSelect = useCallback( async (targetAgentId: string) => { setAgentSelectorOpen(false); @@ -2082,6 +2148,7 @@ export default function App({ !showExitStats && pendingApprovals.length === 0 && !modelSelectorOpen && + !toolsetSelectorOpen && !agentSelectorOpen && !planApprovalPending } @@ -2108,6 +2175,15 @@ export default function App({ /> )} + {/* Toolset Selector - conditionally mounted as overlay */} + {toolsetSelectorOpen && ( + setToolsetSelectorOpen(false)} + /> + )} + {/* Agent Selector - conditionally mounted as overlay */} {agentSelectorOpen && ( = { return "Swapping agent..."; }, }, + "/toolset": { + desc: "Switch toolset (codex/default)", + handler: () => { + // Handled specially in App.tsx to access agent ID and client + return "Opening toolset selector..."; + }, + }, }; /** diff --git a/src/cli/components/ToolsetSelector.tsx b/src/cli/components/ToolsetSelector.tsx new file mode 100644 index 0000000..2f6c05d --- /dev/null +++ b/src/cli/components/ToolsetSelector.tsx @@ -0,0 +1,123 @@ +// Import useInput from vendored Ink for bracketed paste support +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import { colors } from "./colors"; + +interface ToolsetOption { + id: "codex" | "default"; + label: string; + description: string; + tools: string[]; +} + +const toolsets: ToolsetOption[] = [ + { + id: "codex", + label: "Codex Tools", + description: "OpenAI-style tools optimized for GPT models", + tools: [ + "shell_command", + "shell", + "read_file", + "list_dir", + "grep_files", + "apply_patch", + ], + }, + { + id: "default", + label: "Default Tools", + description: "Anthropic-style tools optimized for Claude models", + tools: [ + "Bash", + "BashOutput", + "Edit", + "Glob", + "Grep", + "LS", + "MultiEdit", + "Read", + "TodoWrite", + "Write", + ], + }, +]; + +interface ToolsetSelectorProps { + currentToolset?: "codex" | "default"; + onSelect: (toolsetId: "codex" | "default") => void; + onCancel: () => void; +} + +export function ToolsetSelector({ + currentToolset, + onSelect, + onCancel, +}: ToolsetSelectorProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((_input, key) => { + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(toolsets.length - 1, prev + 1)); + } else if (key.return) { + const selectedToolset = toolsets[selectedIndex]; + if (selectedToolset) { + onSelect(selectedToolset.id); + } + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + + Select Toolset (↑↓ to navigate, Enter to select, ESC to cancel) + + + + + {toolsets.map((toolset, index) => { + const isSelected = index === selectedIndex; + const isCurrent = toolset.id === currentToolset; + + return ( + + + + {isSelected ? "›" : " "} + + + + + {toolset.label} + {isCurrent && ( + + {" "} + (current) + + )} + + + {toolset.description} + + + + ); + })} + + + ); +} diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts index 1905162..73aa95d 100644 --- a/src/tools/toolset.ts +++ b/src/tools/toolset.ts @@ -10,6 +10,36 @@ import { upsertToolsToServer, } from "./manager"; +/** + * Force switch to a specific toolset regardless of model. + * + * @param toolsetName - The toolset to switch to ("codex" or "default") + * @param agentId - Agent to relink tools to + */ +export async function forceToolsetSwitch( + toolsetName: "codex" | "default", + agentId: string, +): Promise { + // Clear currently loaded tools + clearTools(); + + // Load the appropriate toolset by passing a model identifier from that provider + // This triggers the loadTools logic that selects OPENAI_DEFAULT_TOOLS vs ANTHROPIC_DEFAULT_TOOLS + if (toolsetName === "codex") { + await loadTools("openai/gpt-4"); // Pass OpenAI model to trigger codex toolset + } else { + await loadTools("anthropic/claude-sonnet-4"); // Pass Anthropic to trigger default toolset + } + + // Upsert the new toolset to server + const client = await getClient(); + await upsertToolsToServer(client); + + // Remove old Letta tools and add new ones + await unlinkToolsFromAgent(agentId); + await linkToolsToAgent(agentId); +} + /** * Switches the loaded toolset based on the target model identifier, * upserts the tools to the server, and relinks them to the agent.