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