From e782af748b1b48d4e2fdf59cea27bacbdcf7a3b8 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 22 Jan 2026 18:09:57 -0800 Subject: [PATCH] feat: add search field to model selector on both tabs (#649) Co-authored-by: Letta --- src/agent/model.ts | 48 +++++++++++++++++++++++++++- src/cli/App.tsx | 4 +-- src/cli/components/ModelSelector.tsx | 24 +++++++++----- src/models.json | 3 +- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/agent/model.ts b/src/agent/model.ts index f8433bd..33774c6 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -69,16 +69,62 @@ export function getModelUpdateArgs( return modelInfo?.updateArgs; } +/** + * Find a model entry by handle with fuzzy matching support + * @param handle - The full model handle + * @returns The model entry if found, null otherwise + */ +function findModelByHandle(handle: string): (typeof models)[number] | null { + // Try exact match first + const exactMatch = models.find((m) => m.handle === handle); + if (exactMatch) return exactMatch; + + // For handles like "bedrock/claude-opus-4-5-20251101" where the API returns without + // vendor prefix or version suffix, but models.json has + // "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0", try fuzzy matching + const [provider, ...rest] = handle.split("/"); + if (provider && rest.length > 0) { + const modelPortion = rest.join("/"); + // Find models with the same provider where the model portion is contained + // in the models.json handle (handles vendor prefixes and version suffixes) + const partialMatch = models.find((m) => { + if (!m.handle.startsWith(`${provider}/`)) return false; + const mModelPortion = m.handle.slice(provider.length + 1); + // Check if either contains the other (handles both directions) + return ( + mModelPortion.includes(modelPortion) || + modelPortion.includes(mModelPortion) + ); + }); + if (partialMatch) return partialMatch; + } + + return null; +} + /** * Get a display-friendly name for a model by its handle * @param handle - The full model handle (e.g., "anthropic/claude-sonnet-4-5-20250929") * @returns The display name (e.g., "Sonnet 4.5") if found, null otherwise */ export function getModelDisplayName(handle: string): string | null { - const model = models.find((m) => m.handle === handle); + const model = findModelByHandle(handle); return model?.label ?? null; } +/** + * Get a short display name for a model (for status bar) + * Falls back to full label if no shortLabel is defined + * @param handle - The full model handle + * @returns The short name (e.g., "Opus 4.5 BR") if found, null otherwise + */ +export function getModelShortName(handle: string): string | null { + const model = findModelByHandle(handle); + if (!model) return null; + // Use shortLabel if available, otherwise fall back to label + return (model as { shortLabel?: string }).shortLabel ?? model.label; +} + /** * Resolve a model ID from the llm_config.model value * The llm_config.model is the model portion without the provider prefix diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 9594984..248b4ff 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -43,7 +43,7 @@ import { getCurrentAgentId, setCurrentAgentId } from "../agent/context"; import { type AgentProvenance, createAgent } from "../agent/create"; import { ISOLATED_BLOCK_LABELS } from "../agent/memory"; import { sendMessageStream } from "../agent/message"; -import { getModelDisplayName, getModelInfo } from "../agent/model"; +import { getModelInfo, getModelShortName } from "../agent/model"; import { SessionStats } from "../agent/stats"; import { INTERRUPTED_BY_USER, @@ -1047,7 +1047,7 @@ export default function App({ ? `${llmConfig.model_endpoint_type}/${llmConfig.model}` : (llmConfig?.model ?? null); const currentModelDisplay = currentModelLabel - ? (getModelDisplayName(currentModelLabel) ?? + ? (getModelShortName(currentModelLabel) ?? currentModelLabel.split("/").pop()) : null; const currentModelProvider = llmConfig?.provider_name ?? null; diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 5fe7629..265ea67 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -129,10 +129,20 @@ export function ModelSelector({ m.handle.startsWith(`${filterProvider}/`), ); } + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + available = available.filter( + (m) => + m.label.toLowerCase().includes(query) || + m.description.toLowerCase().includes(query) || + m.handle.toLowerCase().includes(query), + ); + } const featured = available.filter((m) => m.isFeatured); const nonFeatured = available.filter((m) => !m.isFeatured); return [...featured, ...nonFeatured]; - }, [typedModels, availableHandles, filterProvider]); + }, [typedModels, availableHandles, filterProvider, searchQuery]); // All other models: API handles not in models.json const otherModelHandles = useMemo(() => { @@ -158,8 +168,8 @@ export function ModelSelector({ })); }, [category, supportedModels, otherModelHandles]); - // Show 1 fewer item in "all" category because Search line takes space - const visibleCount = category === "all" ? VISIBLE_ITEMS - 1 : VISIBLE_ITEMS; + // Show 1 fewer item because Search line takes space + const visibleCount = VISIBLE_ITEMS - 1; // Scrolling - keep selectedIndex in view const startIndex = useMemo(() => { @@ -276,8 +286,8 @@ export function ModelSelector({ if (selectedModel) { onSelect(selectedModel.id); } - } else if (category === "all" && input && input.length === 1) { - // Capture text input for search (only in "all" category) + } else if (input && input.length === 1) { + // Capture text input for search setSearchQuery((prev) => prev + input); setSelectedIndex(0); } @@ -328,9 +338,7 @@ export function ModelSelector({ {!isLoading && !refreshing && ( {renderTabBar()} - {category === "all" && ( - Search: {searchQuery || "(type to filter)"} - )} + Search: {searchQuery || "(type to filter)"} )} diff --git a/src/models.json b/src/models.json index 2948b71..5e0f125 100644 --- a/src/models.json +++ b/src/models.json @@ -38,7 +38,8 @@ { "id": "bedrock-opus", "handle": "bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0", - "label": "Bedrock Claude Opus 4.5", + "label": "Bedrock Opus 4.5", + "shortLabel": "Opus 4.5 BR", "description": "Anthropic's best model (via AWS Bedrock)", "isFeatured": true, "updateArgs": {