feat: add toolset switching UI (#115)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
123
src/cli/components/ToolsetSelector.tsx
Normal file
123
src/cli/components/ToolsetSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user