Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(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<boolean>(VIM_ENABLED_KEY, false, {
Expand Down Expand Up @@ -904,7 +904,6 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
value={preferredModel}
onChange={setPreferredModel}
recentModels={recentModels}
onRemoveModel={evictModel}
onComplete={() => inputRef.current?.focus()}
defaultModel={defaultModel}
onSetDefaultModel={setDefaultModel}
Expand Down
16 changes: 8 additions & 8 deletions src/browser/components/ModelSelector.stories.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -9,6 +10,13 @@ const meta = {
layout: "padded",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<SettingsProvider>
<Story />
</SettingsProvider>
),
],
argTypes: {
value: {
control: { type: "text" },
Expand All @@ -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",
Expand All @@ -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"),
},
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
118 changes: 50 additions & 68 deletions src/browser/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,10 +25,8 @@ export interface ModelSelectorRef {
}

export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
(
{ 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<string | null>(null);
Expand Down Expand Up @@ -83,22 +81,14 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
).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);
Expand All @@ -113,9 +103,11 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
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
Expand Down Expand Up @@ -159,22 +151,6 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
setShowDropdown(false);
};

const handleRemoveModel = useCallback(
(model: string, event: React.MouseEvent<HTMLButtonElement>) => {
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
Expand Down Expand Up @@ -222,6 +198,19 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
>
{value}
</div>
<TooltipWrapper inline>
<button
type="button"
onClick={() => openSettings("models")}
className="text-muted-light hover:text-foreground flex items-center justify-center rounded-sm p-0.5 transition-colors duration-150"
aria-label="Manage models"
>
<Settings className="h-3 w-3" />
</button>
<Tooltip className="tooltip" align="center">
Manage models
</Tooltip>
</TooltipWrapper>
</div>
);
}
Expand All @@ -241,24 +230,28 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
<div className="text-danger-soft font-monospace mt-0.5 text-[9px]">{error}</div>
)}
</div>
{showDropdown && filteredModels.length > 0 && (
{showDropdown && (
<div className="bg-separator border-border-light absolute bottom-full left-0 z-[1000] mb-1 max-h-[200px] min-w-80 overflow-y-auto rounded border shadow-[0_4px_12px_rgba(0,0,0,0.3)]">
{filteredModels.map((model, index) => (
<div
key={model}
ref={(el) => (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)}
>
<div className="grid w-full grid-cols-[1fr_48px] items-center gap-2">
<span className="min-w-0 truncate">{model}</span>
<div className="grid w-[48px] grid-cols-[22px_22px] justify-items-center gap-1">
{filteredModels.length === 0 ? (
<div className="text-muted-light font-monospace px-2.5 py-1.5 text-[11px]">
No matching models
</div>
) : (
filteredModels.map((model, index) => (
<div
key={model}
ref={(el) => (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)}
>
<div className="grid w-full grid-cols-[1fr_24px] items-center gap-2">
<span className="min-w-0 truncate">{model}</span>
{onSetDefaultModel && (
<TooltipWrapper inline>
<button
Expand Down Expand Up @@ -287,21 +280,10 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
</Tooltip>
</TooltipWrapper>
)}
{onRemoveModel && defaultModel !== model && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => handleRemoveModel(model, event)}
className="text-muted-light border-border-light/40 hover:border-danger-soft/60 hover:text-danger-soft rounded-sm border px-1 py-0.5 text-[9px] font-semibold tracking-wide uppercase transition-colors duration-150"
aria-label={`Remove ${model} from recent models`}
>
×
</button>
)}
</div>
</div>
</div>
))}
))
)}
</div>
)}
</div>
Expand Down
49 changes: 46 additions & 3 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down