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 | |||
| 164 | 164 | ||
| 165 | 165 | ### 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 | 169 | ```json | |
| 170 | 170 | { | |
| @@ -177,9 +177,19 @@ Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. | |||
| 177 | 177 | ``` | |
| 178 | 178 | ||
| 179 | 179 | 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. | |
| 183 | 193 | ||
| 184 | 194 | ## Persistent instructions (AGENTS.md) | |
| 185 | 195 | ||
| @@ -164,7 +164,7 @@ The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP | |||||
| 164 | 164 | ||||
| 165 | ### Configurable shortcuts | 165 | ### 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 | ```json | 169 | ```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 as | 179 | 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 | 180 | + | 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{ | |||
| 36 | 36 | // section it lives in. The width is computed from the longest label | |
| 37 | 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | |
| 38 | 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 | 40 | if width < 20 { | |
| 41 | 41 | width = 20 | |
| 42 | 42 | } | |
| @@ -62,8 +62,8 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s | |||
| 62 | 62 | labelWidth = n | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 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 { | |
| 67 | 67 | 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 | 101 | if len(keymap) > 0 { | |
| 102 | 102 | out = append(out, "", tui.Bold("custom keys:")) | |
| 103 | - | for _, key := range sortedCustomKeys(keymap) { | |
| 104 | - | command := strings.TrimSpace(keymap[key]) | |
| 103 | + | for _, b := range keymap { | |
| 105 | 104 | 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))) | |
| 108 | 107 | } | |
| 109 | 108 | } | |
| 110 | 109 | ||
| @@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{ | |||||
| 36 | // section it lives in. The width is computed from the longest label | 36 | // 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 either | 37 | // 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 = 20 | 41 | width = 20 | ||
| 42 | } | 42 | } | ||
| @@ -62,8 +62,8 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[s | |||||
| 62 | labelWidth = n | 62 | 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 = n | 67 | 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) { | |||
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | 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") | |
| 25 | 30 | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | |
| 26 | 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 | } | |
| @@ -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 { | |||
| 580 | 580 | // stay in the scrolling chat until /clear without entering the transcript. | |
| 581 | 581 | 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 | 587 | // shellRunning is true while a !command is executing. It shares | |
| 584 | 588 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | |
| 585 | 589 | // escape can start while one is in flight. | |
| @@ -698,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 698 | 702 | inputHistoryIndex: -1, | |
| 699 | 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 | 708 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 702 | 709 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | |
| 703 | 710 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| @@ -2105,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool { | |||
| 2105 | 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 | 2138 | // restoreConfirmFocus returns input to confirmation after slash input is | |
| 2109 | 2139 | // cleared or a child interaction closes. A non-empty editor keeps command | |
| 2110 | 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 | 2185 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 2148 | 2186 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 2149 | 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 | 2490 | 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 | 2496 | 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 != "" { | |
| 2466 | 2498 | parts := strings.Fields(command) | |
| 2467 | 2499 | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |
| 2468 | 2500 | i.cancelAndWaitForIdle() | |
| @@ -4560,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4560 | 4592 | i.mu.Unlock() | |
| 4561 | 4593 | case "/help": | |
| 4562 | 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 | 4596 | i.statusErr = "" | |
| 4565 | 4597 | i.statusOK = "" | |
| 4566 | 4598 | // 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 []string | 581 | 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 shares | 587 | // shellRunning is true while a !command is executing. It shares | ||
| 584 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | 588 | // 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 is | 2138 | // restoreConfirmFocus returns input to confirmation after slash input is | ||
| 2109 | // cleared or a child interaction closes. A non-empty editor keeps command | 2139 | // 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, so | 2185 | // 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 quit | 2186 | // 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 false | 2490 | 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 ( | |||
| 9 | 9 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 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. | |
| 15 | 75 | func sortedCustomKeys(keymap map[string]string) []string { | |
| 16 | 76 | keys := make([]string, 0, len(keymap)) | |
| 17 | 77 | for key := range keymap { | |
| @@ -27,24 +87,6 @@ func sortedCustomKeys(keymap map[string]string) []string { | |||
| 27 | 87 | 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 | 90 | type configuredKey struct { | |
| 49 | 91 | kind tui.KeyKind | |
| 50 | 92 | rune rune | |
| @@ -53,6 +95,31 @@ type configuredKey struct { | |||
| 53 | 95 | 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 | 123 | func parseConfiguredKey(value string) (configuredKey, bool) { | |
| 57 | 124 | var out configuredKey | |
| 58 | 125 | parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+") | |
| @@ -85,42 +152,46 @@ func parseConfiguredKey(value string) (configuredKey, bool) { | |||
| 85 | 152 | } | |
| 86 | 153 | out.kind = tui.KeyRune | |
| 87 | 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 | 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 | 156 | } 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 | + | } | |
| 116 | 173 | } | |
| 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 | |
| 119 | 179 | } | |
| 120 | 180 | 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 | 195 | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | |
| 125 | 196 | shift := actual.Shift | |
| 126 | 197 | if kind == tui.KeyShiftTab { | |
| @@ -128,12 +199,9 @@ func (k configuredKey) matches(actual tui.Key) bool { | |||
| 128 | 199 | shift = true | |
| 129 | 200 | } | |
| 130 | 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 | 203 | 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 | |
| 137 | 205 | kind = tui.KeyRune | |
| 138 | 206 | } | |
| 139 | 207 | // Some enhanced keyboard protocols report the shifted glyph rather | |
| @@ -148,27 +216,26 @@ func (k configuredKey) matches(actual tui.Key) bool { | |||
| 148 | 216 | // letters, while configured key names are case-insensitive. | |
| 149 | 217 | r = unicode.ToLower(r) | |
| 150 | 218 | 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 | + | } | |
| 156 | 223 | return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt && | |
| 157 | 224 | shiftMatches && actual.Super == k.super | |
| 158 | 225 | } | |
| 159 | 226 | ||
| 160 | 227 | 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 | |
| 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 | 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 | 239 | switch k.Kind { | |
| 173 | 240 | case tui.KeyCtrlC, tui.KeyCtrlD: | |
| 174 | 241 | return true | |
| @@ -178,14 +245,10 @@ func keymapReservedKey(k tui.Key) bool { | |||
| 178 | 245 | return false | |
| 179 | 246 | } | |
| 180 | 247 | ||
| 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) | |
| 191 | 254 | } | |
| @@ -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 are | 12 | + | // keymapBinding is one compiled config.json keymap entry. |
| 13 | - | // case-insensitive and use '+' separated modifiers, for example | 13 | + | 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 keys | 87 | 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.KeyKind | 91 | kind tui.KeyKind | ||
| 50 | rune rune | 92 | rune rune | ||
| @@ -53,6 +95,31 @@ type configuredKey struct { | |||||
| 53 | super bool | 95 | 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 configuredKey | 124 | 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.KeyRune | 153 | 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 = 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 | + | } | |||
| 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, true | 180 | 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.Ctrl | 195 | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | ||
| 125 | shift := actual.Shift | 196 | 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 = true | 199 | 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 = true | 203 | 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.KeyRune | 205 | kind = tui.KeyRune | ||
| 138 | } | 206 | } | ||
| 139 | // Some enhanced keyboard protocols report the shifted glyph rather | 207 | // 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 a | 219 | + | shiftMatches := shift == k.shift |
| 152 | - | // Ctrl+Shift+letter binding must also accept the corresponding raw | 220 | + | 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.super | 224 | 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)], true | 230 | + | 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 true | 241 | return true | ||
| @@ -178,14 +245,10 @@ func keymapReservedKey(k tui.Key) bool { | |||||
| 178 | return false | 245 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 6 | 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 | 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 | 44 | t.Fatalf("configuredKeyCommand = %q, want skill command", got) | |
| 28 | 45 | } | |
| 29 | 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 | 51 | 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 | + | } | |
| 40 | 56 | } | |
| 41 | 57 | key, ok := parseConfiguredKey("alt+enter") | |
| 42 | 58 | if !ok || key.kind != tui.KeyEnter || !key.alt { | |
| @@ -45,7 +61,107 @@ func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) | |||
| 45 | 61 | if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" { | |
| 46 | 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 { | |
| 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") | |
| 50 | 166 | } | |
| 51 | 167 | } | |
| @@ -1,8 +1,12 @@ | |||||
| 1 | package modes | 1 | 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) { | |||
| 129 | 129 | e.newline() | |
| 130 | 130 | 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 | 139 | e.insert(string(k.Rune)) | |
| 133 | 140 | case KeyEnter: | |
| 134 | 141 | 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 false | 130 | 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) { | |||
| 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 | 35 | func TestEditorPlainEnterSubmits(t *testing.T) { | |
| 19 | 36 | e := NewEditor("> ") | |
| 20 | 37 | 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) { | |||
| 94 | 94 | return Key{Kind: KeyCtrlO}, nil | |
| 95 | 95 | case b == 0x16: | |
| 96 | 96 | 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 | 97 | case b == '\r', b == '\n': | |
| 104 | 98 | return Key{Kind: KeyEnter}, nil | |
| 105 | 99 | case b == '\t': | |
| 106 | 100 | return Key{Kind: KeyTab}, nil | |
| 107 | 101 | case b == 0x7f, b == 0x08: | |
| 108 | 102 | 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. | |
| 113 | 111 | return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil | |
| 114 | 112 | case b == 0x1b: | |
| 115 | 113 | return r.readEscape() | |
| @@ -94,22 +94,20 @@ func (r *Reader) Read() (Key, error) { | |||||
| 94 | return Key{Kind: KeyCtrlO}, nil | 94 | return Key{Kind: KeyCtrlO}, nil | ||
| 95 | case b == 0x16: | 95 | case b == 0x16: | ||
| 96 | return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil | 96 | 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}, nil | 98 | return Key{Kind: KeyEnter}, nil | ||
| 105 | case b == '\t': | 99 | case b == '\t': | ||
| 106 | return Key{Kind: KeyTab}, nil | 100 | return Key{Kind: KeyTab}, nil | ||
| 107 | case b == 0x7f, b == 0x08: | 101 | case b == 0x7f, b == 0x08: | ||
| 108 | return Key{Kind: KeyBackspace}, nil | 102 | 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. The | 104 | + | // Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes |
| 111 | - | // commonly used bindings above have dedicated KeyKinds; expose | 105 | + | // 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}, nil | 111 | 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) { | |||
| 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 | 98 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |
| 69 | 99 | k := readKey(t, "\x16") | |
| 70 | 100 | 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 { | ||