feat: add custom hotkeys for slash commands (built-in, extension or skill)
- commit
- aca3e6003795d2df0bc22ac26ae1fa150d423e88
- author
- ggbalaazs · · 2026-09-13 15:40 UTC
- committer
- ggbalaazs · · 2026-09-20 09:42 UTC
- parent
- 98f35c8
- signature
- unsigned
11 files · +391 -4
M README.md +19 -0
| @@ -162,6 +162,25 @@ 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 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. | |
| 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. 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. | |
| 183 | + | ||
| 165 | 184 | ## Persistent instructions (AGENTS.md) | |
| 166 | 185 | ||
| 167 | 186 | 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,25 @@ 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 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. | |||
| 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. 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. | |||
| 183 | + | ||||
| 165 | ## Persistent instructions (AGENTS.md) | 184 | ## Persistent instructions (AGENTS.md) | ||
| 166 | 185 | ||||
| 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. | 186 | 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 +16 -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 map[string]string) []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 key := range keymap { | |
| 66 | + | if n := runewidth.StringWidth(key); 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,16 @@ 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 _, key := range sortedCustomKeys(keymap) { | |
| 104 | + | command := strings.TrimSpace(keymap[key]) | |
| 105 | + | out = append(out, fmt.Sprintf(" %s %s", | |
| 106 | + | th.FG256(th.Accent, pad(key)), | |
| 107 | + | th.FG256(th.Muted, command))) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 96 | 111 | out = append(out, "", frameRule(th, width), "") | |
| 97 | 112 | return out | |
| 98 | 113 | } | |
| @@ -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 map[string]string) []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 key := range keymap { | |||
| 66 | + | if n := runewidth.StringWidth(key); 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,16 @@ 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 _, key := range sortedCustomKeys(keymap) { | |||
| 104 | + | command := strings.TrimSpace(keymap[key]) | |||
| 105 | + | out = append(out, fmt.Sprintf(" %s %s", | |||
| 106 | + | th.FG256(th.Accent, pad(key)), | |||
| 107 | + | th.FG256(th.Muted, command))) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 96 | out = append(out, "", frameRule(th, width), "") | 111 | out = append(out, "", frameRule(th, width), "") | ||
| 97 | return out | 112 | return out | ||
| 98 | } | 113 | } | ||
M packages/agent/modes/help_test.go +11 -2
| @@ -8,12 +8,21 @@ 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 | + | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, map[string]string{ | |
| 23 | + | "ctrl+s": "/skill:review", | |
| 24 | + | }), "\n") | |
| 25 | + | 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) | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -8,12 +8,21 @@ 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 | + | help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, map[string]string{ | |||
| 23 | + | "ctrl+s": "/skill:review", | |||
| 24 | + | }), "\n") | |||
| 25 | + | 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) | |||
| 27 | + | } | |||
| 28 | + | } | |||
M packages/agent/modes/interactive.go +27 -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 | ||
| @@ -2448,6 +2452,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2448 | 2452 | return false | |
| 2449 | 2453 | } | |
| 2450 | 2454 | ||
| 2455 | + | 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) | |
| 2466 | + | parts := strings.Fields(command) | |
| 2467 | + | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |
| 2468 | + | i.cancelAndWaitForIdle() | |
| 2469 | + | } | |
| 2470 | + | i.ed.Clear() | |
| 2471 | + | i.suggest.Reset() | |
| 2472 | + | i.fileSuggest.Reset() | |
| 2473 | + | return i.runSlash(ctx, command) | |
| 2474 | + | } | |
| 2475 | + | } | |
| 2476 | + | ||
| 2451 | 2477 | if slot := quickModelShortcutSlot(k); slot > 0 { | |
| 2452 | 2478 | i.applyQuickModelShortcut(slot) | |
| 2453 | 2479 | return false | |
| @@ -4534,7 +4560,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4534 | 4560 | i.mu.Unlock() | |
| 4535 | 4561 | case "/help": | |
| 4536 | 4562 | i.mu.Lock() | |
| 4537 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 4563 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.Keymap) | |
| 4538 | 4564 | i.statusErr = "" | |
| 4539 | 4565 | i.statusOK = "" | |
| 4540 | 4566 | // 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 | ||||
| @@ -2448,6 +2452,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2448 | return false | 2452 | return false | ||
| 2449 | } | 2453 | } | ||
| 2450 | 2454 | ||||
| 2455 | + | 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) | |||
| 2466 | + | parts := strings.Fields(command) | |||
| 2467 | + | if len(parts) > 0 && slashCancelsTurn(parts[0]) { | |||
| 2468 | + | i.cancelAndWaitForIdle() | |||
| 2469 | + | } | |||
| 2470 | + | i.ed.Clear() | |||
| 2471 | + | i.suggest.Reset() | |||
| 2472 | + | i.fileSuggest.Reset() | |||
| 2473 | + | return i.runSlash(ctx, command) | |||
| 2474 | + | } | |||
| 2475 | + | } | |||
| 2476 | + | ||||
| 2451 | if slot := quickModelShortcutSlot(k); slot > 0 { | 2477 | if slot := quickModelShortcutSlot(k); slot > 0 { | ||
| 2452 | i.applyQuickModelShortcut(slot) | 2478 | i.applyQuickModelShortcut(slot) | ||
| 2453 | return false | 2479 | return false | ||
| @@ -4534,7 +4560,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4534 | i.mu.Unlock() | 4560 | i.mu.Unlock() | ||
| 4535 | case "/help": | 4561 | case "/help": | ||
| 4536 | i.mu.Lock() | 4562 | i.mu.Lock() | ||
| 4537 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | 4563 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.Keymap) |
| 4538 | i.statusErr = "" | 4564 | i.statusErr = "" | ||
| 4539 | i.statusOK = "" | 4565 | i.statusOK = "" | ||
| 4540 | // Pin the viewport to the newest content so the help block, | 4566 | // Pin the viewport to the newest content so the help block, | ||
A packages/agent/modes/keymap.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 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 | + | // 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. | |
| 15 | + | func sortedCustomKeys(keymap map[string]string) []string { | |
| 16 | + | keys := make([]string, 0, len(keymap)) | |
| 17 | + | for key := range keymap { | |
| 18 | + | keys = append(keys, key) | |
| 19 | + | } | |
| 20 | + | sort.Slice(keys, func(i, j int) bool { | |
| 21 | + | left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) | |
| 22 | + | if left == right { | |
| 23 | + | return keys[i] < keys[j] | |
| 24 | + | } | |
| 25 | + | return left < right | |
| 26 | + | }) | |
| 27 | + | return keys | |
| 28 | + | } | |
| 29 | + | ||
| 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 { | |
| 49 | + | kind tui.KeyKind | |
| 50 | + | rune rune | |
| 51 | + | ctrl, alt bool | |
| 52 | + | shift bool | |
| 53 | + | super bool | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func parseConfiguredKey(value string) (configuredKey, bool) { | |
| 57 | + | var out configuredKey | |
| 58 | + | parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+") | |
| 59 | + | if len(parts) == 0 { | |
| 60 | + | return out, false | |
| 61 | + | } | |
| 62 | + | base := "" | |
| 63 | + | for _, part := range parts { | |
| 64 | + | part = strings.TrimSpace(part) | |
| 65 | + | switch part { | |
| 66 | + | case "ctrl", "control": | |
| 67 | + | out.ctrl = true | |
| 68 | + | case "alt", "option", "opt": | |
| 69 | + | out.alt = true | |
| 70 | + | case "shift": | |
| 71 | + | out.shift = true | |
| 72 | + | case "cmd", "command", "super", "meta", "win": | |
| 73 | + | out.super = true | |
| 74 | + | case "": | |
| 75 | + | return out, false | |
| 76 | + | default: | |
| 77 | + | if base != "" { | |
| 78 | + | return out, false | |
| 79 | + | } | |
| 80 | + | base = part | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | if base == "" { | |
| 84 | + | return out, false | |
| 85 | + | } | |
| 86 | + | out.kind = tui.KeyRune | |
| 87 | + | 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] | |
| 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 { | |
| 115 | + | out.kind = kind | |
| 116 | + | } | |
| 117 | + | if base == "space" { | |
| 118 | + | out.rune = ' ' | |
| 119 | + | } | |
| 120 | + | return out, true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (k configuredKey) matches(actual tui.Key) bool { | |
| 124 | + | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | |
| 125 | + | shift := actual.Shift | |
| 126 | + | if kind == tui.KeyShiftTab { | |
| 127 | + | kind = tui.KeyTab | |
| 128 | + | shift = true | |
| 129 | + | } | |
| 130 | + | // Raw control bytes are exposed as dedicated KeyKinds by the reader. | |
| 131 | + | if kind >= tui.KeyCtrlC && kind <= tui.KeyCtrlO { | |
| 132 | + | 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] | |
| 137 | + | kind = tui.KeyRune | |
| 138 | + | } | |
| 139 | + | // Some enhanced keyboard protocols report the shifted glyph rather | |
| 140 | + | // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize | |
| 141 | + | // those common US-layout glyphs so number chords remain bindable. | |
| 142 | + | if ctrl && shift { | |
| 143 | + | if digit, ok := shiftedDigit(r); ok { | |
| 144 | + | r = digit | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | // Enhanced terminal protocols preserve the uppercase glyph for shifted | |
| 148 | + | // letters, while configured key names are case-insensitive. | |
| 149 | + | r = unicode.ToLower(r) | |
| 150 | + | 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') | |
| 156 | + | return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt && | |
| 157 | + | shiftMatches && actual.Super == k.super | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | 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 | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | 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 { | |
| 173 | + | case tui.KeyCtrlC, tui.KeyCtrlD: | |
| 174 | + | return true | |
| 175 | + | case tui.KeyEsc: | |
| 176 | + | return !k.Ctrl && !k.Alt && !k.Shift && !k.Super | |
| 177 | + | } | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | ||
| 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 | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 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 | + | // 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. | |||
| 15 | + | func sortedCustomKeys(keymap map[string]string) []string { | |||
| 16 | + | keys := make([]string, 0, len(keymap)) | |||
| 17 | + | for key := range keymap { | |||
| 18 | + | keys = append(keys, key) | |||
| 19 | + | } | |||
| 20 | + | sort.Slice(keys, func(i, j int) bool { | |||
| 21 | + | left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) | |||
| 22 | + | if left == right { | |||
| 23 | + | return keys[i] < keys[j] | |||
| 24 | + | } | |||
| 25 | + | return left < right | |||
| 26 | + | }) | |||
| 27 | + | return keys | |||
| 28 | + | } | |||
| 29 | + | ||||
| 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 { | |||
| 49 | + | kind tui.KeyKind | |||
| 50 | + | rune rune | |||
| 51 | + | ctrl, alt bool | |||
| 52 | + | shift bool | |||
| 53 | + | super bool | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func parseConfiguredKey(value string) (configuredKey, bool) { | |||
| 57 | + | var out configuredKey | |||
| 58 | + | parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+") | |||
| 59 | + | if len(parts) == 0 { | |||
| 60 | + | return out, false | |||
| 61 | + | } | |||
| 62 | + | base := "" | |||
| 63 | + | for _, part := range parts { | |||
| 64 | + | part = strings.TrimSpace(part) | |||
| 65 | + | switch part { | |||
| 66 | + | case "ctrl", "control": | |||
| 67 | + | out.ctrl = true | |||
| 68 | + | case "alt", "option", "opt": | |||
| 69 | + | out.alt = true | |||
| 70 | + | case "shift": | |||
| 71 | + | out.shift = true | |||
| 72 | + | case "cmd", "command", "super", "meta", "win": | |||
| 73 | + | out.super = true | |||
| 74 | + | case "": | |||
| 75 | + | return out, false | |||
| 76 | + | default: | |||
| 77 | + | if base != "" { | |||
| 78 | + | return out, false | |||
| 79 | + | } | |||
| 80 | + | base = part | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | if base == "" { | |||
| 84 | + | return out, false | |||
| 85 | + | } | |||
| 86 | + | out.kind = tui.KeyRune | |||
| 87 | + | 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] | |||
| 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 { | |||
| 115 | + | out.kind = kind | |||
| 116 | + | } | |||
| 117 | + | if base == "space" { | |||
| 118 | + | out.rune = ' ' | |||
| 119 | + | } | |||
| 120 | + | return out, true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (k configuredKey) matches(actual tui.Key) bool { | |||
| 124 | + | kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl | |||
| 125 | + | shift := actual.Shift | |||
| 126 | + | if kind == tui.KeyShiftTab { | |||
| 127 | + | kind = tui.KeyTab | |||
| 128 | + | shift = true | |||
| 129 | + | } | |||
| 130 | + | // Raw control bytes are exposed as dedicated KeyKinds by the reader. | |||
| 131 | + | if kind >= tui.KeyCtrlC && kind <= tui.KeyCtrlO { | |||
| 132 | + | 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] | |||
| 137 | + | kind = tui.KeyRune | |||
| 138 | + | } | |||
| 139 | + | // Some enhanced keyboard protocols report the shifted glyph rather | |||
| 140 | + | // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize | |||
| 141 | + | // those common US-layout glyphs so number chords remain bindable. | |||
| 142 | + | if ctrl && shift { | |||
| 143 | + | if digit, ok := shiftedDigit(r); ok { | |||
| 144 | + | r = digit | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | // Enhanced terminal protocols preserve the uppercase glyph for shifted | |||
| 148 | + | // letters, while configured key names are case-insensitive. | |||
| 149 | + | r = unicode.ToLower(r) | |||
| 150 | + | 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') | |||
| 156 | + | return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt && | |||
| 157 | + | shiftMatches && actual.Super == k.super | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | 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 | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | 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 { | |||
| 173 | + | case tui.KeyCtrlC, tui.KeyCtrlD: | |||
| 174 | + | return true | |||
| 175 | + | case tui.KeyEsc: | |||
| 176 | + | return !k.Ctrl && !k.Alt && !k.Shift && !k.Super | |||
| 177 | + | } | |||
| 178 | + | return false | |||
| 179 | + | } | |||
| 180 | + | ||||
| 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 | |||
| 191 | + | } | |||
A packages/agent/modes/keymap_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) { | |
| 10 | + | keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"} | |
| 11 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |
| 12 | + | t.Fatalf("configuredKeyCommand = %q, want extension command", got) | |
| 13 | + | } | |
| 14 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |
| 15 | + | t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got) | |
| 16 | + | } | |
| 17 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" { | |
| 18 | + | t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got) | |
| 19 | + | } | |
| 20 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" { | |
| 21 | + | t.Fatalf("unmodified key matched command %q", got) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) { | |
| 26 | + | if got := configuredKeyCommand(map[string]string{"ctrl+c": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlC}); got != "/skill:review" { | |
| 27 | + | t.Fatalf("configuredKeyCommand = %q, want skill command", got) | |
| 28 | + | } | |
| 29 | + | if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" { | |
| 30 | + | t.Fatalf("enhanced ctrl+h binding = %q, want /help", got) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | 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") | |
| 40 | + | } | |
| 41 | + | key, ok := parseConfiguredKey("alt+enter") | |
| 42 | + | if !ok || key.kind != tui.KeyEnter || !key.alt { | |
| 43 | + | t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok) | |
| 44 | + | } | |
| 45 | + | 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) | |
| 47 | + | } | |
| 48 | + | if err := validateKeymap(map[string]string{"no-such-key": "/help"}); err == nil { | |
| 49 | + | t.Fatal("invalid keymap was accepted") | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) { | |||
| 10 | + | keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"} | |||
| 11 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |||
| 12 | + | t.Fatalf("configuredKeyCommand = %q, want extension command", got) | |||
| 13 | + | } | |||
| 14 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" { | |||
| 15 | + | t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got) | |||
| 16 | + | } | |||
| 17 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" { | |||
| 18 | + | t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got) | |||
| 19 | + | } | |||
| 20 | + | if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" { | |||
| 21 | + | t.Fatalf("unmodified key matched command %q", got) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) { | |||
| 26 | + | if got := configuredKeyCommand(map[string]string{"ctrl+c": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlC}); got != "/skill:review" { | |||
| 27 | + | t.Fatalf("configuredKeyCommand = %q, want skill command", got) | |||
| 28 | + | } | |||
| 29 | + | if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" { | |||
| 30 | + | t.Fatalf("enhanced ctrl+h binding = %q, want /help", got) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | 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") | |||
| 40 | + | } | |||
| 41 | + | key, ok := parseConfiguredKey("alt+enter") | |||
| 42 | + | if !ok || key.kind != tui.KeyEnter || !key.alt { | |||
| 43 | + | t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok) | |||
| 44 | + | } | |||
| 45 | + | 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) | |||
| 47 | + | } | |||
| 48 | + | if err := validateKeymap(map[string]string{"no-such-key": "/help"}); err == nil { | |||
| 49 | + | t.Fatal("invalid keymap was accepted") | |||
| 50 | + | } | |||
| 51 | + | } | |||
M packages/tui/input.go +19 -0
| @@ -94,12 +94,23 @@ 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 | |
| 97 | 103 | case b == '\r', b == '\n': | |
| 98 | 104 | return Key{Kind: KeyEnter}, nil | |
| 99 | 105 | case b == '\t': | |
| 100 | 106 | return Key{Kind: KeyTab}, nil | |
| 101 | 107 | case b == 0x7f, b == 0x08: | |
| 102 | 108 | 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. | |
| 113 | + | return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil | |
| 103 | 114 | case b == 0x1b: | |
| 104 | 115 | return r.readEscape() | |
| 105 | 116 | case b < 0x20: | |
| @@ -407,10 +418,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 407 | 418 | case 'v', 'V': | |
| 408 | 419 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 409 | 420 | } | |
| 421 | + | // Enhanced keyboard protocols preserve Ctrl for letters that do | |
| 422 | + | // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S. | |
| 423 | + | if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) { | |
| 424 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true | |
| 425 | + | } | |
| 410 | 426 | } | |
| 411 | 427 | if code >= '0' && code <= '9' { | |
| 412 | 428 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 413 | 429 | } | |
| 430 | + | if code >= 0x20 && code <= 0x7e && (shift || alt || super) { | |
| 431 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 432 | + | } | |
| 414 | 433 | return Key{}, false | |
| 415 | 434 | } | |
| 416 | 435 | ||
| @@ -94,12 +94,23 @@ 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 | |||
| 97 | case b == '\r', b == '\n': | 103 | case b == '\r', b == '\n': | ||
| 98 | return Key{Kind: KeyEnter}, nil | 104 | return Key{Kind: KeyEnter}, nil | ||
| 99 | case b == '\t': | 105 | case b == '\t': | ||
| 100 | return Key{Kind: KeyTab}, nil | 106 | return Key{Kind: KeyTab}, nil | ||
| 101 | case b == 0x7f, b == 0x08: | 107 | case b == 0x7f, b == 0x08: | ||
| 102 | return Key{Kind: KeyBackspace}, nil | 108 | 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. | |||
| 113 | + | return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil | |||
| 103 | case b == 0x1b: | 114 | case b == 0x1b: | ||
| 104 | return r.readEscape() | 115 | return r.readEscape() | ||
| 105 | case b < 0x20: | 116 | case b < 0x20: | ||
| @@ -407,10 +418,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 407 | case 'v', 'V': | 418 | case 'v', 'V': | ||
| 408 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | 419 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 409 | } | 420 | } | ||
| 421 | + | // Enhanced keyboard protocols preserve Ctrl for letters that do | |||
| 422 | + | // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S. | |||
| 423 | + | if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) { | |||
| 424 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true | |||
| 425 | + | } | |||
| 410 | } | 426 | } | ||
| 411 | if code >= '0' && code <= '9' { | 427 | if code >= '0' && code <= '9' { | ||
| 412 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | 428 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | ||
| 413 | } | 429 | } | ||
| 430 | + | if code >= 0x20 && code <= 0x7e && (shift || alt || super) { | |||
| 431 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |||
| 432 | + | } | |||
| 414 | return Key{}, false | 433 | return Key{}, false | ||
| 415 | } | 434 | } | ||
| 416 | 435 | ||||
M packages/tui/input_test.go +14 -0
| @@ -51,6 +51,20 @@ 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 | + | ||
| 54 | 68 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |
| 55 | 69 | k := readKey(t, "\x16") | |
| 56 | 70 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| @@ -51,6 +51,20 @@ 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 | + | ||||
| 54 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | 68 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | ||
| 55 | k := readKey(t, "\x16") | 69 | k := readKey(t, "\x16") | ||
| 56 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 70 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||