v0.19.0 d7aa01a

interactive_settings.go RawHistoryFolder

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

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