v0.23.0 edaaa7c

interactive_settings.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close