Skip to content

Commit 3955a3d

Browse files
authored
🤖 feat: Settings-driven model selector with visibility controls (#1129)
Replace LRU-based recent models with Settings-derived model list: - Model availability comes from provider config + built-in models - Ctrl+/ cycles through models (prefers custom models if any) - Users can hide/unhide models from selector (including built-ins) - Hidden models stored in persisted state, shown inline via 'Show all' - Settings page shows visibility toggle for each model - Grid layout ensures consistent column alignment in selector _Generated with `mux`_
1 parent 59286b9 commit 3955a3d

File tree

20 files changed

+552
-443
lines changed

20 files changed

+552
-443
lines changed

docs/keybinds.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ description: Complete keyboard shortcut reference for mux
66
mux is designed to be keyboard-driven for maximum efficiency. All major actions have keyboard shortcuts.
77

88
<Info>
9-
This document should be kept in sync with `src/utils/ui/keybinds.ts`, which is the source of truth
10-
for keybind definitions.
9+
This document should be kept in sync with `src/browser/utils/ui/keybinds.ts`, which is the source
10+
of truth for keybind definitions.
1111
</Info>
1212

1313
## Platform Conventions
@@ -33,9 +33,9 @@ When documentation shows `Ctrl`, it means:
3333
| Focus chat input | `a`, `i`, or `Ctrl+I` |
3434
| Send message | `Enter` |
3535
| New line in message | `Shift+Enter` |
36-
| Cancel editing message | `Ctrl+Q` |
36+
| Cancel editing message | `Esc` |
3737
| Jump to bottom of chat | `Shift+G` |
38-
| Change model | `Ctrl+/` |
38+
| Cycle model | `Ctrl+/` |
3939
| Toggle thinking level | `Ctrl+Shift+T` |
4040

4141
## Workspaces

docs/models.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,10 +363,13 @@ All providers are configured in `~/.mux/providers.jsonc`. Example configurations
363363

364364
### Model Selection
365365

366-
The quickest way to switch models is with the keyboard shortcut:
366+
Keyboard shortcuts:
367367

368-
- **macOS:** `Cmd+/`
369-
- **Windows/Linux:** `Ctrl+/`
368+
- **Cycle models**
369+
- **macOS:** `Cmd+/`
370+
- **Windows/Linux:** `Ctrl+/`
371+
372+
To _choose_ a specific model, click the model pill in the chat footer.
370373

371374
Alternatively, use the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`):
372375

src/browser/components/AIView.tsx

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ import type { DisplayedMessage } from "@/common/types/message";
5050
import type { RuntimeConfig } from "@/common/types/runtime";
5151
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
5252
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
53-
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
5453
import { QueuedMessage } from "./Messages/QueuedMessage";
5554
import { CompactionWarning } from "./CompactionWarning";
5655
import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning";
@@ -145,33 +144,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
145144
workspaceId,
146145
pendingModel
147146
);
148-
const handledModelErrorsRef = useRef<Set<string>>(new Set());
149-
150-
useEffect(() => {
151-
handledModelErrorsRef.current.clear();
152-
}, [workspaceId]);
153-
154-
useEffect(() => {
155-
if (!workspaceState) {
156-
return;
157-
}
158-
159-
for (const message of workspaceState.messages) {
160-
if (message.type !== "stream-error") {
161-
continue;
162-
}
163-
if (message.errorType !== "model_not_found") {
164-
continue;
165-
}
166-
if (handledModelErrorsRef.current.has(message.id)) {
167-
continue;
168-
}
169-
handledModelErrorsRef.current.add(message.id);
170-
if (message.model) {
171-
evictModelFromLRU(message.model);
172-
}
173-
}
174-
}, [workspaceState, workspaceId]);
175147

176148
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
177149
undefined

src/browser/components/ChatInput/index.tsx

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
isEditableElement,
5454
} from "@/browser/utils/ui/keybinds";
5555
import { ModelSelector, type ModelSelectorRef } from "../ModelSelector";
56-
import { useModelLRU } from "@/browser/hooks/useModelLRU";
56+
import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings";
5757
import { SendHorizontal, X } from "lucide-react";
5858
import { VimTextArea } from "../VimTextArea";
5959
import { ImageAttachments, type ImageAttachment } from "../ImageAttachments";
@@ -180,7 +180,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
180180
const { open } = useSettings();
181181
const { selectedWorkspace } = useWorkspaceContext();
182182
const [mode, setMode] = useMode();
183-
const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU();
183+
const {
184+
models,
185+
customModels,
186+
hiddenModels,
187+
hideModel,
188+
unhideModel,
189+
ensureModelInSettings,
190+
defaultModel,
191+
setDefaultModel,
192+
} = useModelsFromSettings();
184193
const commandListId = useId();
185194
const telemetry = useTelemetry();
186195
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
@@ -229,6 +238,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
229238
// - baseModel: canonical format for UI display and policy checks (e.g., ThinkingSlider)
230239
const preferredModel = sendMessageOptions.model;
231240
const baseModel = sendMessageOptions.baseModel;
241+
242+
const setPreferredModel = useCallback(
243+
(model: string) => {
244+
ensureModelInSettings(model); // Ensure model exists in Settings
245+
updatePersistedState(storageKeys.modelKey, model); // Update workspace or project-specific
246+
},
247+
[storageKeys.modelKey, ensureModelInSettings]
248+
);
232249
const deferredModel = useDeferredValue(preferredModel);
233250
const deferredInput = useDeferredValue(input);
234251
const tokenCountPromise = useMemo(() => {
@@ -243,15 +260,29 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
243260
[tokenCountPromise]
244261
);
245262

246-
// Setter for model - updates localStorage directly so useSendMessageOptions picks it up
247-
const setPreferredModel = useCallback(
248-
(model: string) => {
249-
addModel(model); // Update LRU
250-
updatePersistedState(storageKeys.modelKey, model); // Update workspace or project-specific
251-
},
252-
[storageKeys.modelKey, addModel]
263+
// Model cycling candidates. Prefer the user's custom model list (as configured in Settings).
264+
// If no custom models are configured, fall back to the full suggested list.
265+
const cycleModels = useMemo(
266+
() => (customModels.length > 0 ? customModels : models),
267+
[customModels, models]
253268
);
254269

270+
const cycleToNextModel = useCallback(() => {
271+
if (cycleModels.length < 2) {
272+
return;
273+
}
274+
275+
const currentIndex = cycleModels.indexOf(baseModel);
276+
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleModels.length;
277+
const nextModel = cycleModels[nextIndex];
278+
if (nextModel) {
279+
setPreferredModel(nextModel);
280+
}
281+
}, [baseModel, cycleModels, setPreferredModel]);
282+
283+
const openModelSelector = useCallback(() => {
284+
modelSelectorRef.current?.open();
285+
}, []);
255286
// Creation-specific state (hook always called, but only used when variant === "creation")
256287
// This avoids conditional hook calls which violate React rules
257288
const creationState = useCreationWorkspace(
@@ -388,14 +419,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
388419
if (matchesKeybind(event, KEYBINDS.FOCUS_INPUT_A)) {
389420
event.preventDefault();
390421
focusMessageInput();
422+
return;
423+
}
424+
425+
if (matchesKeybind(event, KEYBINDS.CYCLE_MODEL)) {
426+
event.preventDefault();
427+
focusMessageInput();
428+
cycleToNextModel();
391429
}
392430
};
393431

394432
window.addEventListener("keydown", handleGlobalKeyDown);
395433
return () => {
396434
window.removeEventListener("keydown", handleGlobalKeyDown);
397435
};
398-
}, [focusMessageInput]);
436+
}, [cycleToNextModel, focusMessageInput, openModelSelector]);
399437

400438
// When entering editing mode, save current draft and populate with message content
401439
useEffect(() => {
@@ -1221,10 +1259,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
12211259
return;
12221260
}
12231261

1224-
// Handle open model selector
1225-
if (matchesKeybind(e, KEYBINDS.OPEN_MODEL_SELECTOR)) {
1262+
// Cycle models (Ctrl+/)
1263+
if (matchesKeybind(e, KEYBINDS.CYCLE_MODEL)) {
12261264
e.preventDefault();
1227-
modelSelectorRef.current?.open();
1265+
cycleToNextModel();
12281266
return;
12291267
}
12301268

@@ -1306,7 +1344,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
13061344
hints.push(`${formatKeybind(interruptKeybind)} to interrupt`);
13071345
}
13081346
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to ${canInterrupt ? "queue" : "send"}`);
1309-
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);
1347+
hints.push(`Click model to choose, ${formatKeybind(KEYBINDS.CYCLE_MODEL)} to cycle`);
13101348
hints.push(`/vim to toggle Vim mode (${vimEnabled ? "on" : "off"})`);
13111349

