Skip to content

Commit b8a3b3e

Browse files
committed
feat: add advanced runtime and interface settings
1 parent 4e1d6c3 commit b8a3b3e

8 files changed

Lines changed: 125 additions & 10 deletions

File tree

app/src/llamacpp-manager.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ const modelCache = new Map<string, LlamaModel>();
3838
const modelLoads = new Map<string, Promise<LlamaModel>>();
3939
const modelLastUsed = new Map<string, number>();
4040
const activeModelUsers = new Map<string, number>();
41-
const MAX_CACHED_MODELS = 2;
41+
let maxCachedModels = 2;
42+
43+
export function setModelCacheLimit(limit: number): void {
44+
if (!Number.isFinite(limit)) return;
45+
maxCachedModels = Math.max(1, Math.min(Math.floor(limit), 8));
46+
void evictIdleModels();
47+
}
4248

4349
function modelCacheKey(modelPath: string, gpuLayers?: number): string {
4450
return `${modelPath}\0${gpuLayers ?? "auto"}`;
@@ -139,7 +145,7 @@ async function loadModel(modelPath: string, gpuLayers?: number): Promise<LlamaMo
139145
}
140146

141147
async function evictIdleModels(protectedKey?: string): Promise<void> {
142-
while (modelCache.size > MAX_CACHED_MODELS) {
148+
while (modelCache.size > maxCachedModels) {
143149
const candidate = [...modelCache.keys()]
144150
.filter((key) => key !== protectedKey && (activeModelUsers.get(key) ?? 0) === 0)
145151
.sort((a, b) => (modelLastUsed.get(a) ?? 0) - (modelLastUsed.get(b) ?? 0))[0];

app/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ function registerIpcHandlers(): void {
395395
ipcMain.handle("settings:save", (_event: IpcMainInvokeEvent, partial) => {
396396
const saved = settingsStore.saveSettings(partial);
397397
if (partial.ollamaHost !== undefined) ollama.setHost(saved.ollamaHost);
398+
if (partial.llamaCppMaxCachedModels !== undefined) llamacpp.setModelCacheLimit(saved.llamaCppMaxCachedModels ?? 2);
398399
if (partial.keybindings !== undefined) {
399400
setupMenu(() => mainWindow, () => checkForUpdatesManually(() => mainWindow), saved.keybindings);
400401
}
@@ -750,6 +751,7 @@ app.whenReady().then(async () => {
750751
ollama.setHost(settingsStore.getSettings().ollamaHost);
751752
ollama.setModelsDir(settingsStore.getSettings().modelsDir);
752753
await ollama.start();
754+
llamacpp.setModelCacheLimit(settingsStore.getSettings().llamaCppMaxCachedModels ?? 2);
753755
await llamacpp.setGpuBackend(settingsStore.getSettings().llamaCppGpuBackend ?? "auto");
754756
setupAutoUpdater(() => mainWindow);
755757
void connectEnabledMcpServers();

app/src/settings-store.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export interface AppSettings {
5454
promptPresets: PromptPreset[];
5555
theme: "light" | "dark" | "system";
5656
language: "en" | "tr";
57+
uiDensity?: "comfortable" | "compact";
58+
reduceMotion?: boolean;
59+
agentMaxSteps?: number;
60+
llamaCppMaxCachedModels?: number;
5761
// Text-to-speech: which browser/OS voice to use (voiceURI from
5862
// speechSynthesis.getVoices(), chosen client-side) and whether assistant
5963
// responses should be read aloud automatically as they finish.
@@ -106,6 +110,10 @@ const DEFAULTS: AppSettings = {
106110
promptPresets: [],
107111
theme: "system",
108112
language: "en",
113+
uiDensity: "comfortable",
114+
reduceMotion: false,
115+
agentMaxSteps: 25,
116+
llamaCppMaxCachedModels: 2,
109117
};
110118

111119
function filePath(): string {

frontend/src/components/layout.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,11 +540,23 @@ export default function Layout() {
540540

541541
useEffect(() => {
542542
if (!hasApi) return;
543+
const applyDisplaySettings = (s: { uiDensity?: string; reduceMotion?: boolean }) => {
544+
const root = document.documentElement;
545+
root.classList.toggle("density-compact", s.uiDensity === "compact");
546+
root.classList.toggle("reduce-motion", s.reduceMotion === true);
547+
};
543548
window.api.settings.get().then((s) => {
544549
setShowOnboarding(!s.onboardingComplete);
545550
setKeybindings({ ...DEFAULT_KEYBINDINGS, ...s.keybindings });
551+
applyDisplaySettings(s);
546552
});
547-
return subscribeKeybindings(setKeybindings);
553+
const onDisplaySettings = (event: Event) => applyDisplaySettings((event as CustomEvent).detail);
554+
window.addEventListener("app:display-settings", onDisplaySettings);
555+
const unsubscribe = subscribeKeybindings(setKeybindings);
556+
return () => {
557+
unsubscribe();
558+
window.removeEventListener("app:display-settings", onDisplaySettings);
559+
};
548560
}, [hasApi]);
549561

550562
const query = search.trim().toLowerCase();

frontend/src/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@
158158

159159
* { scrollbar-width: thin; scrollbar-color: color-mix(in oklch, var(--muted-foreground) 35%, transparent) transparent; }
160160

161+
.density-compact {
162+
font-size: 14px;
163+
--radius: 0.625rem;
164+
}
165+
.density-compact [data-slot="button"] { min-height: 1.75rem; }
166+
.density-compact [data-slot="input"] { height: 1.875rem; }
167+
.reduce-motion *,
168+
.reduce-motion *::before,
169+
.reduce-motion *::after {
170+
scroll-behavior: auto !important;
171+
animation-duration: 0.01ms !important;
172+
animation-iteration-count: 1 !important;
173+
transition-duration: 0.01ms !important;
174+
}
175+
161176
.prose-chat p {
162177
margin: 0 0 0.5em;
163178
}

frontend/src/pages/Chat.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ const DIAGRAM_PROMPT_PRESETS: DiagramPromptPreset[] = [
163163
// Caps how many automatic tool-result -> model-continuation round trips can
164164
// happen for a single user turn, so a model that keeps calling tools without
165165
// ever producing a final answer can't loop indefinitely.
166-
const AGENT_MAX_STEPS = 25;
166+
const DEFAULT_AGENT_MAX_STEPS = 25;
167167

168168
function buildMessageContent(text: string, attachments: Attachment[], ragContent = "") {
169169
const fileBlocks = attachments
@@ -395,6 +395,7 @@ export default function Chat() {
395395
const [pendingCustomProvider, setPendingCustomProvider] = useState<ProviderId | null>(null);
396396
const [customModelInput, setCustomModelInput] = useState("");
397397
const [settings, setSettings] = useState<AppSettings | null>(null);
398+
const agentMaxSteps = Math.max(5, Math.min(settings?.agentMaxSteps ?? DEFAULT_AGENT_MAX_STEPS, 100));
398399
const [messages, setMessages] = useState<ChatMessage[]>([]);
399400
const [input, setInput] = useState("");
400401
const [attachments, setAttachments] = useState<Attachment[]>([]);
@@ -959,12 +960,12 @@ export default function Chat() {
959960
// (e.g. read a file, then act on what it found) without the user having
960961
// to prompt again.
961962
function continueAfterTools(updatedMessages: ChatMessage[]) {
962-
if (agentStepCount >= AGENT_MAX_STEPS) {
963+
if (agentStepCount >= agentMaxSteps) {
963964
setMessages((m) => [
964965
...m,
965966
{
966967
role: "assistant",
967-
content: `⚠️ Reached the agent step limit (${AGENT_MAX_STEPS}) for this turn. Send another message to let it continue.`,
968+
content: `⚠️ Reached the agent step limit (${agentMaxSteps}) for this turn. Send another message to let it continue.`,
968969
},
969970
]);
970971
return;
@@ -1408,7 +1409,7 @@ export default function Chat() {
14081409
)}
14091410
{agentStepCount > 0 && (
14101411
<span className="text-xs text-muted-foreground" title={t.agentStepTooltip}>
1411-
{t.agentStep} {agentStepCount}/{AGENT_MAX_STEPS}
1412+
{t.agentStep} {agentStepCount}/{agentMaxSteps}
14121413
</span>
14131414
)}
14141415
{agentMode && agentWorkspace && (

frontend/src/pages/Settings.tsx

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ import { cn } from "@/lib/utils";
8484

8585
type SettingsTab = "general" | "models" | "integrations" | "chat" | "voice" | "automation" | "data";
8686

87+
const SETTINGS_SEARCH_ITEMS: { tab: SettingsTab; label: string; keywords: string }[] = [
88+
{ tab: "general", label: "Appearance, density & motion", keywords: "theme color compact comfortable animation reduced motion language server gpu cache" },
89+
{ tab: "models", label: "Models & hardware", keywords: "ollama hugging face download vram recommendation gguf" },
90+
{ tab: "integrations", label: "Accounts, providers & MCP", keywords: "github hugging face api key custom gpu backend figma mcp" },
91+
{ tab: "chat", label: "Chat & agent behavior", keywords: "prompt temperature context tokens agent steps tool calls" },
92+
{ tab: "voice", label: "Voice & speech", keywords: "microphone transcription tts read aloud voice" },
93+
{ tab: "automation", label: "Automation", keywords: "scheduled task interval prompt" },
94+
{ tab: "data", label: "Data, activity & diagnostics", keywords: "export import logs memory activity clear" },
95+
];
96+
8797
// Ollama pulls Hugging Face GGUF models via a "hf.co/user/repo[:quant]" model
8898
// name — accept a pasted full URL or the "huggingface.co/" host too, rather
8999
// than making the user hand-edit what they copied from their browser.
@@ -132,6 +142,7 @@ export default function Settings() {
132142
const [hasApi, setHasApi] = useState(true);
133143
const [search, setSearch] = useState("");
134144
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
145+
const [settingsQuery, setSettingsQuery] = useState("");
135146
const [openaiKeyInput, setOpenaiKeyInput] = useState("");
136147
const [anthropicKeyInput, setAnthropicKeyInput] = useState("");
137148
const [openaiKeySet, setOpenaiKeySet] = useState(false);
@@ -638,6 +649,9 @@ export default function Settings() {
638649
const merged = { ...settings, ...partial };
639650
setSettings(merged);
640651
await window.api.settings.save(partial);
652+
if (partial.uiDensity !== undefined || partial.reduceMotion !== undefined) {
653+
window.dispatchEvent(new CustomEvent("app:display-settings", { detail: merged }));
654+
}
641655
}
642656

643657
async function addMlxModel() {
@@ -843,11 +857,23 @@ export default function Settings() {
843857
return (
844858
<ScrollArea className="h-full bg-background/25">
845859
<div className="mx-auto max-w-5xl px-4 pb-20 pt-16 sm:px-6 md:pt-8 2xl:max-w-6xl">
846-
<div className="mb-8 flex items-center gap-3">
847-
<span className="flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm"><Settings2 className="size-5" /></span>
848-
<div><h1 className="text-2xl font-semibold tracking-tight">{t.settings}</h1><p className="mt-0.5 text-xs text-muted-foreground">Configure models, integrations, automation, and your workspace.</p></div>
860+
<div className="mb-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
861+
<div className="flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm"><Settings2 className="size-5" /></span>
862+
<div><h1 className="text-2xl font-semibold tracking-tight">{t.settings}</h1><p className="mt-0.5 text-xs text-muted-foreground">Configure models, integrations, automation, and your workspace.</p></div></div>
863+
<div className="relative w-full sm:w-72">
864+
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
865+
<Input value={settingsQuery} onChange={(e) => setSettingsQuery(e.target.value)} placeholder="Search settings…" className="h-10 rounded-xl bg-card pl-9 shadow-sm" aria-label="Search settings" />
866+
</div>
849867
</div>
850868

869+
{settingsQuery.trim() && (
870+
<div className="surface-glass shadow-soft mb-6 grid gap-2 rounded-2xl border border-border/70 p-3 sm:grid-cols-2">
871+
{SETTINGS_SEARCH_ITEMS.filter((item) => `${item.label} ${item.keywords}`.toLowerCase().includes(settingsQuery.trim().toLowerCase())).map((item) => (
872+
<button key={item.tab} onClick={() => { setActiveTab(item.tab); setSettingsQuery(""); }} className="rounded-xl border border-transparent p-3 text-left text-sm font-medium transition-colors hover:border-primary/20 hover:bg-primary/5">{item.label}<span className="mt-0.5 block text-xs font-normal text-muted-foreground">Open {item.tab} settings</span></button>
873+
))}
874+
</div>
875+
)}
876+
851877
<Tabs
852878
value={activeTab}
853879
onValueChange={(v) => setActiveTab(v as SettingsTab)}
@@ -990,6 +1016,17 @@ export default function Settings() {
9901016
);
9911017
})()}
9921018
</SettingsRow>
1019+
<SettingsRow label="Warm model cache" description="Maximum llama.cpp model variants retained in RAM/VRAM. Lower values save memory; higher values make model switching faster.">
1020+
<Select value={String(settings.llamaCppMaxCachedModels ?? 2)} onValueChange={(v) => saveSettings({ llamaCppMaxCachedModels: Number(v) })}>
1021+
<SelectTrigger size="sm" className="w-44"><SelectValue /></SelectTrigger>
1022+
<SelectContent>
1023+
<SelectItem value="1">1 model · minimum memory</SelectItem>
1024+
<SelectItem value="2">2 models · balanced</SelectItem>
1025+
<SelectItem value="3">3 models · faster switching</SelectItem>
1026+
<SelectItem value="4">4 models · workstation</SelectItem>
1027+
</SelectContent>
1028+
</Select>
1029+
</SettingsRow>
9931030
<SettingsRow label={t.modelsDir} stacked>
9941031
<div className="flex flex-wrap items-center gap-2">
9951032
<span className="truncate rounded border border-border bg-muted px-2 py-1 font-mono text-xs">
@@ -1116,6 +1153,17 @@ export default function Settings() {
11161153
))}
11171154
</div>
11181155
</SettingsRow>
1156+
<SettingsRow label="Interface density" description="Choose how much information fits on screen.">
1157+
<Select value={settings?.uiDensity ?? "comfortable"} onValueChange={(v) => saveSettings({ uiDensity: v as "comfortable" | "compact" })}>
1158+
<SelectTrigger size="sm" className="w-40"><SelectValue /></SelectTrigger>
1159+
<SelectContent><SelectItem value="comfortable">Comfortable</SelectItem><SelectItem value="compact">Compact</SelectItem></SelectContent>
1160+
</Select>
1161+
</SettingsRow>
1162+
<SettingsRow label="Reduce motion" description="Minimize animations and smooth transitions for accessibility.">
1163+
<button type="button" role="switch" aria-checked={settings?.reduceMotion ?? false} onClick={() => saveSettings({ reduceMotion: !(settings?.reduceMotion ?? false) })} className={cn("relative h-6 w-11 rounded-full transition-colors", settings?.reduceMotion ? "bg-primary" : "bg-muted")}>
1164+
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform", settings?.reduceMotion ? "translate-x-5" : "translate-x-0.5")} />
1165+
</button>
1166+
</SettingsRow>
11191167
</SettingsSection>
11201168

11211169
<SettingsSection title={t.language} className="mt-8">
@@ -2196,6 +2244,25 @@ export default function Settings() {
21962244
</SettingsRow>
21972245
</SettingsSection>
21982246

2247+
<SettingsSection title="Agent runtime" description="Control how independently Agent Mode can work before returning control to you." className="mt-8">
2248+
<SettingsRow label="Maximum tool steps per turn" description="Stops runaway tool loops. Larger values suit long repository analysis; smaller values provide more frequent checkpoints.">
2249+
<Input type="number" min={5} max={100} step={5} value={settings.agentMaxSteps ?? 25} onChange={(e) => saveSettings({ agentMaxSteps: Math.max(5, Math.min(Number(e.target.value), 100)) })} className="w-24" aria-label="Maximum agent tool steps" />
2250+
</SettingsRow>
2251+
<SettingsRow label="Recommended profile" stacked>
2252+
<div className="grid gap-2 sm:grid-cols-3">
2253+
{[
2254+
{ label: "Cautious", value: 10, note: "Frequent user review" },
2255+
{ label: "Balanced", value: 25, note: "Best default" },
2256+
{ label: "Autonomous", value: 50, note: "Long coding tasks" },
2257+
].map((profile) => (
2258+
<button key={profile.label} onClick={() => saveSettings({ agentMaxSteps: profile.value })} className={cn("rounded-xl border p-3 text-left transition-colors", (settings.agentMaxSteps ?? 25) === profile.value ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/50")}>
2259+
<span className="block text-sm font-medium">{profile.label}</span><span className="mt-0.5 block text-xs text-muted-foreground">{profile.value} steps · {profile.note}</span>
2260+
</button>
2261+
))}
2262+
</div>
2263+
</SettingsRow>
2264+
</SettingsSection>
2265+
21992266
<SettingsSection
22002267
title={t.promptLibrary}
22012268
description={t.promptLibraryVariablesHint}

frontend/src/types/electron.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ export interface AppSettings {
211211
promptPresets: PromptPreset[];
212212
theme: "light" | "dark" | "system";
213213
language: "en" | "tr";
214+
uiDensity?: "comfortable" | "compact";
215+
reduceMotion?: boolean;
216+
agentMaxSteps?: number;
217+
llamaCppMaxCachedModels?: number;
214218
ttsVoiceURI?: string;
215219
ttsAutoRead?: boolean;
216220
mcpServers?: McpServerConfig[];

0 commit comments

Comments
 (0)