Merge pull request #195 from ggbalaazs/custom_shortcuts
feat: add custom hotkeys for slash commands (built-in, extension or skill)
13 files · +671 -4
M README.md +29 -0
| @@ -162,6 +162,35 @@ To route zot-managed HTTP and HTTPS requests through one proxy, add `http_proxy` | |||
| 162 | 162 | ||
| 163 | 163 | The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zot after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store. | |
| 164 | 164 | ||
| 165 | + | ### Configurable shortcuts | |
| 166 | + | ||
| 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 | + | ||
| 169 | + | ```json | |
| 170 | + | { | |
| 171 | + | "keymap": { | |
| 172 | + | "ctrl+s": "/settings", | |
| 173 | + | "ctrl+g": "/git-tools:status", | |
| 174 | + | "alt+r": "/skill:code-review" | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ``` | |
| 178 | + | ||
| 179 | + | Shortcuts run in the main interactive input and use the same dispatch path as | |
| 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. | |
| 193 | + | ||
| 165 | 194 | ## Persistent instructions (AGENTS.md) | |
| 166 | 195 | ||
| 167 | 196 | Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | |
| @@ -162,6 +162,35 @@ To route zot-managed HTTP and HTTPS requests through one proxy, add `http_proxy` | |||||
| 162 | 162 | ||||
| 163 | The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zot after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store. | 163 | The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zot after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store. | ||
| 164 | 164 | ||||
| 165 | + | ### Configurable shortcuts | |||
| 166 | + | ||||
| 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 | + | ||||
| 169 | + | ```json | |||
| 170 | + | { | |||
| 171 | + | "keymap": { | |||
| 172 | + | "ctrl+s": "/settings", | |||
| 173 | + | "ctrl+g": "/git-tools:status", | |||
| 174 | + | "alt+r": "/skill:code-review" | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ``` | |||
| 178 | + | ||||
| 179 | + | Shortcuts run in the main interactive input and use the same dispatch path as | |||
| 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. | |||
| 193 | + | ||||
| 165 | ## Persistent instructions (AGENTS.md) | 194 | ## Persistent instructions (AGENTS.md) | ||
| 166 | 195 | ||||
| 167 | Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | 196 | Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | ||
M packages/agent/cli.go +1 -0
| @@ -1331,6 +1331,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1331 | 1331 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | |
| 1332 | 1332 | NoTools: args.NoTools, | |
| 1333 | 1333 | QuickModelShortcuts: quickModelShortcuts, | |
| 1334 | + | Keymap: initialCfg.Keymap, | |
| 1334 | 1335 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 1335 | 1336 | FuzzySkillSuggest: initialCfg.FuzzySkillSuggest, | |
| 1336 | 1337 | RespectGitignore: initialCfg.RespectGitignore, | |
| @@ -1331,6 +1331,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1331 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | 1331 | OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit, | ||
| 1332 | NoTools: args.NoTools, | 1332 | NoTools: args.NoTools, | ||
| 1333 | QuickModelShortcuts: quickModelShortcuts, | 1333 | QuickModelShortcuts: quickModelShortcuts, | ||
| 1334 | + | Keymap: initialCfg.Keymap, | |||
| 1334 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 1335 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| 1335 | FuzzySkillSuggest: initialCfg.FuzzySkillSuggest, | 1336 | FuzzySkillSuggest: initialCfg.FuzzySkillSuggest, | ||
| 1336 | RespectGitignore: initialCfg.RespectGitignore, | 1337 | RespectGitignore: initialCfg.RespectGitignore, | ||
M packages/agent/config.go +10 -0
| @@ -47,6 +47,11 @@ type Config struct { | |||
| 47 | 47 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |
| 48 | 48 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |
| 49 | 49 | ||
| 50 | + | // Keymap maps terminal key chords (for example "ctrl+shift+g") to | |
| 51 | + | // interactive slash commands. Values may be built-in commands, | |
| 52 | + | // extension commands, or /skill:<name> invocations. | |
| 53 | + | Keymap map[string]string `json:"keymap,omitempty"` | |
| 54 | + | ||
| 50 | 55 | // InlineImagesEnabled controls whether zot draws screenshots inline | |
| 51 | 56 | // when the terminal supports an image protocol. nil/missing means | |
| 52 | 57 | // auto (enabled when supported); false disables; true forces the | |
| @@ -240,6 +245,11 @@ func LoadConfig() (Config, error) { | |||
| 240 | 245 | ||
| 241 | 246 | // SaveConfig writes the config file, creating parent dirs. | |
| 242 | 247 | func SaveConfig(c Config) error { | |
| 248 | + | // Preserve the existing user-defined keymap when saving other settings. | |
| 249 | + | if existing, err := LoadConfig(); err == nil { | |
| 250 | + | c.Keymap = existing.Keymap | |
| 251 | + | } | |
| 252 | + | ||
| 243 | 253 | if err := os.MkdirAll(ZotHome(), 0o755); err != nil { | |
| 244 | 254 | return err | |
| 245 | 255 | } | |
| @@ -47,6 +47,11 @@ type Config struct { | |||||
| 47 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | 47 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | ||
| 48 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | 48 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | ||
| 49 | 49 | ||||
| 50 | + | // Keymap maps terminal key chords (for example "ctrl+shift+g") to | |||
| 51 | + | // interactive slash commands. Values may be built-in commands, | |||
| 52 | + | // extension commands, or /skill:<name> invocations. | |||
| 53 | + | Keymap map[string]string `json:"keymap,omitempty"` | |||
| 54 | + | ||||
| 50 | // InlineImagesEnabled controls whether zot draws screenshots inline | 55 | // InlineImagesEnabled controls whether zot draws screenshots inline | ||
| 51 | // when the terminal supports an image protocol. nil/missing means | 56 | // when the terminal supports an image protocol. nil/missing means | ||
| 52 | // auto (enabled when supported); false disables; true forces the | 57 | // auto (enabled when supported); false disables; true forces the | ||
| @@ -240,6 +245,11 @@ func LoadConfig() (Config, error) { | |||||
| 240 | 245 | ||||
| 241 | // SaveConfig writes the config file, creating parent dirs. | 246 | // SaveConfig writes the config file, creating parent dirs. | ||
| 242 | func SaveConfig(c Config) error { | 247 | func SaveConfig(c Config) error { | ||
| 248 | + | // Preserve the existing user-defined keymap when saving other settings. | |||
| 249 | + | if existing, err := LoadConfig(); err == nil { | |||
| 250 | + | c.Keymap = existing.Keymap | |||
| 251 | + | } | |||
| 252 | + | ||||
| 243 | if err := os.MkdirAll(ZotHome(), 0o755); err != nil { | 253 | if err := os.MkdirAll(ZotHome(), 0o755); err != nil { | ||
| 244 | return err | 254 | return err | ||
| 245 | } | 255 | } | ||
A packages/agent/config_save_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestSaveConfigPreservesKeymapForPartialConfig(t *testing.T) { | |
| 9 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 10 | + | ||
| 11 | + | keymap := map[string]string{"ctrl+shift+g": "/git-tools:status"} | |
| 12 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | if err := os.WriteFile(ConfigPath(), []byte(`{"theme":"dark","keymap":{"ctrl+shift+g":"/git-tools:status"}}`), 0o644); err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | if err := SaveConfig(Config{Theme: "light"}); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | got, err := LoadConfig() | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | if len(got.Keymap) != 1 || got.Keymap["ctrl+shift+g"] != keymap["ctrl+shift+g"] { | |
| 27 | + | t.Fatalf("keymap = %#v, want %#v", got.Keymap, keymap) | |
| 28 | + | } | |
| 29 | + | if got.Theme != "light" { | |
| 30 | + | t.Fatalf("theme = %q, want %q", got.Theme, "light") | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestSaveConfigPreservesKeymapForPartialConfig(t *testing.T) { | |||
| 9 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 10 | + | ||||
| 11 | + | keymap := map[string]string{"ctrl+shift+g": "/git-tools:status"} | |||
| 12 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } | |||
| 15 | + | if err := os.WriteFile(ConfigPath(), []byte(`{"theme":"dark","keymap":{"ctrl+shift+g":"/git-tools:status"}}`), 0o644); err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | if err := SaveConfig(Config{Theme: "light"}); err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | got, err := LoadConfig() | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if len(got.Keymap) != 1 || got.Keymap["ctrl+shift+g"] != keymap["ctrl+shift+g"] { | |||
| 27 | + | t.Fatalf("keymap = %#v, want %#v", got.Keymap, keymap) | |||
| 28 | + | } | |||
| 29 | + | if got.Theme != "light" { | |||
| 30 | + | t.Fatalf("theme = %q, want %q", got.Theme, "light") | |||
| 31 | + | } | |||
| 32 | + | } | |||
M packages/agent/modes/help.go +15 -1
| @@ -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) []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,6 +62,11 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |||
| 62 | 62 | labelWidth = n | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | + | for _, b := range keymap { | |
| 66 | + | if n := runewidth.StringWidth(b.name); n > labelWidth { | |
| 67 | + | labelWidth = n | |
| 68 | + | } | |
| 69 | + | } | |
| 65 | 70 | ||
| 66 | 71 | pad := func(s string) string { | |
| 67 | 72 | w := runewidth.StringWidth(s) | |
| @@ -93,6 +98,15 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |||
| 93 | 98 | th.FG256(th.Muted, k[1]))) | |
| 94 | 99 | } | |
| 95 | 100 | ||
| 101 | + | if len(keymap) > 0 { | |
| 102 | + | out = append(out, "", tui.Bold("custom keys:")) | |
| 103 | + | for _, b := range keymap { | |
| 104 | + | out = append(out, fmt.Sprintf(" %s %s", | |
| 105 | + | th.FG256(th.Accent, pad(b.name)), | |
| 106 | + | th.FG256(th.Muted, b.command))) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 96 | 110 | out = append(out, "", frameRule(th, width), "") | |
| 97 | 111 | return out | |
| 98 | 112 | } | |
| @@ -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) []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,6 +62,11 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |||||
| 62 | labelWidth = n | 62 | labelWidth = n | ||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | + | for _, b := range keymap { | |||
| 66 | + | if n := runewidth.StringWidth(b.name); n > labelWidth { | |||
| 67 | + | labelWidth = n | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 65 | 70 | ||||
| 66 | pad := func(s string) string { | 71 | pad := func(s string) string { | ||
| 67 | w := runewidth.StringWidth(s) | 72 | w := runewidth.StringWidth(s) | ||
| @@ -93,6 +98,15 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |||||
| 93 | th.FG256(th.Muted, k[1]))) | 98 | th.FG256(th.Muted, k[1]))) | ||
| 94 | } | 99 | } | ||
| 95 | 100 | ||||
| 101 | + | if len(keymap) > 0 { | |||
| 102 | + | out = append(out, "", tui.Bold("custom keys:")) | |||
| 103 | + | for _, b := range keymap { | |||
| 104 | + | out = append(out, fmt.Sprintf(" %s %s", | |||
| 105 | + | th.FG256(th.Accent, pad(b.name)), | |||
| 106 | + | th.FG256(th.Muted, b.command))) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 96 | out = append(out, "", frameRule(th, width), "") | 110 | out = append(out, "", frameRule(th, width), "") | ||
| 97 | return out | 111 | return out | ||
| 98 | } | 112 | } | ||
M packages/agent/modes/help_test.go +19 -2
| @@ -8,12 +8,29 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | |
| 11 | - | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | |
| 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n") | |
| 12 | 12 | if strings.Contains(without, "/llama") { | |
| 13 | 13 | t.Fatalf("help exposed /llama without login: %q", without) | |
| 14 | 14 | } | |
| 15 | - | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n") | |
| 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n") | |
| 16 | 16 | if !strings.Contains(with, "/llama") { | |
| 17 | 17 | t.Fatalf("help omitted /llama with login: %q", with) | |
| 18 | 18 | } | |
| 19 | 19 | } | |
| 20 | + | ||
| 21 | + | func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | |
| 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") | |
| 30 | + | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | |
| 31 | + | t.Fatalf("help omitted configured custom key: %q", help) | |
| 32 | + | } | |
| 33 | + | if strings.Contains(help, "bogus") { | |
| 34 | + | t.Fatalf("help advertised an invalid binding: %q", help) | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -8,12 +8,29 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | ||
| 11 | - | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n") |
| 12 | if strings.Contains(without, "/llama") { | 12 | if strings.Contains(without, "/llama") { | ||
| 13 | t.Fatalf("help exposed /llama without login: %q", without) | 13 | t.Fatalf("help exposed /llama without login: %q", without) | ||
| 14 | } | 14 | } | ||
| 15 | - | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n") | 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n") |
| 16 | if !strings.Contains(with, "/llama") { | 16 | if !strings.Contains(with, "/llama") { | ||
| 17 | t.Fatalf("help omitted /llama with login: %q", with) | 17 | t.Fatalf("help omitted /llama with login: %q", with) | ||
| 18 | } | 18 | } | ||
| 19 | } | 19 | } | ||
| 20 | + | ||||
| 21 | + | func TestHelpShowsConfiguredCustomKeys(t *testing.T) { | |||
| 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") | |||
| 30 | + | if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") { | |||
| 31 | + | t.Fatalf("help omitted configured custom key: %q", help) | |||
| 32 | + | } | |||
| 33 | + | if strings.Contains(help, "bogus") { | |||
| 34 | + | t.Fatalf("help advertised an invalid binding: %q", help) | |||
| 35 | + | } | |||
| 36 | + | } | |||
M packages/agent/modes/interactive.go +59 -1
| @@ -140,6 +140,10 @@ type InteractiveConfig struct { | |||
| 140 | 140 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| 141 | 141 | QuickModelShortcuts []QuickModelShortcut | |
| 142 | 142 | ||
| 143 | + | // Keymap maps configured key chords to slash commands. It is copied from | |
| 144 | + | // config.json by the CLI and is only active in the main interactive input. | |
| 145 | + | Keymap map[string]string | |
| 146 | + | ||
| 143 | 147 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| 144 | 148 | ExtensionThemes func() []tui.ThemeOption | |
| 145 | 149 | ||
| @@ -576,6 +580,10 @@ type Interactive struct { | |||
| 576 | 580 | // stay in the scrolling chat until /clear without entering the transcript. | |
| 577 | 581 | reloadErrors []string | |
| 578 | 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 | + | ||
| 579 | 587 | // shellRunning is true while a !command is executing. It shares | |
| 580 | 588 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | |
| 581 | 589 | // escape can start while one is in flight. | |
| @@ -694,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 694 | 702 | inputHistoryIndex: -1, | |
| 695 | 703 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 696 | 704 | } | |
| 705 | + | var keymapIssues []string | |
| 706 | + | i.keymap, keymapIssues = compileKeymap(cfg.Keymap) | |
| 707 | + | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | |
| 697 | 708 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 698 | 709 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | |
| 699 | 710 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| @@ -2101,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool { | |||
| 2101 | 2112 | i.extPanel.Active() | |
| 2102 | 2113 | } | |
| 2103 | 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 | + | ||
| 2104 | 2138 | // restoreConfirmFocus returns input to confirmation after slash input is | |
| 2105 | 2139 | // cleared or a child interaction closes. A non-empty editor keeps command | |
| 2106 | 2140 | // input focused, and an active child continues to own its keys. | |
| @@ -2140,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2140 | 2174 | } | |
| 2141 | 2175 | } | |
| 2142 | 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 | + | ||
| 2143 | 2185 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 2144 | 2186 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 2145 | 2187 | // unexpectedly. The hint message also goes stale; clear it. | |
| @@ -2448,6 +2490,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2448 | 2490 | return false | |
| 2449 | 2491 | } | |
| 2450 | 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 '/'. | |
| 2496 | + | if !keymapReservedKey(k) { | |
| 2497 | + | if command := lookupKeymap(i.keymap, k); command != "" { | |
| 2498 | + | parts := strings.Fields(command) | |
| 2499 | + | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |
| 2500 | + | i.cancelAndWaitForIdle() | |
| 2501 | + | } | |
| 2502 | + | i.ed.Clear() | |
| 2503 | + | i.suggest.Reset() | |
| 2504 | + | i.fileSuggest.Reset() | |
| 2505 | + | return i.runSlash(ctx, command) | |
| 2506 | + | } | |
| 2507 | + | } | |
| 2508 | + | ||
| 2451 | 2509 | if slot := quickModelShortcutSlot(k); slot > 0 { | |
| 2452 | 2510 | i.applyQuickModelShortcut(slot) | |
| 2453 | 2511 | return false | |
| @@ -4534,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4534 | 4592 | i.mu.Unlock() | |
| 4535 | 4593 | case "/help": | |
| 4536 | 4594 | i.mu.Lock() | |
| 4537 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 4595 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap) | |
| 4538 | 4596 | i.statusErr = "" | |
| 4539 | 4597 | i.statusOK = "" | |
| 4540 | 4598 | // Pin the viewport to the newest content so the help block, | |
| @@ -140,6 +140,10 @@ type InteractiveConfig struct { | |||||
| 140 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | 140 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | ||
| 141 | QuickModelShortcuts []QuickModelShortcut | 141 | QuickModelShortcuts []QuickModelShortcut | ||
| 142 | 142 | ||||
| 143 | + | // Keymap maps configured key chords to slash commands. It is copied from | |||
| 144 | + | // config.json by the CLI and is only active in the main interactive input. | |||
| 145 | + | Keymap map[string]string | |||
| 146 | + | ||||
| 143 | // ExtensionThemes returns themes bundled with loaded extensions. | 147 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| 144 | ExtensionThemes func() []tui.ThemeOption | 148 | ExtensionThemes func() []tui.ThemeOption | ||
| 145 | 149 | ||||
| @@ -576,6 +580,10 @@ type Interactive struct { | |||||
| 576 | // stay in the scrolling chat until /clear without entering the transcript. | 580 | // stay in the scrolling chat until /clear without entering the transcript. | ||
| 577 | reloadErrors []string | 581 | reloadErrors []string | ||
| 578 | 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 | + | ||||
| 579 | // shellRunning is true while a !command is executing. It shares | 587 | // shellRunning is true while a !command is executing. It shares | ||
| 580 | // 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 | ||
| 581 | // escape can start while one is in flight. | 589 | // escape can start while one is in flight. | ||
| @@ -694,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 694 | inputHistoryIndex: -1, | 702 | inputHistoryIndex: -1, | ||
| 695 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | 703 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | ||
| 696 | } | 704 | } | ||
| 705 | + | var keymapIssues []string | |||
| 706 | + | i.keymap, keymapIssues = compileKeymap(cfg.Keymap) | |||
| 707 | + | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | |||
| 697 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 708 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 698 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | 709 | i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest) | ||
| 699 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | 710 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | ||
| @@ -2101,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool { | |||||
| 2101 | i.extPanel.Active() | 2112 | i.extPanel.Active() | ||
| 2102 | } | 2113 | } | ||
| 2103 | 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 | + | ||||
| 2104 | // restoreConfirmFocus returns input to confirmation after slash input is | 2138 | // restoreConfirmFocus returns input to confirmation after slash input is | ||
| 2105 | // 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 | ||
| 2106 | // input focused, and an active child continues to own its keys. | 2140 | // input focused, and an active child continues to own its keys. | ||
| @@ -2140,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2140 | } | 2174 | } | ||
| 2141 | } | 2175 | } | ||
| 2142 | 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 | + | ||||
| 2143 | // 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 | ||
| 2144 | // 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 | ||
| 2145 | // unexpectedly. The hint message also goes stale; clear it. | 2187 | // unexpectedly. The hint message also goes stale; clear it. | ||
| @@ -2448,6 +2490,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2448 | return false | 2490 | return false | ||
| 2449 | } | 2491 | } | ||
| 2450 | 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 '/'. | |||
| 2496 | + | if !keymapReservedKey(k) { | |||
| 2497 | + | if command := lookupKeymap(i.keymap, k); command != "" { | |||
| 2498 | + | parts := strings.Fields(command) | |||
| 2499 | + | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |||
| 2500 | + | i.cancelAndWaitForIdle() | |||
| 2501 | + | } | |||
| 2502 | + | i.ed.Clear() | |||
| 2503 | + | i.suggest.Reset() | |||
| 2504 | + | i.fileSuggest.Reset() | |||
| 2505 | + | return i.runSlash(ctx, command) | |||
| 2506 | + | } | |||
| 2507 | + | } | |||
| 2508 | + | ||||
| 2451 | if slot := quickModelShortcutSlot(k); slot > 0 { | 2509 | if slot := quickModelShortcutSlot(k); slot > 0 { | ||
| 2452 | i.applyQuickModelShortcut(slot) | 2510 | i.applyQuickModelShortcut(slot) | ||
| 2453 | return false | 2511 | return false | ||
| @@ -4534,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4534 | i.mu.Unlock() | 4592 | i.mu.Unlock() | ||
| 4535 | case "/help": | 4593 | case "/help": | ||
| 4536 | i.mu.Lock() | 4594 | i.mu.Lock() | ||
| 4537 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | 4595 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap) |
| 4538 | i.statusErr = "" | 4596 | i.statusErr = "" | ||
| 4539 | i.statusOK = "" | 4597 | i.statusOK = "" | ||
| 4540 | // Pin the viewport to the newest content so the help block, | 4598 | // Pin the viewport to the newest content so the help block, | ||
A packages/agent/modes/keymap.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | "unicode" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 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. | |
| 75 | + | func sortedCustomKeys(keymap map[string]string) []string { | |
| 76 | + | keys := make([]string, 0, len(keymap)) | |
| 77 | + | for key := range keymap { | |
| 78 | + | keys = append(keys, key) | |
| 79 | + | } | |
| 80 | + | sort.Slice(keys, func(i, j int) bool { | |
| 81 | + | left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) | |
| 82 | + | if left == right { | |
| 83 | + | return keys[i] < keys[j] | |
| 84 | + | } | |
| 85 | + | return left < right | |
| 86 | + | }) | |
| 87 | + | return keys | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type configuredKey struct { | |
| 91 | + | kind tui.KeyKind | |
| 92 | + | rune rune | |
| 93 | + | ctrl, alt bool | |
| 94 | + | shift bool | |
| 95 | + | super bool | |
| 96 | + | } | |
| 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 | + | ||
| 123 | + | func parseConfiguredKey(value string) (configuredKey, bool) { | |
| 124 | + | var out configuredKey | |
| 125 | + | parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+") | |
| 126 | + | if len(parts) == 0 { | |
| 127 | + | return out, false | |
| 128 | + | } | |
| 129 | + | base := "" | |
| 130 | + | for _, part := range parts { | |
| 131 | + | part = strings.TrimSpace(part) | |
| 132 | + | switch part { | |
| 133 | + | case "ctrl", "control": | |
| 134 | + | out.ctrl = true | |
| 135 | + | case "alt", "option", "opt": | |
| 136 | + | out.alt = true | |
| 137 | + | case "shift": | |
| 138 | + | out.shift = true | |
| 139 | + | case "cmd", "command", "super", "meta", "win": | |
| 140 | + | out.super = true | |
| 141 | + | case "": | |
| 142 | + | return out, false | |
| 143 | + | default: | |
| 144 | + | if base != "" { | |
| 145 | + | return out, false | |
| 146 | + | } | |
| 147 | + | base = part | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if base == "" { | |
| 151 | + | return out, false | |
| 152 | + | } | |
| 153 | + | out.kind = tui.KeyRune | |
| 154 | + | if len([]rune(base)) == 1 { | |
| 155 | + | out.rune = []rune(base)[0] | |
| 156 | + | } else { | |
| 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 | + | } | |
| 173 | + | } | |
| 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 | |
| 179 | + | } | |
| 180 | + | return out, true | |
| 181 | + | } | |
| 182 | + | ||
| 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 { | |
| 195 | + | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | |
| 196 | + | shift := actual.Shift | |
| 197 | + | if kind == tui.KeyShiftTab { | |
| 198 | + | kind = tui.KeyTab | |
| 199 | + | shift = true | |
| 200 | + | } | |
| 201 | + | // Raw control bytes are exposed as dedicated KeyKinds by the reader. | |
| 202 | + | if letter, ok := ctrlKindRunes[kind]; ok { | |
| 203 | + | ctrl = true | |
| 204 | + | r = letter | |
| 205 | + | kind = tui.KeyRune | |
| 206 | + | } | |
| 207 | + | // Some enhanced keyboard protocols report the shifted glyph rather | |
| 208 | + | // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize | |
| 209 | + | // those common US-layout glyphs so number chords remain bindable. | |
| 210 | + | if ctrl && shift { | |
| 211 | + | if digit, ok := shiftedDigit(r); ok { | |
| 212 | + | r = digit | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | // Enhanced terminal protocols preserve the uppercase glyph for shifted | |
| 216 | + | // letters, while configured key names are case-insensitive. | |
| 217 | + | r = unicode.ToLower(r) | |
| 218 | + | wantRune := unicode.ToLower(k.rune) | |
| 219 | + | shiftMatches := shift == k.shift | |
| 220 | + | if !shiftMatches && lenient { | |
| 221 | + | shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z' | |
| 222 | + | } | |
| 223 | + | return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt && | |
| 224 | + | shiftMatches && actual.Super == k.super | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func shiftedDigit(r rune) (rune, bool) { | |
| 228 | + | const glyphs = ")!@#$%^&*(" | |
| 229 | + | if i := strings.IndexRune(glyphs, r); i >= 0 { | |
| 230 | + | return rune('0' + i), true | |
| 231 | + | } | |
| 232 | + | return 0, false | |
| 233 | + | } | |
| 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. | |
| 238 | + | func keymapReservedKey(k tui.Key) bool { | |
| 239 | + | switch k.Kind { | |
| 240 | + | case tui.KeyCtrlC, tui.KeyCtrlD: | |
| 241 | + | return true | |
| 242 | + | case tui.KeyEsc: | |
| 243 | + | return !k.Ctrl && !k.Alt && !k.Shift && !k.Super | |
| 244 | + | } | |
| 245 | + | return false | |
| 246 | + | } | |
| 247 | + | ||
| 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) | |
| 254 | + | } | |
| @@ -0,0 +1,254 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 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. | |||
| 75 | + | func sortedCustomKeys(keymap map[string]string) []string { | |||
| 76 | + | keys := make([]string, 0, len(keymap)) | |||
| 77 | + | for key := range keymap { | |||
| 78 | + | keys = append(keys, key) | |||
| 79 | + | } | |||
| 80 | + | sort.Slice(keys, func(i, j int) bool { | |||
| 81 | + | left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) | |||
| 82 | + | if left == right { | |||
| 83 | + | return keys[i] < keys[j] | |||
| 84 | + | } | |||
| 85 | + | return left < right | |||
| 86 | + | }) | |||
| 87 | + | return keys | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | type configuredKey struct { | |||
| 91 | + | kind tui.KeyKind | |||
| 92 | + | rune rune | |||
| 93 | + | ctrl, alt bool | |||
| 94 | + | shift bool | |||
| 95 | + | super bool | |||
| 96 | + | } | |||
| 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 | + | ||||
| 123 | + | func parseConfiguredKey(value string) (configuredKey, bool) { | |||
| 124 | + | var out configuredKey | |||
| 125 | + | parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+") | |||
| 126 | + | if len(parts) == 0 { | |||
| 127 | + | return out, false | |||
| 128 | + | } | |||
| 129 | + | base := "" | |||
| 130 | + | for _, part := range parts { | |||
| 131 | + | part = strings.TrimSpace(part) | |||
| 132 | + | switch part { | |||
| 133 | + | case "ctrl", "control": | |||
| 134 | + | out.ctrl = true | |||
| 135 | + | case "alt", "option", "opt": | |||
| 136 | + | out.alt = true | |||
| 137 | + | case "shift": | |||
| 138 | + | out.shift = true | |||
| 139 | + | case "cmd", "command", "super", "meta", "win": | |||
| 140 | + | out.super = true | |||
| 141 | + | case "": | |||
| 142 | + | return out, false | |||
| 143 | + | default: | |||
| 144 | + | if base != "" { | |||
| 145 | + | return out, false | |||
| 146 | + | } | |||
| 147 | + | base = part | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if base == "" { | |||
| 151 | + | return out, false | |||
| 152 | + | } | |||
| 153 | + | out.kind = tui.KeyRune | |||
| 154 | + | if len([]rune(base)) == 1 { | |||
| 155 | + | out.rune = []rune(base)[0] | |||
| 156 | + | } else { | |||
| 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 | + | } | |||
| 173 | + | } | |||
| 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 | |||
| 179 | + | } | |||
| 180 | + | return out, true | |||
| 181 | + | } | |||
| 182 | + | ||||
| 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 { | |||
| 195 | + | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | |||
| 196 | + | shift := actual.Shift | |||
| 197 | + | if kind == tui.KeyShiftTab { | |||
| 198 | + | kind = tui.KeyTab | |||
| 199 | + | shift = true | |||
| 200 | + | } | |||
| 201 | + | // Raw control bytes are exposed as dedicated KeyKinds by the reader. | |||
| 202 | + | if letter, ok := ctrlKindRunes[kind]; ok { | |||
| 203 | + | ctrl = true | |||
| 204 | + | r = letter | |||
| 205 | + | kind = tui.KeyRune | |||
| 206 | + | } | |||
| 207 | + | // Some enhanced keyboard protocols report the shifted glyph rather | |||
| 208 | + | // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize | |||
| 209 | + | // those common US-layout glyphs so number chords remain bindable. | |||
| 210 | + | if ctrl && shift { | |||
| 211 | + | if digit, ok := shiftedDigit(r); ok { | |||
| 212 | + | r = digit | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | // Enhanced terminal protocols preserve the uppercase glyph for shifted | |||
| 216 | + | // letters, while configured key names are case-insensitive. | |||
| 217 | + | r = unicode.ToLower(r) | |||
| 218 | + | wantRune := unicode.ToLower(k.rune) | |||
| 219 | + | shiftMatches := shift == k.shift | |||
| 220 | + | if !shiftMatches && lenient { | |||
| 221 | + | shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z' | |||
| 222 | + | } | |||
| 223 | + | return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt && | |||
| 224 | + | shiftMatches && actual.Super == k.super | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func shiftedDigit(r rune) (rune, bool) { | |||
| 228 | + | const glyphs = ")!@#$%^&*(" | |||
| 229 | + | if i := strings.IndexRune(glyphs, r); i >= 0 { | |||
| 230 | + | return rune('0' + i), true | |||
| 231 | + | } | |||
| 232 | + | return 0, false | |||
| 233 | + | } | |||
| 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. | |||
| 238 | + | func keymapReservedKey(k tui.Key) bool { | |||
| 239 | + | switch k.Kind { | |||
| 240 | + | case tui.KeyCtrlC, tui.KeyCtrlD: | |||
| 241 | + | return true | |||
| 242 | + | case tui.KeyEsc: | |||
| 243 | + | return !k.Ctrl && !k.Alt && !k.Shift && !k.Super | |||
| 244 | + | } | |||
| 245 | + | return false | |||
| 246 | + | } | |||
| 247 | + | ||||
| 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) | |||
| 254 | + | } | |||
A packages/agent/modes/keymap_test.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) { | |
| 14 | + | keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"} | |
| 15 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |
| 16 | + | t.Fatalf("configuredKeyCommand = %q, want extension command", got) | |
| 17 | + | } | |
| 18 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |
| 19 | + | t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got) | |
| 20 | + | } | |
| 21 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" { | |
| 22 | + | t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got) | |
| 23 | + | } | |
| 24 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" { | |
| 25 | + | t.Fatalf("unmodified key matched command %q", got) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 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 | + | ||
| 42 | + | func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) { | |
| 43 | + | if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" { | |
| 44 | + | t.Fatalf("configuredKeyCommand = %q, want skill command", got) | |
| 45 | + | } | |
| 46 | + | if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" { | |
| 47 | + | t.Fatalf("enhanced ctrl+h binding = %q, want /help", got) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) { | |
| 52 | + | for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} { | |
| 53 | + | if _, ok := parseConfiguredKey(bad); ok { | |
| 54 | + | t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | key, ok := parseConfiguredKey("alt+enter") | |
| 58 | + | if !ok || key.kind != tui.KeyEnter || !key.alt { | |
| 59 | + | t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok) | |
| 60 | + | } | |
| 61 | + | if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" { | |
| 62 | + | t.Fatalf("shift+tab binding = %q, want /help", got) | |
| 63 | + | } | |
| 64 | + | if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" { | |
| 65 | + | t.Fatalf("ctrl+space binding = %q, want /help", got) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 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") | |
| 166 | + | } | |
| 167 | + | } | |
| @@ -0,0 +1,167 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) { | |||
| 14 | + | keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"} | |||
| 15 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |||
| 16 | + | t.Fatalf("configuredKeyCommand = %q, want extension command", got) | |||
| 17 | + | } | |||
| 18 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |||
| 19 | + | t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got) | |||
| 20 | + | } | |||
| 21 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" { | |||
| 22 | + | t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got) | |||
| 23 | + | } | |||
| 24 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" { | |||
| 25 | + | t.Fatalf("unmodified key matched command %q", got) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 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 | + | ||||
| 42 | + | func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) { | |||
| 43 | + | if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" { | |||
| 44 | + | t.Fatalf("configuredKeyCommand = %q, want skill command", got) | |||
| 45 | + | } | |||
| 46 | + | if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" { | |||
| 47 | + | t.Fatalf("enhanced ctrl+h binding = %q, want /help", got) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) { | |||
| 52 | + | for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} { | |||
| 53 | + | if _, ok := parseConfiguredKey(bad); ok { | |||
| 54 | + | t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | key, ok := parseConfiguredKey("alt+enter") | |||
| 58 | + | if !ok || key.kind != tui.KeyEnter || !key.alt { | |||
| 59 | + | t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok) | |||
| 60 | + | } | |||
| 61 | + | if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" { | |||
| 62 | + | t.Fatalf("shift+tab binding = %q, want /help", got) | |||
| 63 | + | } | |||
| 64 | + | if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" { | |||
| 65 | + | t.Fatalf("ctrl+space binding = %q, want /help", got) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 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") | |||
| 166 | + | } | |||
| 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 +17 -0
| @@ -100,6 +100,15 @@ func (r *Reader) Read() (Key, error) { | |||
| 100 | 100 | return Key{Kind: KeyTab}, nil | |
| 101 | 101 | case b == 0x7f, b == 0x08: | |
| 102 | 102 | return Key{Kind: KeyBackspace}, nil | |
| 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. | |
| 111 | + | return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil | |
| 103 | 112 | case b == 0x1b: | |
| 104 | 113 | return r.readEscape() | |
| 105 | 114 | case b < 0x20: | |
| @@ -407,10 +416,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 407 | 416 | case 'v', 'V': | |
| 408 | 417 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 409 | 418 | } | |
| 419 | + | // Enhanced keyboard protocols preserve Ctrl for letters that do | |
| 420 | + | // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S. | |
| 421 | + | if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) { | |
| 422 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true | |
| 423 | + | } | |
| 410 | 424 | } | |
| 411 | 425 | if code >= '0' && code <= '9' { | |
| 412 | 426 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 413 | 427 | } | |
| 428 | + | if code >= 0x20 && code <= 0x7e && (shift || alt || super) { | |
| 429 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 430 | + | } | |
| 414 | 431 | return Key{}, false | |
| 415 | 432 | } | |
| 416 | 433 | ||
| @@ -100,6 +100,15 @@ func (r *Reader) Read() (Key, error) { | |||||
| 100 | return Key{Kind: KeyTab}, nil | 100 | return Key{Kind: KeyTab}, nil | ||
| 101 | case b == 0x7f, b == 0x08: | 101 | case b == 0x7f, b == 0x08: | ||
| 102 | return Key{Kind: KeyBackspace}, nil | 102 | return Key{Kind: KeyBackspace}, nil | ||
| 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. | |||
| 111 | + | return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil | |||
| 103 | case b == 0x1b: | 112 | case b == 0x1b: | ||
| 104 | return r.readEscape() | 113 | return r.readEscape() | ||
| 105 | case b < 0x20: | 114 | case b < 0x20: | ||
| @@ -407,10 +416,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 407 | case 'v', 'V': | 416 | case 'v', 'V': | ||
| 408 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | 417 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 409 | } | 418 | } | ||
| 419 | + | // Enhanced keyboard protocols preserve Ctrl for letters that do | |||
| 420 | + | // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S. | |||
| 421 | + | if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) { | |||
| 422 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true | |||
| 423 | + | } | |||
| 410 | } | 424 | } | ||
| 411 | if code >= '0' && code <= '9' { | 425 | if code >= '0' && code <= '9' { | ||
| 412 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | 426 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | ||
| 413 | } | 427 | } | ||
| 428 | + | if code >= 0x20 && code <= 0x7e && (shift || alt || super) { | |||
| 429 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |||
| 430 | + | } | |||
| 414 | return Key{}, false | 431 | return Key{}, false | ||
| 415 | } | 432 | } | ||
| 416 | 433 | ||||
M packages/tui/input_test.go +44 -0
| @@ -51,6 +51,50 @@ func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) { | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | func TestReaderParsesRawCtrlGAsModifiedRune(t *testing.T) { | |
| 55 | + | k := readKey(t, "\x07") | |
| 56 | + | if k.Kind != KeyRune || k.Rune != 'g' || !k.Ctrl { | |
| 57 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+g", k.Kind, k.Rune, k.Ctrl) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestReaderParsesRawCtrlSAsModifiedRune(t *testing.T) { | |
| 62 | + | k := readKey(t, "\x13") | |
| 63 | + | if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl { | |
| 64 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+s", k.Kind, k.Rune, k.Ctrl) | |
| 65 | + | } | |
| 66 | + | } | |
| 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 | + | ||
| 54 | 98 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |
| 55 | 99 | k := readKey(t, "\x16") | |
| 56 | 100 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| @@ -51,6 +51,50 @@ func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) { | |||||
| 51 | } | 51 | } | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | func TestReaderParsesRawCtrlGAsModifiedRune(t *testing.T) { | |||
| 55 | + | k := readKey(t, "\x07") | |||
| 56 | + | if k.Kind != KeyRune || k.Rune != 'g' || !k.Ctrl { | |||
| 57 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+g", k.Kind, k.Rune, k.Ctrl) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestReaderParsesRawCtrlSAsModifiedRune(t *testing.T) { | |||
| 62 | + | k := readKey(t, "\x13") | |||
| 63 | + | if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl { | |||
| 64 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+s", k.Kind, k.Rune, k.Ctrl) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 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 | + | ||||
| 54 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | 98 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | ||
| 55 | k := readKey(t, "\x16") | 99 | k := readKey(t, "\x16") | ||
| 56 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 100 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||