v0.38.0 58133ea

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	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 profiles",
412			desc:     "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 model, reasoning, and fast-mode profiles",
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 = modelProfileDescription(current)
483	}
484	if slot == i.cfg.ActiveModelProfile {
485		hint += " (active)"
486	}
487	return settingsItem{
488		key:    "quick_model_" + strconv.Itoa(slot),
489		label:  "profile " + strconv.Itoa(slot),
490		desc:   quickModelShortcutLabel(slot) + " recalls model, reasoning, and fast mode. Enter assigns, Left/Right in the picker sets reasoning, Backspace clears.",
491		picker: true,
492		hint:   hint,
493	}
494}
495func (i *Interactive) compactModeEnabled() bool {
496	return i.cfg.CompactMode != nil && *i.cfg.CompactMode
497}
498func quickModelShortcutSlot(k tui.Key) int {
499	if k.Kind != tui.KeyRune || k.Alt {
500		return 0
501	}
502	if runtime.GOOS == "darwin" {
503		if !k.Super && !k.Ctrl {
504			return 0
505		}
506	} else if !k.Ctrl {
507		return 0
508	}
509	if k.Rune >= '1' && k.Rune <= '9' {
510		return int(k.Rune - '0')
511	}
512	// French AZERTY's unshifted number row. Require a modifier so normal
513	// typing and pasted punctuation never activate profiles.
514	for idx, r := range []rune("&é\"'(-è_ç") {
515		if k.Rune == r {
516			return idx + 1
517		}
518	}
519	return 0
520}
521func quickModelShortcutPrefix() string {
522	return "Ctrl"
523}
524func quickModelShortcutLabel(slot int) string {
525	return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
526}
527func (i *Interactive) openQuickModelPicker(slot int) {
528	if slot < 1 || slot > 9 {
529		return
530	}
531	i.quickModelAssign = slot
532	i.quickModelActivate = false
533	current, reasoning := i.cfg.Model, i.cfg.Reasoning
534	if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
535		current = i.cfg.QuickModelShortcuts[slot-1].Model
536		reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning
537	}
538	var loggedIn []string
539	if i.cfg.LoggedInProviders != nil {
540		loggedIn = i.cfg.LoggedInProviders()
541	}
542	i.modelDialog.Open(current, loggedIn, reasoning)
543}
544func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
545	fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
546	if slot <= len(i.cfg.QuickModelShortcuts) && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
547		fastMode = i.cfg.QuickModelShortcuts[slot-1].FastMode
548	}
549	p := QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.modelDialog.reasoning, FastMode: fastMode}
550	if i.quickModelActivate {
551		i.activateModelProfile(slot, p)
552	} else {
553		i.setQuickModelProfile(slot, p)
554	}
555	i.quickModelActivate = false
556}
557func (i *Interactive) applyQuickModelShortcut(slot int) {
558	if slot < 1 || slot > 9 {
559		return
560	}
561	if i.busy {
562		i.mu.Lock()
563		i.statusErr = "cannot switch model while a turn is running"
564		i.statusOK = ""
565		i.mu.Unlock()
566		i.invalidate()
567		return
568	}
569	p := QuickModelShortcut{}
570	if slot <= len(i.cfg.QuickModelShortcuts) {
571		p = i.cfg.QuickModelShortcuts[slot-1]
572	}
573	if p.Provider == "" && p.Model == "" {
574		i.openQuickModelPicker(slot)
575		i.quickModelActivate = true
576		i.mu.Lock()
577		i.statusErr = ""
578		i.statusOK = fmt.Sprintf("choose a model for profile %d; Enter saves and activates", slot)
579		i.mu.Unlock()
580		i.invalidate()
581		return
582	}
583	i.activateModelProfile(slot, p)
584}
585func (i *Interactive) applyQuickModelSetting(key, value string) {
586	slotText := strings.TrimPrefix(key, "quick_model_")
587	slot, err := strconv.Atoi(slotText)
588	if err != nil || slot < 1 || slot > 9 {
589		return
590	}
591	providerName, model := "", ""
592	if value != "" {
593		parts := strings.SplitN(value, "\t", 2)
594		if len(parts) == 2 {
595			providerName, model = parts[0], parts[1]
596		}
597	}
598	i.setQuickModelShortcut(slot, providerName, model)
599}
600func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
601	i.setQuickModelProfile(slot, QuickModelShortcut{
602		Provider: providerName, Model: model, Reasoning: i.cfg.Reasoning,
603		FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
604	})
605}
606func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
607	if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
608		return
609	}
610	key := "quick_model_" + strconv.Itoa(slot)
611	for idx, it := range i.settingsDialog.items {
612		if it.key == key {
613			i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
614			return
615		}
616	}
617}
618func (i *Interactive) resetSettingsToggle(key string, value bool) {
619	if i.settingsDialog == nil {
620		return
621	}
622	for idx := range i.settingsDialog.items {
623		if i.settingsDialog.items[idx].key == key {
624			i.settingsDialog.items[idx].value = value
625			return
626		}
627	}
628}
629func (i *Interactive) ponytailEnabled() bool {
630	return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
631}
632func (i *Interactive) ponytailSettingsAvailable() bool {
633	_, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
634	return persistent && i.cfg.RefreshPrompt != nil
635}
636func (i *Interactive) webSearchEnabled() bool {
637	return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
638}
639func (i *Interactive) webSearchToolAllowed() bool {
640	return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
641}
642func (i *Interactive) webSearchEffectiveEnabled() bool {
643	return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
644}
645func (i *Interactive) webSearchSettingsAvailable() bool {
646	_, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
647	return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
648}
649func (i *Interactive) webSearchUnavailableHint() string {
650	if !i.webSearchToolAllowed() {
651		return "unavailable in this session due to the launch-time tool capability ceiling"
652	}
653	if i.cfg.WebSearchInvocationOverride {
654		return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
655	}
656	if !i.webSearchSettingsAvailable() {
657		return "requires persistent settings and live tool refresh support"
658	}
659	return ""
660}
661func (i *Interactive) setWebSearchAvailable(available bool) {
662	// Advance before publishing the new gate/child ceiling. A resolver that
663	// captured the prior generation can no longer commit after this transition.
664	// Callers may hold agentMu and i.mu; the callback must complete without
665	// calling back into Interactive or requiring either lock.
666	i.webSearchPolicyGeneration.Add(1)
667	if i.cfg.SetWebSearchAvailable != nil {
668		i.cfg.SetWebSearchAvailable(available)
669	}
670}
671func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
672	previous := i.webSearchEnabled()
673	store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
674	if !i.webSearchSettingsAvailable() || !persistent {
675		i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
676		i.mu.Lock()
677		i.statusOK = ""
678		i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
679		i.mu.Unlock()
680		return
681	}
682	if err := store.SetWebSearchEnabled(value); err != nil {
683		i.resetSettingsToggle(key, previous)
684		i.mu.Lock()
685		i.statusOK = ""
686		i.statusErr = "settings: " + err.Error()
687		i.mu.Unlock()
688		return
689	}
690	val := value
691	i.cfg.WebSearchEnabled = &val
692	// Revoke and advance the policy generation for both directions. Enabling
693	// remains fail-closed until the freshly resolved registry commits, while a
694	// stale resolve from before this persisted transition is rejected.
695	i.stripWebSearchTool()
696	if err := i.cfg.RefreshTools(); err != nil {
697		// Refresh failure leaves the old registry's state unknown. Revoke both
698		// live execution and generic-child inheritance before rollback.
699		i.stripWebSearchTool()
700		errMsg := "settings: web search tool refresh: " + err.Error()
701		if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
702			failClosed := false
703			i.cfg.WebSearchEnabled = &failClosed
704			i.resetSettingsToggle(key, false)
705			errMsg += "; rollback persistence: " + rollbackErr.Error()
706		} else {
707			previousVal := previous
708			i.cfg.WebSearchEnabled = &previousVal
709			i.resetSettingsToggle(key, previous)
710			if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
711				errMsg += "; rollback refresh: " + refreshErr.Error()
712				i.stripWebSearchTool()
713			}
714		}
715		i.mu.Lock()
716		i.statusOK = ""
717		i.statusErr = errMsg
718		i.mu.Unlock()
719		return
720	}
721	i.mu.Lock()
722	i.statusOK = "web search " + onOff(value)
723	i.statusErr = ""
724	i.mu.Unlock()
725}
726func (i *Interactive) stripWebSearchTool() {
727	// Revoke first so a stale tool snapshotted by core cannot execute while
728	// the advertised registry is being replaced.
729	i.setWebSearchAvailable(false)
730	i.agentMu.Lock()
731	defer i.agentMu.Unlock()
732	i.mu.Lock()
733	defer i.mu.Unlock()
734	if i.agent == nil {
735		return
736	}
737	tools := i.agent.ToolsSnapshot()
738	hasWebCapability := false
739	for _, name := range toolspkg.WebCapabilityNames {
740		if _, ok := tools[name]; ok {
741			hasWebCapability = true
742			break
743		}
744	}
745	if !hasWebCapability {
746		return
747	}
748	toolspkg.RemoveWebCapabilities(tools)
749	i.agent.SetTools(tools)
750}
751func (i *Interactive) applySettingToggle(key string, value bool) {
752	// Every setting toggle forces a full repaint at the end — same
753	// effect as the user pressing Ctrl+L — so any per-setting visual
754	// change (image rendering, status copy, future toggles) lands
755	// immediately instead of waiting for the next diff frame.
756	defer func() {
757		i.requestRendererClear()
758		i.invalidate()
759	}()
760	switch key {
761	case "inline_images_enabled":
762		val := value
763		i.cfg.InlineImagesEnabled = &val
764		if i.cfg.SettingsStore != nil {
765			if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
766				i.mu.Lock()
767				i.statusErr = "settings: " + err.Error()
768				i.mu.Unlock()
769				return
770			}
771		}
772		i.mu.Lock()
773		i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
774		i.view.InvalidateRenderCache()
775		i.statusOK = "inline image rendering " + onOff(value)
776		i.statusErr = ""
777		i.mu.Unlock()
778	case "terminal_alerts_enabled":
779		val := value
780		i.mu.Lock()
781		i.cfg.TerminalAlertsEnabled = &val
782		i.mu.Unlock()
783		if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
784			if err := store.SetTerminalAlertsEnabled(value); err != nil {
785				i.mu.Lock()
786				i.statusErr = "settings: " + err.Error()
787				i.mu.Unlock()
788				return
789			}
790		}
791		i.mu.Lock()
792		i.statusOK = "terminal alerts " + onOff(value)
793		i.statusErr = ""
794		i.mu.Unlock()
795	case "terminal_title_enabled":
796		val := value
797		var cancel context.CancelFunc
798		i.mu.Lock()
799		i.cfg.TerminalTitleEnabled = &val
800		if value {
801			i.writeTerminalTitleLocked(i.sessionTitle)
802		} else {
803			cancel = i.titleCancel
804			i.titleCancel = nil
805			i.titleVersion++
806			i.writeTerminalTitleLocked("")
807		}
808		i.mu.Unlock()
809		if cancel != nil {
810			cancel()
811		}
812		if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
813			if err := store.SetTerminalTitleEnabled(value); err != nil {
814				i.mu.Lock()
815				i.statusErr = "settings: " + err.Error()
816				i.mu.Unlock()
817				return
818			}
819		}
820		i.mu.Lock()
821		i.statusOK = "AI terminal titles " + onOff(value)
822		i.statusErr = ""
823		i.mu.Unlock()
824	case "web_search_enabled":
825		apply := func() { i.applyWebSearchSettingToggle(key, value) }
826		if i.cfg.SessionTransition != nil {
827			i.cfg.SessionTransition(apply)
828		} else {
829			apply()
830		}
831	case "ponytail_enabled":
832		previous := i.ponytailEnabled()
833		store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
834		if !available || i.cfg.RefreshPrompt == nil {
835			i.resetSettingsToggle(key, previous)
836			i.mu.Lock()
837			i.statusOK = ""
838			i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
839			i.mu.Unlock()
840			return
841		}
842		if err := store.SetPonytailEnabled(value); err != nil {
843			i.resetSettingsToggle(key, previous)
844			i.mu.Lock()
845			i.statusOK = ""
846			i.statusErr = "settings: " + err.Error()
847			i.mu.Unlock()
848			return
849		}
850		val := value
851		i.cfg.PonytailEnabled = &val
852		if err := i.cfg.RefreshPrompt(); err != nil {
853			errMsg := "settings: ponytail prompt refresh: " + err.Error()
854			if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
855				// The first write succeeded, so keep the in-memory setting at
856				// the value that remains durable instead of claiming that the
857				// rollback took effect.
858				i.resetSettingsToggle(key, value)
859				errMsg += "; rollback persistence: " + rollbackErr.Error()
860				if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
861					errMsg += "; durable-state refresh: " + reconcileErr.Error()
862				}
863			} else {
864				previousVal := previous
865				i.cfg.PonytailEnabled = &previousVal
866				i.resetSettingsToggle(key, previous)
867				if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
868					errMsg += "; rollback refresh: " + refreshErr.Error()
869				}
870			}
871			i.mu.Lock()
872			i.statusOK = ""
873			i.statusErr = errMsg
874			i.mu.Unlock()
875			return
876		}
877		i.mu.Lock()
878		i.statusOK = "ponytail coding mode " + onOff(value)
879		i.statusErr = ""
880		i.mu.Unlock()
881	case "auto_subagents_enabled":
882		if value && !i.autoSubagentsAvailable() {
883			i.mu.Lock()
884			i.statusOK = ""
885			i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
886			i.mu.Unlock()
887			return
888		}
889		i.mu.Lock()
890		previousFlag := i.cfg.AutoSubagentsEnabled
891		previous := previousFlag != nil && *previousFlag
892		val := value
893		i.cfg.AutoSubagentsEnabled = &val
894		i.mu.Unlock()
895		if i.cfg.SettingsStore != nil {
896			if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
897				i.mu.Lock()
898				i.cfg.AutoSubagentsEnabled = previousFlag
899				i.statusOK = ""
900				i.statusErr = "settings: " + err.Error()
901				i.mu.Unlock()
902				i.resetSettingsToggle(key, previous)
903				return
904			}
905		}
906		// The subagent tools remain available for explicit user requests.
907		// Swap only the system-prompt guidance between strict orchestration and
908		// on-demand delegation.
909		i.applyAutoSubagentsTool()
910		// Also swap the system-prompt addendum so the model knows whether it
911		// should orchestrate proactively or delegate only on user request.
912		i.applyAutoSubagentsSystemPrompt(value)
913		i.mu.Lock()
914		i.statusOK = "subagent orchestrator " + onOff(value)
915		i.statusErr = ""
916		i.mu.Unlock()
917	case "fast_mode":
918		previous := i.cfg.FastMode != nil && *i.cfg.FastMode
919		if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
920			i.resetSettingsToggle(key, previous)
921			i.mu.Lock()
922			i.statusErr = "settings: " + err.Error()
923			i.mu.Unlock()
924			return
925		}
926		if slot := i.cfg.ActiveModelProfile; slot > 0 {
927			_, profileStore := i.cfg.SettingsStore.(modelProfileSettingsStore)
928			if i.cfg.SettingsStore == nil || profileStore {
929				p := QuickModelShortcut{
930					Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: i.cfg.Reasoning,
931					FastMode: value,
932				}
933				if !i.persistModelProfile(slot, p, slot) {
934					i.resetSettingsToggle(key, previous)
935					return
936				}
937				i.setLiveFastMode(value)
938				i.mu.Lock()
939				i.statusOK = "fast mode " + onOff(value) + fmt.Sprintf(" — profile %d updated", slot)
940				i.statusErr = ""
941				i.mu.Unlock()
942				return
943			}
944		}
945		if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
946			if err := store.SetFastMode(value); err != nil {
947				i.resetSettingsToggle(key, previous)
948				i.mu.Lock()
949				i.statusErr = "settings: " + err.Error()
950				i.mu.Unlock()
951				return
952			}
953		}
954		i.setLiveFastMode(value)
955		i.mu.Lock()
956		i.statusOK = "fast mode " + onOff(value)
957		i.statusErr = ""
958		i.mu.Unlock()
959	case "lsp_enabled":
960		previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
961		store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
962		if hasStore {
963			if err := store.SetLSPEnabled(value); err != nil {
964				i.mu.Lock()
965				i.statusErr = "settings: " + err.Error()
966				i.mu.Unlock()
967				return
968			}
969		}
970		val := value
971		i.cfg.LSPEnabled = &val
972		if i.cfg.RefreshTools != nil {
973			if err := i.cfg.RefreshTools(); err != nil {
974				errMsg := "settings: refresh tools: " + err.Error()
975				rollbackErr := error(nil)
976				if hasStore {
977					rollbackErr = store.SetLSPEnabled(previous)
978				}
979				if rollbackErr != nil {
980					// The first write succeeded, so keep the in-memory setting at
981					// the value that remains durable instead of claiming that the
982					// rollback took effect.
983					errMsg += "; rollback persistence: " + rollbackErr.Error()
984					if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
985						errMsg += "; durable-state refresh: " + reconcileErr.Error()
986					}
987				} else {
988					previousVal := previous
989					i.cfg.LSPEnabled = &previousVal
990					if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
991						errMsg += "; rollback refresh: " + refreshErr.Error()
992					}
993				}
994				i.mu.Lock()
995				i.statusOK = ""
996				i.statusErr = errMsg
997				i.mu.Unlock()
998				return
999			}
1000		}
1001		i.mu.Lock()
1002		i.statusOK = "lsp in main session " + onOff(value)
1003		i.statusErr = ""
1004		i.mu.Unlock()
1005	case "subagent_lsp_enabled":
1006		if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
1007			if err := store.SetSubagentLSPEnabled(value); err != nil {
1008				i.mu.Lock()
1009				i.statusErr = "settings: " + err.Error()
1010				i.mu.Unlock()
1011				return
1012			}
1013		}
1014		val := value
1015		i.cfg.SubagentLSPEnabled = &val
1016		i.mu.Lock()
1017		i.statusOK = "lsp in sub-agents " + onOff(value)
1018		i.statusErr = ""
1019		i.mu.Unlock()
1020	case "jail_by_default":
1021		val := value
1022		i.cfg.JailByDefault = &val
1023		if i.cfg.SettingsStore != nil {
1024			if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
1025				i.mu.Lock()
1026				i.statusErr = "settings: " + err.Error()
1027				i.mu.Unlock()
1028				return
1029			}
1030		}
1031		if i.cfg.Sandbox != nil {
1032			if value {
1033				i.cfg.Sandbox.Lock()
1034			} else {
1035				i.cfg.Sandbox.Unlock()
1036			}
1037		}
1038		i.mu.Lock()
1039		i.statusOK = "jail by default " + onOff(value)
1040		i.statusErr = ""
1041		i.mu.Unlock()
1042	case "recursive_file_suggest":
1043		val := value
1044		i.cfg.RecursiveFileSuggest = &val
1045		if i.cfg.SettingsStore != nil {
1046			if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
1047				i.mu.Lock()
1048				i.statusErr = "settings: " + err.Error()
1049				i.mu.Unlock()
1050				return
1051			}
1052		}
1053		// Flip the live picker so the next @ reflects the new mode
1054		// without restarting zut. SetRecursive drops its cache.
1055		i.fileSuggest.SetRecursive(value)
1056		i.mu.Lock()
1057		i.statusOK = "recursive @-file search " + onOff(value)
1058		i.statusErr = ""
1059		i.mu.Unlock()
1060	case "respect_gitignore":
1061		val := value
1062		i.cfg.RespectGitignore = &val
1063		if i.cfg.SettingsStore != nil {
1064			if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
1065				i.mu.Lock()
1066				i.statusErr = "settings: " + err.Error()
1067				i.mu.Unlock()
1068				return
1069			}
1070		}
1071		i.fileSuggest.SetRespectGitignore(value)
1072		i.mu.Lock()
1073		i.statusOK = "hide gitignored files in @-picker " + onOff(value)
1074		i.statusErr = ""
1075		i.mu.Unlock()
1076	case "compact_mode":
1077		val := value
1078		i.cfg.CompactMode = &val
1079		if i.cfg.SettingsStore != nil {
1080			if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
1081				i.mu.Lock()
1082				i.statusErr = "settings: " + err.Error()
1083				i.mu.Unlock()
1084				return
1085			}
1086		}
1087		i.mu.Lock()
1088		i.view.CompactMode = value
1089		i.view.InvalidateRenderCache()
1090		i.statusOK = "compact transcript rendering " + onOff(value)
1091		i.statusErr = ""
1092		i.mu.Unlock()
1093	case "show_instructions_at_startup":
1094		val := value
1095		i.cfg.ShowInstructionsAtStartup = &val
1096		if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
1097			if err := store.SetShowInstructionsAtStartup(value); 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.view.StartupAgentName = ""
1106		i.view.StartupContextPaths = nil
1107		i.view.StartupExtensionNames = nil
1108		i.view.StartupSkillNames = nil
1109		i.view.InvalidateRenderCache()
1110		if value {
1111			i.view.StartupAgentName = i.cfg.StartupAgentName
1112			i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
1113			i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
1114			i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
1115		}
1116		i.statusOK = "show loaded resources at startup " + onOff(value)
1117		i.statusErr = ""
1118		i.mu.Unlock()
1119	}
1120}
1121func (i *Interactive) applyTUIInputStyleSetting(style string) {
1122	defer func() {
1123		i.requestRendererClear()
1124		i.invalidate()
1125	}()
1126	style = tui.NormalizeInputStyle(style)
1127	i.cfg.TUIInputStyle = style
1128	i.applyInputCursorColor()
1129	if i.cfg.SettingsStore != nil {
1130		if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
1131			i.mu.Lock()
1132			i.statusErr = "settings: " + err.Error()
1133			i.mu.Unlock()
1134			return
1135		}
1136	}
1137	i.mu.Lock()
1138	i.statusOK = "input style " + style
1139	i.statusErr = ""
1140	i.mu.Unlock()
1141}
1142func (i *Interactive) applyTUIStatusPositionSetting(position string) {
1143	defer func() {
1144		i.requestRendererClear()
1145		i.invalidate()
1146	}()
1147	position = tui.NormalizeStatusPosition(position)
1148	i.cfg.TUIStatusPosition = position
1149	if i.cfg.SettingsStore != nil {
1150		if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
1151			i.mu.Lock()
1152			i.statusErr = "settings: " + err.Error()
1153			i.mu.Unlock()
1154			return
1155		}
1156	}
1157	i.mu.Lock()
1158	label := "above input"
1159	if position == tui.StatusPositionBelowInput {
1160		label = "below input"
1161	}
1162	i.statusOK = "status position " + label
1163	i.statusErr = ""
1164	i.mu.Unlock()
1165}
1166func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
1167	defer func() {
1168		i.requestRendererClear()
1169		i.invalidate()
1170	}()
1171	position = tui.NormalizeWorkingPosition(position)
1172	i.cfg.TUIWorkingPosition = position
1173	if i.cfg.SettingsStore != nil {
1174		if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
1175			i.mu.Lock()
1176			i.statusErr = "settings: " + err.Error()
1177			i.mu.Unlock()
1178			return
1179		}
1180	}
1181	i.mu.Lock()
1182	label := "above input"
1183	if position == tui.WorkingPositionBelowInput {
1184		label = "below input"
1185	}
1186	i.statusOK = "working spinner position " + label
1187	i.statusErr = ""
1188	i.mu.Unlock()
1189}
1190func (i *Interactive) applyThemeSetting(name string) {
1191	if i.cfg.SettingsStore != nil {
1192		if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
1193			i.mu.Lock()
1194			i.statusErr = "settings: " + err.Error()
1195			i.mu.Unlock()
1196			return
1197		}
1198	}
1199	i.cfg.ThemeName = name
1200	if name == "auto" {
1201		i.cfg.ThemeName = ""
1202	}
1203	if !i.cfg.ThemeForced {
1204		i.cfg.EffectiveThemeName = name
1205	}
1206	i.applyThemeNow(i.cfg.EffectiveThemeName)
1207}
1208
1209func (i *Interactive) applyThemeNow(name string) {
1210	if name == "" {
1211		name = "auto"
1212	}
1213	source, err := tui.LoadThemeSource(i.cfg.ZutHome, name)
1214	if err != nil {
1215		if i.cfg.SettingsStore != nil {
1216			_ = i.cfg.SettingsStore.SetTheme("auto")
1217		}
1218		i.cfg.ThemeName = ""
1219		i.cfg.EffectiveThemeName = "auto"
1220		i.activeThemeSource = nil
1221		i.stopThemeWatch()
1222		i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1223		i.mu.Lock()
1224		i.statusErr = "theme missing; reset to auto"
1225		i.mu.Unlock()
1226		return
1227	}
1228	i.activeThemeSource = source
1229	i.restartThemeWatch()
1230	i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile))
1231	i.mu.Lock()
1232	i.statusOK = "theme " + i.cfg.EffectiveThemeName
1233	if i.cfg.ThemeForced {
1234		i.statusOK += " (forced by ZUT_THEME)"
1235	}
1236	i.statusErr = ""
1237	i.mu.Unlock()
1238}
1239
1240// installResolvedTheme is the one main-loop-owned installation boundary for
1241// settings, terminal profile, and accepted theme-file revisions.
1242// applyTerminalProfile records detected appearance independently from the
1243// currently applied theme. Forced and custom selections are re-resolved from
1244// their immutable accepted source; no terminal event performs filesystem I/O.
1245func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) {
1246	i.terminalProfile = profile
1247	i.cfg.TerminalProfile = profile
1248	resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile)
1249	i.installResolvedTheme(resolution)
1250}
1251
1252func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
1253	th := resolution.Theme
1254	i.mu.Lock()
1255	i.cfg.Theme = th
1256	i.view.Theme = th
1257	i.view.InvalidateRenderCache()
1258	i.chatCacheValid = false
1259	i.stableChatCacheValid = false
1260	i.ed.Prompt = th.AccentBar(th.Accent)
1261	i.mu.Unlock()
1262	i.btwDialog.setTheme(th)
1263	if i.residentChildSession != nil {
1264		i.residentChildSession.setTheme(th)
1265	}
1266	i.applyInputCursorColor()
1267	i.spin.Configure(th)
1268	i.requestRendererTheme(th)
1269	i.invalidate()
1270}
1271func (i *Interactive) applyReasoningSetting(level string) {
1272	defer func() {
1273		i.requestRendererClear()
1274		i.invalidate()
1275	}()
1276	level = provider.NormalizeReasoning(level)
1277	if slot := i.cfg.ActiveModelProfile; slot > 0 {
1278		p := QuickModelShortcut{
1279			Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: level,
1280			FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
1281		}
1282		if !i.persistModelProfile(slot, p, slot) {
1283			return
1284		}
1285	} else if i.cfg.SettingsStore != nil {
1286		if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
1287			i.mu.Lock()
1288			i.statusErr = "settings: " + err.Error()
1289			i.mu.Unlock()
1290			return
1291		}
1292	}
1293	i.setLiveReasoning(level)
1294	i.mu.Lock()
1295	label := level
1296	if label == "" {
1297		label = "off"
1298	}
1299	i.statusOK = "reasoning level " + label
1300	if i.cfg.ActiveModelProfile > 0 {
1301		i.statusOK += fmt.Sprintf(" — profile %d updated", i.cfg.ActiveModelProfile)
1302	}
1303	i.statusErr = ""
1304	i.mu.Unlock()
1305}

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