detached aca3e60

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`
162162
163163 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.
164164
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+
165184 ## Persistent instructions (AGENTS.md)
166185
167186 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 {
13311331 OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit,
13321332 NoTools: args.NoTools,
13331333 QuickModelShortcuts: quickModelShortcuts,
1334+ Keymap: initialCfg.Keymap,
13341335 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
13351336 FuzzySkillSuggest: initialCfg.FuzzySkillSuggest,
13361337 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 {
4747 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
4848 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
4949
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+
5055 // InlineImagesEnabled controls whether zot draws screenshots inline
5156 // when the terminal supports an image protocol. nil/missing means
5257 // auto (enabled when supported); false disables; true forces the
@@ -240,6 +245,11 @@ func LoadConfig() (Config, error) {
240245
241246 // SaveConfig writes the config file, creating parent dirs.
242247 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+
243253 if err := os.MkdirAll(ZotHome(), 0o755); err != nil {
244254 return err
245255 }
@@ -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 inline55 // InlineImagesEnabled controls whether zot draws screenshots inline
51 // when the terminal supports an image protocol. nil/missing means56 // when the terminal supports an image protocol. nil/missing means
52 // auto (enabled when supported); false disables; true forces the57 // 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 err254 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{
3636 // section it lives in. The width is computed from the longest label
3737 // across BOTH lists, with a minimum of 14 cells so changes to either
3838 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
39+func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap map[string]string) []string {
4040 if width < 20 {
4141 width = 20
4242 }
@@ -62,6 +62,11 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
6262 labelWidth = n
6363 }
6464 }
65+ for key := range keymap {
66+ if n := runewidth.StringWidth(key); n > labelWidth {
67+ labelWidth = n
68+ }
69+ }
6570
6671 pad := func(s string) string {
6772 w := runewidth.StringWidth(s)
@@ -93,6 +98,16 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
9398 th.FG256(th.Muted, k[1])))
9499 }
95100
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+
96111 out = append(out, "", frameRule(th, width), "")
97112 return out
98113 }
@@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{
36 // section it lives in. The width is computed from the longest label36 // section it lives in. The width is computed from the longest label
37 // across BOTH lists, with a minimum of 14 cells so changes to either37 // across BOTH lists, with a minimum of 14 cells so changes to either
38 // list don't compress the column visually.38 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []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 = 2041 width = 20
42 }42 }
@@ -62,6 +62,11 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
62 labelWidth = n62 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 out112 return out
98 }113 }
M packages/agent/modes/help_test.go +11 -2
@@ -8,12 +8,21 @@ import (
88 )
99
1010 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")
1212 if strings.Contains(without, "/llama") {
1313 t.Fatalf("help exposed /llama without login: %q", without)
1414 }
15- with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n")
15+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n")
1616 if !strings.Contains(with, "/llama") {
1717 t.Fatalf("help omitted /llama with login: %q", with)
1818 }
1919 }
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 {
140140 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
141141 QuickModelShortcuts []QuickModelShortcut
142142
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+
143147 // ExtensionThemes returns themes bundled with loaded extensions.
144148 ExtensionThemes func() []tui.ThemeOption
145149
@@ -2448,6 +2452,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24482452 return false
24492453 }
24502454
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+
24512477 if slot := quickModelShortcutSlot(k); slot > 0 {
24522478 i.applyQuickModelShortcut(slot)
24532479 return false
@@ -4534,7 +4560,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45344560 i.mu.Unlock()
45354561 case "/help":
45364562 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)
45384564 i.statusErr = ""
45394565 i.statusOK = ""
45404566 // 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 []QuickModelShortcut141 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.ThemeOption148 ExtensionThemes func() []tui.ThemeOption
145 149
@@ -2448,6 +2452,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2448 return false2452 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 false2479 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) {
9494 return Key{Kind: KeyCtrlO}, nil
9595 case b == 0x16:
9696 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
97+ case b == 0x07:
98+ // Legacy terminals encode Ctrl+G as BEL. There is no separate
99+ // encoding for Ctrl+Shift+G, so expose the control letter for
100+ // keymap matching; callers that need the distinction require an
101+ // enhanced keyboard protocol.
102+ return Key{Kind: KeyRune, Rune: 'g', Ctrl: true}, nil
97103 case b == '\r', b == '\n':
98104 return Key{Kind: KeyEnter}, nil
99105 case b == '\t':
100106 return Key{Kind: KeyTab}, nil
101107 case b == 0x7f, b == 0x08:
102108 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
103114 case b == 0x1b:
104115 return r.readEscape()
105116 case b < 0x20:
@@ -407,10 +418,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
407418 case 'v', 'V':
408419 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
409420 }
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+ }
410426 }
411427 if code >= '0' && code <= '9' {
412428 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
413429 }
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+ }
414433 return Key{}, false
415434 }
416435
@@ -94,12 +94,23 @@ func (r *Reader) Read() (Key, error) {
94 return Key{Kind: KeyCtrlO}, nil94 return Key{Kind: KeyCtrlO}, nil
95 case b == 0x16:95 case b == 0x16:
96 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil96 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
97+ case b == 0x07:
98+ // Legacy terminals encode Ctrl+G as BEL. There is no separate
99+ // encoding for Ctrl+Shift+G, so expose the control letter for
100+ // keymap matching; callers that need the distinction require an
101+ // enhanced keyboard protocol.
102+ return Key{Kind: KeyRune, Rune: 'g', Ctrl: true}, nil
97 case b == '\r', b == '\n':103 case b == '\r', b == '\n':
98 return Key{Kind: KeyEnter}, nil104 return Key{Kind: KeyEnter}, nil
99 case b == '\t':105 case b == '\t':
100 return Key{Kind: KeyTab}, nil106 return Key{Kind: KeyTab}, nil
101 case b == 0x7f, b == 0x08:107 case b == 0x7f, b == 0x08:
102 return Key{Kind: KeyBackspace}, nil108 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}, true419 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}, true428 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{}, false433 return Key{}, false
415 }434 }
416 435
M packages/tui/input_test.go +14 -0
@@ -51,6 +51,20 @@ func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) {
5151 }
5252 }
5353
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+
5468 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
5569 k := readKey(t, "\x16")
5670 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close