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