v0.4.9 de90baf

keymap_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"strings"
6	"testing"
7
8	"github.com/patriceckhart/zot/packages/agent/tools"
9	"github.com/patriceckhart/zot/packages/core"
10	"github.com/patriceckhart/zot/packages/tui"
11)
12
13func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) {
14	keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"}
15	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" {
16		t.Fatalf("configuredKeyCommand = %q, want extension command", got)
17	}
18	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
19		t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got)
20	}
21	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" {
22		t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got)
23	}
24	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" {
25		t.Fatalf("unmodified key matched command %q", got)
26	}
27}
28
29func TestConfiguredKeyCommandPrefersExactChord(t *testing.T) {
30	keymap := map[string]string{
31		"ctrl+g":       "/help",
32		"ctrl+shift+g": "/git-tools:status",
33	}
34	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/help" {
35		t.Fatalf("plain ctrl+g = %q, want exact /help binding", got)
36	}
37	if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
38		t.Fatalf("ctrl+shift+g = %q, want /git-tools:status", got)
39	}
40}
41
42func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
43	if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" {
44		t.Fatalf("configuredKeyCommand = %q, want skill command", got)
45	}
46	if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {
47		t.Fatalf("enhanced ctrl+h binding = %q, want /help", got)
48	}
49}
50
51func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {
52	for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} {
53		if _, ok := parseConfiguredKey(bad); ok {
54			t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad)
55		}
56	}
57	key, ok := parseConfiguredKey("alt+enter")
58	if !ok || key.kind != tui.KeyEnter || !key.alt {
59		t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok)
60	}
61	if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {
62		t.Fatalf("shift+tab binding = %q, want /help", got)
63	}
64	if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" {
65		t.Fatalf("ctrl+space binding = %q, want /help", got)
66	}
67}
68
69func TestCompileKeymapReportsInvalidAndReservedEntries(t *testing.T) {
70	bindings, issues := compileKeymap(map[string]string{
71		"ctrl+c":      "/help",
72		"ctrl+d":      "/help",
73		"esc":         "/help",
74		"enter":       "/help",
75		"ctrl+s":      "settings",
76		"ctrl+x":      "",
77		"alt+r":       "/skill:code-review",
78		"ctrl+alt+c":  "/help",
79		"Shift+Enter": "/help",
80	})
81	if len(issues) != 6 {
82		t.Fatalf("issues = %v, want 6 entries", issues)
83	}
84	for _, issue := range issues {
85		if !strings.HasPrefix(issue, "keymap: ") {
86			t.Fatalf("issue %q lacks keymap prefix", issue)
87		}
88	}
89	var names []string
90	for _, b := range bindings {
91		names = append(names, b.name)
92	}
93	if got, want := strings.Join(names, ","), "alt+r,ctrl+alt+c,shift+enter"; got != want {
94		t.Fatalf("compiled bindings = %q, want %q", got, want)
95	}
96	if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyCtrlC}); got != "" {
97		t.Fatalf("ctrl+c resolved to %q, want reserved", got)
98	}
99	if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyEnter, Shift: true}); got != "/help" {
100		t.Fatalf("shift+enter = %q, want /help", got)
101	}
102}
103
104func newKeymapInteractive(keymap map[string]string) *Interactive {
105	agent := core.NewAgent(nil, "test-model", "test system", nil)
106	return NewInteractive(InteractiveConfig{
107		Theme:    tui.Dark,
108		Terminal: tui.NewProcTerm(),
109		Agent:    agent,
110		Sandbox:  tools.NewSandbox("."),
111		Keymap:   keymap,
112	})
113}
114
115func TestInteractiveKeymapRunsSlashCommand(t *testing.T) {
116	i := newKeymapInteractive(map[string]string{"ctrl+s": "/help", "bogus": "/help"})
117	if len(i.reloadErrors) != 1 || !strings.Contains(i.reloadErrors[0], `"bogus"`) {
118		t.Fatalf("reloadErrors = %v, want one keymap diagnostic", i.reloadErrors)
119	}
120	i.ed.SetValue("draft")
121	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
122	if len(i.helpBlock) == 0 {
123		t.Fatal("ctrl+s did not run /help")
124	}
125	if got := i.ed.Value(); got != "" {
126		t.Fatalf("editor = %q, want cleared before running the bound command", got)
127	}
128}
129
130func TestInteractiveUnboundChordDoesNotType(t *testing.T) {
131	i := newKeymapInteractive(nil)
132	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
133	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'k', Super: true})
134	if got := i.ed.Value(); got != "" {
135		t.Fatalf("editor = %q, want unbound chords ignored", got)
136	}
137}
138
139func TestInteractiveKeymapInactiveWhileConfirmationFocused(t *testing.T) {
140	i := newKeymapInteractive(map[string]string{"ctrl+s": "/help"})
141	resp := enqueueConfirmation(i)
142	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
143	if len(i.helpBlock) != 0 {
144		t.Fatal("keymap fired while confirmation owned input")
145	}
146	select {
147	case decision := <-resp:
148		t.Fatalf("chord answered confirmation: %+v", decision)
149	default:
150	}
151}
152
153func TestInteractiveChordDoesNotLeakIntoDialogAsLetter(t *testing.T) {
154	i := newKeymapInteractive(map[string]string{"ctrl+r": "/help"})
155	i.sessionDialog.sessions = []core.SessionSummary{{Path: "a.jsonl", Title: "one", MessageCount: 1}}
156	i.sessionDialog.active = true
157	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'r', Ctrl: true})
158	if i.sessionDialog.renaming {
159		t.Fatal("ctrl+r started a rename as if it were a bare 'r'")
160	}
161	if len(i.helpBlock) != 0 {
162		t.Fatal("keymap fired while the session dialog owned input")
163	}
164	if !i.sessionDialog.Active() {
165		t.Fatal("session dialog closed unexpectedly")
166	}
167}

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