v0.25.1 025a25b

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

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