13121350
return `Type a message... (${hints.join(", ")})`;
@@ -1500,18 +1538,23 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
15001538
ref={modelSelectorRef}
15011539
value={baseModel}
15021540
onChange={setPreferredModel}
1503-
recentModels={recentModels}
1541+
models={models}
15041542
onComplete={() => inputRef.current?.focus()}
15051543
defaultModel={defaultModel}
15061544
onSetDefaultModel={setDefaultModel}
1545+
onHideModel={hideModel}
1546+
hiddenModels={hiddenModels}
1547+
onUnhideModel={unhideModel}
1548+
onOpenSettings={() => open("models")}
15071549
/>
15081550
<Tooltip>
15091551
<TooltipTrigger asChild>
15101552
<HelpIndicator>?</HelpIndicator>
15111553
</TooltipTrigger>
15121554
<TooltipContent align="start" className="max-w-80 whitespace-normal">
1513-
<strong>Click to edit</strong> or use{" "}
1514-
{formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)}
1555+
<strong>Click to edit</strong>
1556+
<br />
1557+
<strong>{formatKeybind(KEYBINDS.CYCLE_MODEL)}</strong> to cycle models
15151558
<br />
15161559
<br />
15171560
<strong>Abbreviations:</strong>

0 commit comments

Comments
 (0)