Skip to content

Commit 0220637

Browse files
authored
🤖 refactor: simplify model selector UI (#796)
- Remove add/remove model capabilities from dropdown (use Settings instead) - Keep favorites (star) functionality - Add gear icon to open Settings → Models tab - Only allow selecting from existing models - `/model` command validates provider and auto-adds new models via settings API - Show "No matching models" in dropdown when filter has no results _Generated with `mux`_
1 parent 95c6a30 commit 0220637

File tree

4 files changed

+105
-81
lines changed

4 files changed

+105
-81
lines changed

‎src/browser/components/ChatInput/index.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
144144
const inputRef = useRef<HTMLTextAreaElement>(null);
145145
const modelSelectorRef = useRef<ModelSelectorRef>(null);
146146
const [mode, setMode] = useMode();
147-
const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU();
147+
const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU();
148148
const commandListId = useId();
149149
const telemetry = useTelemetry();
150150
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
@@ -904,7 +904,6 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
904904
value={preferredModel}
905905
onChange={setPreferredModel}
906906
recentModels={recentModels}
907-
onRemoveModel={evictModel}
908907
onComplete={() => inputRef.current?.focus()}
909908
defaultModel={defaultModel}
910909
onSetDefaultModel={setDefaultModel}

‎src/browser/components/ModelSelector.stories.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
22
import { ModelSelector } from "./ModelSelector";
33
import { action } from "storybook/actions";
4+
import { SettingsProvider } from "@/browser/contexts/SettingsContext";
45

56
const meta = {
67
title: "Components/ModelSelector",
@@ -9,6 +10,13 @@ const meta = {
910
layout: "padded",
1011
},
1112
tags: ["autodocs"],
13+
decorators: [
14+
(Story) => (
15+
<SettingsProvider>
16+
<Story />
17+
</SettingsProvider>
18+
),
19+
],
1220
argTypes: {
1321
value: {
1422
control: { type: "text" },
@@ -18,10 +26,6 @@ const meta = {
1826
control: false,
1927
description: "Callback when model changes",
2028
},
21-
onRemoveModel: {
22-
control: false,
23-
description: "Callback when a model is removed",
24-
},
2529
recentModels: {
2630
control: { type: "object" },
2731
description: "List of recently used models",
@@ -40,7 +44,6 @@ export const Default: Story = {
4044
args: {
4145
value: "anthropic:claude-sonnet-4-5",
4246
onChange: action("onChange"),
43-
onRemoveModel: action("onRemoveModel"),
4447
recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"],
4548
onComplete: action("onComplete"),
4649
},
@@ -50,7 +53,6 @@ export const LongModelName: Story = {
5053
args: {
5154
value: "anthropic:claude-opus-4-20250514-preview-experimental",
5255
onChange: action("onChange"),
53-
onRemoveModel: action("onRemoveModel"),
5456
recentModels: [
5557
"anthropic:claude-opus-4-20250514-preview-experimental",
5658
"anthropic:claude-sonnet-4-20250514-preview-experimental",
@@ -64,7 +66,6 @@ export const WithManyModels: Story = {
6466
args: {
6567
value: "anthropic:claude-sonnet-4-5",
6668
onChange: action("onChange"),
67-
onRemoveModel: action("onRemoveModel"),
6869
recentModels: [
6970
"anthropic:claude-sonnet-4-5",
7071
"anthropic:claude-opus-4-1",
@@ -82,7 +83,6 @@ export const WithDefaultModel: Story = {
8283
args: {
8384
value: "anthropic:claude-sonnet-4-5",
8485
onChange: action("onChange"),
85-
onRemoveModel: action("onRemoveModel"),
8686
recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"],
8787
onComplete: action("onComplete"),
8888
defaultModel: "anthropic:claude-opus-4-1",

‎src/browser/components/ModelSelector.tsx‎

Lines changed: 50 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import React, {
77
forwardRef,
88
} from "react";
99
import { cn } from "@/common/lib/utils";
10-
import { Star } from "lucide-react";
10+
import { Settings, Star } from "lucide-react";
1111
import { TooltipWrapper, Tooltip } from "./Tooltip";
12+
import { useSettings } from "@/browser/contexts/SettingsContext";
1213

1314
interface ModelSelectorProps {
1415
value: string;
1516
onChange: (value: string) => void;
1617
recentModels: string[];
17-
onRemoveModel?: (model: string) => void;
1818
onComplete?: () => void;
1919
defaultModel?: string | null;
2020
onSetDefaultModel?: (model: string) => void;
@@ -25,10 +25,8 @@ export interface ModelSelectorRef {
2525
}
2626

2727
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
28-
(
29-
{ value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel },
30-
ref
31-
) => {
28+
({ value, onChange, recentModels, onComplete, defaultModel, onSetDefaultModel }, ref) => {
29+
const { open: openSettings } = useSettings();
3230
const [isEditing, setIsEditing] = useState(false);
3331
const [inputValue, setInputValue] = useState(value);
3432
const [error, setError] = useState<string | null>(null);
@@ -83,22 +81,14 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
8381
).sort();
8482

8583
const handleSave = () => {
86-
// If an item is highlighted, use that instead of inputValue
87-
const valueToSave =
88-
highlightedIndex >= 0 && highlightedIndex < filteredModels.length
89-
? filteredModels[highlightedIndex]
90-
: inputValue.trim();
91-
92-
if (!valueToSave) {
93-
setError("Model cannot be empty");
84+
// No matches - do nothing, let user keep typing or cancel
85+
if (filteredModels.length === 0) {
9486
return;
9587
}
9688

97-
// Basic validation: should have format "provider:model" or be an abbreviation
98-
if (!valueToSave.includes(":") && valueToSave.length < 3) {
99-
setError("Invalid model format");
100-
return;
101-
}
89+
// Use highlighted item, or first item if none highlighted
90+
const selectedIndex = highlightedIndex >= 0 ? highlightedIndex : 0;
91+
const valueToSave = filteredModels[selectedIndex];
10292

10393
onChange(valueToSave);
10494
setIsEditing(false);
@@ -113,9 +103,11 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
113103
handleCancel();
114104
} else if (e.key === "Enter") {
115105
e.preventDefault();
116-
handleSave();
117-
// Focus the main ChatInput after selecting a model
118-
onComplete?.();
106+
// Only call onComplete if save succeeded (had matches)
107+
if (filteredModels.length > 0) {
108+
handleSave();
109+
onComplete?.();
110+
}
119111
} else if (e.key === "Tab") {
120112
e.preventDefault();
121113
// Tab auto-completes the highlighted item without closing
@@ -159,22 +151,6 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
159151
setShowDropdown(false);
160152
};
161153

162-
const handleRemoveModel = useCallback(
163-
(model: string, event: React.MouseEvent<HTMLButtonElement>) => {
164-
event.preventDefault();
165-
event.stopPropagation();
166-
if (!onRemoveModel) {
167-
return;
168-
}
169-
onRemoveModel(model);
170-
setHighlightedIndex(-1);
171-
if (inputValue === model) {
172-
setInputValue("");
173-
}
174-
},
175-
[inputValue, onRemoveModel]
176-
);
177-
178154
const handleClick = useCallback(() => {
179155
setIsEditing(true);
180156
setInputValue(""); // Clear input to show all models
@@ -222,6 +198,19 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
222198
>
223199
{value}
224200
</div>
201+
<TooltipWrapper inline>
202+
<button
203+
type="button"
204+
onClick={() => openSettings("models")}
205+
className="text-muted-light hover:text-foreground flex items-center justify-center rounded-sm p-0.5 transition-colors duration-150"
206+
aria-label="Manage models"
207+
>
208+
<Settings className="h-3 w-3" />
209+
</button>
210+
<Tooltip className="tooltip" align="center">
211+
Manage models
212+
</Tooltip>
213+
</TooltipWrapper>
225214
</div>
226215
);
227216
}
@@ -241,24 +230,28 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
241230
<div className="text-danger-soft font-monospace mt-0.5 text-[9px]">{error}</div>
242231
)}
243232
</div>
244-
{showDropdown && filteredModels.length > 0 && (
233+
{showDropdown && (
245234
<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)]">
246-
{filteredModels.map((model, index) => (
247-
<div
248-
key={model}
249-
ref={(el) => (dropdownItemRefs.current[index] = el)}
250-
className={cn(
251-
"text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100",
252-
"first:rounded-t last:rounded-b",
253-
index === highlightedIndex
254-
? "text-foreground bg-hover"
255-
: "text-light bg-transparent hover:bg-hover hover:text-foreground"
256-
)}
257-
onClick={() => handleSelectModel(model)}
258-
>
259-
<div className="grid w-full grid-cols-[1fr_48px] items-center gap-2">
260-
<span className="min-w-0 truncate">{model}</span>
261-
<div className="grid w-[48px] grid-cols-[22px_22px] justify-items-center gap-1">
235+
{filteredModels.length === 0 ? (
236+
<div className="text-muted-light font-monospace px-2.5 py-1.5 text-[11px]">
237+
No matching models
238+
</div>
239+
) : (
240+
filteredModels.map((model, index) => (
241+
<div
242+
key={model}
243+
ref={(el) => (dropdownItemRefs.current[index] = el)}
244+
className={cn(
245+
"text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100",
246+
"first:rounded-t last:rounded-b",
247+
index === highlightedIndex
248+
? "text-foreground bg-hover"
249+
: "text-light bg-transparent hover:bg-hover hover:text-foreground"
250+
)}
251+
onClick={() => handleSelectModel(model)}
252+
>
253+
<div className="grid w-full grid-cols-[1fr_24px] items-center gap-2">
254+
<span className="min-w-0 truncate">{model}</span>
262255
{onSetDefaultModel && (
263256
<TooltipWrapper inline>
264257
<button
@@ -287,21 +280,10 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
287280
</Tooltip>
288281
</TooltipWrapper>
289282
)}
290-
{onRemoveModel && defaultModel !== model && (
291-
<button
292-
type="button"
293-
onMouseDown={(e) => e.preventDefault()}
294-
onClick={(event) => handleRemoveModel(model, event)}
295-
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"
296-
aria-label={`Remove ${model} from recent models`}
297-
>
298-
×
299-
</button>
300-
)}
301283
</div>
302284
</div>
303-
</div>
304-
))}
285+
))
286+
)}
305287
</div>
306288
)}
307289
</div>

‎src/browser/utils/chatCommands.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,56 @@ export async function processSlashCommand(
160160
}
161161

162162
if (parsed.type === "model-set") {
163+
const modelString = parsed.modelString;
164+
165+
// Validate provider:model format
166+
if (!modelString.includes(":")) {
167+
setToast({
168+
id: Date.now().toString(),
169+
type: "error",
170+
message: `Invalid model format: expected "provider:model"`,
171+
});
172+
return { clearInput: false, toastShown: true };
173+
}
174+
175+
const [provider, modelId] = modelString.split(":", 2);
176+
if (!provider || !modelId) {
177+
setToast({
178+
id: Date.now().toString(),
179+
type: "error",
180+
message: `Invalid model format: expected "provider:model"`,
181+
});
182+
return { clearInput: false, toastShown: true };
183+
}
184+
185+
// Validate provider is supported
186+
const { isValidProvider } = await import("@/common/constants/providers");
187+
if (!isValidProvider(provider)) {
188+
setToast({
189+
id: Date.now().toString(),
190+
type: "error",
191+
message: `Unknown provider "${provider}"`,
192+
});
193+
return { clearInput: false, toastShown: true };
194+
}
195+
196+
// Check if model needs to be added to provider's custom models
197+
const config = await window.api.providers.getConfig();
198+
const existingModels = config[provider]?.models ?? [];
199+
if (!existingModels.includes(modelId)) {
200+
// Add model via the same API as settings
201+
await window.api.providers.setModels(provider, [...existingModels, modelId]);
202+
// Notify other components about the change
203+
window.dispatchEvent(new Event("providers-config-changed"));
204+
}
205+
163206
setInput("");
164-
setPreferredModel(parsed.modelString);
165-
onModelChange?.(parsed.modelString);
207+
setPreferredModel(modelString);
208+
onModelChange?.(modelString);
166209
setToast({
167210
id: Date.now().toString(),
168211
type: "success",
169-
message: `Model changed to ${parsed.modelString}`,
212+
message: `Model changed to ${modelString}`,
170213
});
171214
return { clearInput: true, toastShown: true };
172215
}

0 commit comments

Comments
 (0)