Skip to content

Commit b1224d8

Browse files
committed
🤖 feat: add Settings Manager with Providers/Models/General sections
- Add SettingsModal with sidebar navigation between sections - General section: theme toggle (light/dark) - Providers section: configure API keys and base URLs per provider - Models section: add/remove custom models per provider - Command Palette integration: 'Open Settings', 'Settings: Providers', 'Settings: Models' - New IPC channels: PROVIDERS_GET_CONFIG, PROVIDERS_SET_MODELS - getConfig returns apiKeySet boolean (not the key value) for security _Generated with mux_
1 parent 6460891 commit b1224d8

File tree

16 files changed

+696
-3
lines changed

16 files changed

+696
-3
lines changed

src/browser/App.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ function setupMockAPI(options: {
3737
},
3838
providers: {
3939
setProviderConfig: () => Promise.resolve({ success: true, data: undefined }),
40+
setModels: () => Promise.resolve({ success: true, data: undefined }),
41+
getConfig: () => Promise.resolve({} as Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>),
4042
list: () => Promise.resolve([]),
4143
},
4244
workspace: {
@@ -543,6 +545,8 @@ export const ActiveWorkspaceWithChat: Story = {
543545
apiOverrides: {
544546
providers: {
545547
setProviderConfig: () => Promise.resolve({ success: true, data: undefined }),
548+
setModels: () => Promise.resolve({ success: true, data: undefined }),
549+
getConfig: () => Promise.resolve({} as Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>),
546550
list: () => Promise.resolve(["anthropic", "openai", "xai"]),
547551
},
548552
workspace: {

src/browser/App.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import type { BranchListResult } from "@/common/types/ipc";
3434
import { useTelemetry } from "./hooks/useTelemetry";
3535
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
3636

37+
import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
38+
import { SettingsModal } from "./components/Settings/SettingsModal";
39+
3740
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
3841

3942
function AppInner() {
@@ -50,6 +53,7 @@ function AppInner() {
5053
clearPendingWorkspaceCreation,
5154
} = useWorkspaceContext();
5255
const { theme, setTheme, toggleTheme } = useTheme();
56+
const { open: openSettings } = useSettings();
5357
const setThemePreference = useCallback(
5458
(nextTheme: ThemeMode) => {
5559
setTheme(nextTheme);
@@ -412,6 +416,7 @@ function AppInner() {
412416
onOpenWorkspaceInTerminal: openWorkspaceInTerminal,
413417
onToggleTheme: toggleTheme,
414418
onSetTheme: setThemePreference,
419+
onOpenSettings: openSettings,
415420
};
416421

417422
useEffect(() => {
@@ -623,6 +628,7 @@ function AppInner() {
623628
onClose={closeProjectCreateModal}
624629
onSuccess={addProject}
625630
/>
631+
<SettingsModal />
626632
</div>
627633
</>
628634
);
@@ -631,9 +637,11 @@ function AppInner() {
631637
function App() {
632638
return (
633639
<ThemeProvider>
634-
<CommandRegistryProvider>
635-
<AppInner />
636-
</CommandRegistryProvider>
640+
<SettingsProvider>
641+
<CommandRegistryProvider>
642+
<AppInner />
643+
</CommandRegistryProvider>
644+
</SettingsProvider>
637645
</ThemeProvider>
638646
);
639647
}

src/browser/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ const webApi: IPCApi = {
233233
providers: {
234234
setProviderConfig: (provider, keyPath, value) =>
235235
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value),
236+
setModels: (provider, models) =>
237+
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models),
238+
getConfig: () => invokeIPC(IPC_CHANNELS.PROVIDERS_GET_CONFIG),
236239
list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST),
237240
},
238241
projects: {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React, { useEffect, useCallback } from "react";
2+
import { Settings, Key, Cpu, X } from "lucide-react";
3+
import { useSettings } from "@/browser/contexts/SettingsContext";
4+
import { ModalOverlay } from "@/browser/components/Modal";
5+
import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
6+
import { GeneralSection } from "./sections/GeneralSection";
7+
import { ProvidersSection } from "./sections/ProvidersSection";
8+
import { ModelsSection } from "./sections/ModelsSection";
9+
import type { SettingsSection } from "./types";
10+
11+
const SECTIONS: SettingsSection[] = [
12+
{
13+
id: "general",
14+
label: "General",
15+
icon: <Settings className="h-4 w-4" />,
16+
component: GeneralSection,
17+
},
18+
{
19+
id: "providers",
20+
label: "Providers",
21+
icon: <Key className="h-4 w-4" />,
22+
component: ProvidersSection,
23+
},
24+
{
25+
id: "models",
26+
label: "Models",
27+
icon: <Cpu className="h-4 w-4" />,
28+
component: ModelsSection,
29+
},
30+
];
31+
32+
export function SettingsModal() {
33+
const { isOpen, close, activeSection, setActiveSection } = useSettings();
34+
35+
const handleClose = useCallback(() => {
36+
close();
37+
}, [close]);
38+
39+
// Handle escape key
40+
useEffect(() => {
41+
if (!isOpen) return;
42+
43+
const handleKeyDown = (e: KeyboardEvent) => {
44+
if (matchesKeybind(e, KEYBINDS.CANCEL)) {
45+
e.preventDefault();
46+
handleClose();
47+
}
48+
};
49+
50+
window.addEventListener("keydown", handleKeyDown);
51+
return () => window.removeEventListener("keydown", handleKeyDown);
52+
}, [isOpen, handleClose]);
53+
54+
if (!isOpen) return null;
55+
56+
const currentSection = SECTIONS.find((s) => s.id === activeSection) ?? SECTIONS[0];
57+
const SectionComponent = currentSection.component;
58+
59+
return (
60+
<ModalOverlay role="presentation" onClick={handleClose}>
61+
<div
62+
role="dialog"
63+
aria-modal="true"
64+
aria-labelledby="settings-title"
65+
onClick={(e) => e.stopPropagation()}
66+
className="bg-dark border-border flex h-[70vh] max-h-[600px] w-[90%] max-w-[800px] overflow-hidden rounded-lg border shadow-lg"
67+
>
68+
{/* Sidebar */}
69+
<div className="border-border-medium flex w-48 shrink-0 flex-col border-r">
70+
<div className="border-border-medium flex items-center justify-between border-b px-4 py-3">
71+
<h2 id="settings-title" className="text-foreground text-sm font-semibold">
72+
Settings
73+
</h2>
74+
</div>
75+
<nav className="flex-1 overflow-y-auto p-2">
76+
{SECTIONS.map((section) => (
77+
<button
78+
key={section.id}
79+
type="button"
80+
onClick={() => setActiveSection(section.id)}
81+
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
82+
activeSection === section.id
83+
? "bg-accent/20 text-accent"
84+
: "text-muted hover:bg-hover hover:text-foreground"
85+
}`}
86+
>
87+
{section.icon}
88+
{section.label}
89+
</button>
90+
))}
91+
</nav>
92+
</div>
93+
94+
{/* Content */}
95+
<div className="flex flex-1 flex-col overflow-hidden">
96+
<div className="border-border-medium flex items-center justify-between border-b px-6 py-3">
97+
<h3 className="text-foreground text-sm font-medium">{currentSection.label}</h3>
98+
<button
99+
type="button"
100+
onClick={handleClose}
101+
className="text-muted hover:text-foreground rounded p-1 transition-colors"
102+
aria-label="Close settings"
103+
>
104+
<X className="h-4 w-4" />
105+
</button>
106+
</div>
107+
<div className="flex-1 overflow-y-auto p-6">
108+
<SectionComponent />
109+
</div>
110+
</div>
111+
</div>
112+
</ModalOverlay>
113+
);
114+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SettingsModal } from "./SettingsModal";
2+
export { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext";
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from "react";
2+
import { MoonStar, SunMedium } from "lucide-react";
3+
import { useTheme } from "@/browser/contexts/ThemeContext";
4+
5+
export function GeneralSection() {
6+
const { theme, toggleTheme } = useTheme();
7+
8+
return (
9+
<div className="space-y-6">
10+
<div>
11+
<h3 className="text-foreground mb-4 text-sm font-medium">Appearance</h3>
12+
<div className="flex items-center justify-between">
13+
<div>
14+
<div className="text-foreground text-sm">Theme</div>
15+
<div className="text-muted text-xs">Choose light or dark appearance</div>
16+
</div>
17+
<button
18+
type="button"
19+
onClick={toggleTheme}
20+
className="border-border-medium bg-background-secondary hover:bg-hover flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors"
21+
>
22+
{theme === "light" ? (
23+
<>
24+
<SunMedium className="h-4 w-4" />
25+
<span>Light</span>
26+
</>
27+
) : (
28+
<>
29+
<MoonStar className="h-4 w-4" />
30+
<span>Dark</span>
31+
</>
32+
)}
33+
</button>
34+
</div>
35+
</div>
36+
</div>
37+
);
38+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import React, { useState, useEffect, useCallback } from "react";
2+
import { Plus, Trash2 } from "lucide-react";
3+
import type { ProvidersConfigMap } from "../types";
4+
import { SUPPORTED_PROVIDERS } from "@/common/constants/providers";
5+
6+
interface NewModelForm {
7+
provider: string;
8+
modelId: string;
9+
}
10+
11+
export function ModelsSection() {
12+
const [config, setConfig] = useState<ProvidersConfigMap>({});
13+
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
14+
const [saving, setSaving] = useState(false);
15+
16+
// Load config on mount
17+
useEffect(() => {
18+
void (async () => {
19+
const cfg = await window.api.providers.getConfig();
20+
setConfig(cfg);
21+
})();
22+
}, []);
23+
24+
// Get all custom models across providers
25+
const getAllModels = (): Array<{ provider: string; modelId: string }> => {
26+
const models: Array<{ provider: string; modelId: string }> = [];
27+
for (const [provider, providerConfig] of Object.entries(config)) {
28+
if (providerConfig.models) {
29+
for (const modelId of providerConfig.models) {
30+
models.push({ provider, modelId });
31+
}
32+
}
33+
}
34+
return models;
35+
};
36+
37+
const handleAddModel = useCallback(async () => {
38+
if (!newModel.provider || !newModel.modelId.trim()) return;
39+
40+
setSaving(true);
41+
try {
42+
const currentModels = config[newModel.provider]?.models ?? [];
43+
const updatedModels = [...currentModels, newModel.modelId.trim()];
44+
45+
await window.api.providers.setModels(newModel.provider, updatedModels);
46+
47+
// Refresh config
48+
const cfg = await window.api.providers.getConfig();
49+
setConfig(cfg);
50+
setNewModel({ provider: "", modelId: "" });
51+
} finally {
52+
setSaving(false);
53+
}
54+
}, [newModel, config]);
55+
56+
const handleRemoveModel = useCallback(
57+
async (provider: string, modelId: string) => {
58+
setSaving(true);
59+
try {
60+
const currentModels = config[provider]?.models ?? [];
61+
const updatedModels = currentModels.filter((m) => m !== modelId);
62+
63+
await window.api.providers.setModels(provider, updatedModels);
64+
65+
// Refresh config
66+
const cfg = await window.api.providers.getConfig();
67+
setConfig(cfg);
68+
} finally {
69+
setSaving(false);
70+
}
71+
},
72+
[config]
73+
);
74+
75+
const allModels = getAllModels();
76+
77+
return (
78+
<div className="space-y-4">
79+
<p className="text-muted text-xs">
80+
Add custom models to use with your providers. These will appear in the model selector.
81+
</p>
82+
83+
{/* Add new model form */}
84+
<div className="border-border-medium bg-background-secondary rounded-md border p-4">
85+
<div className="mb-3 text-sm font-medium">Add Custom Model</div>
86+
<div className="flex gap-2">
87+
<select
88+
value={newModel.provider}
89+
onChange={(e) => setNewModel((prev) => ({ ...prev, provider: e.target.value }))}
90+
className="bg-modal-bg border-border-medium focus:border-accent rounded border px-2 py-1.5 text-sm focus:outline-none"
91+
>
92+
<option value="">Select provider</option>
93+
{SUPPORTED_PROVIDERS.map((p) => (
94+
<option key={p} value={p}>
95+
{p}
96+
</option>
97+
))}
98+
</select>
99+
<input
100+
type="text"
101+
value={newModel.modelId}
102+
onChange={(e) => setNewModel((prev) => ({ ...prev, modelId: e.target.value }))}
103+
placeholder="model-id (e.g., gpt-4-turbo)"
104+
className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none"
105+
onKeyDown={(e) => {
106+
if (e.key === "Enter") void handleAddModel();
107+
}}
108+
/>
109+
<button
110+
type="button"
111+
onClick={() => void handleAddModel()}
112+
disabled={saving || !newModel.provider || !newModel.modelId.trim()}
113+
className="bg-accent hover:bg-accent-dark disabled:bg-border-medium flex items-center gap-1 rounded px-3 py-1.5 text-sm text-white transition-colors disabled:cursor-not-allowed"
114+
>
115+
<Plus className="h-4 w-4" />
116+
Add
117+
</button>
118+
</div>
119+
</div>
120+
121+
{/* List of custom models */}
122+
{allModels.length > 0 ? (
123+
<div className="space-y-2">
124+
<div className="text-muted text-xs font-medium tracking-wide uppercase">
125+
Custom Models
126+
</div>
127+
{allModels.map(({ provider, modelId }) => (
128+
<div
129+
key={`${provider}-${modelId}`}
130+
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
131+
>
132+
<div className="flex items-center gap-3">
133+
<span className="text-muted text-xs capitalize">{provider}</span>
134+
<span className="text-foreground font-mono text-sm">{modelId}</span>
135+
</div>
136+
<button
137+
type="button"
138+
onClick={() => void handleRemoveModel(provider, modelId)}
139+
disabled={saving}
140+
className="text-muted hover:text-error p-1 transition-colors"
141+
title="Remove model"
142+
>
143+
<Trash2 className="h-4 w-4" />
144+
</button>
145+
</div>
146+
))}
147+
</div>
148+
) : (
149+
<div className="text-muted py-8 text-center text-sm">
150+
No custom models configured. Add one above to get started.
151+
</div>
152+
)}
153+
</div>
154+
);
155+
}

0 commit comments

Comments
 (0)