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