v0.50.0 c4ec31f

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

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