detached 53ffdc6

Harden keymap bindings and stop Ctrl chords from typing

Unbound Ctrl and Super chords surfaced as KeyRune events and were
inserted into the editor as plain letters on every terminal, with or
without a keymap configured. The editor now ignores Ctrl/Super runes,
and interactive mode neutralizes them while a dialog owns input so
ctrl+r cannot act as a bare 'r' in the session picker.

Keymap entries are compiled once at startup. Unmodified keys such as
enter or space, values that are not slash commands, and the reserved
ctrl+c, ctrl+d and esc chords are rejected and reported through the
startup diagnostics instead of being silently ignored. Exact chord
matches take precedence over the legacy Ctrl+Shift+letter fallback so
ctrl+g and ctrl+shift+g can coexist on enhanced-protocol terminals.
/help lists only the compiled bindings.
commit
53ffdc6c07495d12ad16c97b2f3b190e64c67f6f
author
patriceckhart · · 2026-09-21 09:20 UTC
committer
patriceckhart · · 2026-09-21 09:20 UTC
parent
aca3e60
signature
unsigned
10 files · +400 -120
M README.md +14 -4
@@ -164,7 +164,7 @@ The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP
164164
165165 ### Configurable shortcuts
166166
167-Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. Each key is a case-insensitive chord using `ctrl`, `alt`/`option`, `shift`, or `cmd`/`super`, and each value is a slash command. This works for built-in commands, extension commands, and explicitly invoked skills. Note: keybindings requiring indistinguishable control sequences - such as Ctrl+H (translates to Backspace) and many Ctrl+Shift+letter chords - require enhanced keyboard reporting and so currently may not be used in certain terminals.
167+Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. Each key is a case-insensitive chord made of at least one modifier (`ctrl`, `alt`/`option`, `shift`, `cmd`/`super`) plus a single character or a named key (`enter`, `tab`, `space`, `esc`, `backspace`, `delete`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`). Each value is a slash command: built-in commands, extension commands, and explicitly invoked `/skill:<name>` all work.
168168
169169 ```json
170170 {
@@ -177,9 +177,19 @@ Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`.
177177 ```
178178
179179 Shortcuts run in the main interactive input and use the same dispatch path as
180-typing the command. Invalid key names are ignored; values must begin with
181-`/`. The emergency `ctrl+c`, `ctrl+d`, and `esc` bindings remain reserved.
182-Shortcuts do not apply while a dialog or extension panel has focus.
180+typing the command. They do not apply while a dialog, extension panel, or
181+tool confirmation has focus. Entries that cannot be bound are skipped and
182+reported at startup (the message clears with `/clear`): unmodified keys such
183+as `enter` or `g`, values that do not start with `/`, and the reserved
184+`ctrl+c`, `ctrl+d`, and `esc` chords. `/help` lists the active bindings.
185+
186+Terminal limitations: without enhanced keyboard reporting (kitty keyboard
187+protocol or xterm `modifyOtherKeys`), a terminal cannot distinguish
188+`ctrl+h`, `ctrl+i`, and `ctrl+m` from Backspace, Tab, and Enter, and it does
189+not report Shift on Ctrl+letter chords. zot therefore lets a `ctrl+shift+<letter>`
190+binding also fire on plain `ctrl+<letter>` unless a separate `ctrl+<letter>`
191+binding exists. Chords the terminal emulator or multiplexer consumes itself
192+(a tmux prefix, `cmd+k` clearing scrollback, tab switching) never reach zot.
183193
184194 ## Persistent instructions (AGENTS.md)
185195
@@ -164,7 +164,7 @@ The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP
164 164
165 ### Configurable shortcuts165 ### Configurable shortcuts
166 166
167-Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. Each key is a case-insensitive chord using `ctrl`, `alt`/`option`, `shift`, or `cmd`/`super`, and each value is a slash command. This works for built-in commands, extension commands, and explicitly invoked skills. Note: keybindings requiring indistinguishable control sequences - such as Ctrl+H (translates to Backspace) and many Ctrl+Shift+letter chords - require enhanced keyboard reporting and so currently may not be used in certain terminals.167+Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. Each key is a case-insensitive chord made of at least one modifier (`ctrl`, `alt`/`option`, `shift`, `cmd`/`super`) plus a single character or a named key (`enter`, `tab`, `space`, `esc`, `backspace`, `delete`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`). Each value is a slash command: built-in commands, extension commands, and explicitly invoked `/skill:<name>` all work.
168 168
169 ```json169 ```json
170 {170 {
@@ -177,9 +177,19 @@ Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`.
177 ```177 ```
178 178
179 Shortcuts run in the main interactive input and use the same dispatch path as179 Shortcuts run in the main interactive input and use the same dispatch path as
180-typing the command. Invalid key names are ignored; values must begin with180+typing the command. They do not apply while a dialog, extension panel, or
181-`/`. The emergency `ctrl+c`, `ctrl+d`, and `esc` bindings remain reserved.181+tool confirmation has focus. Entries that cannot be bound are skipped and
182-Shortcuts do not apply while a dialog or extension panel has focus.182+reported at startup (the message clears with `/clear`): unmodified keys such
183+as `enter` or `g`, values that do not start with `/`, and the reserved
184+`ctrl+c`, `ctrl+d`, and `esc` chords. `/help` lists the active bindings.
185+
186+Terminal limitations: without enhanced keyboard reporting (kitty keyboard
187+protocol or xterm `modifyOtherKeys`), a terminal cannot distinguish
188+`ctrl+h`, `ctrl+i`, and `ctrl+m` from Backspace, Tab, and Enter, and it does
189+not report Shift on Ctrl+letter chords. zot therefore lets a `ctrl+shift+<letter>`
190+binding also fire on plain `ctrl+<letter>` unless a separate `ctrl+<letter>`
191+binding exists. Chords the terminal emulator or multiplexer consumes itself
192+(a tmux prefix, `cmd+k` clearing scrollback, tab switching) never reach zot.
183 193
184 ## Persistent instructions (AGENTS.md)194 ## Persistent instructions (AGENTS.md)
185 195
M packages/agent/modes/help.go +6 -7
@@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{
3636 // section it lives in. The width is computed from the longest label
3737 // across BOTH lists, with a minimum of 14 cells so changes to either
3838 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[string]string) []string {
39+func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string {
4040 if width < 20 {
4141 width = 20
4242 }
@@ -62,8 +62,8 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s
6262 labelWidth = n
6363 }
6464 }
65- for key := range keymap {
66- if n := runewidth.StringWidth(key); n > labelWidth {
65+ for _, b := range keymap {
66+ if n := runewidth.StringWidth(b.name); n > labelWidth {
6767 labelWidth = n
6868 }
6969 }
@@ -100,11 +100,10 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s
100100
101101 if len(keymap) > 0 {
102102 out = append(out, "", tui.Bold("custom keys:"))
103- for _, key := range sortedCustomKeys(keymap) {
104- command := strings.TrimSpace(keymap[key])
103+ for _, b := range keymap {
105104 out = append(out, fmt.Sprintf(" %s %s",
106- th.FG256(th.Accent, pad(key)),
107- th.FG256(th.Muted, command)))
105+ th.FG256(th.Accent, pad(b.name)),
106+ th.FG256(th.Muted, b.command)))
108107 }
109108 }
110109
@@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{
36 // section it lives in. The width is computed from the longest label36 // section it lives in. The width is computed from the longest label
37 // across BOTH lists, with a minimum of 14 cells so changes to either37 // across BOTH lists, with a minimum of 14 cells so changes to either
38 // list don't compress the column visually.38 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[string]string) []string {39+func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string {
40 if width < 20 {40 if width < 20 {
41 width = 2041 width = 20
42 }42 }
@@ -62,8 +62,8 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s
62 labelWidth = n62 labelWidth = n
63 }63 }
64 }64 }
65- for key := range keymap {65+ for _, b := range keymap {
66- if n := runewidth.StringWidth(key); n > labelWidth {66+ if n := runewidth.StringWidth(b.name); n > labelWidth {
67 labelWidth = n67 labelWidth = n
68 }68 }
69 }69 }
@@ -100,11 +100,10 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s
100 100
101 if len(keymap) > 0 {101 if len(keymap) > 0 {
102 out = append(out, "", tui.Bold("custom keys:"))102 out = append(out, "", tui.Bold("custom keys:"))
103- for _, key := range sortedCustomKeys(keymap) {103+ for _, b := range keymap {
104- command := strings.TrimSpace(keymap[key])
105 out = append(out, fmt.Sprintf(" %s %s",104 out = append(out, fmt.Sprintf(" %s %s",
106- th.FG256(th.Accent, pad(key)),105+ th.FG256(th.Accent, pad(b.name)),
107- th.FG256(th.Muted, command)))106+ th.FG256(th.Muted, b.command)))
108 }107 }
109 }108 }
110 109
M packages/agent/modes/help_test.go +11 -3
@@ -19,10 +19,18 @@ func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
1919 }
2020
2121 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
22- help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, map[string]string{
23- "ctrl+s": "/skill:review",
24- }), "\n")
22+ bindings, issues := compileKeymap(map[string]string{
23+ "Ctrl+S": "/skill:review",
24+ "bogus": "/help",
25+ })
26+ if len(issues) != 1 {
27+ t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
28+ }
29+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")
2530 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
2631 t.Fatalf("help omitted configured custom key: %q", help)
2732 }
33+ if strings.Contains(help, "bogus") {
34+ t.Fatalf("help advertised an invalid binding: %q", help)
35+ }
2836 }
@@ -19,10 +19,18 @@ func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
19 }19 }
20 20
21 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {21 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
22- help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, map[string]string{22+ bindings, issues := compileKeymap(map[string]string{
23- "ctrl+s": "/skill:review",23+ "Ctrl+S": "/skill:review",
24- }), "\n")24+ "bogus": "/help",
25+ })
26+ if len(issues) != 1 {
27+ t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
28+ }
29+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")
25 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {30 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
26 t.Fatalf("help omitted configured custom key: %q", help)31 t.Fatalf("help omitted configured custom key: %q", help)
27 }32 }
33+ if strings.Contains(help, "bogus") {
34+ t.Fatalf("help advertised an invalid binding: %q", help)
35+ }
28 }36 }
M packages/agent/modes/interactive.go +43 -11
@@ -580,6 +580,10 @@ type Interactive struct {
580580 // stay in the scrolling chat until /clear without entering the transcript.
581581 reloadErrors []string
582582
583+ // keymap holds the compiled config.json keymap bindings, sorted by
584+ // chord name. Invalid entries are reported through reloadErrors.
585+ keymap []keymapBinding
586+
583587 // shellRunning is true while a !command is executing. It shares
584588 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
585589 // escape can start while one is in flight.
@@ -698,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
698702 inputHistoryIndex: -1,
699703 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
700704 }
705+ var keymapIssues []string
706+ i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
707+ i.reloadErrors = append(i.reloadErrors, keymapIssues...)
701708 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
702709 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
703710 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -2105,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool {
21052112 i.extPanel.Active()
21062113 }
21072114
2115+// dialogOwnsInput reports whether a dialog, panel, or focused
2116+// confirmation prompt currently consumes keys instead of the main editor.
2117+func (i *Interactive) dialogOwnsInput() bool {
2118+ return i.confirmDialog.Focused() ||
2119+ i.dialog.Active() ||
2120+ i.modelDialog.Active() ||
2121+ i.llamaDialog.Active() ||
2122+ i.rescueDialog.Active() ||
2123+ i.sessionDialog.Active() ||
2124+ i.swarmDialog.Active() ||
2125+ i.jumpDialog.Active() ||
2126+ i.btwDialog.Active() ||
2127+ i.skillsDialog.Active() ||
2128+ i.changelogDialog.Active() ||
2129+ i.logoutDialog.Active() ||
2130+ i.telegramDialog.Active() ||
2131+ i.settingsDialog.Active() ||
2132+ i.sessionOpsDialog.Active() ||
2133+ i.sessionTreeDialog.Active() ||
2134+ i.timeline.Active() ||
2135+ i.extPanel.Active()
2136+}
2137+
21082138 // restoreConfirmFocus returns input to confirmation after slash input is
21092139 // cleared or a child interaction closes. A non-empty editor keeps command
21102140 // input focused, and an active child continues to own its keys.
@@ -2144,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21442174 }
21452175 }
21462176
2177+ // Ctrl and Super chords reach us as modified runes so the keymap can
2178+ // bind them on the main input. Dialogs key off bare letters ('r'
2179+ // renames, 'q' closes, digits answer confirmations) and must not see
2180+ // a chord as its letter, so neutralize it while one owns input.
2181+ if isChordRune(k) && i.dialogOwnsInput() {
2182+ k = tui.Key{Kind: tui.KeyUnknown}
2183+ }
2184+
21472185 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
21482186 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
21492187 // unexpectedly. The hint message also goes stale; clear it.
@@ -2452,17 +2490,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24522490 return false
24532491 }
24542492
2493+ // Configured shortcuts only apply to the main input. Every dialog
2494+ // above has already consumed its keys, and the confirmation prompt
2495+ // keeps focus until the user explicitly moves it with '/'.
24552496 if !keymapReservedKey(k) {
2456- if command := configuredKeyCommand(i.cfg.Keymap, k); command != "" {
2457- if !strings.HasPrefix(strings.TrimSpace(command), "/") {
2458- i.mu.Lock()
2459- i.statusErr = "keymap command must be a slash command: " + command
2460- i.statusOK = ""
2461- i.mu.Unlock()
2462- i.invalidate()
2463- return false
2464- }
2465- command = strings.TrimSpace(command)
2497+ if command := lookupKeymap(i.keymap, k); command != "" {
24662498 parts := strings.Fields(command)
24672499 if len(parts) > 0 && slashCancelsTurn(parts[0]) {
24682500 i.cancelAndWaitForIdle()
@@ -4560,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45604592 i.mu.Unlock()
45614593 case "/help":
45624594 i.mu.Lock()
4563- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.Keymap)
4595+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
45644596 i.statusErr = ""
45654597 i.statusOK = ""
45664598 // Pin the viewport to the newest content so the help block,
@@ -580,6 +580,10 @@ type Interactive struct {
580 // stay in the scrolling chat until /clear without entering the transcript.580 // stay in the scrolling chat until /clear without entering the transcript.
581 reloadErrors []string581 reloadErrors []string
582 582
583+ // keymap holds the compiled config.json keymap bindings, sorted by
584+ // chord name. Invalid entries are reported through reloadErrors.
585+ keymap []keymapBinding
586+
583 // shellRunning is true while a !command is executing. It shares587 // shellRunning is true while a !command is executing. It shares
584 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell588 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
585 // escape can start while one is in flight.589 // escape can start while one is in flight.
@@ -698,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
698 inputHistoryIndex: -1,702 inputHistoryIndex: -1,
699 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),703 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
700 }704 }
705+ var keymapIssues []string
706+ i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
707+ i.reloadErrors = append(i.reloadErrors, keymapIssues...)
701 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)708 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
702 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)709 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
703 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)710 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -2105,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool {
2105 i.extPanel.Active()2112 i.extPanel.Active()
2106 }2113 }
2107 2114
2115+// dialogOwnsInput reports whether a dialog, panel, or focused
2116+// confirmation prompt currently consumes keys instead of the main editor.
2117+func (i *Interactive) dialogOwnsInput() bool {
2118+ return i.confirmDialog.Focused() ||
2119+ i.dialog.Active() ||
2120+ i.modelDialog.Active() ||
2121+ i.llamaDialog.Active() ||
2122+ i.rescueDialog.Active() ||
2123+ i.sessionDialog.Active() ||
2124+ i.swarmDialog.Active() ||
2125+ i.jumpDialog.Active() ||
2126+ i.btwDialog.Active() ||
2127+ i.skillsDialog.Active() ||
2128+ i.changelogDialog.Active() ||
2129+ i.logoutDialog.Active() ||
2130+ i.telegramDialog.Active() ||
2131+ i.settingsDialog.Active() ||
2132+ i.sessionOpsDialog.Active() ||
2133+ i.sessionTreeDialog.Active() ||
2134+ i.timeline.Active() ||
2135+ i.extPanel.Active()
2136+}
2137+
2108 // restoreConfirmFocus returns input to confirmation after slash input is2138 // restoreConfirmFocus returns input to confirmation after slash input is
2109 // cleared or a child interaction closes. A non-empty editor keeps command2139 // cleared or a child interaction closes. A non-empty editor keeps command
2110 // input focused, and an active child continues to own its keys.2140 // input focused, and an active child continues to own its keys.
@@ -2144,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2144 }2174 }
2145 }2175 }
2146 2176
2177+ // Ctrl and Super chords reach us as modified runes so the keymap can
2178+ // bind them on the main input. Dialogs key off bare letters ('r'
2179+ // renames, 'q' closes, digits answer confirmations) and must not see
2180+ // a chord as its letter, so neutralize it while one owns input.
2181+ if isChordRune(k) && i.dialogOwnsInput() {
2182+ k = tui.Key{Kind: tui.KeyUnknown}
2183+ }
2184+
2147 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so2185 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2148 // pressing ctrl+c then typing then ctrl+c much later doesn't quit2186 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2149 // unexpectedly. The hint message also goes stale; clear it.2187 // unexpectedly. The hint message also goes stale; clear it.
@@ -2452,17 +2490,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2452 return false2490 return false
2453 }2491 }
2454 2492
2493+ // Configured shortcuts only apply to the main input. Every dialog
2494+ // above has already consumed its keys, and the confirmation prompt
2495+ // keeps focus until the user explicitly moves it with '/'.
2455 if !keymapReservedKey(k) {2496 if !keymapReservedKey(k) {
2456- if command := configuredKeyCommand(i.cfg.Keymap, k); command != "" {2497+ if command := lookupKeymap(i.keymap, k); command != "" {
2457- if !strings.HasPrefix(strings.TrimSpace(command), "/") {
2458- i.mu.Lock()
2459- i.statusErr = "keymap command must be a slash command: " + command
2460- i.statusOK = ""
2461- i.mu.Unlock()
2462- i.invalidate()
2463- return false
2464- }
2465- command = strings.TrimSpace(command)
2466 parts := strings.Fields(command)2498 parts := strings.Fields(command)
2467 if len(parts) > 0 && slashCancelsTurn(parts[0]) {2499 if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2468 i.cancelAndWaitForIdle()2500 i.cancelAndWaitForIdle()
@@ -4560,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4560 i.mu.Unlock()4592 i.mu.Unlock()
4561 case "/help":4593 case "/help":
4562 i.mu.Lock()4594 i.mu.Lock()
4563- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.Keymap)4595+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
4564 i.statusErr = ""4596 i.statusErr = ""
4565 i.statusOK = ""4597 i.statusOK = ""
4566 // Pin the viewport to the newest content so the help block,4598 // Pin the viewport to the newest content so the help block,
M packages/agent/modes/keymap.go +140 -77
@@ -9,9 +9,69 @@ import (
99 "github.com/patriceckhart/zot/packages/tui"
1010 )
1111
12-// configuredKeyCommand resolves a configured key chord. Key names are
13-// case-insensitive and use '+' separated modifiers, for example
14-// "ctrl+shift+g", "alt+enter", or "cmd+k". Invalid entries are ignored.
12+// keymapBinding is one compiled config.json keymap entry.
13+type keymapBinding struct {
14+ name string // normalized chord name, for example "ctrl+shift+g"
15+ spec configuredKey
16+ command string
17+}
18+
19+// compileKeymap parses a config.json keymap into bindings sorted by
20+// chord name. Invalid entries are dropped and reported in issues so the
21+// interactive mode can surface them instead of silently ignoring them.
22+func compileKeymap(keymap map[string]string) (bindings []keymapBinding, issues []string) {
23+ for _, name := range sortedCustomKeys(keymap) {
24+ spec, ok := parseConfiguredKey(name)
25+ if !ok {
26+ issues = append(issues, fmt.Sprintf("keymap: invalid key %q (use modifier+key, for example ctrl+shift+g)", name))
27+ continue
28+ }
29+ if spec.reserved() {
30+ issues = append(issues, fmt.Sprintf("keymap: %q is reserved and cannot be rebound", name))
31+ continue
32+ }
33+ command := strings.TrimSpace(keymap[name])
34+ if command == "" || !strings.HasPrefix(command, "/") {
35+ issues = append(issues, fmt.Sprintf("keymap: %q must map to a slash command, got %q", name, command))
36+ continue
37+ }
38+ bindings = append(bindings, keymapBinding{name: strings.ToLower(strings.TrimSpace(name)), spec: spec, command: command})
39+ }
40+ return bindings, issues
41+}
42+
43+// lookupKeymap resolves key against compiled bindings. An exact chord
44+// match wins over the legacy Ctrl+letter fallback so enhanced-protocol
45+// terminals can bind ctrl+g and ctrl+shift+g independently.
46+func lookupKeymap(bindings []keymapBinding, key tui.Key) string {
47+ for _, b := range bindings {
48+ if b.spec.matches(key, false) {
49+ return b.command
50+ }
51+ }
52+ for _, b := range bindings {
53+ if b.spec.matches(key, true) {
54+ return b.command
55+ }
56+ }
57+ return ""
58+}
59+
60+// configuredKeyCommand resolves a configured key chord straight from a
61+// config.json keymap. Key names are case-insensitive and use '+'
62+// separated modifiers, for example "ctrl+shift+g", "alt+enter", or
63+// "cmd+k". Invalid entries are ignored. Interactive mode compiles the
64+// map once; this helper exists for callers that only have the map.
65+func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
66+ if len(keymap) == 0 {
67+ return ""
68+ }
69+ bindings, _ := compileKeymap(keymap)
70+ return lookupKeymap(bindings, key)
71+}
72+
73+// sortedCustomKeys returns keymap names in a stable, case-insensitive
74+// order for help output and deterministic lookup precedence.
1575 func sortedCustomKeys(keymap map[string]string) []string {
1676 keys := make([]string, 0, len(keymap))
1777 for key := range keymap {
@@ -27,24 +87,6 @@ func sortedCustomKeys(keymap map[string]string) []string {
2787 return keys
2888 }
2989
30-func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
31- if len(keymap) == 0 {
32- return ""
33- }
34- keys := make([]string, 0, len(keymap))
35- for name := range keymap {
36- keys = append(keys, name)
37- }
38- sort.Strings(keys)
39- for _, name := range keys {
40- spec, ok := parseConfiguredKey(name)
41- if ok && spec.matches(key) {
42- return strings.TrimSpace(keymap[name])
43- }
44- }
45- return ""
46-}
47-
4890 type configuredKey struct {
4991 kind tui.KeyKind
5092 rune rune
@@ -53,6 +95,31 @@ type configuredKey struct {
5395 super bool
5496 }
5597
98+func (k configuredKey) modified() bool {
99+ return k.ctrl || k.alt || k.shift || k.super
100+}
101+
102+// reserved reports whether the chord belongs to the emergency/exit
103+// controls that config.json must never override.
104+func (k configuredKey) reserved() bool {
105+ switch {
106+ case k.kind == tui.KeyRune && k.ctrl && !k.alt && !k.super && (k.rune == 'c' || k.rune == 'd'):
107+ return true
108+ case k.kind == tui.KeyEsc && !k.modified():
109+ return true
110+ }
111+ return false
112+}
113+
114+var specialConfiguredKeys = map[string]tui.KeyKind{
115+ "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
116+ "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
117+ "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
118+ "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
119+ "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
120+ "space": tui.KeyRune,
121+}
122+
56123 func parseConfiguredKey(value string) (configuredKey, bool) {
57124 var out configuredKey
58125 parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")
@@ -85,42 +152,46 @@ func parseConfiguredKey(value string) (configuredKey, bool) {
85152 }
86153 out.kind = tui.KeyRune
87154 if len([]rune(base)) == 1 {
88- // Bare printable keys belong to normal text entry and must not be
89- // intercepted by a typo in config.json.
90- if !out.ctrl && !out.alt && !out.shift && !out.super {
91- return out, false
92- }
93155 out.rune = []rune(base)[0]
94- return out, true
95- }
96- special := map[string]tui.KeyKind{
97- "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
98- "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
99- "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
100- "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
101- "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
102- "space": tui.KeyRune,
103- }
104- kind, ok := special[base]
105- if !ok {
106- return out, false
107- }
108- // Shift+Tab is represented as a dedicated kind by some terminals and
109- // as Tab+Shift by enhanced keyboard protocols; normalize it to the
110- // latter so both forms match.
111- if kind == tui.KeyShiftTab {
112- out.kind = tui.KeyTab
113- out.shift = true
114156 } else {
115- out.kind = kind
157+ kind, ok := specialConfiguredKeys[base]
158+ if !ok {
159+ return out, false
160+ }
161+ // Shift+Tab is represented as a dedicated kind by some terminals
162+ // and as Tab+Shift by enhanced keyboard protocols; normalize it
163+ // to the latter so both forms match.
164+ if kind == tui.KeyShiftTab {
165+ out.kind = tui.KeyTab
166+ out.shift = true
167+ } else {
168+ out.kind = kind
169+ }
170+ if base == "space" {
171+ out.rune = ' '
172+ }
116173 }
117- if base == "space" {
118- out.rune = ' '
174+ // Unmodified keys belong to normal text entry and navigation. A
175+ // typo in config.json must not be able to capture Enter, Space,
176+ // arrows, or a plain letter.
177+ if !out.modified() {
178+ return out, false
119179 }
120180 return out, true
121181 }
122182
123-func (k configuredKey) matches(actual tui.Key) bool {
183+// ctrlKindRunes maps the reader's dedicated control-key kinds back to
184+// the letter they encode so "ctrl+l" style bindings can match them.
185+var ctrlKindRunes = map[tui.KeyKind]rune{
186+ tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
187+ tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
188+}
189+
190+// matches reports whether actual is the configured chord. With lenient
191+// set, a Ctrl+Shift+letter binding also accepts the plain Ctrl+letter
192+// event because legacy terminal encodings cannot carry Shift on control
193+// letters. Callers should try exact matching first.
194+func (k configuredKey) matches(actual tui.Key, lenient bool) bool {
124195 kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl
125196 shift := actual.Shift
126197 if kind == tui.KeyShiftTab {
@@ -128,12 +199,9 @@ func (k configuredKey) matches(actual tui.Key) bool {
128199 shift = true
129200 }
130201 // Raw control bytes are exposed as dedicated KeyKinds by the reader.
131- if kind >= tui.KeyCtrlC && kind <= tui.KeyCtrlO {
202+ if letter, ok := ctrlKindRunes[kind]; ok {
132203 ctrl = true
133- r = map[tui.KeyKind]rune{
134- tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
135- tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
136- }[kind]
204+ r = letter
137205 kind = tui.KeyRune
138206 }
139207 // Some enhanced keyboard protocols report the shifted glyph rather
@@ -148,27 +216,26 @@ func (k configuredKey) matches(actual tui.Key) bool {
148216 // letters, while configured key names are case-insensitive.
149217 r = unicode.ToLower(r)
150218 wantRune := unicode.ToLower(k.rune)
151- // Legacy terminals do not encode Shift on control letters, so a
152- // Ctrl+Shift+letter binding must also accept the corresponding raw
153- // Ctrl+letter event. Enhanced protocols still match the exact chord.
154- shiftMatches := shift == k.shift ||
155- (k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z')
219+ shiftMatches := shift == k.shift
220+ if !shiftMatches && lenient {
221+ shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z'
222+ }
156223 return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&
157224 shiftMatches && actual.Super == k.super
158225 }
159226
160227 func shiftedDigit(r rune) (rune, bool) {
161- switch r {
162- case ')', '!', '@', '#', '$', '%', '^', '&', '*', '(':
163- return []rune("0123456789")[strings.IndexRune(")!@#$%^&*(", r)], true
164- default:
165- return 0, false
228+ const glyphs = ")!@#$%^&*("
229+ if i := strings.IndexRune(glyphs, r); i >= 0 {
230+ return rune('0' + i), true
166231 }
232+ return 0, false
167233 }
168234
235+// keymapReservedKey reports whether k is one of the emergency/exit
236+// controls. They stay authoritative at runtime even if a binding somehow
237+// resolves to them.
169238 func keymapReservedKey(k tui.Key) bool {
170- // Keep the emergency/exit controls authoritative even when a stale or
171- // overly broad config.json attempts to bind them.
172239 switch k.Kind {
173240 case tui.KeyCtrlC, tui.KeyCtrlD:
174241 return true
@@ -178,14 +245,10 @@ func keymapReservedKey(k tui.Key) bool {
178245 return false
179246 }
180247
181-func validateKeymap(keymap map[string]string) error {
182- for key, command := range keymap {
183- if _, ok := parseConfiguredKey(key); !ok {
184- return fmt.Errorf("invalid keymap key %q", key)
185- }
186- if strings.TrimSpace(command) == "" {
187- return fmt.Errorf("empty keymap command for %q", key)
188- }
189- }
190- return nil
248+// isChordRune reports whether k is a Ctrl or Super modified rune. The
249+// reader surfaces unbound chords this way so keymaps can match them;
250+// they are never typed text and dialogs must not treat them as bare
251+// letters ('r' renames, 'q' closes, ...).
252+func isChordRune(k tui.Key) bool {
253+ return k.Kind == tui.KeyRune && (k.Ctrl || k.Super)
191254 }
@@ -9,9 +9,69 @@ import (
9 "github.com/patriceckhart/zot/packages/tui"9 "github.com/patriceckhart/zot/packages/tui"
10 )10 )
11 11
12-// configuredKeyCommand resolves a configured key chord. Key names are12+// keymapBinding is one compiled config.json keymap entry.
13-// case-insensitive and use '+' separated modifiers, for example13+type keymapBinding struct {
14-// "ctrl+shift+g", "alt+enter", or "cmd+k". Invalid entries are ignored.14+ name string // normalized chord name, for example "ctrl+shift+g"
15+ spec configuredKey
16+ command string
17+}
18+
19+// compileKeymap parses a config.json keymap into bindings sorted by
20+// chord name. Invalid entries are dropped and reported in issues so the
21+// interactive mode can surface them instead of silently ignoring them.
22+func compileKeymap(keymap map[string]string) (bindings []keymapBinding, issues []string) {
23+ for _, name := range sortedCustomKeys(keymap) {
24+ spec, ok := parseConfiguredKey(name)
25+ if !ok {
26+ issues = append(issues, fmt.Sprintf("keymap: invalid key %q (use modifier+key, for example ctrl+shift+g)", name))
27+ continue
28+ }
29+ if spec.reserved() {
30+ issues = append(issues, fmt.Sprintf("keymap: %q is reserved and cannot be rebound", name))
31+ continue
32+ }
33+ command := strings.TrimSpace(keymap[name])
34+ if command == "" || !strings.HasPrefix(command, "/") {
35+ issues = append(issues, fmt.Sprintf("keymap: %q must map to a slash command, got %q", name, command))
36+ continue
37+ }
38+ bindings = append(bindings, keymapBinding{name: strings.ToLower(strings.TrimSpace(name)), spec: spec, command: command})
39+ }
40+ return bindings, issues
41+}
42+
43+// lookupKeymap resolves key against compiled bindings. An exact chord
44+// match wins over the legacy Ctrl+letter fallback so enhanced-protocol
45+// terminals can bind ctrl+g and ctrl+shift+g independently.
46+func lookupKeymap(bindings []keymapBinding, key tui.Key) string {
47+ for _, b := range bindings {
48+ if b.spec.matches(key, false) {
49+ return b.command
50+ }
51+ }
52+ for _, b := range bindings {
53+ if b.spec.matches(key, true) {
54+ return b.command
55+ }
56+ }
57+ return ""
58+}
59+
60+// configuredKeyCommand resolves a configured key chord straight from a
61+// config.json keymap. Key names are case-insensitive and use '+'
62+// separated modifiers, for example "ctrl+shift+g", "alt+enter", or
63+// "cmd+k". Invalid entries are ignored. Interactive mode compiles the
64+// map once; this helper exists for callers that only have the map.
65+func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
66+ if len(keymap) == 0 {
67+ return ""
68+ }
69+ bindings, _ := compileKeymap(keymap)
70+ return lookupKeymap(bindings, key)
71+}
72+
73+// sortedCustomKeys returns keymap names in a stable, case-insensitive
74+// order for help output and deterministic lookup precedence.
15 func sortedCustomKeys(keymap map[string]string) []string {75 func sortedCustomKeys(keymap map[string]string) []string {
16 keys := make([]string, 0, len(keymap))76 keys := make([]string, 0, len(keymap))
17 for key := range keymap {77 for key := range keymap {
@@ -27,24 +87,6 @@ func sortedCustomKeys(keymap map[string]string) []string {
27 return keys87 return keys
28 }88 }
29 89
30-func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
31- if len(keymap) == 0 {
32- return ""
33- }
34- keys := make([]string, 0, len(keymap))
35- for name := range keymap {
36- keys = append(keys, name)
37- }
38- sort.Strings(keys)
39- for _, name := range keys {
40- spec, ok := parseConfiguredKey(name)
41- if ok && spec.matches(key) {
42- return strings.TrimSpace(keymap[name])
43- }
44- }
45- return ""
46-}
47-
48 type configuredKey struct {90 type configuredKey struct {
49 kind tui.KeyKind91 kind tui.KeyKind
50 rune rune92 rune rune
@@ -53,6 +95,31 @@ type configuredKey struct {
53 super bool95 super bool
54 }96 }
55 97
98+func (k configuredKey) modified() bool {
99+ return k.ctrl || k.alt || k.shift || k.super
100+}
101+
102+// reserved reports whether the chord belongs to the emergency/exit
103+// controls that config.json must never override.
104+func (k configuredKey) reserved() bool {
105+ switch {
106+ case k.kind == tui.KeyRune && k.ctrl && !k.alt && !k.super && (k.rune == 'c' || k.rune == 'd'):
107+ return true
108+ case k.kind == tui.KeyEsc && !k.modified():
109+ return true
110+ }
111+ return false
112+}
113+
114+var specialConfiguredKeys = map[string]tui.KeyKind{
115+ "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
116+ "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
117+ "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
118+ "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
119+ "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
120+ "space": tui.KeyRune,
121+}
122+
56 func parseConfiguredKey(value string) (configuredKey, bool) {123 func parseConfiguredKey(value string) (configuredKey, bool) {
57 var out configuredKey124 var out configuredKey
58 parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")125 parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")
@@ -85,42 +152,46 @@ func parseConfiguredKey(value string) (configuredKey, bool) {
85 }152 }
86 out.kind = tui.KeyRune153 out.kind = tui.KeyRune
87 if len([]rune(base)) == 1 {154 if len([]rune(base)) == 1 {
88- // Bare printable keys belong to normal text entry and must not be
89- // intercepted by a typo in config.json.
90- if !out.ctrl && !out.alt && !out.shift && !out.super {
91- return out, false
92- }
93 out.rune = []rune(base)[0]155 out.rune = []rune(base)[0]
94- return out, true
95- }
96- special := map[string]tui.KeyKind{
97- "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
98- "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
99- "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
100- "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
101- "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
102- "space": tui.KeyRune,
103- }
104- kind, ok := special[base]
105- if !ok {
106- return out, false
107- }
108- // Shift+Tab is represented as a dedicated kind by some terminals and
109- // as Tab+Shift by enhanced keyboard protocols; normalize it to the
110- // latter so both forms match.
111- if kind == tui.KeyShiftTab {
112- out.kind = tui.KeyTab
113- out.shift = true
114 } else {156 } else {
115- out.kind = kind157+ kind, ok := specialConfiguredKeys[base]
158+ if !ok {
159+ return out, false
160+ }
161+ // Shift+Tab is represented as a dedicated kind by some terminals
162+ // and as Tab+Shift by enhanced keyboard protocols; normalize it
163+ // to the latter so both forms match.
164+ if kind == tui.KeyShiftTab {
165+ out.kind = tui.KeyTab
166+ out.shift = true
167+ } else {
168+ out.kind = kind
169+ }
170+ if base == "space" {
171+ out.rune = ' '
172+ }
116 }173 }
117- if base == "space" {174+ // Unmodified keys belong to normal text entry and navigation. A
118- out.rune = ' '175+ // typo in config.json must not be able to capture Enter, Space,
176+ // arrows, or a plain letter.
177+ if !out.modified() {
178+ return out, false
119 }179 }
120 return out, true180 return out, true
121 }181 }
122 182
123-func (k configuredKey) matches(actual tui.Key) bool {183+// ctrlKindRunes maps the reader's dedicated control-key kinds back to
184+// the letter they encode so "ctrl+l" style bindings can match them.
185+var ctrlKindRunes = map[tui.KeyKind]rune{
186+ tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
187+ tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
188+}
189+
190+// matches reports whether actual is the configured chord. With lenient
191+// set, a Ctrl+Shift+letter binding also accepts the plain Ctrl+letter
192+// event because legacy terminal encodings cannot carry Shift on control
193+// letters. Callers should try exact matching first.
194+func (k configuredKey) matches(actual tui.Key, lenient bool) bool {
124 kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl195 kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl
125 shift := actual.Shift196 shift := actual.Shift
126 if kind == tui.KeyShiftTab {197 if kind == tui.KeyShiftTab {
@@ -128,12 +199,9 @@ func (k configuredKey) matches(actual tui.Key) bool {
128 shift = true199 shift = true
129 }200 }
130 // Raw control bytes are exposed as dedicated KeyKinds by the reader.201 // Raw control bytes are exposed as dedicated KeyKinds by the reader.
131- if kind >= tui.KeyCtrlC && kind <= tui.KeyCtrlO {202+ if letter, ok := ctrlKindRunes[kind]; ok {
132 ctrl = true203 ctrl = true
133- r = map[tui.KeyKind]rune{204+ r = letter
134- tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
135- tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
136- }[kind]
137 kind = tui.KeyRune205 kind = tui.KeyRune
138 }206 }
139 // Some enhanced keyboard protocols report the shifted glyph rather207 // Some enhanced keyboard protocols report the shifted glyph rather
@@ -148,27 +216,26 @@ func (k configuredKey) matches(actual tui.Key) bool {
148 // letters, while configured key names are case-insensitive.216 // letters, while configured key names are case-insensitive.
149 r = unicode.ToLower(r)217 r = unicode.ToLower(r)
150 wantRune := unicode.ToLower(k.rune)218 wantRune := unicode.ToLower(k.rune)
151- // Legacy terminals do not encode Shift on control letters, so a219+ shiftMatches := shift == k.shift
152- // Ctrl+Shift+letter binding must also accept the corresponding raw220+ if !shiftMatches && lenient {
153- // Ctrl+letter event. Enhanced protocols still match the exact chord.221+ shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z'
154- shiftMatches := shift == k.shift ||222+ }
155- (k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z')
156 return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&223 return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&
157 shiftMatches && actual.Super == k.super224 shiftMatches && actual.Super == k.super
158 }225 }
159 226
160 func shiftedDigit(r rune) (rune, bool) {227 func shiftedDigit(r rune) (rune, bool) {
161- switch r {228+ const glyphs = ")!@#$%^&*("
162- case ')', '!', '@', '#', '$', '%', '^', '&', '*', '(':229+ if i := strings.IndexRune(glyphs, r); i >= 0 {
163- return []rune("0123456789")[strings.IndexRune(")!@#$%^&*(", r)], true230+ return rune('0' + i), true
164- default:
165- return 0, false
166 }231 }
232+ return 0, false
167 }233 }
168 234
235+// keymapReservedKey reports whether k is one of the emergency/exit
236+// controls. They stay authoritative at runtime even if a binding somehow
237+// resolves to them.
169 func keymapReservedKey(k tui.Key) bool {238 func keymapReservedKey(k tui.Key) bool {
170- // Keep the emergency/exit controls authoritative even when a stale or
171- // overly broad config.json attempts to bind them.
172 switch k.Kind {239 switch k.Kind {
173 case tui.KeyCtrlC, tui.KeyCtrlD:240 case tui.KeyCtrlC, tui.KeyCtrlD:
174 return true241 return true
@@ -178,14 +245,10 @@ func keymapReservedKey(k tui.Key) bool {
178 return false245 return false
179 }246 }
180 247
181-func validateKeymap(keymap map[string]string) error {248+// isChordRune reports whether k is a Ctrl or Super modified rune. The
182- for key, command := range keymap {249+// reader surfaces unbound chords this way so keymaps can match them;
183- if _, ok := parseConfiguredKey(key); !ok {250+// they are never typed text and dialogs must not treat them as bare
184- return fmt.Errorf("invalid keymap key %q", key)251+// letters ('r' renames, 'q' closes, ...).
185- }252+func isChordRune(k tui.Key) bool {
186- if strings.TrimSpace(command) == "" {253+ return k.Kind == tui.KeyRune && (k.Ctrl || k.Super)
187- return fmt.Errorf("empty keymap command for %q", key)
188- }
189- }
190- return nil
191 }254 }
M packages/agent/modes/keymap_test.go +124 -8
@@ -1,8 +1,12 @@
11 package modes
22
33 import (
4+ "context"
5+ "strings"
46 "testing"
57
8+ "github.com/patriceckhart/zot/packages/agent/tools"
9+ "github.com/patriceckhart/zot/packages/core"
610 "github.com/patriceckhart/zot/packages/tui"
711 )
812
@@ -22,8 +26,21 @@ func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) {
2226 }
2327 }
2428
29+func 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+
2542 func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
26- if got := configuredKeyCommand(map[string]string{"ctrl+c": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlC}); got != "/skill:review" {
43+ if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" {
2744 t.Fatalf("configuredKeyCommand = %q, want skill command", got)
2845 }
2946 if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {
@@ -32,11 +49,10 @@ func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
3249 }
3350
3451 func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {
35- if _, ok := parseConfiguredKey("ctrl+shift"); ok {
36- t.Fatal("modifier-only key was accepted")
37- }
38- if _, ok := parseConfiguredKey("g"); ok {
39- t.Fatal("bare printable key was accepted")
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+ }
4056 }
4157 key, ok := parseConfiguredKey("alt+enter")
4258 if !ok || key.kind != tui.KeyEnter || !key.alt {
@@ -45,7 +61,107 @@ func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T)
4561 if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {
4662 t.Fatalf("shift+tab binding = %q, want /help", got)
4763 }
48- if err := validateKeymap(map[string]string{"no-such-key": "/help"}); err == nil {
49- t.Fatal("invalid keymap was accepted")
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+
69+func 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+
104+func 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+
115+func 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+
130+func 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+
139+func 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+
153+func 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")
50166 }
51167 }
@@ -1,8 +1,12 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "strings"
4 "testing"6 "testing"
5 7
8+ "github.com/patriceckhart/zot/packages/agent/tools"
9+ "github.com/patriceckhart/zot/packages/core"
6 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
7 )11 )
8 12
@@ -22,8 +26,21 @@ func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) {
22 }26 }
23 }27 }
24 28
29+func 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+
25 func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {42 func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
26- if got := configuredKeyCommand(map[string]string{"ctrl+c": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlC}); got != "/skill:review" {43+ if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" {
27 t.Fatalf("configuredKeyCommand = %q, want skill command", got)44 t.Fatalf("configuredKeyCommand = %q, want skill command", got)
28 }45 }
29 if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {46 if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {
@@ -32,11 +49,10 @@ func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
32 }49 }
33 50
34 func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {51 func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {
35- if _, ok := parseConfiguredKey("ctrl+shift"); ok {52+ for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} {
36- t.Fatal("modifier-only key was accepted")53+ if _, ok := parseConfiguredKey(bad); ok {
37- }54+ t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad)
38- if _, ok := parseConfiguredKey("g"); ok {55+ }
39- t.Fatal("bare printable key was accepted")
40 }56 }
41 key, ok := parseConfiguredKey("alt+enter")57 key, ok := parseConfiguredKey("alt+enter")
42 if !ok || key.kind != tui.KeyEnter || !key.alt {58 if !ok || key.kind != tui.KeyEnter || !key.alt {
@@ -45,7 +61,107 @@ func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T)
45 if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {61 if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {
46 t.Fatalf("shift+tab binding = %q, want /help", got)62 t.Fatalf("shift+tab binding = %q, want /help", got)
47 }63 }
48- if err := validateKeymap(map[string]string{"no-such-key": "/help"}); err == nil {64+ if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" {
49- t.Fatal("invalid keymap was accepted")65+ t.Fatalf("ctrl+space binding = %q, want /help", got)
66+ }
67+}
68+
69+func 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+
104+func 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+
115+func 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+
130+func 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+
139+func 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+
153+func 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")
50 }166 }
51 }167 }
M packages/tui/editor.go +7 -0
@@ -129,6 +129,13 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
129129 e.newline()
130130 return false
131131 }
132+ // Ctrl and Super chords are shortcuts, not typed text. The
133+ // reader surfaces unbound ones (Ctrl+S, Cmd+K, ...) as runes so
134+ // callers can match keymaps; anything that reaches the editor
135+ // unmatched must be ignored rather than inserted.
136+ if k.Ctrl || k.Super {
137+ return false
138+ }
132139 e.insert(string(k.Rune))
133140 case KeyEnter:
134141 if k.Shift || k.Alt {
@@ -129,6 +129,13 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
129 e.newline()129 e.newline()
130 return false130 return false
131 }131 }
132+ // Ctrl and Super chords are shortcuts, not typed text. The
133+ // reader surfaces unbound ones (Ctrl+S, Cmd+K, ...) as runes so
134+ // callers can match keymaps; anything that reaches the editor
135+ // unmatched must be ignored rather than inserted.
136+ if k.Ctrl || k.Super {
137+ return false
138+ }
132 e.insert(string(k.Rune))139 e.insert(string(k.Rune))
133 case KeyEnter:140 case KeyEnter:
134 if k.Shift || k.Alt {141 if k.Shift || k.Alt {
M packages/tui/editor_shift_enter_test.go +17 -0
@@ -15,6 +15,23 @@ func TestEditorShiftEnterInsertsNewline(t *testing.T) {
1515 }
1616 }
1717
18+func TestEditorIgnoresCtrlAndSuperRunes(t *testing.T) {
19+ e := NewEditor("> ")
20+ e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
21+ for _, k := range []Key{
22+ {Kind: KeyRune, Rune: 's', Ctrl: true},
23+ {Kind: KeyRune, Rune: 'G', Ctrl: true, Shift: true},
24+ {Kind: KeyRune, Rune: 'k', Super: true},
25+ } {
26+ if submit := e.HandleKey(k); submit {
27+ t.Fatalf("%+v submitted", k)
28+ }
29+ }
30+ if got, want := e.Value(), "a"; got != want {
31+ t.Fatalf("Value() = %q, want %q (chords must not insert text)", got, want)
32+ }
33+}
34+
1835 func TestEditorPlainEnterSubmits(t *testing.T) {
1936 e := NewEditor("> ")
2037 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
@@ -15,6 +15,23 @@ func TestEditorShiftEnterInsertsNewline(t *testing.T) {
15 }15 }
16 }16 }
17 17
18+func TestEditorIgnoresCtrlAndSuperRunes(t *testing.T) {
19+ e := NewEditor("> ")
20+ e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
21+ for _, k := range []Key{
22+ {Kind: KeyRune, Rune: 's', Ctrl: true},
23+ {Kind: KeyRune, Rune: 'G', Ctrl: true, Shift: true},
24+ {Kind: KeyRune, Rune: 'k', Super: true},
25+ } {
26+ if submit := e.HandleKey(k); submit {
27+ t.Fatalf("%+v submitted", k)
28+ }
29+ }
30+ if got, want := e.Value(), "a"; got != want {
31+ t.Fatalf("Value() = %q, want %q (chords must not insert text)", got, want)
32+ }
33+}
34+
18 func TestEditorPlainEnterSubmits(t *testing.T) {35 func TestEditorPlainEnterSubmits(t *testing.T) {
19 e := NewEditor("> ")36 e := NewEditor("> ")
20 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})37 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
M packages/tui/input.go +8 -10
@@ -94,22 +94,20 @@ func (r *Reader) Read() (Key, error) {
9494 return Key{Kind: KeyCtrlO}, nil
9595 case b == 0x16:
9696 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
97- case b == 0x07:
98- // Legacy terminals encode Ctrl+G as BEL. There is no separate
99- // encoding for Ctrl+Shift+G, so expose the control letter for
100- // keymap matching; callers that need the distinction require an
101- // enhanced keyboard protocol.
102- return Key{Kind: KeyRune, Rune: 'g', Ctrl: true}, nil
10397 case b == '\r', b == '\n':
10498 return Key{Kind: KeyEnter}, nil
10599 case b == '\t':
106100 return Key{Kind: KeyTab}, nil
107101 case b == 0x7f, b == 0x08:
108102 return Key{Kind: KeyBackspace}, nil
109- case b >= 0x10 && b <= 0x1a:
110- // Raw terminals encode Ctrl+Q..Ctrl+Z as control bytes. The
111- // commonly used bindings above have dedicated KeyKinds; expose
112- // the remaining letters as modified runes for keymaps.
103+ case b >= 0x01 && b <= 0x1a:
104+ // Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes
105+ // 0x01..0x1a. The bindings above have dedicated KeyKinds; the
106+ // remaining letters surface as Ctrl-modified runes so keymaps
107+ // can bind them. Legacy encodings cannot carry Shift, so
108+ // Ctrl+Shift+letter arrives identically; only enhanced keyboard
109+ // protocols distinguish the two. Consumers must treat a Ctrl
110+ // rune as a chord, never as typed text.
113111 return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil
114112 case b == 0x1b:
115113 return r.readEscape()
@@ -94,22 +94,20 @@ func (r *Reader) Read() (Key, error) {
94 return Key{Kind: KeyCtrlO}, nil94 return Key{Kind: KeyCtrlO}, nil
95 case b == 0x16:95 case b == 0x16:
96 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil96 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
97- case b == 0x07:
98- // Legacy terminals encode Ctrl+G as BEL. There is no separate
99- // encoding for Ctrl+Shift+G, so expose the control letter for
100- // keymap matching; callers that need the distinction require an
101- // enhanced keyboard protocol.
102- return Key{Kind: KeyRune, Rune: 'g', Ctrl: true}, nil
103 case b == '\r', b == '\n':97 case b == '\r', b == '\n':
104 return Key{Kind: KeyEnter}, nil98 return Key{Kind: KeyEnter}, nil
105 case b == '\t':99 case b == '\t':
106 return Key{Kind: KeyTab}, nil100 return Key{Kind: KeyTab}, nil
107 case b == 0x7f, b == 0x08:101 case b == 0x7f, b == 0x08:
108 return Key{Kind: KeyBackspace}, nil102 return Key{Kind: KeyBackspace}, nil
109- case b >= 0x10 && b <= 0x1a:103+ case b >= 0x01 && b <= 0x1a:
110- // Raw terminals encode Ctrl+Q..Ctrl+Z as control bytes. The104+ // Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes
111- // commonly used bindings above have dedicated KeyKinds; expose105+ // 0x01..0x1a. The bindings above have dedicated KeyKinds; the
112- // the remaining letters as modified runes for keymaps.106+ // remaining letters surface as Ctrl-modified runes so keymaps
107+ // can bind them. Legacy encodings cannot carry Shift, so
108+ // Ctrl+Shift+letter arrives identically; only enhanced keyboard
109+ // protocols distinguish the two. Consumers must treat a Ctrl
110+ // rune as a chord, never as typed text.
113 return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil111 return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil
114 case b == 0x1b:112 case b == 0x1b:
115 return r.readEscape()113 return r.readEscape()
M packages/tui/input_test.go +30 -0
@@ -65,6 +65,36 @@ func TestReaderParsesRawCtrlSAsModifiedRune(t *testing.T) {
6565 }
6666 }
6767
68+func TestReaderParsesRawCtrlBAsModifiedRune(t *testing.T) {
69+ k := readKey(t, "\x02")
70+ if k.Kind != KeyRune || k.Rune != 'b' || !k.Ctrl {
71+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+b", k.Kind, k.Rune, k.Ctrl)
72+ }
73+}
74+
75+func TestReaderKeepsDedicatedControlKinds(t *testing.T) {
76+ for in, want := range map[string]KeyKind{
77+ "\x01": KeyCtrlA, "\x03": KeyCtrlC, "\x04": KeyCtrlD, "\x05": KeyCtrlE,
78+ "\x0b": KeyCtrlK, "\x0c": KeyCtrlL, "\x0f": KeyCtrlO, "\x15": KeyCtrlU,
79+ "\x17": KeyCtrlW, "\x16": KeyPasteClipboard, "\t": KeyTab, "\r": KeyEnter, "\x08": KeyBackspace,
80+ } {
81+ if k := readKey(t, in); k.Kind != want {
82+ t.Fatalf("Read(%q) kind=%v, want %v", in, k.Kind, want)
83+ }
84+ }
85+}
86+
87+func TestReaderParsesCSIUCtrlLetterAsModifiedRune(t *testing.T) {
88+ k := readKey(t, "\x1b[115;5u")
89+ if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl || k.Shift {
90+ t.Fatalf("Read = %+v, want ctrl+s", k)
91+ }
92+ k = readKey(t, "\x1b[71;6u")
93+ if k.Kind != KeyRune || k.Rune != 'G' || !k.Ctrl || !k.Shift {
94+ t.Fatalf("Read = %+v, want ctrl+shift+G", k)
95+ }
96+}
97+
6898 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
6999 k := readKey(t, "\x16")
70100 if k.Kind != KeyPasteClipboard || !k.Ctrl {
@@ -65,6 +65,36 @@ func TestReaderParsesRawCtrlSAsModifiedRune(t *testing.T) {
65 }65 }
66 }66 }
67 67
68+func TestReaderParsesRawCtrlBAsModifiedRune(t *testing.T) {
69+ k := readKey(t, "\x02")
70+ if k.Kind != KeyRune || k.Rune != 'b' || !k.Ctrl {
71+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+b", k.Kind, k.Rune, k.Ctrl)
72+ }
73+}
74+
75+func TestReaderKeepsDedicatedControlKinds(t *testing.T) {
76+ for in, want := range map[string]KeyKind{
77+ "\x01": KeyCtrlA, "\x03": KeyCtrlC, "\x04": KeyCtrlD, "\x05": KeyCtrlE,
78+ "\x0b": KeyCtrlK, "\x0c": KeyCtrlL, "\x0f": KeyCtrlO, "\x15": KeyCtrlU,
79+ "\x17": KeyCtrlW, "\x16": KeyPasteClipboard, "\t": KeyTab, "\r": KeyEnter, "\x08": KeyBackspace,
80+ } {
81+ if k := readKey(t, in); k.Kind != want {
82+ t.Fatalf("Read(%q) kind=%v, want %v", in, k.Kind, want)
83+ }
84+ }
85+}
86+
87+func TestReaderParsesCSIUCtrlLetterAsModifiedRune(t *testing.T) {
88+ k := readKey(t, "\x1b[115;5u")
89+ if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl || k.Shift {
90+ t.Fatalf("Read = %+v, want ctrl+s", k)
91+ }
92+ k = readKey(t, "\x1b[71;6u")
93+ if k.Kind != KeyRune || k.Rune != 'G' || !k.Ctrl || !k.Shift {
94+ t.Fatalf("Read = %+v, want ctrl+shift+G", k)
95+ }
96+}
97+
68 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {98 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
69 k := readKey(t, "\x16")99 k := readKey(t, "\x16")
70 if k.Kind != KeyPasteClipboard || !k.Ctrl {100 if k.Kind != KeyPasteClipboard || !k.Ctrl {

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