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