detached 2e387ff

Merge pull request #195 from ggbalaazs/custom_shortcuts

feat: add custom hotkeys for slash commands (built-in, extension or skill)
commit
2e387ffac5fda434adc9a8393416959a9d1650e3
author
Patric Eckhart · · 2026-09-21 09:22 UTC
committer
GitHub · · 2026-09-21 09:22 UTC
merge of
98f35c8 + 53ffdc6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.82
13 files · +671 -4
M README.md +29 -0
@@ -162,6 +162,35 @@ To route zot-managed HTTP and HTTPS requests through one proxy, add `http_proxy`
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 made of at least one modifier (`ctrl`, `alt`/`option`, `shift`, `cmd`/`super`) plus a single character or a named key (`enter`, `tab`, `space`, `esc`, `backspace`, `delete`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`). Each value is a slash command: built-in commands, extension commands, and explicitly invoked `/skill:<name>` all work.
168+
169+```json
170+{
171+ "keymap": {
172+ "ctrl+s": "/settings",
173+ "ctrl+g": "/git-tools:status",
174+ "alt+r": "/skill:code-review"
175+ }
176+}
177+```
178+
179+Shortcuts run in the main interactive input and use the same dispatch path as
180+typing the command. They do not apply while a dialog, extension panel, or
181+tool confirmation has focus. Entries that cannot be bound are skipped and
182+reported at startup (the message clears with `/clear`): unmodified keys such
183+as `enter` or `g`, values that do not start with `/`, and the reserved
184+`ctrl+c`, `ctrl+d`, and `esc` chords. `/help` lists the active bindings.
185+
186+Terminal limitations: without enhanced keyboard reporting (kitty keyboard
187+protocol or xterm `modifyOtherKeys`), a terminal cannot distinguish
188+`ctrl+h`, `ctrl+i`, and `ctrl+m` from Backspace, Tab, and Enter, and it does
189+not report Shift on Ctrl+letter chords. zot therefore lets a `ctrl+shift+<letter>`
190+binding also fire on plain `ctrl+<letter>` unless a separate `ctrl+<letter>`
191+binding exists. Chords the terminal emulator or multiplexer consumes itself
192+(a tmux prefix, `cmd+k` clearing scrollback, tab switching) never reach zot.
193+
165194 ## Persistent instructions (AGENTS.md)
166195
167196 Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.
@@ -162,6 +162,35 @@ To route zot-managed HTTP and HTTPS requests through one proxy, add `http_proxy`
162 162
163 The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zot after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store.163 The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zot after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store.
164 164
165+### Configurable shortcuts
166+
167+Interactive keybindings can be added to `$ZOT_HOME/config.json` under `keymap`. Each key is a case-insensitive chord made of at least one modifier (`ctrl`, `alt`/`option`, `shift`, `cmd`/`super`) plus a single character or a named key (`enter`, `tab`, `space`, `esc`, `backspace`, `delete`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`). Each value is a slash command: built-in commands, extension commands, and explicitly invoked `/skill:<name>` all work.
168+
169+```json
170+{
171+ "keymap": {
172+ "ctrl+s": "/settings",
173+ "ctrl+g": "/git-tools:status",
174+ "alt+r": "/skill:code-review"
175+ }
176+}
177+```
178+
179+Shortcuts run in the main interactive input and use the same dispatch path as
180+typing the command. They do not apply while a dialog, extension panel, or
181+tool confirmation has focus. Entries that cannot be bound are skipped and
182+reported at startup (the message clears with `/clear`): unmodified keys such
183+as `enter` or `g`, values that do not start with `/`, and the reserved
184+`ctrl+c`, `ctrl+d`, and `esc` chords. `/help` lists the active bindings.
185+
186+Terminal limitations: without enhanced keyboard reporting (kitty keyboard
187+protocol or xterm `modifyOtherKeys`), a terminal cannot distinguish
188+`ctrl+h`, `ctrl+i`, and `ctrl+m` from Backspace, Tab, and Enter, and it does
189+not report Shift on Ctrl+letter chords. zot therefore lets a `ctrl+shift+<letter>`
190+binding also fire on plain `ctrl+<letter>` unless a separate `ctrl+<letter>`
191+binding exists. Chords the terminal emulator or multiplexer consumes itself
192+(a tmux prefix, `cmd+k` clearing scrollback, tab switching) never reach zot.
193+
165 ## Persistent instructions (AGENTS.md)194 ## Persistent instructions (AGENTS.md)
166 195
167 Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.196 Use `AGENTS.md` to give zot standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.
M packages/agent/cli.go +1 -0
@@ -1331,6 +1331,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
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 +15 -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 []keymapBinding) []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 _, b := range keymap {
66+ if n := runewidth.StringWidth(b.name); n > labelWidth {
67+ labelWidth = n
68+ }
69+ }
6570
6671 pad := func(s string) string {
6772 w := runewidth.StringWidth(s)
@@ -93,6 +98,15 @@ 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 _, b := range keymap {
104+ out = append(out, fmt.Sprintf(" %s %s",
105+ th.FG256(th.Accent, pad(b.name)),
106+ th.FG256(th.Muted, b.command)))
107+ }
108+ }
109+
96110 out = append(out, "", frameRule(th, width), "")
97111 return out
98112 }
@@ -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 []keymapBinding) []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 _, b := range keymap {
66+ if n := runewidth.StringWidth(b.name); n > labelWidth {
67+ labelWidth = n
68+ }
69+ }
65 70
66 pad := func(s string) string {71 pad := func(s string) string {
67 w := runewidth.StringWidth(s)72 w := runewidth.StringWidth(s)
@@ -93,6 +98,15 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string {
93 th.FG256(th.Muted, k[1])))98 th.FG256(th.Muted, k[1])))
94 }99 }
95 100
101+ if len(keymap) > 0 {
102+ out = append(out, "", tui.Bold("custom keys:"))
103+ for _, b := range keymap {
104+ out = append(out, fmt.Sprintf(" %s %s",
105+ th.FG256(th.Accent, pad(b.name)),
106+ th.FG256(th.Muted, b.command)))
107+ }
108+ }
109+
96 out = append(out, "", frameRule(th, width), "")110 out = append(out, "", frameRule(th, width), "")
97 return out111 return out
98 }112 }
M packages/agent/modes/help_test.go +19 -2
@@ -8,12 +8,29 @@ 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+ bindings, issues := compileKeymap(map[string]string{
23+ "Ctrl+S": "/skill:review",
24+ "bogus": "/help",
25+ })
26+ if len(issues) != 1 {
27+ t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
28+ }
29+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")
30+ if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
31+ t.Fatalf("help omitted configured custom key: %q", help)
32+ }
33+ if strings.Contains(help, "bogus") {
34+ t.Fatalf("help advertised an invalid binding: %q", help)
35+ }
36+}
@@ -8,12 +8,29 @@ import (
8 )8 )
9 9
10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
11- without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n")11+ without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n")
12 if strings.Contains(without, "/llama") {12 if strings.Contains(without, "/llama") {
13 t.Fatalf("help exposed /llama without login: %q", without)13 t.Fatalf("help exposed /llama without login: %q", without)
14 }14 }
15- with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n")15+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n")
16 if !strings.Contains(with, "/llama") {16 if !strings.Contains(with, "/llama") {
17 t.Fatalf("help omitted /llama with login: %q", with)17 t.Fatalf("help omitted /llama with login: %q", with)
18 }18 }
19 }19 }
20+
21+func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
22+ bindings, issues := compileKeymap(map[string]string{
23+ "Ctrl+S": "/skill:review",
24+ "bogus": "/help",
25+ })
26+ if len(issues) != 1 {
27+ t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
28+ }
29+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")
30+ if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
31+ t.Fatalf("help omitted configured custom key: %q", help)
32+ }
33+ if strings.Contains(help, "bogus") {
34+ t.Fatalf("help advertised an invalid binding: %q", help)
35+ }
36+}
M packages/agent/modes/interactive.go +59 -1
@@ -140,6 +140,10 @@ type InteractiveConfig struct {
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
@@ -576,6 +580,10 @@ type Interactive struct {
576580 // stay in the scrolling chat until /clear without entering the transcript.
577581 reloadErrors []string
578582
583+ // keymap holds the compiled config.json keymap bindings, sorted by
584+ // chord name. Invalid entries are reported through reloadErrors.
585+ keymap []keymapBinding
586+
579587 // shellRunning is true while a !command is executing. It shares
580588 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
581589 // escape can start while one is in flight.
@@ -694,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
694702 inputHistoryIndex: -1,
695703 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
696704 }
705+ var keymapIssues []string
706+ i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
707+ i.reloadErrors = append(i.reloadErrors, keymapIssues...)
697708 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
698709 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
699710 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -2101,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool {
21012112 i.extPanel.Active()
21022113 }
21032114
2115+// dialogOwnsInput reports whether a dialog, panel, or focused
2116+// confirmation prompt currently consumes keys instead of the main editor.
2117+func (i *Interactive) dialogOwnsInput() bool {
2118+ return i.confirmDialog.Focused() ||
2119+ i.dialog.Active() ||
2120+ i.modelDialog.Active() ||
2121+ i.llamaDialog.Active() ||
2122+ i.rescueDialog.Active() ||
2123+ i.sessionDialog.Active() ||
2124+ i.swarmDialog.Active() ||
2125+ i.jumpDialog.Active() ||
2126+ i.btwDialog.Active() ||
2127+ i.skillsDialog.Active() ||
2128+ i.changelogDialog.Active() ||
2129+ i.logoutDialog.Active() ||
2130+ i.telegramDialog.Active() ||
2131+ i.settingsDialog.Active() ||
2132+ i.sessionOpsDialog.Active() ||
2133+ i.sessionTreeDialog.Active() ||
2134+ i.timeline.Active() ||
2135+ i.extPanel.Active()
2136+}
2137+
21042138 // restoreConfirmFocus returns input to confirmation after slash input is
21052139 // cleared or a child interaction closes. A non-empty editor keeps command
21062140 // input focused, and an active child continues to own its keys.
@@ -2140,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21402174 }
21412175 }
21422176
2177+ // Ctrl and Super chords reach us as modified runes so the keymap can
2178+ // bind them on the main input. Dialogs key off bare letters ('r'
2179+ // renames, 'q' closes, digits answer confirmations) and must not see
2180+ // a chord as its letter, so neutralize it while one owns input.
2181+ if isChordRune(k) && i.dialogOwnsInput() {
2182+ k = tui.Key{Kind: tui.KeyUnknown}
2183+ }
2184+
21432185 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
21442186 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
21452187 // unexpectedly. The hint message also goes stale; clear it.
@@ -2448,6 +2490,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24482490 return false
24492491 }
24502492
2493+ // Configured shortcuts only apply to the main input. Every dialog
2494+ // above has already consumed its keys, and the confirmation prompt
2495+ // keeps focus until the user explicitly moves it with '/'.
2496+ if !keymapReservedKey(k) {
2497+ if command := lookupKeymap(i.keymap, k); command != "" {
2498+ parts := strings.Fields(command)
2499+ if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2500+ i.cancelAndWaitForIdle()
2501+ }
2502+ i.ed.Clear()
2503+ i.suggest.Reset()
2504+ i.fileSuggest.Reset()
2505+ return i.runSlash(ctx, command)
2506+ }
2507+ }
2508+
24512509 if slot := quickModelShortcutSlot(k); slot > 0 {
24522510 i.applyQuickModelShortcut(slot)
24532511 return false
@@ -4534,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45344592 i.mu.Unlock()
45354593 case "/help":
45364594 i.mu.Lock()
4537- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
4595+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
45384596 i.statusErr = ""
45394597 i.statusOK = ""
45404598 // 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
@@ -576,6 +580,10 @@ type Interactive struct {
576 // stay in the scrolling chat until /clear without entering the transcript.580 // stay in the scrolling chat until /clear without entering the transcript.
577 reloadErrors []string581 reloadErrors []string
578 582
583+ // keymap holds the compiled config.json keymap bindings, sorted by
584+ // chord name. Invalid entries are reported through reloadErrors.
585+ keymap []keymapBinding
586+
579 // shellRunning is true while a !command is executing. It shares587 // shellRunning is true while a !command is executing. It shares
580 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell588 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
581 // escape can start while one is in flight.589 // escape can start while one is in flight.
@@ -694,6 +702,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
694 inputHistoryIndex: -1,702 inputHistoryIndex: -1,
695 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),703 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
696 }704 }
705+ var keymapIssues []string
706+ i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
707+ i.reloadErrors = append(i.reloadErrors, keymapIssues...)
697 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)708 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
698 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)709 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
699 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)710 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -2101,6 +2112,29 @@ func (i *Interactive) confirmChildActive() bool {
2101 i.extPanel.Active()2112 i.extPanel.Active()
2102 }2113 }
2103 2114
2115+// dialogOwnsInput reports whether a dialog, panel, or focused
2116+// confirmation prompt currently consumes keys instead of the main editor.
2117+func (i *Interactive) dialogOwnsInput() bool {
2118+ return i.confirmDialog.Focused() ||
2119+ i.dialog.Active() ||
2120+ i.modelDialog.Active() ||
2121+ i.llamaDialog.Active() ||
2122+ i.rescueDialog.Active() ||
2123+ i.sessionDialog.Active() ||
2124+ i.swarmDialog.Active() ||
2125+ i.jumpDialog.Active() ||
2126+ i.btwDialog.Active() ||
2127+ i.skillsDialog.Active() ||
2128+ i.changelogDialog.Active() ||
2129+ i.logoutDialog.Active() ||
2130+ i.telegramDialog.Active() ||
2131+ i.settingsDialog.Active() ||
2132+ i.sessionOpsDialog.Active() ||
2133+ i.sessionTreeDialog.Active() ||
2134+ i.timeline.Active() ||
2135+ i.extPanel.Active()
2136+}
2137+
2104 // restoreConfirmFocus returns input to confirmation after slash input is2138 // restoreConfirmFocus returns input to confirmation after slash input is
2105 // cleared or a child interaction closes. A non-empty editor keeps command2139 // cleared or a child interaction closes. A non-empty editor keeps command
2106 // input focused, and an active child continues to own its keys.2140 // input focused, and an active child continues to own its keys.
@@ -2140,6 +2174,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2140 }2174 }
2141 }2175 }
2142 2176
2177+ // Ctrl and Super chords reach us as modified runes so the keymap can
2178+ // bind them on the main input. Dialogs key off bare letters ('r'
2179+ // renames, 'q' closes, digits answer confirmations) and must not see
2180+ // a chord as its letter, so neutralize it while one owns input.
2181+ if isChordRune(k) && i.dialogOwnsInput() {
2182+ k = tui.Key{Kind: tui.KeyUnknown}
2183+ }
2184+
2143 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so2185 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2144 // pressing ctrl+c then typing then ctrl+c much later doesn't quit2186 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2145 // unexpectedly. The hint message also goes stale; clear it.2187 // unexpectedly. The hint message also goes stale; clear it.
@@ -2448,6 +2490,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2448 return false2490 return false
2449 }2491 }
2450 2492
2493+ // Configured shortcuts only apply to the main input. Every dialog
2494+ // above has already consumed its keys, and the confirmation prompt
2495+ // keeps focus until the user explicitly moves it with '/'.
2496+ if !keymapReservedKey(k) {
2497+ if command := lookupKeymap(i.keymap, k); command != "" {
2498+ parts := strings.Fields(command)
2499+ if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2500+ i.cancelAndWaitForIdle()
2501+ }
2502+ i.ed.Clear()
2503+ i.suggest.Reset()
2504+ i.fileSuggest.Reset()
2505+ return i.runSlash(ctx, command)
2506+ }
2507+ }
2508+
2451 if slot := quickModelShortcutSlot(k); slot > 0 {2509 if slot := quickModelShortcutSlot(k); slot > 0 {
2452 i.applyQuickModelShortcut(slot)2510 i.applyQuickModelShortcut(slot)
2453 return false2511 return false
@@ -4534,7 +4592,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4534 i.mu.Unlock()4592 i.mu.Unlock()
4535 case "/help":4593 case "/help":
4536 i.mu.Lock()4594 i.mu.Lock()
4537- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)4595+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
4538 i.statusErr = ""4596 i.statusErr = ""
4539 i.statusOK = ""4597 i.statusOK = ""
4540 // Pin the viewport to the newest content so the help block,4598 // Pin the viewport to the newest content so the help block,
A packages/agent/modes/keymap.go +254 -0
@@ -0,0 +1,254 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/tui"
10+)
11+
12+// keymapBinding is one compiled config.json keymap entry.
13+type keymapBinding struct {
14+ name string // normalized chord name, for example "ctrl+shift+g"
15+ spec configuredKey
16+ command string
17+}
18+
19+// compileKeymap parses a config.json keymap into bindings sorted by
20+// chord name. Invalid entries are dropped and reported in issues so the
21+// interactive mode can surface them instead of silently ignoring them.
22+func compileKeymap(keymap map[string]string) (bindings []keymapBinding, issues []string) {
23+ for _, name := range sortedCustomKeys(keymap) {
24+ spec, ok := parseConfiguredKey(name)
25+ if !ok {
26+ issues = append(issues, fmt.Sprintf("keymap: invalid key %q (use modifier+key, for example ctrl+shift+g)", name))
27+ continue
28+ }
29+ if spec.reserved() {
30+ issues = append(issues, fmt.Sprintf("keymap: %q is reserved and cannot be rebound", name))
31+ continue
32+ }
33+ command := strings.TrimSpace(keymap[name])
34+ if command == "" || !strings.HasPrefix(command, "/") {
35+ issues = append(issues, fmt.Sprintf("keymap: %q must map to a slash command, got %q", name, command))
36+ continue
37+ }
38+ bindings = append(bindings, keymapBinding{name: strings.ToLower(strings.TrimSpace(name)), spec: spec, command: command})
39+ }
40+ return bindings, issues
41+}
42+
43+// lookupKeymap resolves key against compiled bindings. An exact chord
44+// match wins over the legacy Ctrl+letter fallback so enhanced-protocol
45+// terminals can bind ctrl+g and ctrl+shift+g independently.
46+func lookupKeymap(bindings []keymapBinding, key tui.Key) string {
47+ for _, b := range bindings {
48+ if b.spec.matches(key, false) {
49+ return b.command
50+ }
51+ }
52+ for _, b := range bindings {
53+ if b.spec.matches(key, true) {
54+ return b.command
55+ }
56+ }
57+ return ""
58+}
59+
60+// configuredKeyCommand resolves a configured key chord straight from a
61+// config.json keymap. Key names are case-insensitive and use '+'
62+// separated modifiers, for example "ctrl+shift+g", "alt+enter", or
63+// "cmd+k". Invalid entries are ignored. Interactive mode compiles the
64+// map once; this helper exists for callers that only have the map.
65+func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
66+ if len(keymap) == 0 {
67+ return ""
68+ }
69+ bindings, _ := compileKeymap(keymap)
70+ return lookupKeymap(bindings, key)
71+}
72+
73+// sortedCustomKeys returns keymap names in a stable, case-insensitive
74+// order for help output and deterministic lookup precedence.
75+func sortedCustomKeys(keymap map[string]string) []string {
76+ keys := make([]string, 0, len(keymap))
77+ for key := range keymap {
78+ keys = append(keys, key)
79+ }
80+ sort.Slice(keys, func(i, j int) bool {
81+ left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j])
82+ if left == right {
83+ return keys[i] < keys[j]
84+ }
85+ return left < right
86+ })
87+ return keys
88+}
89+
90+type configuredKey struct {
91+ kind tui.KeyKind
92+ rune rune
93+ ctrl, alt bool
94+ shift bool
95+ super bool
96+}
97+
98+func (k configuredKey) modified() bool {
99+ return k.ctrl || k.alt || k.shift || k.super
100+}
101+
102+// reserved reports whether the chord belongs to the emergency/exit
103+// controls that config.json must never override.
104+func (k configuredKey) reserved() bool {
105+ switch {
106+ case k.kind == tui.KeyRune && k.ctrl && !k.alt && !k.super && (k.rune == 'c' || k.rune == 'd'):
107+ return true
108+ case k.kind == tui.KeyEsc && !k.modified():
109+ return true
110+ }
111+ return false
112+}
113+
114+var specialConfiguredKeys = map[string]tui.KeyKind{
115+ "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
116+ "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
117+ "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
118+ "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
119+ "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
120+ "space": tui.KeyRune,
121+}
122+
123+func parseConfiguredKey(value string) (configuredKey, bool) {
124+ var out configuredKey
125+ parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")
126+ if len(parts) == 0 {
127+ return out, false
128+ }
129+ base := ""
130+ for _, part := range parts {
131+ part = strings.TrimSpace(part)
132+ switch part {
133+ case "ctrl", "control":
134+ out.ctrl = true
135+ case "alt", "option", "opt":
136+ out.alt = true
137+ case "shift":
138+ out.shift = true
139+ case "cmd", "command", "super", "meta", "win":
140+ out.super = true
141+ case "":
142+ return out, false
143+ default:
144+ if base != "" {
145+ return out, false
146+ }
147+ base = part
148+ }
149+ }
150+ if base == "" {
151+ return out, false
152+ }
153+ out.kind = tui.KeyRune
154+ if len([]rune(base)) == 1 {
155+ out.rune = []rune(base)[0]
156+ } else {
157+ kind, ok := specialConfiguredKeys[base]
158+ if !ok {
159+ return out, false
160+ }
161+ // Shift+Tab is represented as a dedicated kind by some terminals
162+ // and as Tab+Shift by enhanced keyboard protocols; normalize it
163+ // to the latter so both forms match.
164+ if kind == tui.KeyShiftTab {
165+ out.kind = tui.KeyTab
166+ out.shift = true
167+ } else {
168+ out.kind = kind
169+ }
170+ if base == "space" {
171+ out.rune = ' '
172+ }
173+ }
174+ // Unmodified keys belong to normal text entry and navigation. A
175+ // typo in config.json must not be able to capture Enter, Space,
176+ // arrows, or a plain letter.
177+ if !out.modified() {
178+ return out, false
179+ }
180+ return out, true
181+}
182+
183+// ctrlKindRunes maps the reader's dedicated control-key kinds back to
184+// the letter they encode so "ctrl+l" style bindings can match them.
185+var ctrlKindRunes = map[tui.KeyKind]rune{
186+ tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
187+ tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
188+}
189+
190+// matches reports whether actual is the configured chord. With lenient
191+// set, a Ctrl+Shift+letter binding also accepts the plain Ctrl+letter
192+// event because legacy terminal encodings cannot carry Shift on control
193+// letters. Callers should try exact matching first.
194+func (k configuredKey) matches(actual tui.Key, lenient bool) bool {
195+ kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl
196+ shift := actual.Shift
197+ if kind == tui.KeyShiftTab {
198+ kind = tui.KeyTab
199+ shift = true
200+ }
201+ // Raw control bytes are exposed as dedicated KeyKinds by the reader.
202+ if letter, ok := ctrlKindRunes[kind]; ok {
203+ ctrl = true
204+ r = letter
205+ kind = tui.KeyRune
206+ }
207+ // Some enhanced keyboard protocols report the shifted glyph rather
208+ // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize
209+ // those common US-layout glyphs so number chords remain bindable.
210+ if ctrl && shift {
211+ if digit, ok := shiftedDigit(r); ok {
212+ r = digit
213+ }
214+ }
215+ // Enhanced terminal protocols preserve the uppercase glyph for shifted
216+ // letters, while configured key names are case-insensitive.
217+ r = unicode.ToLower(r)
218+ wantRune := unicode.ToLower(k.rune)
219+ shiftMatches := shift == k.shift
220+ if !shiftMatches && lenient {
221+ shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z'
222+ }
223+ return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&
224+ shiftMatches && actual.Super == k.super
225+}
226+
227+func shiftedDigit(r rune) (rune, bool) {
228+ const glyphs = ")!@#$%^&*("
229+ if i := strings.IndexRune(glyphs, r); i >= 0 {
230+ return rune('0' + i), true
231+ }
232+ return 0, false
233+}
234+
235+// keymapReservedKey reports whether k is one of the emergency/exit
236+// controls. They stay authoritative at runtime even if a binding somehow
237+// resolves to them.
238+func keymapReservedKey(k tui.Key) bool {
239+ switch k.Kind {
240+ case tui.KeyCtrlC, tui.KeyCtrlD:
241+ return true
242+ case tui.KeyEsc:
243+ return !k.Ctrl && !k.Alt && !k.Shift && !k.Super
244+ }
245+ return false
246+}
247+
248+// isChordRune reports whether k is a Ctrl or Super modified rune. The
249+// reader surfaces unbound chords this way so keymaps can match them;
250+// they are never typed text and dialogs must not treat them as bare
251+// letters ('r' renames, 'q' closes, ...).
252+func isChordRune(k tui.Key) bool {
253+ return k.Kind == tui.KeyRune && (k.Ctrl || k.Super)
254+}
@@ -0,0 +1,254 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "unicode"
8+
9+ "github.com/patriceckhart/zot/packages/tui"
10+)
11+
12+// keymapBinding is one compiled config.json keymap entry.
13+type keymapBinding struct {
14+ name string // normalized chord name, for example "ctrl+shift+g"
15+ spec configuredKey
16+ command string
17+}
18+
19+// compileKeymap parses a config.json keymap into bindings sorted by
20+// chord name. Invalid entries are dropped and reported in issues so the
21+// interactive mode can surface them instead of silently ignoring them.
22+func compileKeymap(keymap map[string]string) (bindings []keymapBinding, issues []string) {
23+ for _, name := range sortedCustomKeys(keymap) {
24+ spec, ok := parseConfiguredKey(name)
25+ if !ok {
26+ issues = append(issues, fmt.Sprintf("keymap: invalid key %q (use modifier+key, for example ctrl+shift+g)", name))
27+ continue
28+ }
29+ if spec.reserved() {
30+ issues = append(issues, fmt.Sprintf("keymap: %q is reserved and cannot be rebound", name))
31+ continue
32+ }
33+ command := strings.TrimSpace(keymap[name])
34+ if command == "" || !strings.HasPrefix(command, "/") {
35+ issues = append(issues, fmt.Sprintf("keymap: %q must map to a slash command, got %q", name, command))
36+ continue
37+ }
38+ bindings = append(bindings, keymapBinding{name: strings.ToLower(strings.TrimSpace(name)), spec: spec, command: command})
39+ }
40+ return bindings, issues
41+}
42+
43+// lookupKeymap resolves key against compiled bindings. An exact chord
44+// match wins over the legacy Ctrl+letter fallback so enhanced-protocol
45+// terminals can bind ctrl+g and ctrl+shift+g independently.
46+func lookupKeymap(bindings []keymapBinding, key tui.Key) string {
47+ for _, b := range bindings {
48+ if b.spec.matches(key, false) {
49+ return b.command
50+ }
51+ }
52+ for _, b := range bindings {
53+ if b.spec.matches(key, true) {
54+ return b.command
55+ }
56+ }
57+ return ""
58+}
59+
60+// configuredKeyCommand resolves a configured key chord straight from a
61+// config.json keymap. Key names are case-insensitive and use '+'
62+// separated modifiers, for example "ctrl+shift+g", "alt+enter", or
63+// "cmd+k". Invalid entries are ignored. Interactive mode compiles the
64+// map once; this helper exists for callers that only have the map.
65+func configuredKeyCommand(keymap map[string]string, key tui.Key) string {
66+ if len(keymap) == 0 {
67+ return ""
68+ }
69+ bindings, _ := compileKeymap(keymap)
70+ return lookupKeymap(bindings, key)
71+}
72+
73+// sortedCustomKeys returns keymap names in a stable, case-insensitive
74+// order for help output and deterministic lookup precedence.
75+func sortedCustomKeys(keymap map[string]string) []string {
76+ keys := make([]string, 0, len(keymap))
77+ for key := range keymap {
78+ keys = append(keys, key)
79+ }
80+ sort.Slice(keys, func(i, j int) bool {
81+ left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j])
82+ if left == right {
83+ return keys[i] < keys[j]
84+ }
85+ return left < right
86+ })
87+ return keys
88+}
89+
90+type configuredKey struct {
91+ kind tui.KeyKind
92+ rune rune
93+ ctrl, alt bool
94+ shift bool
95+ super bool
96+}
97+
98+func (k configuredKey) modified() bool {
99+ return k.ctrl || k.alt || k.shift || k.super
100+}
101+
102+// reserved reports whether the chord belongs to the emergency/exit
103+// controls that config.json must never override.
104+func (k configuredKey) reserved() bool {
105+ switch {
106+ case k.kind == tui.KeyRune && k.ctrl && !k.alt && !k.super && (k.rune == 'c' || k.rune == 'd'):
107+ return true
108+ case k.kind == tui.KeyEsc && !k.modified():
109+ return true
110+ }
111+ return false
112+}
113+
114+var specialConfiguredKeys = map[string]tui.KeyKind{
115+ "enter": tui.KeyEnter, "return": tui.KeyEnter, "tab": tui.KeyTab,
116+ "shift-tab": tui.KeyShiftTab, "esc": tui.KeyEsc, "escape": tui.KeyEsc,
117+ "backspace": tui.KeyBackspace, "delete": tui.KeyDelete,
118+ "up": tui.KeyUp, "down": tui.KeyDown, "left": tui.KeyLeft, "right": tui.KeyRight,
119+ "home": tui.KeyHome, "end": tui.KeyEnd, "pageup": tui.KeyPageUp, "pagedown": tui.KeyPageDown,
120+ "space": tui.KeyRune,
121+}
122+
123+func parseConfiguredKey(value string) (configuredKey, bool) {
124+ var out configuredKey
125+ parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "+")
126+ if len(parts) == 0 {
127+ return out, false
128+ }
129+ base := ""
130+ for _, part := range parts {
131+ part = strings.TrimSpace(part)
132+ switch part {
133+ case "ctrl", "control":
134+ out.ctrl = true
135+ case "alt", "option", "opt":
136+ out.alt = true
137+ case "shift":
138+ out.shift = true
139+ case "cmd", "command", "super", "meta", "win":
140+ out.super = true
141+ case "":
142+ return out, false
143+ default:
144+ if base != "" {
145+ return out, false
146+ }
147+ base = part
148+ }
149+ }
150+ if base == "" {
151+ return out, false
152+ }
153+ out.kind = tui.KeyRune
154+ if len([]rune(base)) == 1 {
155+ out.rune = []rune(base)[0]
156+ } else {
157+ kind, ok := specialConfiguredKeys[base]
158+ if !ok {
159+ return out, false
160+ }
161+ // Shift+Tab is represented as a dedicated kind by some terminals
162+ // and as Tab+Shift by enhanced keyboard protocols; normalize it
163+ // to the latter so both forms match.
164+ if kind == tui.KeyShiftTab {
165+ out.kind = tui.KeyTab
166+ out.shift = true
167+ } else {
168+ out.kind = kind
169+ }
170+ if base == "space" {
171+ out.rune = ' '
172+ }
173+ }
174+ // Unmodified keys belong to normal text entry and navigation. A
175+ // typo in config.json must not be able to capture Enter, Space,
176+ // arrows, or a plain letter.
177+ if !out.modified() {
178+ return out, false
179+ }
180+ return out, true
181+}
182+
183+// ctrlKindRunes maps the reader's dedicated control-key kinds back to
184+// the letter they encode so "ctrl+l" style bindings can match them.
185+var ctrlKindRunes = map[tui.KeyKind]rune{
186+ tui.KeyCtrlC: 'c', tui.KeyCtrlD: 'd', tui.KeyCtrlL: 'l', tui.KeyCtrlU: 'u',
187+ tui.KeyCtrlK: 'k', tui.KeyCtrlA: 'a', tui.KeyCtrlE: 'e', tui.KeyCtrlW: 'w', tui.KeyCtrlO: 'o',
188+}
189+
190+// matches reports whether actual is the configured chord. With lenient
191+// set, a Ctrl+Shift+letter binding also accepts the plain Ctrl+letter
192+// event because legacy terminal encodings cannot carry Shift on control
193+// letters. Callers should try exact matching first.
194+func (k configuredKey) matches(actual tui.Key, lenient bool) bool {
195+ kind, r, ctrl := actual.Kind, actual.Rune, actual.Ctrl
196+ shift := actual.Shift
197+ if kind == tui.KeyShiftTab {
198+ kind = tui.KeyTab
199+ shift = true
200+ }
201+ // Raw control bytes are exposed as dedicated KeyKinds by the reader.
202+ if letter, ok := ctrlKindRunes[kind]; ok {
203+ ctrl = true
204+ r = letter
205+ kind = tui.KeyRune
206+ }
207+ // Some enhanced keyboard protocols report the shifted glyph rather
208+ // than the physical number key (Ctrl+Shift+7 becomes '&'). Normalize
209+ // those common US-layout glyphs so number chords remain bindable.
210+ if ctrl && shift {
211+ if digit, ok := shiftedDigit(r); ok {
212+ r = digit
213+ }
214+ }
215+ // Enhanced terminal protocols preserve the uppercase glyph for shifted
216+ // letters, while configured key names are case-insensitive.
217+ r = unicode.ToLower(r)
218+ wantRune := unicode.ToLower(k.rune)
219+ shiftMatches := shift == k.shift
220+ if !shiftMatches && lenient {
221+ shiftMatches = k.ctrl && k.shift && !shift && ctrl && r >= 'a' && r <= 'z'
222+ }
223+ return kind == k.kind && r == wantRune && ctrl == k.ctrl && actual.Alt == k.alt &&
224+ shiftMatches && actual.Super == k.super
225+}
226+
227+func shiftedDigit(r rune) (rune, bool) {
228+ const glyphs = ")!@#$%^&*("
229+ if i := strings.IndexRune(glyphs, r); i >= 0 {
230+ return rune('0' + i), true
231+ }
232+ return 0, false
233+}
234+
235+// keymapReservedKey reports whether k is one of the emergency/exit
236+// controls. They stay authoritative at runtime even if a binding somehow
237+// resolves to them.
238+func keymapReservedKey(k tui.Key) bool {
239+ switch k.Kind {
240+ case tui.KeyCtrlC, tui.KeyCtrlD:
241+ return true
242+ case tui.KeyEsc:
243+ return !k.Ctrl && !k.Alt && !k.Shift && !k.Super
244+ }
245+ return false
246+}
247+
248+// isChordRune reports whether k is a Ctrl or Super modified rune. The
249+// reader surfaces unbound chords this way so keymaps can match them;
250+// they are never typed text and dialogs must not treat them as bare
251+// letters ('r' renames, 'q' closes, ...).
252+func isChordRune(k tui.Key) bool {
253+ return k.Kind == tui.KeyRune && (k.Ctrl || k.Super)
254+}
A packages/agent/modes/keymap_test.go +167 -0
@@ -0,0 +1,167 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/agent/tools"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) {
14+ keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"}
15+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" {
16+ t.Fatalf("configuredKeyCommand = %q, want extension command", got)
17+ }
18+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
19+ t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got)
20+ }
21+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" {
22+ t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got)
23+ }
24+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" {
25+ t.Fatalf("unmodified key matched command %q", got)
26+ }
27+}
28+
29+func TestConfiguredKeyCommandPrefersExactChord(t *testing.T) {
30+ keymap := map[string]string{
31+ "ctrl+g": "/help",
32+ "ctrl+shift+g": "/git-tools:status",
33+ }
34+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/help" {
35+ t.Fatalf("plain ctrl+g = %q, want exact /help binding", got)
36+ }
37+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
38+ t.Fatalf("ctrl+shift+g = %q, want /git-tools:status", got)
39+ }
40+}
41+
42+func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
43+ if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" {
44+ t.Fatalf("configuredKeyCommand = %q, want skill command", got)
45+ }
46+ if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {
47+ t.Fatalf("enhanced ctrl+h binding = %q, want /help", got)
48+ }
49+}
50+
51+func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {
52+ for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} {
53+ if _, ok := parseConfiguredKey(bad); ok {
54+ t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad)
55+ }
56+ }
57+ key, ok := parseConfiguredKey("alt+enter")
58+ if !ok || key.kind != tui.KeyEnter || !key.alt {
59+ t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok)
60+ }
61+ if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {
62+ t.Fatalf("shift+tab binding = %q, want /help", got)
63+ }
64+ if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" {
65+ t.Fatalf("ctrl+space binding = %q, want /help", got)
66+ }
67+}
68+
69+func TestCompileKeymapReportsInvalidAndReservedEntries(t *testing.T) {
70+ bindings, issues := compileKeymap(map[string]string{
71+ "ctrl+c": "/help",
72+ "ctrl+d": "/help",
73+ "esc": "/help",
74+ "enter": "/help",
75+ "ctrl+s": "settings",
76+ "ctrl+x": "",
77+ "alt+r": "/skill:code-review",
78+ "ctrl+alt+c": "/help",
79+ "Shift+Enter": "/help",
80+ })
81+ if len(issues) != 6 {
82+ t.Fatalf("issues = %v, want 6 entries", issues)
83+ }
84+ for _, issue := range issues {
85+ if !strings.HasPrefix(issue, "keymap: ") {
86+ t.Fatalf("issue %q lacks keymap prefix", issue)
87+ }
88+ }
89+ var names []string
90+ for _, b := range bindings {
91+ names = append(names, b.name)
92+ }
93+ if got, want := strings.Join(names, ","), "alt+r,ctrl+alt+c,shift+enter"; got != want {
94+ t.Fatalf("compiled bindings = %q, want %q", got, want)
95+ }
96+ if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyCtrlC}); got != "" {
97+ t.Fatalf("ctrl+c resolved to %q, want reserved", got)
98+ }
99+ if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyEnter, Shift: true}); got != "/help" {
100+ t.Fatalf("shift+enter = %q, want /help", got)
101+ }
102+}
103+
104+func newKeymapInteractive(keymap map[string]string) *Interactive {
105+ agent := core.NewAgent(nil, "test-model", "test system", nil)
106+ return NewInteractive(InteractiveConfig{
107+ Theme: tui.Dark,
108+ Terminal: tui.NewProcTerm(),
109+ Agent: agent,
110+ Sandbox: tools.NewSandbox("."),
111+ Keymap: keymap,
112+ })
113+}
114+
115+func TestInteractiveKeymapRunsSlashCommand(t *testing.T) {
116+ i := newKeymapInteractive(map[string]string{"ctrl+s": "/help", "bogus": "/help"})
117+ if len(i.reloadErrors) != 1 || !strings.Contains(i.reloadErrors[0], `"bogus"`) {
118+ t.Fatalf("reloadErrors = %v, want one keymap diagnostic", i.reloadErrors)
119+ }
120+ i.ed.SetValue("draft")
121+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
122+ if len(i.helpBlock) == 0 {
123+ t.Fatal("ctrl+s did not run /help")
124+ }
125+ if got := i.ed.Value(); got != "" {
126+ t.Fatalf("editor = %q, want cleared before running the bound command", got)
127+ }
128+}
129+
130+func TestInteractiveUnboundChordDoesNotType(t *testing.T) {
131+ i := newKeymapInteractive(nil)
132+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
133+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'k', Super: true})
134+ if got := i.ed.Value(); got != "" {
135+ t.Fatalf("editor = %q, want unbound chords ignored", got)
136+ }
137+}
138+
139+func TestInteractiveKeymapInactiveWhileConfirmationFocused(t *testing.T) {
140+ i := newKeymapInteractive(map[string]string{"ctrl+s": "/help"})
141+ resp := enqueueConfirmation(i)
142+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
143+ if len(i.helpBlock) != 0 {
144+ t.Fatal("keymap fired while confirmation owned input")
145+ }
146+ select {
147+ case decision := <-resp:
148+ t.Fatalf("chord answered confirmation: %+v", decision)
149+ default:
150+ }
151+}
152+
153+func TestInteractiveChordDoesNotLeakIntoDialogAsLetter(t *testing.T) {
154+ i := newKeymapInteractive(map[string]string{"ctrl+r": "/help"})
155+ i.sessionDialog.sessions = []core.SessionSummary{{Path: "a.jsonl", Title: "one", MessageCount: 1}}
156+ i.sessionDialog.active = true
157+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'r', Ctrl: true})
158+ if i.sessionDialog.renaming {
159+ t.Fatal("ctrl+r started a rename as if it were a bare 'r'")
160+ }
161+ if len(i.helpBlock) != 0 {
162+ t.Fatal("keymap fired while the session dialog owned input")
163+ }
164+ if !i.sessionDialog.Active() {
165+ t.Fatal("session dialog closed unexpectedly")
166+ }
167+}
@@ -0,0 +1,167 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/agent/tools"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func TestConfiguredKeyCommandMatchesModifiers(t *testing.T) {
14+ keymap := map[string]string{"Ctrl+Shift+G": "/git-tools:status"}
15+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true, Shift: true}); got != "/git-tools:status" {
16+ t.Fatalf("configuredKeyCommand = %q, want extension command", got)
17+ }
18+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
19+ t.Fatalf("shifted uppercase configuredKeyCommand = %q, want extension command", got)
20+ }
21+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/git-tools:status" {
22+ t.Fatalf("legacy ctrl-letter configuredKeyCommand = %q, want extension command", got)
23+ }
24+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Shift: true}); got != "" {
25+ t.Fatalf("unmodified key matched command %q", got)
26+ }
27+}
28+
29+func TestConfiguredKeyCommandPrefersExactChord(t *testing.T) {
30+ keymap := map[string]string{
31+ "ctrl+g": "/help",
32+ "ctrl+shift+g": "/git-tools:status",
33+ }
34+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'g', Ctrl: true}); got != "/help" {
35+ t.Fatalf("plain ctrl+g = %q, want exact /help binding", got)
36+ }
37+ if got := configuredKeyCommand(keymap, tui.Key{Kind: tui.KeyRune, Rune: 'G', Ctrl: true, Shift: true}); got != "/git-tools:status" {
38+ t.Fatalf("ctrl+shift+g = %q, want /git-tools:status", got)
39+ }
40+}
41+
42+func TestConfiguredKeyCommandMatchesDedicatedControlKeys(t *testing.T) {
43+ if got := configuredKeyCommand(map[string]string{"ctrl+l": "/skill:review"}, tui.Key{Kind: tui.KeyCtrlL}); got != "/skill:review" {
44+ t.Fatalf("configuredKeyCommand = %q, want skill command", got)
45+ }
46+ if got := configuredKeyCommand(map[string]string{"ctrl+h": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: 'h', Ctrl: true}); got != "/help" {
47+ t.Fatalf("enhanced ctrl+h binding = %q, want /help", got)
48+ }
49+}
50+
51+func TestParseConfiguredKeyRejectsMalformedAndSupportsSpecialKeys(t *testing.T) {
52+ for _, bad := range []string{"ctrl+shift", "g", "enter", "space", "tab", "up", "backspace", "no-such-key", "ctrl+", "ctrl+a+b", ""} {
53+ if _, ok := parseConfiguredKey(bad); ok {
54+ t.Fatalf("parseConfiguredKey(%q) accepted an unbindable key", bad)
55+ }
56+ }
57+ key, ok := parseConfiguredKey("alt+enter")
58+ if !ok || key.kind != tui.KeyEnter || !key.alt {
59+ t.Fatalf("parseConfiguredKey(alt+enter) = %#v, %v", key, ok)
60+ }
61+ if got := configuredKeyCommand(map[string]string{"shift+tab": "/help"}, tui.Key{Kind: tui.KeyShiftTab}); got != "/help" {
62+ t.Fatalf("shift+tab binding = %q, want /help", got)
63+ }
64+ if got := configuredKeyCommand(map[string]string{"ctrl+space": "/help"}, tui.Key{Kind: tui.KeyRune, Rune: ' ', Ctrl: true}); got != "/help" {
65+ t.Fatalf("ctrl+space binding = %q, want /help", got)
66+ }
67+}
68+
69+func TestCompileKeymapReportsInvalidAndReservedEntries(t *testing.T) {
70+ bindings, issues := compileKeymap(map[string]string{
71+ "ctrl+c": "/help",
72+ "ctrl+d": "/help",
73+ "esc": "/help",
74+ "enter": "/help",
75+ "ctrl+s": "settings",
76+ "ctrl+x": "",
77+ "alt+r": "/skill:code-review",
78+ "ctrl+alt+c": "/help",
79+ "Shift+Enter": "/help",
80+ })
81+ if len(issues) != 6 {
82+ t.Fatalf("issues = %v, want 6 entries", issues)
83+ }
84+ for _, issue := range issues {
85+ if !strings.HasPrefix(issue, "keymap: ") {
86+ t.Fatalf("issue %q lacks keymap prefix", issue)
87+ }
88+ }
89+ var names []string
90+ for _, b := range bindings {
91+ names = append(names, b.name)
92+ }
93+ if got, want := strings.Join(names, ","), "alt+r,ctrl+alt+c,shift+enter"; got != want {
94+ t.Fatalf("compiled bindings = %q, want %q", got, want)
95+ }
96+ if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyCtrlC}); got != "" {
97+ t.Fatalf("ctrl+c resolved to %q, want reserved", got)
98+ }
99+ if got := lookupKeymap(bindings, tui.Key{Kind: tui.KeyEnter, Shift: true}); got != "/help" {
100+ t.Fatalf("shift+enter = %q, want /help", got)
101+ }
102+}
103+
104+func newKeymapInteractive(keymap map[string]string) *Interactive {
105+ agent := core.NewAgent(nil, "test-model", "test system", nil)
106+ return NewInteractive(InteractiveConfig{
107+ Theme: tui.Dark,
108+ Terminal: tui.NewProcTerm(),
109+ Agent: agent,
110+ Sandbox: tools.NewSandbox("."),
111+ Keymap: keymap,
112+ })
113+}
114+
115+func TestInteractiveKeymapRunsSlashCommand(t *testing.T) {
116+ i := newKeymapInteractive(map[string]string{"ctrl+s": "/help", "bogus": "/help"})
117+ if len(i.reloadErrors) != 1 || !strings.Contains(i.reloadErrors[0], `"bogus"`) {
118+ t.Fatalf("reloadErrors = %v, want one keymap diagnostic", i.reloadErrors)
119+ }
120+ i.ed.SetValue("draft")
121+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
122+ if len(i.helpBlock) == 0 {
123+ t.Fatal("ctrl+s did not run /help")
124+ }
125+ if got := i.ed.Value(); got != "" {
126+ t.Fatalf("editor = %q, want cleared before running the bound command", got)
127+ }
128+}
129+
130+func TestInteractiveUnboundChordDoesNotType(t *testing.T) {
131+ i := newKeymapInteractive(nil)
132+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
133+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'k', Super: true})
134+ if got := i.ed.Value(); got != "" {
135+ t.Fatalf("editor = %q, want unbound chords ignored", got)
136+ }
137+}
138+
139+func TestInteractiveKeymapInactiveWhileConfirmationFocused(t *testing.T) {
140+ i := newKeymapInteractive(map[string]string{"ctrl+s": "/help"})
141+ resp := enqueueConfirmation(i)
142+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 's', Ctrl: true})
143+ if len(i.helpBlock) != 0 {
144+ t.Fatal("keymap fired while confirmation owned input")
145+ }
146+ select {
147+ case decision := <-resp:
148+ t.Fatalf("chord answered confirmation: %+v", decision)
149+ default:
150+ }
151+}
152+
153+func TestInteractiveChordDoesNotLeakIntoDialogAsLetter(t *testing.T) {
154+ i := newKeymapInteractive(map[string]string{"ctrl+r": "/help"})
155+ i.sessionDialog.sessions = []core.SessionSummary{{Path: "a.jsonl", Title: "one", MessageCount: 1}}
156+ i.sessionDialog.active = true
157+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'r', Ctrl: true})
158+ if i.sessionDialog.renaming {
159+ t.Fatal("ctrl+r started a rename as if it were a bare 'r'")
160+ }
161+ if len(i.helpBlock) != 0 {
162+ t.Fatal("keymap fired while the session dialog owned input")
163+ }
164+ if !i.sessionDialog.Active() {
165+ t.Fatal("session dialog closed unexpectedly")
166+ }
167+}
M packages/tui/editor.go +7 -0
@@ -129,6 +129,13 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
129129 e.newline()
130130 return false
131131 }
132+ // Ctrl and Super chords are shortcuts, not typed text. The
133+ // reader surfaces unbound ones (Ctrl+S, Cmd+K, ...) as runes so
134+ // callers can match keymaps; anything that reaches the editor
135+ // unmatched must be ignored rather than inserted.
136+ if k.Ctrl || k.Super {
137+ return false
138+ }
132139 e.insert(string(k.Rune))
133140 case KeyEnter:
134141 if k.Shift || k.Alt {
@@ -129,6 +129,13 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
129 e.newline()129 e.newline()
130 return false130 return false
131 }131 }
132+ // Ctrl and Super chords are shortcuts, not typed text. The
133+ // reader surfaces unbound ones (Ctrl+S, Cmd+K, ...) as runes so
134+ // callers can match keymaps; anything that reaches the editor
135+ // unmatched must be ignored rather than inserted.
136+ if k.Ctrl || k.Super {
137+ return false
138+ }
132 e.insert(string(k.Rune))139 e.insert(string(k.Rune))
133 case KeyEnter:140 case KeyEnter:
134 if k.Shift || k.Alt {141 if k.Shift || k.Alt {
M packages/tui/editor_shift_enter_test.go +17 -0
@@ -15,6 +15,23 @@ func TestEditorShiftEnterInsertsNewline(t *testing.T) {
1515 }
1616 }
1717
18+func TestEditorIgnoresCtrlAndSuperRunes(t *testing.T) {
19+ e := NewEditor("> ")
20+ e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
21+ for _, k := range []Key{
22+ {Kind: KeyRune, Rune: 's', Ctrl: true},
23+ {Kind: KeyRune, Rune: 'G', Ctrl: true, Shift: true},
24+ {Kind: KeyRune, Rune: 'k', Super: true},
25+ } {
26+ if submit := e.HandleKey(k); submit {
27+ t.Fatalf("%+v submitted", k)
28+ }
29+ }
30+ if got, want := e.Value(), "a"; got != want {
31+ t.Fatalf("Value() = %q, want %q (chords must not insert text)", got, want)
32+ }
33+}
34+
1835 func TestEditorPlainEnterSubmits(t *testing.T) {
1936 e := NewEditor("> ")
2037 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
@@ -15,6 +15,23 @@ func TestEditorShiftEnterInsertsNewline(t *testing.T) {
15 }15 }
16 }16 }
17 17
18+func TestEditorIgnoresCtrlAndSuperRunes(t *testing.T) {
19+ e := NewEditor("> ")
20+ e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
21+ for _, k := range []Key{
22+ {Kind: KeyRune, Rune: 's', Ctrl: true},
23+ {Kind: KeyRune, Rune: 'G', Ctrl: true, Shift: true},
24+ {Kind: KeyRune, Rune: 'k', Super: true},
25+ } {
26+ if submit := e.HandleKey(k); submit {
27+ t.Fatalf("%+v submitted", k)
28+ }
29+ }
30+ if got, want := e.Value(), "a"; got != want {
31+ t.Fatalf("Value() = %q, want %q (chords must not insert text)", got, want)
32+ }
33+}
34+
18 func TestEditorPlainEnterSubmits(t *testing.T) {35 func TestEditorPlainEnterSubmits(t *testing.T) {
19 e := NewEditor("> ")36 e := NewEditor("> ")
20 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})37 e.HandleKey(Key{Kind: KeyRune, Rune: 'a'})
M packages/tui/input.go +17 -0
@@ -100,6 +100,15 @@ func (r *Reader) Read() (Key, error) {
100100 return Key{Kind: KeyTab}, nil
101101 case b == 0x7f, b == 0x08:
102102 return Key{Kind: KeyBackspace}, nil
103+ case b >= 0x01 && b <= 0x1a:
104+ // Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes
105+ // 0x01..0x1a. The bindings above have dedicated KeyKinds; the
106+ // remaining letters surface as Ctrl-modified runes so keymaps
107+ // can bind them. Legacy encodings cannot carry Shift, so
108+ // Ctrl+Shift+letter arrives identically; only enhanced keyboard
109+ // protocols distinguish the two. Consumers must treat a Ctrl
110+ // rune as a chord, never as typed text.
111+ return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil
103112 case b == 0x1b:
104113 return r.readEscape()
105114 case b < 0x20:
@@ -407,10 +416,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
407416 case 'v', 'V':
408417 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
409418 }
419+ // Enhanced keyboard protocols preserve Ctrl for letters that do
420+ // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S.
421+ if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) {
422+ return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true
423+ }
410424 }
411425 if code >= '0' && code <= '9' {
412426 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
413427 }
428+ if code >= 0x20 && code <= 0x7e && (shift || alt || super) {
429+ return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
430+ }
414431 return Key{}, false
415432 }
416433
@@ -100,6 +100,15 @@ func (r *Reader) Read() (Key, error) {
100 return Key{Kind: KeyTab}, nil100 return Key{Kind: KeyTab}, nil
101 case b == 0x7f, b == 0x08:101 case b == 0x7f, b == 0x08:
102 return Key{Kind: KeyBackspace}, nil102 return Key{Kind: KeyBackspace}, nil
103+ case b >= 0x01 && b <= 0x1a:
104+ // Legacy terminals encode Ctrl+A..Ctrl+Z as the control bytes
105+ // 0x01..0x1a. The bindings above have dedicated KeyKinds; the
106+ // remaining letters surface as Ctrl-modified runes so keymaps
107+ // can bind them. Legacy encodings cannot carry Shift, so
108+ // Ctrl+Shift+letter arrives identically; only enhanced keyboard
109+ // protocols distinguish the two. Consumers must treat a Ctrl
110+ // rune as a chord, never as typed text.
111+ return Key{Kind: KeyRune, Rune: rune('a' + b - 1), Ctrl: true}, nil
103 case b == 0x1b:112 case b == 0x1b:
104 return r.readEscape()113 return r.readEscape()
105 case b < 0x20:114 case b < 0x20:
@@ -407,10 +416,18 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
407 case 'v', 'V':416 case 'v', 'V':
408 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true417 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
409 }418 }
419+ // Enhanced keyboard protocols preserve Ctrl for letters that do
420+ // not have a dedicated KeyKind, such as Ctrl+H and Ctrl+S.
421+ if (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z') || (code >= 0x20 && code <= 0x7e) {
422+ return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: true, Super: super}, true
423+ }
410 }424 }
411 if code >= '0' && code <= '9' {425 if code >= '0' && code <= '9' {
412 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true426 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
413 }427 }
428+ if code >= 0x20 && code <= 0x7e && (shift || alt || super) {
429+ return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
430+ }
414 return Key{}, false431 return Key{}, false
415 }432 }
416 433
M packages/tui/input_test.go +44 -0
@@ -51,6 +51,50 @@ 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+
68+func TestReaderParsesRawCtrlBAsModifiedRune(t *testing.T) {
69+ k := readKey(t, "\x02")
70+ if k.Kind != KeyRune || k.Rune != 'b' || !k.Ctrl {
71+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+b", k.Kind, k.Rune, k.Ctrl)
72+ }
73+}
74+
75+func TestReaderKeepsDedicatedControlKinds(t *testing.T) {
76+ for in, want := range map[string]KeyKind{
77+ "\x01": KeyCtrlA, "\x03": KeyCtrlC, "\x04": KeyCtrlD, "\x05": KeyCtrlE,
78+ "\x0b": KeyCtrlK, "\x0c": KeyCtrlL, "\x0f": KeyCtrlO, "\x15": KeyCtrlU,
79+ "\x17": KeyCtrlW, "\x16": KeyPasteClipboard, "\t": KeyTab, "\r": KeyEnter, "\x08": KeyBackspace,
80+ } {
81+ if k := readKey(t, in); k.Kind != want {
82+ t.Fatalf("Read(%q) kind=%v, want %v", in, k.Kind, want)
83+ }
84+ }
85+}
86+
87+func TestReaderParsesCSIUCtrlLetterAsModifiedRune(t *testing.T) {
88+ k := readKey(t, "\x1b[115;5u")
89+ if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl || k.Shift {
90+ t.Fatalf("Read = %+v, want ctrl+s", k)
91+ }
92+ k = readKey(t, "\x1b[71;6u")
93+ if k.Kind != KeyRune || k.Rune != 'G' || !k.Ctrl || !k.Shift {
94+ t.Fatalf("Read = %+v, want ctrl+shift+G", k)
95+ }
96+}
97+
5498 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
5599 k := readKey(t, "\x16")
56100 if k.Kind != KeyPasteClipboard || !k.Ctrl {
@@ -51,6 +51,50 @@ func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) {
51 }51 }
52 }52 }
53 53
54+func TestReaderParsesRawCtrlGAsModifiedRune(t *testing.T) {
55+ k := readKey(t, "\x07")
56+ if k.Kind != KeyRune || k.Rune != 'g' || !k.Ctrl {
57+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+g", k.Kind, k.Rune, k.Ctrl)
58+ }
59+}
60+
61+func TestReaderParsesRawCtrlSAsModifiedRune(t *testing.T) {
62+ k := readKey(t, "\x13")
63+ if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl {
64+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+s", k.Kind, k.Rune, k.Ctrl)
65+ }
66+}
67+
68+func TestReaderParsesRawCtrlBAsModifiedRune(t *testing.T) {
69+ k := readKey(t, "\x02")
70+ if k.Kind != KeyRune || k.Rune != 'b' || !k.Ctrl {
71+ t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+b", k.Kind, k.Rune, k.Ctrl)
72+ }
73+}
74+
75+func TestReaderKeepsDedicatedControlKinds(t *testing.T) {
76+ for in, want := range map[string]KeyKind{
77+ "\x01": KeyCtrlA, "\x03": KeyCtrlC, "\x04": KeyCtrlD, "\x05": KeyCtrlE,
78+ "\x0b": KeyCtrlK, "\x0c": KeyCtrlL, "\x0f": KeyCtrlO, "\x15": KeyCtrlU,
79+ "\x17": KeyCtrlW, "\x16": KeyPasteClipboard, "\t": KeyTab, "\r": KeyEnter, "\x08": KeyBackspace,
80+ } {
81+ if k := readKey(t, in); k.Kind != want {
82+ t.Fatalf("Read(%q) kind=%v, want %v", in, k.Kind, want)
83+ }
84+ }
85+}
86+
87+func TestReaderParsesCSIUCtrlLetterAsModifiedRune(t *testing.T) {
88+ k := readKey(t, "\x1b[115;5u")
89+ if k.Kind != KeyRune || k.Rune != 's' || !k.Ctrl || k.Shift {
90+ t.Fatalf("Read = %+v, want ctrl+s", k)
91+ }
92+ k = readKey(t, "\x1b[71;6u")
93+ if k.Kind != KeyRune || k.Rune != 'G' || !k.Ctrl || !k.Shift {
94+ t.Fatalf("Read = %+v, want ctrl+shift+G", k)
95+ }
96+}
97+
54 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {98 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
55 k := readKey(t, "\x16")99 k := readKey(t, "\x16")
56 if k.Kind != KeyPasteClipboard || !k.Ctrl {100 if k.Kind != KeyPasteClipboard || !k.Ctrl {

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 477

↑↓ moveenter openesc close