v0.48.0 ad3af57

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

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 477

↑↓ moveenter openesc close