feat: add toolset switching UI (#115)

This commit is contained in:
Charles Packer
2025-11-23 20:02:04 -08:00
committed by GitHub
parent 836a488263
commit 592ed66e1b
4 changed files with 236 additions and 0 deletions

View File

@@ -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<LlmConfig | null>(null);
const [agentName, setAgentName] = useState<string | null>(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 && (
<ToolsetSelector
currentToolset={currentToolset ?? undefined}
onSelect={handleToolsetSelect}
onCancel={() => setToolsetSelectorOpen(false)}
/>
)}
{/* Agent Selector - conditionally mounted as overlay */}
{agentSelectorOpen && (
<AgentSelector

View File

@@ -78,6 +78,13 @@ export const commands: Record<string, Command> = {
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...";
},
},
};
/**

View File

@@ -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 (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Select Toolset ( to navigate, Enter to select, ESC to cancel)
</Text>
</Box>
<Box flexDirection="column">
{toolsets.map((toolset, index) => {
const isSelected = index === selectedIndex;
const isCurrent = toolset.id === currentToolset;
return (
<Box key={toolset.id} flexDirection="column">
<Box flexDirection="row" gap={1}>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "" : " "}
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{toolset.label}
{isCurrent && (
<Text color={colors.selector.itemCurrent}>
{" "}
(current)
</Text>
)}
</Text>
</Box>
<Text dimColor> {toolset.description}</Text>
</Box>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -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<void> {
// 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.