1package modes
2
3import (
4 "context"
5 "fmt"
6 "runtime"
7 "strconv"
8 "strings"
9
10 toolspkg "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"
13)
14
15func effectiveImageProtocol(override *bool) tui.ImageProtocol {
16 detected := tui.DetectImageProtocol()
17 if override == nil {
18 return detected
19 }
20 if !*override {
21 return tui.ImageProtocolNone
22 }
23 return detected
24}
25func imageProtocolName(p tui.ImageProtocol) string {
26 switch p {
27 case tui.ImageProtocolKitty:
28 return "kitty/ghostty"
29 case tui.ImageProtocolITerm2:
30 return "iTerm2"
31 default:
32 return "none"
33 }
34}
35func onOff(v bool) string {
36 if v {
37 return "enabled"
38 }
39 return "disabled"
40}
41func (i *Interactive) applyInputCursorColor() {
42 if i == nil || i.cfg.Terminal == nil {
43 return
44 }
45 if i.cfg.EffectiveThemeName == "auto" && i.cfg.Theme.FG == tui.TerminalDefault() {
46 _, _ = i.cfg.Terminal.Write([]byte(tui.ResetCursorColor() + tui.CursorShapeBlock()))
47 return
48 }
49 color := tui.Color256(15)
50 if i.cursorDimmed {
51 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
52 }
53 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
54}
55func (i *Interactive) setInputCursorDimmed(dimmed bool) {
56 if i == nil || i.cursorDimmed == dimmed {
57 return
58 }
59 i.cursorDimmed = dimmed
60 i.applyInputCursorColor()
61}
62func reasoningSettingOptions(levels []string) []settingsOption {
63 descriptions := map[string]string{
64 "": "no reasoning",
65 "minimum": "very brief (~1k tokens)",
66 "low": "light (~2k tokens)",
67 "medium": "moderate (~8k tokens)",
68 "high": "deep (~16k tokens)",
69 "xhigh": "extra-high effort",
70 "max": "unconstrained effort",
71 }
72 options := make([]settingsOption, 0, len(levels))
73 for _, level := range levels {
74 label := level
75 if label == "" {
76 label = "off"
77 }
78 options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
79 }
80 return options
81}
82func (i *Interactive) reasoningSettingItem() settingsItem {
83 levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
84 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
85 desc := "reasoning depth for reasoning-capable models"
86 hint := ""
87 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
88 levels = provider.AvailableReasoningLevels(m)
89 reasoning = provider.ClampReasoningForModel(m, reasoning)
90 if !m.Reasoning {
91 hint = "current model does not support reasoning"
92 desc += "; current model does not support reasoning"
93 } else if len(levels) == 1 {
94 hint = "current model has no adjustable reasoning levels"
95 desc += "; current model has no adjustable reasoning levels"
96 } else {
97 desc += "; only levels supported by the current model are shown"
98 }
99 }
100 options := reasoningSettingOptions(levels)
101 choice := 0
102 for idx, opt := range options {
103 if opt.value == reasoning {
104 choice = idx
105 break
106 }
107 }
108
109 return settingsItem{
110 key: "reasoning",
111 label: "reasoning level",
112 desc: desc,
113 options: options,
114 choice: choice,
115 hint: hint,
116 }
117}
118func (i *Interactive) openReasoningDialog() {
119 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
120}
121func (i *Interactive) cycleReasoningSetting() {
122 item := i.reasoningSettingItem()
123 if len(item.options) <= 1 {
124 i.mu.Lock()
125 i.statusOK = item.hint
126 i.statusErr = ""
127 i.mu.Unlock()
128 i.invalidate()
129 return
130 }
131 next := item.options[(item.choice+1)%len(item.options)]
132 i.applyReasoningSetting(next.value)
133}
134func (i *Interactive) openSettingsDialog() {
135 detected := tui.DetectImageProtocol()
136 imgEnabled := detected != tui.ImageProtocolNone
137 if i.cfg.InlineImagesEnabled != nil {
138 imgEnabled = *i.cfg.InlineImagesEnabled
139 }
140 imgDisabled := detected == tui.ImageProtocolNone
141 imgHint := ""
142 if imgDisabled {
143 imgEnabled = false
144 imgHint = "this terminal does not support inline images"
145 } else {
146 imgHint = "terminal supports " + imageProtocolName(detected)
147 }
148
149 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
150 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
151 autoSubagents := false
152 if i.cfg.AutoSubagentsEnabled != nil {
153 autoSubagents = *i.cfg.AutoSubagentsEnabled
154 }
155 autoSubagentsDisabled := !i.autoSubagentsAvailable()
156 autoSubagentsHint := i.autoSubagentsUnavailableHint()
157 if autoSubagentsDisabled {
158 autoSubagents = false
159 }
160
161 ponytailEnabled := i.ponytailEnabled()
162 ponytailDisabled := !i.ponytailSettingsAvailable()
163 ponytailHint := ""
164 if ponytailDisabled {
165 ponytailHint = "requires persistent settings and live prompt refresh support"
166 }
167
168 webSearchEnabled := i.webSearchEffectiveEnabled()
169 webSearchDisabled := !i.webSearchSettingsAvailable()
170 webSearchHint := i.webSearchUnavailableHint()
171
172 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
173 fastModeHint := "OpenAI service tier"
174 if !provider.SupportsFastMode(i.cfg.Provider) {
175 fastModeHint = "only supported for OpenAI providers"
176 }
177 lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
178 subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
179
180 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
181 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
182 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
183 compactMode := i.compactModeEnabled()
184 showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
185 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
186 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
187 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
188 quickItems := i.quickModelSettingItems()
189
190 autoCompactOptions := []settingsOption{
191 {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
192 {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
193 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
194 {value: "85", label: "85%", desc: "default balance between history and headroom"},
195 {value: "90", label: "90%", desc: "retain more history before compacting"},
196 {value: "95", label: "95%", desc: "retain most history before compacting"},
197 }
198 autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
199 autoCompactChoice := 0
200 for idx, opt := range autoCompactOptions {
201 if opt.value == strconv.Itoa(autoCompactThreshold) {
202 autoCompactChoice = idx
203 break
204 }
205 }
206
207 reasoningItem := i.reasoningSettingItem()
208
209 themeName := i.cfg.ThemeName
210 if themeName == "" {
211 themeName = "auto"
212 }
213 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
214 themeName = "auto"
215 i.cfg.ThemeName = ""
216 if i.cfg.SettingsStore != nil {
217 _ = i.cfg.SettingsStore.SetTheme("auto")
218 }
219 i.applyThemeNow("auto")
220 }
221 themeOptions := []settingsOption{}
222 themeChoice := 0
223 availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
224 if i.cfg.ExtensionThemes != nil {
225 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
226 }
227 for idx, opt := range availableThemes {
228 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
229 if opt.Value == themeName {
230 themeChoice = idx
231 }
232 }
233
234 inputStyleOptions := []settingsOption{
235 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
236 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
237 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
238 }
239 inputStyleChoice := 0
240 for idx, opt := range inputStyleOptions {
241 if opt.value == inputStyle {
242 inputStyleChoice = idx
243 break
244 }
245 }
246 statusPositionOptions := []settingsOption{
247 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
248 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
249 }
250 statusPositionChoice := 0
251 for idx, opt := range statusPositionOptions {
252 if opt.value == statusPosition {
253 statusPositionChoice = idx
254 break
255 }
256 }
257
258 workingPositionOptions := []settingsOption{
259 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
260 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
261 }
262 workingPositionChoice := 0
263 for idx, opt := range workingPositionOptions {
264 if opt.value == workingPosition {
265 workingPositionChoice = idx
266 break
267 }
268 }
269
270 items := []settingsItem{
271 {
272 key: "inline_images_enabled",
273 label: "render images when supported",
274 desc: "draw screenshots inline instead of showing a text placeholder",
275 value: imgEnabled,
276 disabled: imgDisabled,
277 hint: imgHint,
278 },
279 {
280 key: "terminal_alerts_enabled",
281 label: "terminal alerts",
282 desc: "ring the terminal when the main session needs attention or an extension raises an alert",
283 value: terminalAlerts,
284 },
285 {
286 key: "terminal_title_enabled",
287 label: "AI terminal titles",
288 desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
289 value: terminalTitles,
290 },
291 {
292 key: "auto_subagents_enabled",
293 label: "proactive delegation",
294 desc: "delegate independent sidecar work while keeping the critical path local",
295 value: autoSubagents,
296 disabled: autoSubagentsDisabled,
297 hint: autoSubagentsHint,
298 },
299 {
300 key: "ponytail_enabled",
301 label: "ponytail coding mode",
302 desc: "apply compact coding guidance that favors small, validated, maintainable changes",
303 value: ponytailEnabled,
304 disabled: ponytailDisabled,
305 hint: ponytailHint,
306 },
307 {
308 key: "web_search_enabled",
309 label: "web search",
310 desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
311 value: webSearchEnabled,
312 disabled: webSearchDisabled,
313 hint: webSearchHint,
314 },
315 {
316 key: "fast_mode",
317 label: "fast mode",
318 desc: "request the provider's fast tier where supported; unsupported providers return an error",
319 value: fastMode,
320 hint: fastModeHint,
321 },
322 {
323 key: "lsp_enabled",
324 label: "lsp in main session",
325 desc: "enable the lsp tool and code diagnostics for the main agent",
326 value: lspEnabled,
327 },
328 {
329 key: "subagent_lsp_enabled",
330 label: "lsp in sub-agents",
331 desc: "allow newly spawned background sub-agents to use the lsp tool",
332 value: subagentLSPEnabled,
333 },
334 {
335 key: "auto_compact_threshold",
336 label: "auto-compact threshold",
337 desc: "choose how full the model context can get before zut condenses conversation history",
338 options: autoCompactOptions,
339 choice: autoCompactChoice,
340 },
341 {
342 key: "jail_by_default",
343 label: "jail new sessions by default",
344 desc: "confine tools to the session working directory unless /unjail is used",
345 value: jailByDefault,
346 },
347 {
348 key: "recursive_file_suggest",
349 label: "recursive @-file search",
350 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
351 value: recursiveFiles,
352 },
353 {
354 key: "respect_gitignore",
355 label: "hide gitignored files in @-picker",
356 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
357 value: respectGitignore,
358 },
359 {
360 key: "compact_mode",
361 label: "compact transcript rendering",
362 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
363 value: compactMode,
364 },
365 {
366 key: "show_instructions_at_startup",
367 label: "show loaded resources at startup",
368 desc: "list loaded context files, extensions, and user-installed skills above the transcript",
369 value: showInstructions,
370 },
371 {
372 key: "tui_settings",
373 label: "tui settings",
374 desc: "choose input chrome in its order around the prompt",
375 children: []settingsItem{
376 {
377 key: "tui_status_position",
378 label: "status position",
379 desc: "place model, usage, and working directory above or below the input",
380 options: statusPositionOptions,
381 choice: statusPositionChoice,
382 },
383 {
384 key: "tui_working_position",
385 label: "working spinner position",
386 desc: "place the working spinner above or below the input",
387 options: workingPositionOptions,
388 choice: workingPositionChoice,
389 },
390 {
391 key: "tui_input_style",
392 label: "input style",
393 desc: "choose between the plain prompt, lines, and a block input area",
394 options: inputStyleOptions,
395 choice: inputStyleChoice,
396 },
397 },
398 },
399 reasoningItem,
400 {
401 key: "theme",
402 label: "color theme",
403 desc: "choose terminal-owned auto, a built-in dark/light theme, or a loaded theme file",
404 options: themeOptions,
405 choice: themeChoice,
406 },
407 }
408 if len(quickItems) > 0 {
409 items = append(items, settingsItem{
410 key: "quick_models",
411 label: "model profiles",
412 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 model, reasoning, and fast-mode profiles",
413 children: quickItems,
414 })
415 }
416 i.settingsDialog.Open(items)
417}
418func (i *Interactive) applySettingChange(act settingsAction) {
419 switch {
420 case strings.HasPrefix(act.Key, "quick_model_"):
421 i.applyQuickModelSetting(act.Key, act.StringValue)
422 case act.Key == "reasoning":
423 i.applyReasoningSetting(act.StringValue)
424 case act.Key == "theme":
425 i.applyThemeSetting(act.StringValue)
426 case act.Key == "auto_compact_threshold":
427 i.applyAutoCompactThresholdSetting(act.StringValue)
428 case act.Key == "tui_input_style":
429 i.applyTUIInputStyleSetting(act.StringValue)
430 case act.Key == "tui_status_position":
431 i.applyTUIStatusPositionSetting(act.StringValue)
432 case act.Key == "tui_working_position":
433 i.applyTUIWorkingPositionSetting(act.StringValue)
434 default:
435 i.applySettingToggle(act.Key, act.Value)
436 }
437}
438func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
439 threshold, err := strconv.Atoi(value)
440 if err != nil {
441 return
442 }
443 threshold = NormalizeAutoCompactThreshold(&threshold)
444 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
445 if err := store.SetAutoCompactThreshold(threshold); err != nil {
446 i.mu.Lock()
447 i.statusErr = "settings: " + err.Error()
448 i.mu.Unlock()
449 return
450 }
451 }
452 i.mu.Lock()
453 i.cfg.AutoCompactThreshold = &threshold
454 if threshold == 0 {
455 i.statusOK = "auto-compact threshold off"
456 } else {
457 i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
458 }
459 i.statusErr = ""
460 i.mu.Unlock()
461 i.invalidate()
462}
463func (i *Interactive) quickModelSettingItems() []settingsItem {
464 if len(i.cfg.QuickModelShortcuts) < 9 {
465 next := make([]QuickModelShortcut, 9)
466 copy(next, i.cfg.QuickModelShortcuts)
467 i.cfg.QuickModelShortcuts = next
468 }
469 items := make([]settingsItem, 0, 9)
470 for slot := 1; slot <= 9; slot++ {
471 items = append(items, i.quickModelSettingItem(slot))
472 }
473 return items
474}
475func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
476 current := QuickModelShortcut{}
477 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
478 current = i.cfg.QuickModelShortcuts[slot-1]
479 }
480 hint := "not assigned"
481 if current.Provider != "" && current.Model != "" {
482 hint = modelProfileDescription(current)
483 }
484 if slot == i.cfg.ActiveModelProfile {
485 hint += " (active)"
486 }
487 return settingsItem{
488 key: "quick_model_" + strconv.Itoa(slot),
489 label: "profile " + strconv.Itoa(slot),
490 desc: quickModelShortcutLabel(slot) + " recalls model, reasoning, and fast mode. Enter assigns, Left/Right in the picker sets reasoning, Backspace clears.",
491 picker: true,
492 hint: hint,
493 }
494}
495func (i *Interactive) compactModeEnabled() bool {
496 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
497}
498func quickModelShortcutSlot(k tui.Key) int {
499 if k.Kind != tui.KeyRune || k.Alt {
500 return 0
501 }
502 if runtime.GOOS == "darwin" {
503 if !k.Super && !k.Ctrl {
504 return 0
505 }
506 } else if !k.Ctrl {
507 return 0
508 }
509 if k.Rune >= '1' && k.Rune <= '9' {
510 return int(k.Rune - '0')
511 }
512 // French AZERTY's unshifted number row. Require a modifier so normal
513 // typing and pasted punctuation never activate profiles.
514 for idx, r := range []rune("&é\"'(-è_ç") {
515 if k.Rune == r {
516 return idx + 1
517 }
518 }
519 return 0
520}
521func quickModelShortcutPrefix() string {
522 return "Ctrl"
523}
524func quickModelShortcutLabel(slot int) string {
525 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
526}
527func (i *Interactive) openQuickModelPicker(slot int) {
528 if slot < 1 || slot > 9 {
529 return
530 }
531 i.quickModelAssign = slot
532 i.quickModelActivate = false
533 current, reasoning := i.cfg.Model, i.cfg.Reasoning
534 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
535 current = i.cfg.QuickModelShortcuts[slot-1].Model
536 reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning
537 }
538 var loggedIn []string
539 if i.cfg.LoggedInProviders != nil {
540 loggedIn = i.cfg.LoggedInProviders()
541 }
542 i.modelDialog.Open(current, loggedIn, reasoning)
543}
544func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
545 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
546 if slot <= len(i.cfg.QuickModelShortcuts) && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
547 fastMode = i.cfg.QuickModelShortcuts[slot-1].FastMode
548 }
549 p := QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.modelDialog.reasoning, FastMode: fastMode}
550 if i.quickModelActivate {
551 i.activateModelProfile(slot, p)
552 } else {
553 i.setQuickModelProfile(slot, p)
554 }
555 i.quickModelActivate = false
556}
557func (i *Interactive) applyQuickModelShortcut(slot int) {
558 if slot < 1 || slot > 9 {
559 return
560 }
561 if i.busy {
562 i.mu.Lock()
563 i.statusErr = "cannot switch model while a turn is running"
564 i.statusOK = ""
565 i.mu.Unlock()
566 i.invalidate()
567 return
568 }
569 p := QuickModelShortcut{}
570 if slot <= len(i.cfg.QuickModelShortcuts) {
571 p = i.cfg.QuickModelShortcuts[slot-1]
572 }
573 if p.Provider == "" && p.Model == "" {
574 i.openQuickModelPicker(slot)
575 i.quickModelActivate = true
576 i.mu.Lock()
577 i.statusErr = ""
578 i.statusOK = fmt.Sprintf("choose a model for profile %d; Enter saves and activates", slot)
579 i.mu.Unlock()
580 i.invalidate()
581 return
582 }
583 i.activateModelProfile(slot, p)
584}
585func (i *Interactive) applyQuickModelSetting(key, value string) {
586 slotText := strings.TrimPrefix(key, "quick_model_")
587 slot, err := strconv.Atoi(slotText)
588 if err != nil || slot < 1 || slot > 9 {
589 return
590 }
591 providerName, model := "", ""
592 if value != "" {
593 parts := strings.SplitN(value, "\t", 2)
594 if len(parts) == 2 {
595 providerName, model = parts[0], parts[1]
596 }
597 }
598 i.setQuickModelShortcut(slot, providerName, model)
599}
600func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
601 i.setQuickModelProfile(slot, QuickModelShortcut{
602 Provider: providerName, Model: model, Reasoning: i.cfg.Reasoning,
603 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
604 })
605}
606func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
607 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
608 return
609 }
610 key := "quick_model_" + strconv.Itoa(slot)
611 for idx, it := range i.settingsDialog.items {
612 if it.key == key {
613 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
614 return
615 }
616 }
617}
618func (i *Interactive) resetSettingsToggle(key string, value bool) {
619 if i.settingsDialog == nil {
620 return
621 }
622 for idx := range i.settingsDialog.items {
623 if i.settingsDialog.items[idx].key == key {
624 i.settingsDialog.items[idx].value = value
625 return
626 }
627 }
628}
629func (i *Interactive) ponytailEnabled() bool {
630 return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
631}
632func (i *Interactive) ponytailSettingsAvailable() bool {
633 _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
634 return persistent && i.cfg.RefreshPrompt != nil
635}
636func (i *Interactive) webSearchEnabled() bool {
637 return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
638}
639func (i *Interactive) webSearchToolAllowed() bool {
640 return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
641}
642func (i *Interactive) webSearchEffectiveEnabled() bool {
643 return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
644}
645func (i *Interactive) webSearchSettingsAvailable() bool {
646 _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
647 return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
648}
649func (i *Interactive) webSearchUnavailableHint() string {
650 if !i.webSearchToolAllowed() {
651 return "unavailable in this session due to the launch-time tool capability ceiling"
652 }
653 if i.cfg.WebSearchInvocationOverride {
654 return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
655 }
656 if !i.webSearchSettingsAvailable() {
657 return "requires persistent settings and live tool refresh support"
658 }
659 return ""
660}
661func (i *Interactive) setWebSearchAvailable(available bool) {
662 // Advance before publishing the new gate/child ceiling. A resolver that
663 // captured the prior generation can no longer commit after this transition.
664 // Callers may hold agentMu and i.mu; the callback must complete without
665 // calling back into Interactive or requiring either lock.
666 i.webSearchPolicyGeneration.Add(1)
667 if i.cfg.SetWebSearchAvailable != nil {
668 i.cfg.SetWebSearchAvailable(available)
669 }
670}
671func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
672 previous := i.webSearchEnabled()
673 store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
674 if !i.webSearchSettingsAvailable() || !persistent {
675 i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
676 i.mu.Lock()
677 i.statusOK = ""
678 i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
679 i.mu.Unlock()
680 return
681 }
682 if err := store.SetWebSearchEnabled(value); err != nil {
683 i.resetSettingsToggle(key, previous)
684 i.mu.Lock()
685 i.statusOK = ""
686 i.statusErr = "settings: " + err.Error()
687 i.mu.Unlock()
688 return
689 }
690 val := value
691 i.cfg.WebSearchEnabled = &val
692 // Revoke and advance the policy generation for both directions. Enabling
693 // remains fail-closed until the freshly resolved registry commits, while a
694 // stale resolve from before this persisted transition is rejected.
695 i.stripWebSearchTool()
696 if err := i.cfg.RefreshTools(); err != nil {
697 // Refresh failure leaves the old registry's state unknown. Revoke both
698 // live execution and generic-child inheritance before rollback.
699 i.stripWebSearchTool()
700 errMsg := "settings: web search tool refresh: " + err.Error()
701 if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
702 failClosed := false
703 i.cfg.WebSearchEnabled = &failClosed
704 i.resetSettingsToggle(key, false)
705 errMsg += "; rollback persistence: " + rollbackErr.Error()
706 } else {
707 previousVal := previous
708 i.cfg.WebSearchEnabled = &previousVal
709 i.resetSettingsToggle(key, previous)
710 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
711 errMsg += "; rollback refresh: " + refreshErr.Error()
712 i.stripWebSearchTool()
713 }
714 }
715 i.mu.Lock()
716 i.statusOK = ""
717 i.statusErr = errMsg
718 i.mu.Unlock()
719 return
720 }
721 i.mu.Lock()
722 i.statusOK = "web search " + onOff(value)
723 i.statusErr = ""
724 i.mu.Unlock()
725}
726func (i *Interactive) stripWebSearchTool() {
727 // Revoke first so a stale tool snapshotted by core cannot execute while
728 // the advertised registry is being replaced.
729 i.setWebSearchAvailable(false)
730 i.agentMu.Lock()
731 defer i.agentMu.Unlock()
732 i.mu.Lock()
733 defer i.mu.Unlock()
734 if i.agent == nil {
735 return
736 }
737 tools := i.agent.ToolsSnapshot()
738 hasWebCapability := false
739 for _, name := range toolspkg.WebCapabilityNames {
740 if _, ok := tools[name]; ok {
741 hasWebCapability = true
742 break
743 }
744 }
745 if !hasWebCapability {
746 return
747 }
748 toolspkg.RemoveWebCapabilities(tools)
749 i.agent.SetTools(tools)
750}
751func (i *Interactive) applySettingToggle(key string, value bool) {
752 // Every setting toggle forces a full repaint at the end — same
753 // effect as the user pressing Ctrl+L — so any per-setting visual
754 // change (image rendering, status copy, future toggles) lands
755 // immediately instead of waiting for the next diff frame.
756 defer func() {
757 i.requestRendererClear()
758 i.invalidate()
759 }()
760 switch key {
761 case "inline_images_enabled":
762 val := value
763 i.cfg.InlineImagesEnabled = &val
764 if i.cfg.SettingsStore != nil {
765 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
766 i.mu.Lock()
767 i.statusErr = "settings: " + err.Error()
768 i.mu.Unlock()
769 return
770 }
771 }
772 i.mu.Lock()
773 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
774 i.view.InvalidateRenderCache()
775 i.statusOK = "inline image rendering " + onOff(value)
776 i.statusErr = ""
777 i.mu.Unlock()
778 case "terminal_alerts_enabled":
779 val := value
780 i.mu.Lock()
781 i.cfg.TerminalAlertsEnabled = &val
782 i.mu.Unlock()
783 if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
784 if err := store.SetTerminalAlertsEnabled(value); err != nil {
785 i.mu.Lock()
786 i.statusErr = "settings: " + err.Error()
787 i.mu.Unlock()
788 return
789 }
790 }
791 i.mu.Lock()
792 i.statusOK = "terminal alerts " + onOff(value)
793 i.statusErr = ""
794 i.mu.Unlock()
795 case "terminal_title_enabled":
796 val := value
797 var cancel context.CancelFunc
798 i.mu.Lock()
799 i.cfg.TerminalTitleEnabled = &val
800 if value {
801 i.writeTerminalTitleLocked(i.sessionTitle)
802 } else {
803 cancel = i.titleCancel
804 i.titleCancel = nil
805 i.titleVersion++
806 i.writeTerminalTitleLocked("")
807 }
808 i.mu.Unlock()
809 if cancel != nil {
810 cancel()
811 }
812 if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
813 if err := store.SetTerminalTitleEnabled(value); err != nil {
814 i.mu.Lock()
815 i.statusErr = "settings: " + err.Error()
816 i.mu.Unlock()
817 return
818 }
819 }
820 i.mu.Lock()
821 i.statusOK = "AI terminal titles " + onOff(value)
822 i.statusErr = ""
823 i.mu.Unlock()
824 case "web_search_enabled":
825 apply := func() { i.applyWebSearchSettingToggle(key, value) }
826 if i.cfg.SessionTransition != nil {
827 i.cfg.SessionTransition(apply)
828 } else {
829 apply()
830 }
831 case "ponytail_enabled":
832 previous := i.ponytailEnabled()
833 store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
834 if !available || i.cfg.RefreshPrompt == nil {
835 i.resetSettingsToggle(key, previous)
836 i.mu.Lock()
837 i.statusOK = ""
838 i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
839 i.mu.Unlock()
840 return
841 }
842 if err := store.SetPonytailEnabled(value); err != nil {
843 i.resetSettingsToggle(key, previous)
844 i.mu.Lock()
845 i.statusOK = ""
846 i.statusErr = "settings: " + err.Error()
847 i.mu.Unlock()
848 return
849 }
850 val := value
851 i.cfg.PonytailEnabled = &val
852 if err := i.cfg.RefreshPrompt(); err != nil {
853 errMsg := "settings: ponytail prompt refresh: " + err.Error()
854 if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
855 // The first write succeeded, so keep the in-memory setting at
856 // the value that remains durable instead of claiming that the
857 // rollback took effect.
858 i.resetSettingsToggle(key, value)
859 errMsg += "; rollback persistence: " + rollbackErr.Error()
860 if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
861 errMsg += "; durable-state refresh: " + reconcileErr.Error()
862 }
863 } else {
864 previousVal := previous
865 i.cfg.PonytailEnabled = &previousVal
866 i.resetSettingsToggle(key, previous)
867 if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
868 errMsg += "; rollback refresh: " + refreshErr.Error()
869 }
870 }
871 i.mu.Lock()
872 i.statusOK = ""
873 i.statusErr = errMsg
874 i.mu.Unlock()
875 return
876 }
877 i.mu.Lock()
878 i.statusOK = "ponytail coding mode " + onOff(value)
879 i.statusErr = ""
880 i.mu.Unlock()
881 case "auto_subagents_enabled":
882 if value && !i.autoSubagentsAvailable() {
883 i.mu.Lock()
884 i.statusOK = ""
885 i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
886 i.mu.Unlock()
887 return
888 }
889 i.mu.Lock()
890 previousFlag := i.cfg.AutoSubagentsEnabled
891 previous := previousFlag != nil && *previousFlag
892 val := value
893 i.cfg.AutoSubagentsEnabled = &val
894 i.mu.Unlock()
895 if i.cfg.SettingsStore != nil {
896 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
897 i.mu.Lock()
898 i.cfg.AutoSubagentsEnabled = previousFlag
899 i.statusOK = ""
900 i.statusErr = "settings: " + err.Error()
901 i.mu.Unlock()
902 i.resetSettingsToggle(key, previous)
903 return
904 }
905 }
906 // The subagent tools remain available for explicit user requests.
907 // Swap only the system-prompt guidance between strict orchestration and
908 // on-demand delegation.
909 i.applyAutoSubagentsTool()
910 // Also swap the system-prompt addendum so the model knows whether it
911 // should orchestrate proactively or delegate only on user request.
912 i.applyAutoSubagentsSystemPrompt(value)
913 i.mu.Lock()
914 i.statusOK = "subagent orchestrator " + onOff(value)
915 i.statusErr = ""
916 i.mu.Unlock()
917 case "fast_mode":
918 previous := i.cfg.FastMode != nil && *i.cfg.FastMode
919 if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
920 i.resetSettingsToggle(key, previous)
921 i.mu.Lock()
922 i.statusErr = "settings: " + err.Error()
923 i.mu.Unlock()
924 return
925 }
926 if slot := i.cfg.ActiveModelProfile; slot > 0 {
927 _, profileStore := i.cfg.SettingsStore.(modelProfileSettingsStore)
928 if i.cfg.SettingsStore == nil || profileStore {
929 p := QuickModelShortcut{
930 Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: i.cfg.Reasoning,
931 FastMode: value,
932 }
933 if !i.persistModelProfile(slot, p, slot) {
934 i.resetSettingsToggle(key, previous)
935 return
936 }
937 i.setLiveFastMode(value)
938 i.mu.Lock()
939 i.statusOK = "fast mode " + onOff(value) + fmt.Sprintf(" — profile %d updated", slot)
940 i.statusErr = ""
941 i.mu.Unlock()
942 return
943 }
944 }
945 if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
946 if err := store.SetFastMode(value); err != nil {
947 i.resetSettingsToggle(key, previous)
948 i.mu.Lock()
949 i.statusErr = "settings: " + err.Error()
950 i.mu.Unlock()
951 return
952 }
953 }
954 i.setLiveFastMode(value)
955 i.mu.Lock()
956 i.statusOK = "fast mode " + onOff(value)
957 i.statusErr = ""
958 i.mu.Unlock()
959 case "lsp_enabled":
960 previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
961 store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
962 if hasStore {
963 if err := store.SetLSPEnabled(value); err != nil {
964 i.mu.Lock()
965 i.statusErr = "settings: " + err.Error()
966 i.mu.Unlock()
967 return
968 }
969 }
970 val := value
971 i.cfg.LSPEnabled = &val
972 if i.cfg.RefreshTools != nil {
973 if err := i.cfg.RefreshTools(); err != nil {
974 errMsg := "settings: refresh tools: " + err.Error()
975 rollbackErr := error(nil)
976 if hasStore {
977 rollbackErr = store.SetLSPEnabled(previous)
978 }
979 if rollbackErr != nil {
980 // The first write succeeded, so keep the in-memory setting at
981 // the value that remains durable instead of claiming that the
982 // rollback took effect.
983 errMsg += "; rollback persistence: " + rollbackErr.Error()
984 if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
985 errMsg += "; durable-state refresh: " + reconcileErr.Error()
986 }
987 } else {
988 previousVal := previous
989 i.cfg.LSPEnabled = &previousVal
990 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
991 errMsg += "; rollback refresh: " + refreshErr.Error()
992 }
993 }
994 i.mu.Lock()
995 i.statusOK = ""
996 i.statusErr = errMsg
997 i.mu.Unlock()
998 return
999 }
1000 }
1001 i.mu.Lock()
1002 i.statusOK = "lsp in main session " + onOff(value)
1003 i.statusErr = ""
1004 i.mu.Unlock()
1005 case "subagent_lsp_enabled":
1006 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
1007 if err := store.SetSubagentLSPEnabled(value); err != nil {
1008 i.mu.Lock()
1009 i.statusErr = "settings: " + err.Error()
1010 i.mu.Unlock()
1011 return
1012 }
1013 }
1014 val := value
1015 i.cfg.SubagentLSPEnabled = &val
1016 i.mu.Lock()
1017 i.statusOK = "lsp in sub-agents " + onOff(value)
1018 i.statusErr = ""
1019 i.mu.Unlock()
1020 case "jail_by_default":
1021 val := value
1022 i.cfg.JailByDefault = &val
1023 if i.cfg.SettingsStore != nil {
1024 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
1025 i.mu.Lock()
1026 i.statusErr = "settings: " + err.Error()
1027 i.mu.Unlock()
1028 return
1029 }
1030 }
1031 if i.cfg.Sandbox != nil {
1032 if value {
1033 i.cfg.Sandbox.Lock()
1034 } else {
1035 i.cfg.Sandbox.Unlock()
1036 }
1037 }
1038 i.mu.Lock()
1039 i.statusOK = "jail by default " + onOff(value)
1040 i.statusErr = ""
1041 i.mu.Unlock()
1042 case "recursive_file_suggest":
1043 val := value
1044 i.cfg.RecursiveFileSuggest = &val
1045 if i.cfg.SettingsStore != nil {
1046 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
1047 i.mu.Lock()
1048 i.statusErr = "settings: " + err.Error()
1049 i.mu.Unlock()
1050 return
1051 }
1052 }
1053 // Flip the live picker so the next @ reflects the new mode
1054 // without restarting zut. SetRecursive drops its cache.
1055 i.fileSuggest.SetRecursive(value)
1056 i.mu.Lock()
1057 i.statusOK = "recursive @-file search " + onOff(value)
1058 i.statusErr = ""
1059 i.mu.Unlock()
1060 case "respect_gitignore":
1061 val := value
1062 i.cfg.RespectGitignore = &val
1063 if i.cfg.SettingsStore != nil {
1064 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
1065 i.mu.Lock()
1066 i.statusErr = "settings: " + err.Error()
1067 i.mu.Unlock()
1068 return
1069 }
1070 }
1071 i.fileSuggest.SetRespectGitignore(value)
1072 i.mu.Lock()
1073 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
1074 i.statusErr = ""
1075 i.mu.Unlock()
1076 case "compact_mode":
1077 val := value
1078 i.cfg.CompactMode = &val
1079 if i.cfg.SettingsStore != nil {
1080 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
1081 i.mu.Lock()
1082 i.statusErr = "settings: " + err.Error()
1083 i.mu.Unlock()
1084 return
1085 }
1086 }
1087 i.mu.Lock()
1088 i.view.CompactMode = value
1089 i.view.InvalidateRenderCache()
1090 i.statusOK = "compact transcript rendering " + onOff(value)
1091 i.statusErr = ""
1092 i.mu.Unlock()
1093 case "show_instructions_at_startup":
1094 val := value
1095 i.cfg.ShowInstructionsAtStartup = &val
1096 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
1097 if err := store.SetShowInstructionsAtStartup(value); err != nil {
1098 i.mu.Lock()
1099 i.statusErr = "settings: " + err.Error()
1100 i.mu.Unlock()
1101 return
1102 }
1103 }
1104 i.mu.Lock()
1105 i.view.StartupAgentName = ""
1106 i.view.StartupContextPaths = nil
1107 i.view.StartupExtensionNames = nil
1108 i.view.StartupSkillNames = nil
1109 i.view.InvalidateRenderCache()
1110 if value {
1111 i.view.StartupAgentName = i.cfg.StartupAgentName
1112 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
1113 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
1114 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
1115 }
1116 i.statusOK = "show loaded resources at startup " + onOff(value)
1117 i.statusErr = ""
1118 i.mu.Unlock()
1119 }
1120}
1121func (i *Interactive) applyTUIInputStyleSetting(style string) {
1122 defer func() {
1123 i.requestRendererClear()
1124 i.invalidate()
1125 }()
1126 style = tui.NormalizeInputStyle(style)
1127 i.cfg.TUIInputStyle = style
1128 i.applyInputCursorColor()
1129 if i.cfg.SettingsStore != nil {
1130 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
1131 i.mu.Lock()
1132 i.statusErr = "settings: " + err.Error()
1133 i.mu.Unlock()
1134 return
1135 }
1136 }
1137 i.mu.Lock()
1138 i.statusOK = "input style " + style
1139 i.statusErr = ""
1140 i.mu.Unlock()
1141}
1142func (i *Interactive) applyTUIStatusPositionSetting(position string) {
1143 defer func() {
1144 i.requestRendererClear()
1145 i.invalidate()
1146 }()
1147 position = tui.NormalizeStatusPosition(position)
1148 i.cfg.TUIStatusPosition = position
1149 if i.cfg.SettingsStore != nil {
1150 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
1151 i.mu.Lock()
1152 i.statusErr = "settings: " + err.Error()
1153 i.mu.Unlock()
1154 return
1155 }
1156 }
1157 i.mu.Lock()
1158 label := "above input"
1159 if position == tui.StatusPositionBelowInput {
1160 label = "below input"
1161 }
1162 i.statusOK = "status position " + label
1163 i.statusErr = ""
1164 i.mu.Unlock()
1165}
1166func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
1167 defer func() {
1168 i.requestRendererClear()
1169 i.invalidate()
1170 }()
1171 position = tui.NormalizeWorkingPosition(position)
1172 i.cfg.TUIWorkingPosition = position
1173 if i.cfg.SettingsStore != nil {
1174 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
1175 i.mu.Lock()
1176 i.statusErr = "settings: " + err.Error()
1177 i.mu.Unlock()
1178 return
1179 }
1180 }
1181 i.mu.Lock()
1182 label := "above input"
1183 if position == tui.WorkingPositionBelowInput {
1184 label = "below input"
1185 }
1186 i.statusOK = "working spinner position " + label
1187 i.statusErr = ""
1188 i.mu.Unlock()
1189}
1190func (i *Interactive) applyThemeSetting(name string) {
1191 if i.cfg.SettingsStore != nil {
1192 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
1193 i.mu.Lock()
1194 i.statusErr = "settings: " + err.Error()
1195 i.mu.Unlock()
1196 return
1197 }
1198 }
1199 i.cfg.ThemeName = name
1200 if name == "auto" {
1201 i.cfg.ThemeName = ""
1202 }
1203 if !i.cfg.ThemeForced {
1204 i.cfg.EffectiveThemeName = name
1205 }
1206 i.applyThemeNow(i.cfg.EffectiveThemeName)
1207}
1208
1209func (i *Interactive) applyThemeNow(name string) {
1210 if name == "" {
1211 name = "auto"
1212 }
1213 source, err := tui.LoadThemeSource(i.cfg.ZutHome, name)
1214 if err != nil {
1215 if i.cfg.SettingsStore != nil {
1216 _ = i.cfg.SettingsStore.SetTheme("auto")
1217 }
1218 i.cfg.ThemeName = ""
1219 i.cfg.EffectiveThemeName = "auto"
1220 i.activeThemeSource = nil
1221 i.stopThemeWatch()
1222 i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1223 i.mu.Lock()
1224 i.statusErr = "theme missing; reset to auto"
1225 i.mu.Unlock()
1226 return
1227 }
1228 i.activeThemeSource = source
1229 i.restartThemeWatch()
1230 i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile))
1231 i.mu.Lock()
1232 i.statusOK = "theme " + i.cfg.EffectiveThemeName
1233 if i.cfg.ThemeForced {
1234 i.statusOK += " (forced by ZUT_THEME)"
1235 }
1236 i.statusErr = ""
1237 i.mu.Unlock()
1238}
1239
1240// installResolvedTheme is the one main-loop-owned installation boundary for
1241// settings, terminal profile, and accepted theme-file revisions.
1242// applyTerminalProfile records detected appearance independently from the
1243// currently applied theme. Forced and custom selections are re-resolved from
1244// their immutable accepted source; no terminal event performs filesystem I/O.
1245func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) {
1246 i.terminalProfile = profile
1247 i.cfg.TerminalProfile = profile
1248 resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile)
1249 i.installResolvedTheme(resolution)
1250}
1251
1252func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
1253 th := resolution.Theme
1254 i.mu.Lock()
1255 i.cfg.Theme = th
1256 i.view.Theme = th
1257 i.view.InvalidateRenderCache()
1258 i.chatCacheValid = false
1259 i.stableChatCacheValid = false
1260 i.ed.Prompt = th.AccentBar(th.Accent)
1261 i.mu.Unlock()
1262 i.btwDialog.setTheme(th)
1263 if i.residentChildSession != nil {
1264 i.residentChildSession.setTheme(th)
1265 }
1266 i.applyInputCursorColor()
1267 i.spin.Configure(th)
1268 i.requestRendererTheme(th)
1269 i.invalidate()
1270}
1271func (i *Interactive) applyReasoningSetting(level string) {
1272 defer func() {
1273 i.requestRendererClear()
1274 i.invalidate()
1275 }()
1276 level = provider.NormalizeReasoning(level)
1277 if slot := i.cfg.ActiveModelProfile; slot > 0 {
1278 p := QuickModelShortcut{
1279 Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: level,
1280 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
1281 }
1282 if !i.persistModelProfile(slot, p, slot) {
1283 return
1284 }
1285 } else if i.cfg.SettingsStore != nil {
1286 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
1287 i.mu.Lock()
1288 i.statusErr = "settings: " + err.Error()
1289 i.mu.Unlock()
1290 return
1291 }
1292 }
1293 i.setLiveReasoning(level)
1294 i.mu.Lock()
1295 label := level
1296 if label == "" {
1297 label = "off"
1298 }
1299 i.statusOK = "reasoning level " + label
1300 if i.cfg.ActiveModelProfile > 0 {
1301 i.statusOK += fmt.Sprintf(" — profile %d updated", i.cfg.ActiveModelProfile)
1302 }
1303 i.statusErr = ""
1304 i.mu.Unlock()
1305}