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