v0.27.0 01c3d31

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

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