diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b6cf018aa..98a16bc9e 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -144,7 +144,7 @@ export const ChatInput: React.FC = (props) => { const inputRef = useRef(null); const modelSelectorRef = useRef(null); const [mode, setMode] = useMode(); - const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU(); + const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -904,7 +904,6 @@ export const ChatInput: React.FC = (props) => { value={preferredModel} onChange={setPreferredModel} recentModels={recentModels} - onRemoveModel={evictModel} onComplete={() => inputRef.current?.focus()} defaultModel={defaultModel} onSetDefaultModel={setDefaultModel} diff --git a/src/browser/components/ModelSelector.stories.tsx b/src/browser/components/ModelSelector.stories.tsx index ca236ce23..43736d319 100644 --- a/src/browser/components/ModelSelector.stories.tsx +++ b/src/browser/components/ModelSelector.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ModelSelector } from "./ModelSelector"; import { action } from "storybook/actions"; +import { SettingsProvider } from "@/browser/contexts/SettingsContext"; const meta = { title: "Components/ModelSelector", @@ -9,6 +10,13 @@ const meta = { layout: "padded", }, tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], argTypes: { value: { control: { type: "text" }, @@ -18,10 +26,6 @@ const meta = { control: false, description: "Callback when model changes", }, - onRemoveModel: { - control: false, - description: "Callback when a model is removed", - }, recentModels: { control: { type: "object" }, description: "List of recently used models", @@ -40,7 +44,6 @@ export const Default: Story = { args: { value: "anthropic:claude-sonnet-4-5", onChange: action("onChange"), - onRemoveModel: action("onRemoveModel"), recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"], onComplete: action("onComplete"), }, @@ -50,7 +53,6 @@ export const LongModelName: Story = { args: { value: "anthropic:claude-opus-4-20250514-preview-experimental", onChange: action("onChange"), - onRemoveModel: action("onRemoveModel"), recentModels: [ "anthropic:claude-opus-4-20250514-preview-experimental", "anthropic:claude-sonnet-4-20250514-preview-experimental", @@ -64,7 +66,6 @@ export const WithManyModels: Story = { args: { value: "anthropic:claude-sonnet-4-5", onChange: action("onChange"), - onRemoveModel: action("onRemoveModel"), recentModels: [ "anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", @@ -82,7 +83,6 @@ export const WithDefaultModel: Story = { args: { value: "anthropic:claude-sonnet-4-5", onChange: action("onChange"), - onRemoveModel: action("onRemoveModel"), recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"], onComplete: action("onComplete"), defaultModel: "anthropic:claude-opus-4-1", diff --git a/src/browser/components/ModelSelector.tsx b/src/browser/components/ModelSelector.tsx index b3eb8306a..1e7509e6c 100644 --- a/src/browser/components/ModelSelector.tsx +++ b/src/browser/components/ModelSelector.tsx @@ -7,14 +7,14 @@ import React, { forwardRef, } from "react"; import { cn } from "@/common/lib/utils"; -import { Star } from "lucide-react"; +import { Settings, Star } from "lucide-react"; import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { useSettings } from "@/browser/contexts/SettingsContext"; interface ModelSelectorProps { value: string; onChange: (value: string) => void; recentModels: string[]; - onRemoveModel?: (model: string) => void; onComplete?: () => void; defaultModel?: string | null; onSetDefaultModel?: (model: string) => void; @@ -25,10 +25,8 @@ export interface ModelSelectorRef { } export const ModelSelector = forwardRef( - ( - { value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel }, - ref - ) => { + ({ value, onChange, recentModels, onComplete, defaultModel, onSetDefaultModel }, ref) => { + const { open: openSettings } = useSettings(); const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(value); const [error, setError] = useState(null); @@ -83,22 +81,14 @@ export const ModelSelector = forwardRef( ).sort(); const handleSave = () => { - // If an item is highlighted, use that instead of inputValue - const valueToSave = - highlightedIndex >= 0 && highlightedIndex < filteredModels.length - ? filteredModels[highlightedIndex] - : inputValue.trim(); - - if (!valueToSave) { - setError("Model cannot be empty"); + // No matches - do nothing, let user keep typing or cancel + if (filteredModels.length === 0) { return; } - // Basic validation: should have format "provider:model" or be an abbreviation - if (!valueToSave.includes(":") && valueToSave.length < 3) { - setError("Invalid model format"); - return; - } + // Use highlighted item, or first item if none highlighted + const selectedIndex = highlightedIndex >= 0 ? highlightedIndex : 0; + const valueToSave = filteredModels[selectedIndex]; onChange(valueToSave); setIsEditing(false); @@ -113,9 +103,11 @@ export const ModelSelector = forwardRef( handleCancel(); } else if (e.key === "Enter") { e.preventDefault(); - handleSave(); - // Focus the main ChatInput after selecting a model - onComplete?.(); + // Only call onComplete if save succeeded (had matches) + if (filteredModels.length > 0) { + handleSave(); + onComplete?.(); + } } else if (e.key === "Tab") { e.preventDefault(); // Tab auto-completes the highlighted item without closing @@ -159,22 +151,6 @@ export const ModelSelector = forwardRef( setShowDropdown(false); }; - const handleRemoveModel = useCallback( - (model: string, event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (!onRemoveModel) { - return; - } - onRemoveModel(model); - setHighlightedIndex(-1); - if (inputValue === model) { - setInputValue(""); - } - }, - [inputValue, onRemoveModel] - ); - const handleClick = useCallback(() => { setIsEditing(true); setInputValue(""); // Clear input to show all models @@ -222,6 +198,19 @@ export const ModelSelector = forwardRef( > {value} + + + + Manage models + + ); } @@ -241,24 +230,28 @@ export const ModelSelector = forwardRef(
{error}
)} - {showDropdown && filteredModels.length > 0 && ( + {showDropdown && (
- {filteredModels.map((model, index) => ( -
(dropdownItemRefs.current[index] = el)} - className={cn( - "text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100", - "first:rounded-t last:rounded-b", - index === highlightedIndex - ? "text-foreground bg-hover" - : "text-light bg-transparent hover:bg-hover hover:text-foreground" - )} - onClick={() => handleSelectModel(model)} - > -
- {model} -
+ {filteredModels.length === 0 ? ( +
+ No matching models +
+ ) : ( + filteredModels.map((model, index) => ( +
(dropdownItemRefs.current[index] = el)} + className={cn( + "text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100", + "first:rounded-t last:rounded-b", + index === highlightedIndex + ? "text-foreground bg-hover" + : "text-light bg-transparent hover:bg-hover hover:text-foreground" + )} + onClick={() => handleSelectModel(model)} + > +
+ {model} {onSetDefaultModel && ( - )}
-
- ))} + )) + )}
)}
diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 2acc50671..af79c1134 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -160,13 +160,56 @@ export async function processSlashCommand( } if (parsed.type === "model-set") { + const modelString = parsed.modelString; + + // Validate provider:model format + if (!modelString.includes(":")) { + setToast({ + id: Date.now().toString(), + type: "error", + message: `Invalid model format: expected "provider:model"`, + }); + return { clearInput: false, toastShown: true }; + } + + const [provider, modelId] = modelString.split(":", 2); + if (!provider || !modelId) { + setToast({ + id: Date.now().toString(), + type: "error", + message: `Invalid model format: expected "provider:model"`, + }); + return { clearInput: false, toastShown: true }; + } + + // Validate provider is supported + const { isValidProvider } = await import("@/common/constants/providers"); + if (!isValidProvider(provider)) { + setToast({ + id: Date.now().toString(), + type: "error", + message: `Unknown provider "${provider}"`, + }); + return { clearInput: false, toastShown: true }; + } + + // Check if model needs to be added to provider's custom models + const config = await window.api.providers.getConfig(); + const existingModels = config[provider]?.models ?? []; + if (!existingModels.includes(modelId)) { + // Add model via the same API as settings + await window.api.providers.setModels(provider, [...existingModels, modelId]); + // Notify other components about the change + window.dispatchEvent(new Event("providers-config-changed")); + } + setInput(""); - setPreferredModel(parsed.modelString); - onModelChange?.(parsed.modelString); + setPreferredModel(modelString); + onModelChange?.(modelString); setToast({ id: Date.now().toString(), type: "success", - message: `Model changed to ${parsed.modelString}`, + message: `Model changed to ${modelString}`, }); return { clearInput: true, toastShown: true }; }