detached d625c0c

feat(input): add tmux keymap preset with one-shot prefix (#464)

Add workspace.keymap (zellij default, tmux) and workspace.prefix_mode.
The tmux keymap replaces per-mode activation shortcuts with a single
prefix (ctrl+space) followed by one action key. Modifier and dead-key
presses keep the prefix waiting; an unbound key cancels it.

Move hard-coded browser shortcuts (omnibox, reload, zoom, pane focus,
session manager, ...) into workspace.shortcuts.actions defaults so they
can be rebound or disabled, with a fallback for configs that predate
them. Built-ins win shared keys in a stable order.

config migrate adds the keymap and prefix_mode keys and the new
shortcut actions while keeping user overrides.
commit
d625c0cbfd735cb8f348a9020a3f9a7ac3af7145
author
bnema · · 2026-10-07 06:54 UTC
committer
GitHub · · 2026-10-07 06:54 UTC
parent
e23543e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
30 files · +772 -153
M README.md +1 -1
@@ -90,7 +90,7 @@ For dependencies, distribution notes, and troubleshooting, see the [installation
9090
9191 ## Keyboard modes
9292
93-Dumber uses modal keybindings for browser management.
93+Dumber uses modal keybindings for browser management. The default `zellij` keymap gives each mode its own key; set `workspace.keymap = "tmux"` to use a single prefix (`Ctrl+Space`) followed by one action key instead.
9494
9595 | Mode | Default key | Used for |
9696 |------|-------------|----------|
@@ -90,7 +90,7 @@ For dependencies, distribution notes, and troubleshooting, see the [installation
90 90
91 ## Keyboard modes91 ## Keyboard modes
92 92
93-Dumber uses modal keybindings for browser management.93+Dumber uses modal keybindings for browser management. The default `zellij` keymap gives each mode its own key; set `workspace.keymap = "tmux"` to use a single prefix (`Ctrl+Space`) followed by one action key instead.
94 94
95 | Mode | Default key | Used for |95 | Mode | Default key | Used for |
96 |------|-------------|----------|96 |------|-------------|----------|
M docs/getting-started.md +1 -1
@@ -10,7 +10,7 @@ Or use the desktop entry after running `dumber setup`.
1010
1111 ## Basic Navigation
1212
13-Dumber uses modal keybindings inspired by Zellij:
13+Dumber uses modal keybindings inspired by Zellij. If you prefer tmux, set `keymap = "tmux"` under `[workspace]` to use one prefix key (`Ctrl+Space`) followed by a single action key; see [Keymap presets](./reference/keybindings.md#keymap-presets).
1414
1515 | Mode | Activation | Purpose |
1616 |------|------------|---------|
@@ -10,7 +10,7 @@ Or use the desktop entry after running `dumber setup`.
10 10
11 ## Basic Navigation11 ## Basic Navigation
12 12
13-Dumber uses modal keybindings inspired by Zellij:13+Dumber uses modal keybindings inspired by Zellij. If you prefer tmux, set `keymap = "tmux"` under `[workspace]` to use one prefix key (`Ctrl+Space`) followed by a single action key; see [Keymap presets](./reference/keybindings.md#keymap-presets).
14 14
15 | Mode | Activation | Purpose |15 | Mode | Activation | Purpose |
16 |------|------------|---------|16 |------|------------|---------|
M docs/reference/configuration.md +5 -1
@@ -98,6 +98,10 @@
9898 | `workspace.switch_to_tab_on_move` | bool | `true` | |
9999 | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` |
100100 | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | |
101+| `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102+| `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103+| `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |
104+| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix |
101105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
102106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
103107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
@@ -114,7 +118,7 @@
114118 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
115119 | `workspace.resize_mode.step_percent` | float | `5.0` | |
116120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
117-| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings |
121+| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one |
118122 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
119123 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
120124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
@@ -98,6 +98,10 @@
98 | `workspace.switch_to_tab_on_move` | bool | `true` | |98 | `workspace.switch_to_tab_on_move` | bool | `true` | |
99 | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` |99 | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` |
100 | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | |100 | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | |
101+| `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102+| `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103+| `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |
104+| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix |
101 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
102 | `workspace.pane_mode.timeout_ms` | int | `3000` | |106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
103 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
@@ -114,7 +118,7 @@
114 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |118 | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings |
115 | `workspace.resize_mode.step_percent` | float | `5.0` | |119 | `workspace.resize_mode.step_percent` | float | `5.0` | |
116 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
117-| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings |121+| `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one |
118 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |122 | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
119 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |123 | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
120 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
M docs/reference/keybindings.md +53 -2
@@ -1,6 +1,9 @@
11 # Keybindings
22
3-Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, then use action keys within that mode.
3+Dumber uses modal keybindings. Pick the activation style with `workspace.keymap`:
4+
5+- `zellij` (default): each mode has its own activation key, listed below.
6+- `tmux`: one prefix key, then a single action key. See [Keymap presets](#keymap-presets).
47
58 ## Mode Activation
69
@@ -101,12 +104,60 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
101104 | Confirm | `Enter` |
102105 | Cancel | `Escape` |
103106
107+## Keymap Presets
108+
109+```toml
110+[workspace]
111+keymap = "tmux" # or "zellij" (default)
112+```
113+
114+With `keymap = "tmux"`, the Pane, Tab, Resize, and Session activation keys are unbound. Press the prefix (`Ctrl+Space` by default), then one action key. Dumber runs the action and returns to normal mode; an unbound key or `Escape` cancels the prefix. Vim Mode keeps `Ctrl+Y` in both presets.
115+
116+| After `Ctrl+Space` | Action |
117+|--------------------|--------|
118+| `%` | Split right |
119+| `"` | Split down |
120+| `x` | Close pane |
121+| `Shift+S` | Stack pane |
122+| `!` | Move pane to another tab |
123+| `←` `→` `↑` `↓` | Focus pane |
124+| `c` | New tab |
125+| `&` | Close tab |
126+| `n` / `p` | Next / previous tab |
127+| `,` | Rename tab |
128+| `s` | Session manager |
129+| `r` | Enter Resize Mode |
130+| `[` | Enter Vim Mode |
131+
132+Change the prefix or its actions under `[workspace.prefix_mode]`. Symbols such as `%` and `"` match whichever Shift state your layout needs, so write them without `shift+`. `Ctrl+Space` is often used to switch input methods; pick another prefix (for example `alt+a`) if your desktop already uses it.
133+
134+```toml
135+[workspace.prefix_mode]
136+activation_shortcut = "alt+a"
137+timeout_ms = 2000
138+
139+[workspace.prefix_mode.actions.split-right]
140+keys = ["%", "v"]
141+```
142+
104143 ## Global Shortcuts
105144
106-These work outside modal modes:
145+These work outside modal modes in both keymaps. Every entry lives under `[workspace.shortcuts.actions]`, so you can rebind it or disable it with `keys = []`.
107146
108147 | Action | Keys |
109148 |--------|------|
149+| Open omnibox | `Ctrl+L` |
150+| Find in page / next / previous | `Ctrl+F` / `F3`, `Ctrl+G` / `Shift+F3`, `Ctrl+Shift+G` |
151+| Reload / hard reload | `Ctrl+R`, `F5` / `Ctrl+Shift+R`, `Ctrl+F5` |
152+| Back / forward | `Ctrl+←` / `Ctrl+→` |
153+| Zoom in / out / reset | `Ctrl++`, `Ctrl+=` / `Ctrl+-` / `Ctrl+0` |
154+| Focus pane | `Alt+H/J/K/L`, `Alt+arrows` |
155+| Copy URL | `Ctrl+Shift+C` |
156+| Print | `Ctrl+Shift+P` |
157+| Session manager | `Ctrl+Shift+S` |
158+| Developer tools | `F12` |
159+| Fullscreen | `F11` |
160+| Quit | `Ctrl+Q` |
110161 | Toggle floating pane | `Alt+F` |
111162 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
112163 | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` |
@@ -1,6 +1,9 @@
1 # Keybindings1 # Keybindings
2 2
3-Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, then use action keys within that mode.3+Dumber uses modal keybindings. Pick the activation style with `workspace.keymap`:
4+
5+- `zellij` (default): each mode has its own activation key, listed below.
6+- `tmux`: one prefix key, then a single action key. See [Keymap presets](#keymap-presets).
4 7
5 ## Mode Activation8 ## Mode Activation
6 9
@@ -101,12 +104,60 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
101 | Confirm | `Enter` |104 | Confirm | `Enter` |
102 | Cancel | `Escape` |105 | Cancel | `Escape` |
103 106
107+## Keymap Presets
108+
109+```toml
110+[workspace]
111+keymap = "tmux" # or "zellij" (default)
112+```
113+
114+With `keymap = "tmux"`, the Pane, Tab, Resize, and Session activation keys are unbound. Press the prefix (`Ctrl+Space` by default), then one action key. Dumber runs the action and returns to normal mode; an unbound key or `Escape` cancels the prefix. Vim Mode keeps `Ctrl+Y` in both presets.
115+
116+| After `Ctrl+Space` | Action |
117+|--------------------|--------|
118+| `%` | Split right |
119+| `"` | Split down |
120+| `x` | Close pane |
121+| `Shift+S` | Stack pane |
122+| `!` | Move pane to another tab |
123+| `←` `→` `↑` `↓` | Focus pane |
124+| `c` | New tab |
125+| `&` | Close tab |
126+| `n` / `p` | Next / previous tab |
127+| `,` | Rename tab |
128+| `s` | Session manager |
129+| `r` | Enter Resize Mode |
130+| `[` | Enter Vim Mode |
131+
132+Change the prefix or its actions under `[workspace.prefix_mode]`. Symbols such as `%` and `"` match whichever Shift state your layout needs, so write them without `shift+`. `Ctrl+Space` is often used to switch input methods; pick another prefix (for example `alt+a`) if your desktop already uses it.
133+
134+```toml
135+[workspace.prefix_mode]
136+activation_shortcut = "alt+a"
137+timeout_ms = 2000
138+
139+[workspace.prefix_mode.actions.split-right]
140+keys = ["%", "v"]
141+```
142+
104 ## Global Shortcuts143 ## Global Shortcuts
105 144
106-These work outside modal modes:145+These work outside modal modes in both keymaps. Every entry lives under `[workspace.shortcuts.actions]`, so you can rebind it or disable it with `keys = []`.
107 146
108 | Action | Keys |147 | Action | Keys |
109 |--------|------|148 |--------|------|
149+| Open omnibox | `Ctrl+L` |
150+| Find in page / next / previous | `Ctrl+F` / `F3`, `Ctrl+G` / `Shift+F3`, `Ctrl+Shift+G` |
151+| Reload / hard reload | `Ctrl+R`, `F5` / `Ctrl+Shift+R`, `Ctrl+F5` |
152+| Back / forward | `Ctrl+←` / `Ctrl+→` |
153+| Zoom in / out / reset | `Ctrl++`, `Ctrl+=` / `Ctrl+-` / `Ctrl+0` |
154+| Focus pane | `Alt+H/J/K/L`, `Alt+arrows` |
155+| Copy URL | `Ctrl+Shift+C` |
156+| Print | `Ctrl+Shift+P` |
157+| Session manager | `Ctrl+Shift+S` |
158+| Developer tools | `F12` |
159+| Fullscreen | `F11` |
160+| Quit | `Ctrl+Q` |
110 | Toggle floating pane | `Alt+F` |161 | Toggle floating pane | `Alt+F` |
111 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |162 | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` |
112 | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` |163 | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` |
M internal/application/usecase/keybindings.go +1 -0
@@ -16,6 +16,7 @@ var _ port.AllKeybindingsResetter = (*ResetAllKeybindingsUseCase)(nil)
1616 // validModes defines the valid keybinding modes.
1717 var validModes = map[string]bool{
1818 "global": true,
19+ "prefix": true,
1920 "pane": true,
2021 "tab": true,
2122 "vim": true,
@@ -16,6 +16,7 @@ var _ port.AllKeybindingsResetter = (*ResetAllKeybindingsUseCase)(nil)
16 // validModes defines the valid keybinding modes.16 // validModes defines the valid keybinding modes.
17 var validModes = map[string]bool{17 var validModes = map[string]bool{
18 "global": true,18 "global": true,
19+ "prefix": true,
19 "pane": true,20 "pane": true,
20 "tab": true,21 "tab": true,
21 "vim": true,22 "vim": true,
M internal/bootstrap/runtime_config.go +1 -0
@@ -139,6 +139,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map
139139 }
140140
141141 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
142+ in.PrefixMode.Actions = cloneActionBindings(in.PrefixMode.Actions)
142143 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
143144 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
144145 in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions)
@@ -139,6 +139,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map
139 }139 }
140 140
141 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {141 func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
142+ in.PrefixMode.Actions = cloneActionBindings(in.PrefixMode.Actions)
142 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)143 in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
143 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)144 in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
144 in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions)145 in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions)
A internal/domain/entity/builtin_shortcuts.go +31 -0
@@ -0,0 +1,31 @@
1+package entity
2+
3+// BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
4+// keymap preset. Config defaults include them so users can rebind or disable
5+// them; the input layer also falls back to them for actions missing from an
6+// older config file. An action present with empty keys stays disabled.
7+func BuiltinGlobalShortcuts() map[string]ActionBinding {
8+ return map[string]ActionBinding{
9+ "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"},
10+ "open-find": {Keys: []string{"ctrl+f"}, Desc: "Find in page"},
11+ "find-next": {Keys: []string{"f3", "ctrl+g"}, Desc: "Find next match"},
12+ "find-prev": {Keys: []string{"shift+f3", "ctrl+shift+g"}, Desc: "Find previous match"},
13+ "reload": {Keys: []string{"ctrl+r", "f5"}, Desc: "Reload page"},
14+ "hard-reload": {Keys: []string{"ctrl+shift+r", "ctrl+f5"}, Desc: "Reload page bypassing cache"},
15+ "open-devtools": {Keys: []string{"f12"}, Desc: "Open developer tools"},
16+ "go-back": {Keys: []string{"ctrl+arrowleft"}, Desc: "Go back"},
17+ "go-forward": {Keys: []string{"ctrl+arrowright"}, Desc: "Go forward"},
18+ "zoom-in": {Keys: []string{"ctrl+plus", "ctrl+equal"}, Desc: "Zoom in"},
19+ "zoom-out": {Keys: []string{"ctrl+minus"}, Desc: "Zoom out"},
20+ "zoom-reset": {Keys: []string{"ctrl+0"}, Desc: "Reset zoom"},
21+ "quit": {Keys: []string{"ctrl+q"}, Desc: "Quit"},
22+ "toggle-fullscreen": {Keys: []string{"f11"}, Desc: "Toggle fullscreen"},
23+ "copy-url": {Keys: []string{"ctrl+shift+c"}, Desc: "Copy current URL"},
24+ "print-page": {Keys: []string{"ctrl+shift+p"}, Desc: "Print page"},
25+ "session-manager": {Keys: []string{"ctrl+shift+s"}, Desc: "Open session manager"},
26+ "focus-left": {Keys: []string{"alt+h", "alt+arrowleft"}, Desc: "Focus pane to the left"},
27+ "focus-right": {Keys: []string{"alt+l", "alt+arrowright"}, Desc: "Focus pane to the right"},
28+ "focus-up": {Keys: []string{"alt+k", "alt+arrowup"}, Desc: "Focus pane above"},
29+ "focus-down": {Keys: []string{"alt+j", "alt+arrowdown"}, Desc: "Focus pane below"},
30+ }
31+}
@@ -0,0 +1,31 @@
1+package entity
2+
3+// BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
4+// keymap preset. Config defaults include them so users can rebind or disable
5+// them; the input layer also falls back to them for actions missing from an
6+// older config file. An action present with empty keys stays disabled.
7+func BuiltinGlobalShortcuts() map[string]ActionBinding {
8+ return map[string]ActionBinding{
9+ "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"},
10+ "open-find": {Keys: []string{"ctrl+f"}, Desc: "Find in page"},
11+ "find-next": {Keys: []string{"f3", "ctrl+g"}, Desc: "Find next match"},
12+ "find-prev": {Keys: []string{"shift+f3", "ctrl+shift+g"}, Desc: "Find previous match"},
13+ "reload": {Keys: []string{"ctrl+r", "f5"}, Desc: "Reload page"},
14+ "hard-reload": {Keys: []string{"ctrl+shift+r", "ctrl+f5"}, Desc: "Reload page bypassing cache"},
15+ "open-devtools": {Keys: []string{"f12"}, Desc: "Open developer tools"},
16+ "go-back": {Keys: []string{"ctrl+arrowleft"}, Desc: "Go back"},
17+ "go-forward": {Keys: []string{"ctrl+arrowright"}, Desc: "Go forward"},
18+ "zoom-in": {Keys: []string{"ctrl+plus", "ctrl+equal"}, Desc: "Zoom in"},
19+ "zoom-out": {Keys: []string{"ctrl+minus"}, Desc: "Zoom out"},
20+ "zoom-reset": {Keys: []string{"ctrl+0"}, Desc: "Reset zoom"},
21+ "quit": {Keys: []string{"ctrl+q"}, Desc: "Quit"},
22+ "toggle-fullscreen": {Keys: []string{"f11"}, Desc: "Toggle fullscreen"},
23+ "copy-url": {Keys: []string{"ctrl+shift+c"}, Desc: "Copy current URL"},
24+ "print-page": {Keys: []string{"ctrl+shift+p"}, Desc: "Print page"},
25+ "session-manager": {Keys: []string{"ctrl+shift+s"}, Desc: "Open session manager"},
26+ "focus-left": {Keys: []string{"alt+h", "alt+arrowleft"}, Desc: "Focus pane to the left"},
27+ "focus-right": {Keys: []string{"alt+l", "alt+arrowright"}, Desc: "Focus pane to the right"},
28+ "focus-up": {Keys: []string{"alt+k", "alt+arrowup"}, Desc: "Focus pane above"},
29+ "focus-down": {Keys: []string{"alt+j", "alt+arrowdown"}, Desc: "Focus pane below"},
30+ }
31+}
M internal/domain/entity/config_types.go +24 -0
@@ -78,6 +78,28 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
7878 return keyBindingsFromActions(t.Actions)
7979 }
8080
81+// KeymapPreset selects how workspace modes are activated.
82+type KeymapPreset string
83+
84+const (
85+ // KeymapZellij activates each mode with its own shortcut (Ctrl+P, Ctrl+T, ...).
86+ KeymapZellij KeymapPreset = "zellij"
87+ // KeymapTmux activates a single one-shot prefix key; mode shortcuts are unbound.
88+ KeymapTmux KeymapPreset = "tmux"
89+)
90+
91+// PrefixModeConfig holds the tmux-style one-shot prefix configuration.
92+type PrefixModeConfig struct {
93+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
94+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
95+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
96+}
97+
98+// GetKeyBindings returns a map from key string to action name.
99+func (p *PrefixModeConfig) GetKeyBindings() map[string]string {
100+ return keyBindingsFromActions(p.Actions)
101+}
102+
81103 // VimModeConfig holds vim scroll mode shortcut configuration.
82104 type VimModeConfig struct {
83105 ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
@@ -245,6 +267,8 @@ type ExternalLinksConfig struct {
245267 // WorkspaceConfig holds all workspace layout and behavior settings.
246268 type WorkspaceConfig struct {
247269 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
270+ Keymap KeymapPreset `mapstructure:"keymap" yaml:"keymap" toml:"keymap" json:"keymap"`
271+ PrefixMode PrefixModeConfig `mapstructure:"prefix_mode" yaml:"prefix_mode" toml:"prefix_mode" json:"prefix_mode"`
248272 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
249273 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
250274 VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
@@ -78,6 +78,28 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
78 return keyBindingsFromActions(t.Actions)78 return keyBindingsFromActions(t.Actions)
79 }79 }
80 80
81+// KeymapPreset selects how workspace modes are activated.
82+type KeymapPreset string
83+
84+const (
85+ // KeymapZellij activates each mode with its own shortcut (Ctrl+P, Ctrl+T, ...).
86+ KeymapZellij KeymapPreset = "zellij"
87+ // KeymapTmux activates a single one-shot prefix key; mode shortcuts are unbound.
88+ KeymapTmux KeymapPreset = "tmux"
89+)
90+
91+// PrefixModeConfig holds the tmux-style one-shot prefix configuration.
92+type PrefixModeConfig struct {
93+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
94+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
95+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
96+}
97+
98+// GetKeyBindings returns a map from key string to action name.
99+func (p *PrefixModeConfig) GetKeyBindings() map[string]string {
100+ return keyBindingsFromActions(p.Actions)
101+}
102+
81 // VimModeConfig holds vim scroll mode shortcut configuration.103 // VimModeConfig holds vim scroll mode shortcut configuration.
82 type VimModeConfig struct {104 type VimModeConfig struct {
83 ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line105 ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
@@ -245,6 +267,8 @@ type ExternalLinksConfig struct {
245 // WorkspaceConfig holds all workspace layout and behavior settings.267 // WorkspaceConfig holds all workspace layout and behavior settings.
246 type WorkspaceConfig struct {268 type WorkspaceConfig struct {
247 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`269 NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
270+ Keymap KeymapPreset `mapstructure:"keymap" yaml:"keymap" toml:"keymap" json:"keymap"`
271+ PrefixMode PrefixModeConfig `mapstructure:"prefix_mode" yaml:"prefix_mode" toml:"prefix_mode" json:"prefix_mode"`
248 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`272 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
249 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`273 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
250 VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`274 VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
M internal/infrastructure/config/defaults.go +38 -2
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "maps"
45 "os"
56 "path/filepath"
67
@@ -39,6 +40,9 @@ const (
3940 defaultOmniboxAutoOpenOnNewPane = false
4041
4142 // Workspace defaults
43+ defaultKeymap = KeymapZellij
44+ defaultPrefixActivationShortcut = "ctrl+space"
45+ defaultPrefixTimeoutMilliseconds = 2000
4246 defaultPaneActivationShortcut = "ctrl+p"
4347 defaultPaneTimeoutMilliseconds = 3000
4448 defaultTabActivationShortcut = "ctrl+t"
@@ -158,6 +162,13 @@ func getDefaultExternalThemeTemplatePath() string {
158162 return filepath.Join(configDir, defaultExternalThemeTemplateFilename)
159163 }
160164
165+// withBuiltinGlobalShortcuts adds the preset-independent browser shortcuts
166+// to the configured global shortcut defaults.
167+func withBuiltinGlobalShortcuts(actions map[string]ActionBinding) map[string]ActionBinding {
168+ maps.Copy(actions, entity.BuiltinGlobalShortcuts())
169+ return actions
170+}
171+
161172 // DefaultConfig returns the default configuration values for dumber.
162173 func DefaultConfig() *Config {
163174 browsingContextDefaults := defaultBrowsingContextConfig()
@@ -271,6 +282,31 @@ func DefaultConfig() *Config {
271282 Workspace: WorkspaceConfig{
272283 NewPaneURL: defaultNewPaneURL,
273284 SwitchToTabOnMove: true,
285+ Keymap: defaultKeymap,
286+ PrefixMode: PrefixModeConfig{
287+ ActivationShortcut: defaultPrefixActivationShortcut,
288+ TimeoutMilliseconds: defaultPrefixTimeoutMilliseconds,
289+ Actions: map[string]ActionBinding{
290+ "split-right": {Keys: []string{"%"}, Desc: "Split pane to the right"},
291+ "split-down": {Keys: []string{"\""}, Desc: "Split pane downward"},
292+ "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},
293+ "stack-pane": {Keys: []string{"shift+s"}, Desc: "Stack pane with sibling"},
294+ "move-pane-to-tab": {Keys: []string{"!"}, Desc: "Move pane to different tab"},
295+ "focus-left": {Keys: []string{"arrowleft"}, Desc: "Focus pane to the left"},
296+ "focus-right": {Keys: []string{"arrowright"}, Desc: "Focus pane to the right"},
297+ "focus-up": {Keys: []string{"arrowup"}, Desc: "Focus pane above"},
298+ "focus-down": {Keys: []string{"arrowdown"}, Desc: "Focus pane below"},
299+ "new-tab": {Keys: []string{"c"}, Desc: "Create new tab"},
300+ "close-tab": {Keys: []string{"&"}, Desc: "Close current tab"},
301+ "next-tab": {Keys: []string{"n"}, Desc: "Switch to next tab"},
302+ "previous-tab": {Keys: []string{"p"}, Desc: "Switch to previous tab"},
303+ "rename-tab": {Keys: []string{","}, Desc: "Rename current tab"},
304+ "session-manager": {Keys: []string{"s"}, Desc: "Open session manager"},
305+ "enter-resize-mode": {Keys: []string{"r"}, Desc: "Enter resize mode"},
306+ "enter-vim-mode": {Keys: []string{"["}, Desc: "Enter Vim mode"},
307+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit prefix"},
308+ },
309+ },
274310 PaneMode: PaneModeConfig{
275311 ActivationShortcut: defaultPaneActivationShortcut,
276312 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
@@ -359,7 +395,7 @@ func DefaultConfig() *Config {
359395 },
360396 },
361397 Shortcuts: GlobalShortcutsConfig{
362- Actions: map[string]ActionBinding{
398+ Actions: withBuiltinGlobalShortcuts(map[string]ActionBinding{
363399 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
364400 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
365401 "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
@@ -372,7 +408,7 @@ func DefaultConfig() *Config {
372408 "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
373409 "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
374410 "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
375- },
411+ }),
376412 },
377413 FloatingPane: FloatingPaneConfig{
378414 WidthPct: defaultFloatingPaneWidthPct,
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "maps"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 7
@@ -39,6 +40,9 @@ const (
39 defaultOmniboxAutoOpenOnNewPane = false40 defaultOmniboxAutoOpenOnNewPane = false
40 41
41 // Workspace defaults42 // Workspace defaults
43+ defaultKeymap = KeymapZellij
44+ defaultPrefixActivationShortcut = "ctrl+space"
45+ defaultPrefixTimeoutMilliseconds = 2000
42 defaultPaneActivationShortcut = "ctrl+p"46 defaultPaneActivationShortcut = "ctrl+p"
43 defaultPaneTimeoutMilliseconds = 300047 defaultPaneTimeoutMilliseconds = 3000
44 defaultTabActivationShortcut = "ctrl+t"48 defaultTabActivationShortcut = "ctrl+t"
@@ -158,6 +162,13 @@ func getDefaultExternalThemeTemplatePath() string {
158 return filepath.Join(configDir, defaultExternalThemeTemplateFilename)162 return filepath.Join(configDir, defaultExternalThemeTemplateFilename)
159 }163 }
160 164
165+// withBuiltinGlobalShortcuts adds the preset-independent browser shortcuts
166+// to the configured global shortcut defaults.
167+func withBuiltinGlobalShortcuts(actions map[string]ActionBinding) map[string]ActionBinding {
168+ maps.Copy(actions, entity.BuiltinGlobalShortcuts())
169+ return actions
170+}
171+
161 // DefaultConfig returns the default configuration values for dumber.172 // DefaultConfig returns the default configuration values for dumber.
162 func DefaultConfig() *Config {173 func DefaultConfig() *Config {
163 browsingContextDefaults := defaultBrowsingContextConfig()174 browsingContextDefaults := defaultBrowsingContextConfig()
@@ -271,6 +282,31 @@ func DefaultConfig() *Config {
271 Workspace: WorkspaceConfig{282 Workspace: WorkspaceConfig{
272 NewPaneURL: defaultNewPaneURL,283 NewPaneURL: defaultNewPaneURL,
273 SwitchToTabOnMove: true,284 SwitchToTabOnMove: true,
285+ Keymap: defaultKeymap,
286+ PrefixMode: PrefixModeConfig{
287+ ActivationShortcut: defaultPrefixActivationShortcut,
288+ TimeoutMilliseconds: defaultPrefixTimeoutMilliseconds,
289+ Actions: map[string]ActionBinding{
290+ "split-right": {Keys: []string{"%"}, Desc: "Split pane to the right"},
291+ "split-down": {Keys: []string{"\""}, Desc: "Split pane downward"},
292+ "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"},
293+ "stack-pane": {Keys: []string{"shift+s"}, Desc: "Stack pane with sibling"},
294+ "move-pane-to-tab": {Keys: []string{"!"}, Desc: "Move pane to different tab"},
295+ "focus-left": {Keys: []string{"arrowleft"}, Desc: "Focus pane to the left"},
296+ "focus-right": {Keys: []string{"arrowright"}, Desc: "Focus pane to the right"},
297+ "focus-up": {Keys: []string{"arrowup"}, Desc: "Focus pane above"},
298+ "focus-down": {Keys: []string{"arrowdown"}, Desc: "Focus pane below"},
299+ "new-tab": {Keys: []string{"c"}, Desc: "Create new tab"},
300+ "close-tab": {Keys: []string{"&"}, Desc: "Close current tab"},
301+ "next-tab": {Keys: []string{"n"}, Desc: "Switch to next tab"},
302+ "previous-tab": {Keys: []string{"p"}, Desc: "Switch to previous tab"},
303+ "rename-tab": {Keys: []string{","}, Desc: "Rename current tab"},
304+ "session-manager": {Keys: []string{"s"}, Desc: "Open session manager"},
305+ "enter-resize-mode": {Keys: []string{"r"}, Desc: "Enter resize mode"},
306+ "enter-vim-mode": {Keys: []string{"["}, Desc: "Enter Vim mode"},
307+ "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit prefix"},
308+ },
309+ },
274 PaneMode: PaneModeConfig{310 PaneMode: PaneModeConfig{
275 ActivationShortcut: defaultPaneActivationShortcut,311 ActivationShortcut: defaultPaneActivationShortcut,
276 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,312 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
@@ -359,7 +395,7 @@ func DefaultConfig() *Config {
359 },395 },
360 },396 },
361 Shortcuts: GlobalShortcutsConfig{397 Shortcuts: GlobalShortcutsConfig{
362- Actions: map[string]ActionBinding{398+ Actions: withBuiltinGlobalShortcuts(map[string]ActionBinding{
363 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},399 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
364 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},400 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
365 "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},401 "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
@@ -372,7 +408,7 @@ func DefaultConfig() *Config {
372 "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},408 "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
373 "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},409 "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
374 "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},410 "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
375- },411+ }),
376 },412 },
377 FloatingPane: FloatingPaneConfig{413 FloatingPane: FloatingPaneConfig{
378 WidthPct: defaultFloatingPaneWidthPct,414 WidthPct: defaultFloatingPaneWidthPct,
M internal/infrastructure/config/keybindings_gateway.go +27 -1
@@ -9,7 +9,10 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12-const modeGlobal = "global"
12+const (
13+ modeGlobal = "global"
14+ modePrefix = "prefix"
15+)
1316
1417 // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver.
1518 type KeybindingsGateway struct {
@@ -30,6 +33,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin
3033
3134 groups := []port.KeybindingGroup{
3235 g.buildGlobalGroup(cfg, defaults),
36+ g.buildPrefixModeGroup(cfg, defaults),
3337 g.buildPaneModeGroup(cfg, defaults),
3438 g.buildTabModeGroup(cfg, defaults),
3539 g.buildVimModeGroup(cfg, defaults),
@@ -47,6 +51,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke
4751
4852 groups := []port.KeybindingGroup{
4953 g.buildGlobalGroup(defaults, defaults),
54+ g.buildPrefixModeGroup(defaults, defaults),
5055 g.buildPaneModeGroup(defaults, defaults),
5156 g.buildTabModeGroup(defaults, defaults),
5257 g.buildVimModeGroup(defaults, defaults),
@@ -108,6 +113,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
108113 cfg := g.mgr.Get()
109114 defaults := DefaultConfig()
110115
116+ cfg.Workspace.PrefixMode.Actions = defaults.Workspace.PrefixMode.Actions
111117 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
112118 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113119 cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions
@@ -127,6 +133,16 @@ func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.Keybin
127133 }
128134 }
129135
136+// buildPrefixModeGroup builds the tmux-style prefix group.
137+func (g *KeybindingsGateway) buildPrefixModeGroup(cfg, defaults *Config) port.KeybindingGroup {
138+ return port.KeybindingGroup{
139+ Mode: modePrefix,
140+ DisplayName: "Prefix (tmux keymap)",
141+ Bindings: g.buildModeBindings(cfg.Workspace.PrefixMode.Actions, defaults.Workspace.PrefixMode.Actions),
142+ Activation: cfg.Workspace.PrefixMode.ActivationShortcut,
143+ }
144+}
145+
130146 // buildPaneModeGroup builds the pane mode group.
131147 func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup {
132148 return port.KeybindingGroup{
@@ -211,6 +227,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR
211227 existing.Keys = req.Keys
212228 cfg.Workspace.Shortcuts.Actions[req.Action] = existing
213229 }
230+ case modePrefix:
231+ if existing, ok := cfg.Workspace.PrefixMode.Actions[req.Action]; ok {
232+ existing.Keys = req.Keys
233+ cfg.Workspace.PrefixMode.Actions[req.Action] = existing
234+ }
214235 case "pane":
215236 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {
216237 existing.Keys = req.Keys
@@ -246,6 +267,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
246267 if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok {
247268 return binding.Keys
248269 }
270+ case modePrefix:
271+ if binding, ok := defaults.Workspace.PrefixMode.Actions[action]; ok {
272+ return binding.Keys
273+ }
249274 case "pane":
250275 if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok {
251276 return binding.Keys
@@ -289,6 +314,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
289314 }
290315
291316 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
317+ addBindings(modePrefix, cfg.Workspace.PrefixMode.Actions)
292318 addBindings("pane", cfg.Workspace.PaneMode.Actions)
293319 addBindings("tab", cfg.Workspace.TabMode.Actions)
294320 addBindings("vim", cfg.Workspace.VimMode.Actions)
@@ -9,7 +9,10 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
12-const modeGlobal = "global"12+const (
13+ modeGlobal = "global"
14+ modePrefix = "prefix"
15+)
13 16
14 // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver.17 // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver.
15 type KeybindingsGateway struct {18 type KeybindingsGateway struct {
@@ -30,6 +33,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin
30 33
31 groups := []port.KeybindingGroup{34 groups := []port.KeybindingGroup{
32 g.buildGlobalGroup(cfg, defaults),35 g.buildGlobalGroup(cfg, defaults),
36+ g.buildPrefixModeGroup(cfg, defaults),
33 g.buildPaneModeGroup(cfg, defaults),37 g.buildPaneModeGroup(cfg, defaults),
34 g.buildTabModeGroup(cfg, defaults),38 g.buildTabModeGroup(cfg, defaults),
35 g.buildVimModeGroup(cfg, defaults),39 g.buildVimModeGroup(cfg, defaults),
@@ -47,6 +51,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke
47 51
48 groups := []port.KeybindingGroup{52 groups := []port.KeybindingGroup{
49 g.buildGlobalGroup(defaults, defaults),53 g.buildGlobalGroup(defaults, defaults),
54+ g.buildPrefixModeGroup(defaults, defaults),
50 g.buildPaneModeGroup(defaults, defaults),55 g.buildPaneModeGroup(defaults, defaults),
51 g.buildTabModeGroup(defaults, defaults),56 g.buildTabModeGroup(defaults, defaults),
52 g.buildVimModeGroup(defaults, defaults),57 g.buildVimModeGroup(defaults, defaults),
@@ -108,6 +113,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error {
108 cfg := g.mgr.Get()113 cfg := g.mgr.Get()
109 defaults := DefaultConfig()114 defaults := DefaultConfig()
110 115
116+ cfg.Workspace.PrefixMode.Actions = defaults.Workspace.PrefixMode.Actions
111 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions117 cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions
112 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions118 cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions
113 cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions119 cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions
@@ -127,6 +133,16 @@ func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.Keybin
127 }133 }
128 }134 }
129 135
136+// buildPrefixModeGroup builds the tmux-style prefix group.
137+func (g *KeybindingsGateway) buildPrefixModeGroup(cfg, defaults *Config) port.KeybindingGroup {
138+ return port.KeybindingGroup{
139+ Mode: modePrefix,
140+ DisplayName: "Prefix (tmux keymap)",
141+ Bindings: g.buildModeBindings(cfg.Workspace.PrefixMode.Actions, defaults.Workspace.PrefixMode.Actions),
142+ Activation: cfg.Workspace.PrefixMode.ActivationShortcut,
143+ }
144+}
145+
130 // buildPaneModeGroup builds the pane mode group.146 // buildPaneModeGroup builds the pane mode group.
131 func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup {147 func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup {
132 return port.KeybindingGroup{148 return port.KeybindingGroup{
@@ -211,6 +227,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR
211 existing.Keys = req.Keys227 existing.Keys = req.Keys
212 cfg.Workspace.Shortcuts.Actions[req.Action] = existing228 cfg.Workspace.Shortcuts.Actions[req.Action] = existing
213 }229 }
230+ case modePrefix:
231+ if existing, ok := cfg.Workspace.PrefixMode.Actions[req.Action]; ok {
232+ existing.Keys = req.Keys
233+ cfg.Workspace.PrefixMode.Actions[req.Action] = existing
234+ }
214 case "pane":235 case "pane":
215 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {236 if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok {
216 existing.Keys = req.Keys237 existing.Keys = req.Keys
@@ -246,6 +267,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string)
246 if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok {267 if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok {
247 return binding.Keys268 return binding.Keys
248 }269 }
270+ case modePrefix:
271+ if binding, ok := defaults.Workspace.PrefixMode.Actions[action]; ok {
272+ return binding.Keys
273+ }
249 case "pane":274 case "pane":
250 if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok {275 if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok {
251 return binding.Keys276 return binding.Keys
@@ -289,6 +314,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction
289 }314 }
290 315
291 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)316 addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions)
317+ addBindings(modePrefix, cfg.Workspace.PrefixMode.Actions)
292 addBindings("pane", cfg.Workspace.PaneMode.Actions)318 addBindings("pane", cfg.Workspace.PaneMode.Actions)
293 addBindings("tab", cfg.Workspace.TabMode.Actions)319 addBindings("tab", cfg.Workspace.TabMode.Actions)
294 addBindings("vim", cfg.Workspace.VimMode.Actions)320 addBindings("vim", cfg.Workspace.VimMode.Actions)
M internal/infrastructure/config/loader.go +8 -0
@@ -698,8 +698,16 @@ func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
698698 m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
699699 }
700700
701+func (m *Manager) setKeymapDefaults(defaults *Config) {
702+ m.viper.SetDefault("workspace.keymap", string(defaults.Workspace.Keymap))
703+ m.viper.SetDefault("workspace.prefix_mode.activation_shortcut", defaults.Workspace.PrefixMode.ActivationShortcut)
704+ m.viper.SetDefault("workspace.prefix_mode.timeout_ms", defaults.Workspace.PrefixMode.TimeoutMilliseconds)
705+ m.viper.SetDefault("workspace.prefix_mode.actions", defaults.Workspace.PrefixMode.Actions)
706+}
707+
701708 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
702709 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
710+ m.setKeymapDefaults(defaults)
703711 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
704712 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
705713 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
@@ -698,8 +698,16 @@ func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
698 m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)698 m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
699 }699 }
700 700
701+func (m *Manager) setKeymapDefaults(defaults *Config) {
702+ m.viper.SetDefault("workspace.keymap", string(defaults.Workspace.Keymap))
703+ m.viper.SetDefault("workspace.prefix_mode.activation_shortcut", defaults.Workspace.PrefixMode.ActivationShortcut)
704+ m.viper.SetDefault("workspace.prefix_mode.timeout_ms", defaults.Workspace.PrefixMode.TimeoutMilliseconds)
705+ m.viper.SetDefault("workspace.prefix_mode.actions", defaults.Workspace.PrefixMode.Actions)
706+}
707+
701 func (m *Manager) setWorkspaceDefaults(defaults *Config) {708 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
702 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)709 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
710+ m.setKeymapDefaults(defaults)
703 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)711 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
704 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)712 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
705 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)713 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
M internal/infrastructure/config/migrate.go +1 -0
@@ -302,6 +302,7 @@ type defaultActionMap struct {
302302 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303303 return []defaultActionMap{
304304 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
305+ {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},
305306 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
306307 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
307308 {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
@@ -302,6 +302,7 @@ type defaultActionMap struct {
302 func (m *Migrator) defaultActionMaps() []defaultActionMap {302 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303 return []defaultActionMap{303 return []defaultActionMap{
304 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},304 {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
305+ {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},
305 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},306 {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
306 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},307 {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
307 {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},308 {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
M internal/infrastructure/config/migrate_test.go +50 -0
@@ -1617,3 +1617,53 @@ func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.
16171617 assert.Contains(t, string(migrated), "max_history_days = 7")
16181618 assert.NotContains(t, string(migrated), "most_visited_days")
16191619 }
1620+
1621+func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) {
1622+ home := t.TempDir()
1623+ t.Setenv("HOME", home)
1624+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1625+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1626+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1627+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1628+
1629+ configFile, err := GetConfigFile()
1630+ require.NoError(t, err)
1631+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1632+ // A config written before keymap presets: no keymap, no prefix_mode, and a
1633+ // shortcuts table without the configurable browser actions.
1634+ require.NoError(t, os.WriteFile(configFile, []byte(`
1635+[workspace.shortcuts.actions.quit]
1636+keys = ["ctrl+shift+q"]
1637+desc = "Custom quit"
1638+
1639+[workspace.shortcuts.actions.toggle-floating-pane]
1640+keys = ["alt+f"]
1641+desc = "Toggle floating pane"
1642+`), 0o644))
1643+
1644+ m := NewMigrator()
1645+ result, err := m.CheckMigration()
1646+ require.NoError(t, err)
1647+ require.NotNil(t, result)
1648+ assert.Contains(t, result.MissingKeys, "workspace.keymap")
1649+ assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut")
1650+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox")
1651+
1652+ _, err = m.Migrate()
1653+ require.NoError(t, err)
1654+
1655+ mgr, err := NewManager()
1656+ require.NoError(t, err)
1657+ require.NoError(t, mgr.Load())
1658+ cfg := mgr.Get()
1659+
1660+ assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap)
1661+ assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut)
1662+ assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions)
1663+ assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys)
1664+ assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept")
1665+
1666+ again, err := m.CheckMigration()
1667+ require.NoError(t, err)
1668+ assert.Nil(t, again, "migration is idempotent")
1669+}
@@ -1617,3 +1617,53 @@ func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing.
1617 assert.Contains(t, string(migrated), "max_history_days = 7")1617 assert.Contains(t, string(migrated), "max_history_days = 7")
1618 assert.NotContains(t, string(migrated), "most_visited_days")1618 assert.NotContains(t, string(migrated), "most_visited_days")
1619 }1619 }
1620+
1621+func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) {
1622+ home := t.TempDir()
1623+ t.Setenv("HOME", home)
1624+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1625+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1626+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1627+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1628+
1629+ configFile, err := GetConfigFile()
1630+ require.NoError(t, err)
1631+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1632+ // A config written before keymap presets: no keymap, no prefix_mode, and a
1633+ // shortcuts table without the configurable browser actions.
1634+ require.NoError(t, os.WriteFile(configFile, []byte(`
1635+[workspace.shortcuts.actions.quit]
1636+keys = ["ctrl+shift+q"]
1637+desc = "Custom quit"
1638+
1639+[workspace.shortcuts.actions.toggle-floating-pane]
1640+keys = ["alt+f"]
1641+desc = "Toggle floating pane"
1642+`), 0o644))
1643+
1644+ m := NewMigrator()
1645+ result, err := m.CheckMigration()
1646+ require.NoError(t, err)
1647+ require.NotNil(t, result)
1648+ assert.Contains(t, result.MissingKeys, "workspace.keymap")
1649+ assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut")
1650+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox")
1651+
1652+ _, err = m.Migrate()
1653+ require.NoError(t, err)
1654+
1655+ mgr, err := NewManager()
1656+ require.NoError(t, err)
1657+ require.NoError(t, mgr.Load())
1658+ cfg := mgr.Get()
1659+
1660+ assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap)
1661+ assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut)
1662+ assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions)
1663+ assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys)
1664+ assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept")
1665+
1666+ again, err := m.CheckMigration()
1667+ require.NoError(t, err)
1668+ assert.Nil(t, again, "migration is idempotent")
1669+}
M internal/infrastructure/config/schema.go +11 -0
@@ -15,6 +15,17 @@ type ColorPalette = entity.ColorPalette
1515 // ActionBinding defines a keybinding with optional description.
1616 type ActionBinding = entity.ActionBinding
1717
18+// PrefixModeConfig defines the tmux-style one-shot prefix.
19+type PrefixModeConfig = entity.PrefixModeConfig
20+
21+// KeymapPreset selects how workspace modes are activated.
22+type KeymapPreset = entity.KeymapPreset
23+
24+const (
25+ KeymapZellij = entity.KeymapZellij
26+ KeymapTmux = entity.KeymapTmux
27+)
28+
1829 // PaneModeConfig defines modal behavior for pane management.
1930 type PaneModeConfig = entity.PaneModeConfig
2031
@@ -15,6 +15,17 @@ type ColorPalette = entity.ColorPalette
15 // ActionBinding defines a keybinding with optional description.15 // ActionBinding defines a keybinding with optional description.
16 type ActionBinding = entity.ActionBinding16 type ActionBinding = entity.ActionBinding
17 17
18+// PrefixModeConfig defines the tmux-style one-shot prefix.
19+type PrefixModeConfig = entity.PrefixModeConfig
20+
21+// KeymapPreset selects how workspace modes are activated.
22+type KeymapPreset = entity.KeymapPreset
23+
24+const (
25+ KeymapZellij = entity.KeymapZellij
26+ KeymapTmux = entity.KeymapTmux
27+)
28+
18 // PaneModeConfig defines modal behavior for pane management.29 // PaneModeConfig defines modal behavior for pane management.
19 type PaneModeConfig = entity.PaneModeConfig30 type PaneModeConfig = entity.PaneModeConfig
20 31
M internal/infrastructure/config/schema_provider.go +31 -0
@@ -426,6 +426,37 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
426426 Description: "Switch focus to tab when moving pane to it",
427427 Section: SectionWorkspace,
428428 },
429+ // Keymap preset
430+ {
431+ Key: "workspace.keymap",
432+ Type: "string",
433+ Default: string(defaults.Workspace.Keymap),
434+ Description: "Mode activation style: zellij (one shortcut per mode) or tmux (single prefix key)",
435+ Values: []string{string(KeymapZellij), string(KeymapTmux)},
436+ Section: SectionWorkspace,
437+ },
438+ {
439+ Key: "workspace.prefix_mode.activation_shortcut",
440+ Type: "string",
441+ Default: defaults.Workspace.PrefixMode.ActivationShortcut,
442+ Description: "Prefix key used when workspace.keymap is tmux",
443+ Section: SectionWorkspace,
444+ },
445+ {
446+ Key: "workspace.prefix_mode.timeout_ms",
447+ Type: "int",
448+ Default: fmt.Sprintf("%d", defaults.Workspace.PrefixMode.TimeoutMilliseconds),
449+ Description: "Prefix timeout in milliseconds (0 = no timeout)",
450+ Range: ">=0",
451+ Section: SectionWorkspace,
452+ },
453+ {
454+ Key: "workspace.prefix_mode.actions.<action>",
455+ Type: "object",
456+ Default: "(see defaults)",
457+ Description: "One-shot actions available after the prefix key",
458+ Section: SectionWorkspace,
459+ },
429460 // Pane mode
430461 {
431462 Key: "workspace.pane_mode.activation_shortcut",
@@ -426,6 +426,37 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
426 Description: "Switch focus to tab when moving pane to it",426 Description: "Switch focus to tab when moving pane to it",
427 Section: SectionWorkspace,427 Section: SectionWorkspace,
428 },428 },
429+ // Keymap preset
430+ {
431+ Key: "workspace.keymap",
432+ Type: "string",
433+ Default: string(defaults.Workspace.Keymap),
434+ Description: "Mode activation style: zellij (one shortcut per mode) or tmux (single prefix key)",
435+ Values: []string{string(KeymapZellij), string(KeymapTmux)},
436+ Section: SectionWorkspace,
437+ },
438+ {
439+ Key: "workspace.prefix_mode.activation_shortcut",
440+ Type: "string",
441+ Default: defaults.Workspace.PrefixMode.ActivationShortcut,
442+ Description: "Prefix key used when workspace.keymap is tmux",
443+ Section: SectionWorkspace,
444+ },
445+ {
446+ Key: "workspace.prefix_mode.timeout_ms",
447+ Type: "int",
448+ Default: fmt.Sprintf("%d", defaults.Workspace.PrefixMode.TimeoutMilliseconds),
449+ Description: "Prefix timeout in milliseconds (0 = no timeout)",
450+ Range: ">=0",
451+ Section: SectionWorkspace,
452+ },
453+ {
454+ Key: "workspace.prefix_mode.actions.<action>",
455+ Type: "object",
456+ Default: "(see defaults)",
457+ Description: "One-shot actions available after the prefix key",
458+ Section: SectionWorkspace,
459+ },
429 // Pane mode460 // Pane mode
430 {461 {
431 Key: "workspace.pane_mode.activation_shortcut",462 Key: "workspace.pane_mode.activation_shortcut",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -166,6 +166,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) {
166166 "appearance.light_palette": "appearance.light_palette.*",
167167 "appearance.dark_palette": "appearance.dark_palette.*",
168168 "search_shortcuts": "search_shortcuts.<key>",
169+ "workspace.prefix_mode.actions": "workspace.prefix_mode.actions.<action>",
169170 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
170171 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
171172 "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>",
@@ -166,6 +166,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) {
166 "appearance.light_palette": "appearance.light_palette.*",166 "appearance.light_palette": "appearance.light_palette.*",
167 "appearance.dark_palette": "appearance.dark_palette.*",167 "appearance.dark_palette": "appearance.dark_palette.*",
168 "search_shortcuts": "search_shortcuts.<key>",168 "search_shortcuts": "search_shortcuts.<key>",
169+ "workspace.prefix_mode.actions": "workspace.prefix_mode.actions.<action>",
169 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",170 "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>",
170 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",171 "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>",
171 "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>",172 "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>",
M internal/infrastructure/config/transform_legacy.go +5 -0
@@ -31,6 +31,7 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
3131 // desc = "Focus pane to the left"
3232 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
3333 actionPaths := [][]string{
34+ {"workspace", "prefix_mode", "actions"},
3435 {"workspace", "pane_mode", "actions"},
3536 {"workspace", "tab_mode", "actions"},
3637 {"workspace", "resize_mode", "actions"},
@@ -296,6 +297,10 @@ func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig ma
296297
297298 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
298299 switch {
300+ case path[0] == sectionWorkspace && path[1] == "prefix_mode":
301+ if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok {
302+ return b.Desc
303+ }
299304 case path[0] == sectionWorkspace && path[1] == "pane_mode":
300305 if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
301306 return b.Desc
@@ -31,6 +31,7 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
31 // desc = "Focus pane to the left"31 // desc = "Focus pane to the left"
32 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {32 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
33 actionPaths := [][]string{33 actionPaths := [][]string{
34+ {"workspace", "prefix_mode", "actions"},
34 {"workspace", "pane_mode", "actions"},35 {"workspace", "pane_mode", "actions"},
35 {"workspace", "tab_mode", "actions"},36 {"workspace", "tab_mode", "actions"},
36 {"workspace", "resize_mode", "actions"},37 {"workspace", "resize_mode", "actions"},
@@ -296,6 +297,10 @@ func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig ma
296 297
297 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {298 func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
298 switch {299 switch {
300+ case path[0] == sectionWorkspace && path[1] == "prefix_mode":
301+ if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok {
302+ return b.Desc
303+ }
299 case path[0] == sectionWorkspace && path[1] == "pane_mode":304 case path[0] == sectionWorkspace && path[1] == "pane_mode":
300 if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {305 if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
301 return b.Desc306 return b.Desc
M internal/infrastructure/config/validation.go +14 -0
@@ -27,6 +27,7 @@ func validateConfig(config *Config) error {
2727 validationErrors = append(validationErrors, validatePopups(config)...)
2828 validationErrors = append(validationErrors, validateExternalLinks(config)...)
2929 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
30+ validationErrors = append(validationErrors, validateKeymap(config)...)
3031 validationErrors = append(validationErrors, validatePaneMode(config)...)
3132 validationErrors = append(validationErrors, validateTabBar(config)...)
3233 validationErrors = append(validationErrors, validateTabMode(config)...)
@@ -234,6 +235,19 @@ func validateWorkspaceStyling(config *Config) []string {
234235 return validationErrors
235236 }
236237
238+func validateKeymap(config *Config) []string {
239+ var validationErrors []string
240+ switch config.Workspace.Keymap {
241+ case KeymapZellij, KeymapTmux:
242+ default:
243+ validationErrors = append(validationErrors, fmt.Sprintf(
244+ "workspace.keymap must be 'zellij' or 'tmux' (got: %s)", config.Workspace.Keymap))
245+ }
246+ validationErrors = append(validationErrors, validateModalModeActions(
247+ "prefix_mode", config.Workspace.PrefixMode.TimeoutMilliseconds, config.Workspace.PrefixMode.Actions)...)
248+ return validationErrors
249+}
250+
237251 func validatePaneMode(config *Config) []string {
238252 return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
239253 }
@@ -27,6 +27,7 @@ func validateConfig(config *Config) error {
27 validationErrors = append(validationErrors, validatePopups(config)...)27 validationErrors = append(validationErrors, validatePopups(config)...)
28 validationErrors = append(validationErrors, validateExternalLinks(config)...)28 validationErrors = append(validationErrors, validateExternalLinks(config)...)
29 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)29 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
30+ validationErrors = append(validationErrors, validateKeymap(config)...)
30 validationErrors = append(validationErrors, validatePaneMode(config)...)31 validationErrors = append(validationErrors, validatePaneMode(config)...)
31 validationErrors = append(validationErrors, validateTabBar(config)...)32 validationErrors = append(validationErrors, validateTabBar(config)...)
32 validationErrors = append(validationErrors, validateTabMode(config)...)33 validationErrors = append(validationErrors, validateTabMode(config)...)
@@ -234,6 +235,19 @@ func validateWorkspaceStyling(config *Config) []string {
234 return validationErrors235 return validationErrors
235 }236 }
236 237
238+func validateKeymap(config *Config) []string {
239+ var validationErrors []string
240+ switch config.Workspace.Keymap {
241+ case KeymapZellij, KeymapTmux:
242+ default:
243+ validationErrors = append(validationErrors, fmt.Sprintf(
244+ "workspace.keymap must be 'zellij' or 'tmux' (got: %s)", config.Workspace.Keymap))
245+ }
246+ validationErrors = append(validationErrors, validateModalModeActions(
247+ "prefix_mode", config.Workspace.PrefixMode.TimeoutMilliseconds, config.Workspace.PrefixMode.Actions)...)
248+ return validationErrors
249+}
250+
237 func validatePaneMode(config *Config) []string {251 func validatePaneMode(config *Config) []string {
238 return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)252 return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
239 }253 }
M internal/infrastructure/config/validation_test.go +15 -0
@@ -531,3 +531,18 @@ func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) {
531531 assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")
532532 })
533533 }
534+
535+func TestValidateKeymap(t *testing.T) {
536+ cfg := DefaultConfig()
537+ assert.Empty(t, validateKeymap(cfg))
538+
539+ cfg.Workspace.Keymap = KeymapTmux
540+ assert.Empty(t, validateKeymap(cfg))
541+
542+ cfg.Workspace.Keymap = "emacs"
543+ assert.Contains(t, validateKeymap(cfg), "workspace.keymap must be 'zellij' or 'tmux' (got: emacs)")
544+
545+ cfg.Workspace.Keymap = KeymapZellij
546+ cfg.Workspace.PrefixMode.TimeoutMilliseconds = -1
547+ assert.Contains(t, validateKeymap(cfg), "workspace.prefix_mode.timeout_ms must be non-negative")
548+}
@@ -531,3 +531,18 @@ func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) {
531 assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")531 assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")
532 })532 })
533 }533 }
534+
535+func TestValidateKeymap(t *testing.T) {
536+ cfg := DefaultConfig()
537+ assert.Empty(t, validateKeymap(cfg))
538+
539+ cfg.Workspace.Keymap = KeymapTmux
540+ assert.Empty(t, validateKeymap(cfg))
541+
542+ cfg.Workspace.Keymap = "emacs"
543+ assert.Contains(t, validateKeymap(cfg), "workspace.keymap must be 'zellij' or 'tmux' (got: emacs)")
544+
545+ cfg.Workspace.Keymap = KeymapZellij
546+ cfg.Workspace.PrefixMode.TimeoutMilliseconds = -1
547+ assert.Contains(t, validateKeymap(cfg), "workspace.prefix_mode.timeout_ms must be non-negative")
548+}
M internal/ui/app.go +2 -0
@@ -3912,6 +3912,8 @@ func getModeToastClass(mode input.Mode) string {
39123912 return "toast-session-mode"
39133913 case input.ModeResize:
39143914 return "toast-resize-mode"
3915+ case input.ModePrefix:
3916+ return "toast-prefix-mode"
39153917 default:
39163918 return ""
39173919 }
@@ -3912,6 +3912,8 @@ func getModeToastClass(mode input.Mode) string {
3912 return "toast-session-mode"3912 return "toast-session-mode"
3913 case input.ModeResize:3913 case input.ModeResize:
3914 return "toast-resize-mode"3914 return "toast-resize-mode"
3915+ case input.ModePrefix:
3916+ return "toast-prefix-mode"
3915 default:3917 default:
3916 return ""3918 return ""
3917 }3919 }
M internal/ui/app_mode_frame.go +1 -1
@@ -10,7 +10,7 @@ func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget
1010 if wsView == nil {
1111 return nil
1212 }
13- if mode == input.ModeTab || mode == input.ModeSession {
13+ if mode == input.ModeTab || mode == input.ModeSession || mode == input.ModePrefix {
1414 return wsView.Widget()
1515 }
1616 ws := a.activeWorkspaceForBrowserWindow(bw)
@@ -10,7 +10,7 @@ func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget
10 if wsView == nil {10 if wsView == nil {
11 return nil11 return nil
12 }12 }
13- if mode == input.ModeTab || mode == input.ModeSession {13+ if mode == input.ModeTab || mode == input.ModeSession || mode == input.ModePrefix {
14 return wsView.Widget()14 return wsView.Widget()
15 }15 }
16 ws := a.activeWorkspaceForBrowserWindow(bw)16 ws := a.activeWorkspaceForBrowserWindow(bw)
M internal/ui/input/global_shortcuts.go +12 -12
@@ -81,12 +81,12 @@ func NewGlobalShortcutHandler(
8181 if workspace != nil {
8282 actionMap := globalShortcutActionMap()
8383
84- for actionName, actionBinding := range workspace.Shortcuts.Actions {
84+ for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
8585 action, ok := actionMap[actionName]
8686 if !ok {
8787 continue
8888 }
89- for _, keyStr := range actionBinding.Keys {
89+ for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
9090 binding, ok := ParseKeyString(keyStr)
9191 if !ok {
9292 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
@@ -195,13 +195,6 @@ func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logg
195195 Uint("keyval", uint(gdk.KEY_Tab)).
196196 Str("action", string(ActionSwitchLastTab)).
197197 Msg("registered global shortcut")
198-
199- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
200- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
201- log.Trace().
202- Uint("keyval", uint(gdk.KEY_s)).
203- Str("action", string(ActionOpenSessionManager)).
204- Msg("registered global shortcut")
205198 }
206199
207200 // registerShortcut creates and registers a single shortcut with the controller.
@@ -340,6 +333,12 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
340333 Msg("mode action triggered but keyboard handler not set, falling through to default handler")
341334 }
342335
336+ // After the tmux prefix, dispatch through KeyboardHandler so the
337+ // one-shot exit follows the same order as keys it handles directly.
338+ if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix {
339+ return h.kbHandler.DispatchAction(actionToDispatch)
340+ }
341+
343342 if h.onAction != nil {
344343 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
345344 log.Error().
@@ -559,12 +558,12 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
559558 if workspace != nil {
560559 actionMap := globalShortcutActionMap()
561560
562- for actionName, actionBinding := range workspace.Shortcuts.Actions {
561+ for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
563562 action, ok := actionMap[actionName]
564563 if !ok {
565564 continue
566565 }
567- for _, keyStr := range actionBinding.Keys {
566+ for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
568567 binding, ok := ParseKeyString(keyStr)
569568 if !ok {
570569 continue
@@ -840,7 +839,8 @@ func formatEventType(eventType gdk.EventType) string {
840839
841840 func isModeAction(action Action) bool {
842841 switch action {
843- case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:
842+ case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
843+ ActionEnterPrefixMode, ActionExitMode:
844844 return true
845845 default:
846846 return false
@@ -81,12 +81,12 @@ func NewGlobalShortcutHandler(
81 if workspace != nil {81 if workspace != nil {
82 actionMap := globalShortcutActionMap()82 actionMap := globalShortcutActionMap()
83 83
84- for actionName, actionBinding := range workspace.Shortcuts.Actions {84+ for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
85 action, ok := actionMap[actionName]85 action, ok := actionMap[actionName]
86 if !ok {86 if !ok {
87 continue87 continue
88 }88 }
89- for _, keyStr := range actionBinding.Keys {89+ for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
90 binding, ok := ParseKeyString(keyStr)90 binding, ok := ParseKeyString(keyStr)
91 if !ok {91 if !ok {
92 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")92 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
@@ -195,13 +195,6 @@ func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logg
195 Uint("keyval", uint(gdk.KEY_Tab)).195 Uint("keyval", uint(gdk.KEY_Tab)).
196 Str("action", string(ActionSwitchLastTab)).196 Str("action", string(ActionSwitchLastTab)).
197 Msg("registered global shortcut")197 Msg("registered global shortcut")
198-
199- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
200- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
201- log.Trace().
202- Uint("keyval", uint(gdk.KEY_s)).
203- Str("action", string(ActionOpenSessionManager)).
204- Msg("registered global shortcut")
205 }198 }
206 199
207 // registerShortcut creates and registers a single shortcut with the controller.200 // registerShortcut creates and registers a single shortcut with the controller.
@@ -340,6 +333,12 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action,
340 Msg("mode action triggered but keyboard handler not set, falling through to default handler")333 Msg("mode action triggered but keyboard handler not set, falling through to default handler")
341 }334 }
342 335
336+ // After the tmux prefix, dispatch through KeyboardHandler so the
337+ // one-shot exit follows the same order as keys it handles directly.
338+ if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix {
339+ return h.kbHandler.DispatchAction(actionToDispatch)
340+ }
341+
343 if h.onAction != nil {342 if h.onAction != nil {
344 if err := h.onAction(h.ctx, actionToDispatch); err != nil {343 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
345 log.Error().344 log.Error().
@@ -559,12 +558,12 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
559 if workspace != nil {558 if workspace != nil {
560 actionMap := globalShortcutActionMap()559 actionMap := globalShortcutActionMap()
561 560
562- for actionName, actionBinding := range workspace.Shortcuts.Actions {561+ for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
563 action, ok := actionMap[actionName]562 action, ok := actionMap[actionName]
564 if !ok {563 if !ok {
565 continue564 continue
566 }565 }
567- for _, keyStr := range actionBinding.Keys {566+ for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
568 binding, ok := ParseKeyString(keyStr)567 binding, ok := ParseKeyString(keyStr)
569 if !ok {568 if !ok {
570 continue569 continue
@@ -840,7 +839,8 @@ func formatEventType(eventType gdk.EventType) string {
840 839
841 func isModeAction(action Action) bool {840 func isModeAction(action Action) bool {
842 switch action {841 switch action {
843- case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:842+ case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
843+ ActionEnterPrefixMode, ActionExitMode:
844 return true844 return true
845 default:845 default:
846 return false846 return false
M internal/ui/input/keyboard.go +39 -1
@@ -502,6 +502,9 @@ func (h *KeyboardHandler) handleShortcutLookupResult(
502502 log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")
503503 return false
504504 }
505+ if mode == ModePrefix {
506+ return h.handleUnboundPrefixKey(keyval, modifiers)
507+ }
505508 return mode != ModeNormal // Consume unrecognized keys in modal mode
506509 }
507510 if h.shouldPassthroughVimModeActivation(action, mode) {
@@ -697,6 +700,26 @@ func (h *KeyboardHandler) confirmVimMode() {
697700 h.modal.ExitMode(h.ctx)
698701 }
699702
703+// handleUnboundPrefixKey handles a key with no prefix action. Modifier and
704+// dead-key presses (the Shift before '%') keep the prefix waiting. Any other
705+// key cancels the prefix, tmux-style; modified keys then continue to their
706+// normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed.
707+func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool {
708+ if isNonCharacterKeysym(keyval) {
709+ return true
710+ }
711+ h.modal.ExitMode(h.ctx)
712+ return !IsShortcutModified(modifiers)
713+}
714+
715+// isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms:
716+// keys pressed on the way to a character rather than characters themselves.
717+func isNonCharacterKeysym(keyval uint) bool {
718+ isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R)
719+ isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek)
720+ return isModifier || isISOOrDead
721+}
722+
700723 // dispatchAction dispatches the action and handles mode-related logic.
701724 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
702725 if h.handleModeAction(action) {
@@ -721,7 +744,10 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
721744 h.modal.ResetTimeout(h.ctx)
722745 }
723746
724- if mode != ModeNormal && ShouldAutoExitMode(action) {
747+ // The prefix is one-shot: any action returns to normal mode. Mode-enter
748+ // actions already left ModePrefix in handleModeAction.
749+ if (mode == ModePrefix && h.modal.Mode() == ModePrefix) ||
750+ (mode != ModeNormal && ShouldAutoExitMode(action)) {
725751 h.modal.ExitMode(h.ctx)
726752 }
727753
@@ -755,6 +781,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
755781 ActionEnterSessionMode,
756782 ActionEnterResizeMode,
757783 ActionEnterVimMode,
784+ ActionEnterPrefixMode,
758785 ActionNewTab,
759786 ActionRenameTab,
760787 ActionSplitRight,
@@ -880,6 +907,17 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
880907 }
881908
882909 switch action {
910+ case ActionEnterPrefixMode:
911+ if h.modal.Mode() == ModePrefix {
912+ h.modal.ExitMode(h.ctx)
913+ return true
914+ }
915+ var ms int
916+ if workspace != nil {
917+ ms = workspace.PrefixMode.TimeoutMilliseconds
918+ }
919+ h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond)
920+ return true
883921 case ActionEnterTabMode:
884922 var ms int
885923 if workspace != nil {
@@ -502,6 +502,9 @@ func (h *KeyboardHandler) handleShortcutLookupResult(
502 log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")502 log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")
503 return false503 return false
504 }504 }
505+ if mode == ModePrefix {
506+ return h.handleUnboundPrefixKey(keyval, modifiers)
507+ }
505 return mode != ModeNormal // Consume unrecognized keys in modal mode508 return mode != ModeNormal // Consume unrecognized keys in modal mode
506 }509 }
507 if h.shouldPassthroughVimModeActivation(action, mode) {510 if h.shouldPassthroughVimModeActivation(action, mode) {
@@ -697,6 +700,26 @@ func (h *KeyboardHandler) confirmVimMode() {
697 h.modal.ExitMode(h.ctx)700 h.modal.ExitMode(h.ctx)
698 }701 }
699 702
703+// handleUnboundPrefixKey handles a key with no prefix action. Modifier and
704+// dead-key presses (the Shift before '%') keep the prefix waiting. Any other
705+// key cancels the prefix, tmux-style; modified keys then continue to their
706+// normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed.
707+func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool {
708+ if isNonCharacterKeysym(keyval) {
709+ return true
710+ }
711+ h.modal.ExitMode(h.ctx)
712+ return !IsShortcutModified(modifiers)
713+}
714+
715+// isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms:
716+// keys pressed on the way to a character rather than characters themselves.
717+func isNonCharacterKeysym(keyval uint) bool {
718+ isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R)
719+ isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek)
720+ return isModifier || isISOOrDead
721+}
722+
700 // dispatchAction dispatches the action and handles mode-related logic.723 // dispatchAction dispatches the action and handles mode-related logic.
701 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {724 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
702 if h.handleModeAction(action) {725 if h.handleModeAction(action) {
@@ -721,7 +744,10 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
721 h.modal.ResetTimeout(h.ctx)744 h.modal.ResetTimeout(h.ctx)
722 }745 }
723 746
724- if mode != ModeNormal && ShouldAutoExitMode(action) {747+ // The prefix is one-shot: any action returns to normal mode. Mode-enter
748+ // actions already left ModePrefix in handleModeAction.
749+ if (mode == ModePrefix && h.modal.Mode() == ModePrefix) ||
750+ (mode != ModeNormal && ShouldAutoExitMode(action)) {
725 h.modal.ExitMode(h.ctx)751 h.modal.ExitMode(h.ctx)
726 }752 }
727 753
@@ -755,6 +781,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool {
755 ActionEnterSessionMode,781 ActionEnterSessionMode,
756 ActionEnterResizeMode,782 ActionEnterResizeMode,
757 ActionEnterVimMode,783 ActionEnterVimMode,
784+ ActionEnterPrefixMode,
758 ActionNewTab,785 ActionNewTab,
759 ActionRenameTab,786 ActionRenameTab,
760 ActionSplitRight,787 ActionSplitRight,
@@ -880,6 +907,17 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
880 }907 }
881 908
882 switch action {909 switch action {
910+ case ActionEnterPrefixMode:
911+ if h.modal.Mode() == ModePrefix {
912+ h.modal.ExitMode(h.ctx)
913+ return true
914+ }
915+ var ms int
916+ if workspace != nil {
917+ ms = workspace.PrefixMode.TimeoutMilliseconds
918+ }
919+ h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond)
920+ return true
883 case ActionEnterTabMode:921 case ActionEnterTabMode:
884 var ms int922 var ms int
885 if workspace != nil {923 if workspace != nil {
M internal/ui/input/modal.go +11 -0
@@ -28,6 +28,8 @@ const (
2828 ModeResize
2929 // ModeVim is the modal Vim-style scrolling mode.
3030 ModeVim
31+ // ModePrefix is the tmux-style one-shot prefix mode.
32+ ModePrefix
3133 )
3234
3335 // String returns a human-readable mode name.
@@ -45,6 +47,8 @@ func (m Mode) String() string {
4547 return "resize"
4648 case ModeVim:
4749 return "vim"
50+ case ModePrefix:
51+ return "prefix"
4852 default:
4953 return "unknown"
5054 }
@@ -63,6 +67,8 @@ func (m Mode) DisplayName() string {
6367 return "RESIZE MODE"
6468 case ModeVim:
6569 return "VIM MODE"
70+ case ModePrefix:
71+ return "PREFIX"
6672 default:
6773 return ""
6874 }
@@ -140,6 +146,11 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration
140146 m.enterMode(ctx, ModeSession, timeout)
141147 }
142148
149+// EnterPrefixMode switches to the one-shot prefix mode with an optional timeout.
150+func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) {
151+ m.enterMode(ctx, ModePrefix, timeout)
152+}
153+
143154 // EnterResizeMode switches to resize mode with an optional timeout.
144155 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
145156 m.enterMode(ctx, ModeResize, timeout)
@@ -28,6 +28,8 @@ const (
28 ModeResize28 ModeResize
29 // ModeVim is the modal Vim-style scrolling mode.29 // ModeVim is the modal Vim-style scrolling mode.
30 ModeVim30 ModeVim
31+ // ModePrefix is the tmux-style one-shot prefix mode.
32+ ModePrefix
31 )33 )
32 34
33 // String returns a human-readable mode name.35 // String returns a human-readable mode name.
@@ -45,6 +47,8 @@ func (m Mode) String() string {
45 return "resize"47 return "resize"
46 case ModeVim:48 case ModeVim:
47 return "vim"49 return "vim"
50+ case ModePrefix:
51+ return "prefix"
48 default:52 default:
49 return "unknown"53 return "unknown"
50 }54 }
@@ -63,6 +67,8 @@ func (m Mode) DisplayName() string {
63 return "RESIZE MODE"67 return "RESIZE MODE"
64 case ModeVim:68 case ModeVim:
65 return "VIM MODE"69 return "VIM MODE"
70+ case ModePrefix:
71+ return "PREFIX"
66 default:72 default:
67 return ""73 return ""
68 }74 }
@@ -140,6 +146,11 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration
140 m.enterMode(ctx, ModeSession, timeout)146 m.enterMode(ctx, ModeSession, timeout)
141 }147 }
142 148
149+// EnterPrefixMode switches to the one-shot prefix mode with an optional timeout.
150+func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) {
151+ m.enterMode(ctx, ModePrefix, timeout)
152+}
153+
143 // EnterResizeMode switches to resize mode with an optional timeout.154 // EnterResizeMode switches to resize mode with an optional timeout.
144 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {155 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
145 m.enterMode(ctx, ModeResize, timeout)156 m.enterMode(ctx, ModeResize, timeout)
A internal/ui/input/prefix_mode_test.go +228 -0
@@ -0,0 +1,228 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func newTmuxWorkspace() *entity.WorkspaceConfig {
14+ ws := newTestWorkspace()
15+ ws.Keymap = entity.KeymapTmux
16+ ws.VimMode.ActivationShortcut = "ctrl+y"
17+ ws.PrefixMode = entity.PrefixModeConfig{
18+ ActivationShortcut: "ctrl+space",
19+ Actions: map[string]entity.ActionBinding{
20+ "split-right": {Keys: []string{"%"}},
21+ "split-down": {Keys: []string{"\""}},
22+ "new-tab": {Keys: []string{"c"}},
23+ "next-tab": {Keys: []string{"n"}},
24+ "enter-resize-mode": {Keys: []string{"r"}},
25+ "cancel": {Keys: []string{"escape"}},
26+ },
27+ }
28+ return ws
29+}
30+
31+func recordActions(h *KeyboardHandler) *[]Action {
32+ var got []Action
33+ h.SetOnAction(func(_ context.Context, action Action) error {
34+ got = append(got, action)
35+ return nil
36+ })
37+ return &got
38+}
39+
40+func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) {
41+ set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession())
42+
43+ prefix, _ := ParseKeyString("ctrl+space")
44+ assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix])
45+ for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} {
46+ binding, ok := ParseKeyString(key)
47+ require.True(t, ok)
48+ _, bound := set.Global[binding]
49+ assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key)
50+ }
51+ vim, _ := ParseKeyString("ctrl+y")
52+ assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation")
53+}
54+
55+func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) {
56+ ws := newTmuxWorkspace()
57+ ws.Keymap = entity.KeymapZellij
58+ set := NewShortcutSet(context.Background(), ws, newTestSession())
59+
60+ prefix, _ := ParseKeyString("ctrl+space")
61+ _, bound := set.Global[prefix]
62+ assert.False(t, bound)
63+ tab, _ := ParseKeyString("ctrl+t")
64+ assert.Equal(t, ActionEnterTabMode, set.Global[tab])
65+}
66+
67+func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) {
68+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
69+ got := recordActions(h)
70+
71+ require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
72+ require.Equal(t, ModePrefix, h.Mode())
73+
74+ // '%' is typed with Shift on most layouts; the binding omits Shift.
75+ require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue))
76+ assert.Equal(t, []Action{ActionSplitRight}, *got)
77+ assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot")
78+
79+ // A plain key after the prefix exited must not dispatch again.
80+ assert.False(t, h.handleKeyPress(uint('n'), 0, 0))
81+ assert.Len(t, *got, 1)
82+}
83+
84+func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) {
85+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
86+ got := recordActions(h)
87+
88+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
89+ assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed")
90+ assert.Equal(t, ModeNormal, h.Mode())
91+ assert.Empty(t, *got)
92+}
93+
94+func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) {
95+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
96+
97+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
98+ h.handleKeyPress(uint('r'), 0, 0)
99+ assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode")
100+}
101+
102+func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
103+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
104+
105+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
106+ h.handleKeyRelease(uint(gdk.KEY_space))
107+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
108+ assert.Equal(t, ModeNormal, h.Mode())
109+}
110+
111+func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
112+ ws := newTestWorkspace()
113+ ws.Shortcuts.Actions = map[string]entity.ActionBinding{
114+ "reload": {Keys: []string{}}, // disabled by user
115+ "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116+ }
117+ set := NewShortcutSet(context.Background(), ws, newTestSession())
118+
119+ lookup := func(key string) (Action, bool) {
120+ binding, ok := ParseKeyString(key)
121+ require.True(t, ok, key)
122+ action, found := set.Global[binding]
123+ return action, found
124+ }
125+
126+ action, ok := lookup("ctrl+l")
127+ assert.True(t, ok, "missing builtin action falls back to its default key")
128+ assert.Equal(t, ActionOpenOmnibox, action)
129+
130+ _, ok = lookup("ctrl+r")
131+ assert.False(t, ok, "empty keys disable a builtin shortcut")
132+
133+ _, ok = lookup("ctrl+q")
134+ assert.False(t, ok, "rebound builtin drops its default key")
135+ action, _ = lookup("ctrl+shift+q")
136+ assert.Equal(t, ActionQuit, action)
137+}
138+
139+func TestParseKeyString_PrintableSymbols(t *testing.T) {
140+ for _, key := range []string{"%", "\"", "&", ",", "!"} {
141+ binding, ok := ParseKeyString(key)
142+ require.Truef(t, ok, "%q should parse", key)
143+ assert.Equal(t, uint(key[0]), binding.Keyval)
144+ assert.Equal(t, ModNone, binding.Modifiers)
145+ }
146+}
147+
148+type keyEvent struct {
149+ keyval uint
150+ state gdk.ModifierType
151+}
152+
153+// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
154+// them: modifier and dead-key presses arrive before the character.
155+func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
156+ shift := gdk.ShiftMaskValue
157+ tests := []struct {
158+ name string
159+ events []keyEvent
160+ wantAction []Action
161+ wantMode Mode
162+ lastResult bool
163+ }{
164+ {
165+ name: "shift then percent splits right",
166+ events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
167+ wantAction: []Action{ActionSplitRight},
168+ wantMode: ModeNormal,
169+ lastResult: true,
170+ },
171+ {
172+ name: "shift then quote splits down",
173+ events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
174+ wantAction: []Action{ActionSplitDown},
175+ wantMode: ModeNormal,
176+ lastResult: true,
177+ },
178+ {
179+ name: "dead diaeresis splits down on international layouts",
180+ events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
181+ wantAction: []Action{ActionSplitDown},
182+ wantMode: ModeNormal,
183+ lastResult: true,
184+ },
185+ {
186+ name: "altgr alone keeps the prefix waiting",
187+ events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
188+ wantMode: ModePrefix,
189+ lastResult: true,
190+ },
191+ {
192+ name: "unbound modified key cancels and passes through",
193+ events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
194+ wantMode: ModeNormal,
195+ lastResult: false,
196+ },
197+ }
198+
199+ for _, tt := range tests {
200+ t.Run(tt.name, func(t *testing.T) {
201+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
202+ got := recordActions(h)
203+ require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
204+ h.handleKeyRelease(uint(gdk.KEY_space))
205+
206+ var result bool
207+ for _, ev := range tt.events {
208+ result = h.handleKeyPress(ev.keyval, 0, ev.state)
209+ }
210+ assert.Equal(t, tt.lastResult, result)
211+ assert.Equal(t, tt.wantMode, h.Mode())
212+ assert.Equal(t, tt.wantAction, *got)
213+ })
214+ }
215+}
216+
217+func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
218+ ws := newTestWorkspace()
219+ ws.Shortcuts.Actions = map[string]entity.ActionBinding{
220+ "toggle-floating-pane": {Keys: []string{"ctrl+l"}},
221+ "open-omnibox": {Keys: []string{"ctrl+l"}},
222+ }
223+ for range 20 {
224+ set := NewShortcutSet(context.Background(), ws, newTestSession())
225+ binding, _ := ParseKeyString("ctrl+l")
226+ require.Equal(t, ActionOpenOmnibox, set.Global[binding])
227+ }
228+}
@@ -0,0 +1,228 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func newTmuxWorkspace() *entity.WorkspaceConfig {
14+ ws := newTestWorkspace()
15+ ws.Keymap = entity.KeymapTmux
16+ ws.VimMode.ActivationShortcut = "ctrl+y"
17+ ws.PrefixMode = entity.PrefixModeConfig{
18+ ActivationShortcut: "ctrl+space",
19+ Actions: map[string]entity.ActionBinding{
20+ "split-right": {Keys: []string{"%"}},
21+ "split-down": {Keys: []string{"\""}},
22+ "new-tab": {Keys: []string{"c"}},
23+ "next-tab": {Keys: []string{"n"}},
24+ "enter-resize-mode": {Keys: []string{"r"}},
25+ "cancel": {Keys: []string{"escape"}},
26+ },
27+ }
28+ return ws
29+}
30+
31+func recordActions(h *KeyboardHandler) *[]Action {
32+ var got []Action
33+ h.SetOnAction(func(_ context.Context, action Action) error {
34+ got = append(got, action)
35+ return nil
36+ })
37+ return &got
38+}
39+
40+func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) {
41+ set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession())
42+
43+ prefix, _ := ParseKeyString("ctrl+space")
44+ assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix])
45+ for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} {
46+ binding, ok := ParseKeyString(key)
47+ require.True(t, ok)
48+ _, bound := set.Global[binding]
49+ assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key)
50+ }
51+ vim, _ := ParseKeyString("ctrl+y")
52+ assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation")
53+}
54+
55+func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) {
56+ ws := newTmuxWorkspace()
57+ ws.Keymap = entity.KeymapZellij
58+ set := NewShortcutSet(context.Background(), ws, newTestSession())
59+
60+ prefix, _ := ParseKeyString("ctrl+space")
61+ _, bound := set.Global[prefix]
62+ assert.False(t, bound)
63+ tab, _ := ParseKeyString("ctrl+t")
64+ assert.Equal(t, ActionEnterTabMode, set.Global[tab])
65+}
66+
67+func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) {
68+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
69+ got := recordActions(h)
70+
71+ require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
72+ require.Equal(t, ModePrefix, h.Mode())
73+
74+ // '%' is typed with Shift on most layouts; the binding omits Shift.
75+ require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue))
76+ assert.Equal(t, []Action{ActionSplitRight}, *got)
77+ assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot")
78+
79+ // A plain key after the prefix exited must not dispatch again.
80+ assert.False(t, h.handleKeyPress(uint('n'), 0, 0))
81+ assert.Len(t, *got, 1)
82+}
83+
84+func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) {
85+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
86+ got := recordActions(h)
87+
88+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
89+ assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed")
90+ assert.Equal(t, ModeNormal, h.Mode())
91+ assert.Empty(t, *got)
92+}
93+
94+func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) {
95+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
96+
97+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
98+ h.handleKeyPress(uint('r'), 0, 0)
99+ assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode")
100+}
101+
102+func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
103+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
104+
105+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
106+ h.handleKeyRelease(uint(gdk.KEY_space))
107+ h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
108+ assert.Equal(t, ModeNormal, h.Mode())
109+}
110+
111+func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
112+ ws := newTestWorkspace()
113+ ws.Shortcuts.Actions = map[string]entity.ActionBinding{
114+ "reload": {Keys: []string{}}, // disabled by user
115+ "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116+ }
117+ set := NewShortcutSet(context.Background(), ws, newTestSession())
118+
119+ lookup := func(key string) (Action, bool) {
120+ binding, ok := ParseKeyString(key)
121+ require.True(t, ok, key)
122+ action, found := set.Global[binding]
123+ return action, found
124+ }
125+
126+ action, ok := lookup("ctrl+l")
127+ assert.True(t, ok, "missing builtin action falls back to its default key")
128+ assert.Equal(t, ActionOpenOmnibox, action)
129+
130+ _, ok = lookup("ctrl+r")
131+ assert.False(t, ok, "empty keys disable a builtin shortcut")
132+
133+ _, ok = lookup("ctrl+q")
134+ assert.False(t, ok, "rebound builtin drops its default key")
135+ action, _ = lookup("ctrl+shift+q")
136+ assert.Equal(t, ActionQuit, action)
137+}
138+
139+func TestParseKeyString_PrintableSymbols(t *testing.T) {
140+ for _, key := range []string{"%", "\"", "&", ",", "!"} {
141+ binding, ok := ParseKeyString(key)
142+ require.Truef(t, ok, "%q should parse", key)
143+ assert.Equal(t, uint(key[0]), binding.Keyval)
144+ assert.Equal(t, ModNone, binding.Modifiers)
145+ }
146+}
147+
148+type keyEvent struct {
149+ keyval uint
150+ state gdk.ModifierType
151+}
152+
153+// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
154+// them: modifier and dead-key presses arrive before the character.
155+func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
156+ shift := gdk.ShiftMaskValue
157+ tests := []struct {
158+ name string
159+ events []keyEvent
160+ wantAction []Action
161+ wantMode Mode
162+ lastResult bool
163+ }{
164+ {
165+ name: "shift then percent splits right",
166+ events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
167+ wantAction: []Action{ActionSplitRight},
168+ wantMode: ModeNormal,
169+ lastResult: true,
170+ },
171+ {
172+ name: "shift then quote splits down",
173+ events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
174+ wantAction: []Action{ActionSplitDown},
175+ wantMode: ModeNormal,
176+ lastResult: true,
177+ },
178+ {
179+ name: "dead diaeresis splits down on international layouts",
180+ events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
181+ wantAction: []Action{ActionSplitDown},
182+ wantMode: ModeNormal,
183+ lastResult: true,
184+ },
185+ {
186+ name: "altgr alone keeps the prefix waiting",
187+ events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
188+ wantMode: ModePrefix,
189+ lastResult: true,
190+ },
191+ {
192+ name: "unbound modified key cancels and passes through",
193+ events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
194+ wantMode: ModeNormal,
195+ lastResult: false,
196+ },
197+ }
198+
199+ for _, tt := range tests {
200+ t.Run(tt.name, func(t *testing.T) {
201+ h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
202+ got := recordActions(h)
203+ require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
204+ h.handleKeyRelease(uint(gdk.KEY_space))
205+
206+ var result bool
207+ for _, ev := range tt.events {
208+ result = h.handleKeyPress(ev.keyval, 0, ev.state)
209+ }
210+ assert.Equal(t, tt.lastResult, result)
211+ assert.Equal(t, tt.wantMode, h.Mode())
212+ assert.Equal(t, tt.wantAction, *got)
213+ })
214+ }
215+}
216+
217+func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
218+ ws := newTestWorkspace()
219+ ws.Shortcuts.Actions = map[string]entity.ActionBinding{
220+ "toggle-floating-pane": {Keys: []string{"ctrl+l"}},
221+ "open-omnibox": {Keys: []string{"ctrl+l"}},
222+ }
223+ for range 20 {
224+ set := NewShortcutSet(context.Background(), ws, newTestSession())
225+ binding, _ := ParseKeyString("ctrl+l")
226+ require.Equal(t, ActionOpenOmnibox, set.Global[binding])
227+ }
228+}
M internal/ui/input/shortcuts.go +143 -129
@@ -4,6 +4,7 @@ package input
44 import (
55 "context"
66 "maps"
7+ "slices"
78 "sort"
89 "strings"
910
@@ -109,6 +110,7 @@ const (
109110 ActionEnterPaneMode Action = "enter_pane_mode"
110111 ActionEnterSessionMode Action = "enter_session_mode"
111112 ActionEnterResizeMode Action = "enter_resize_mode"
113+ ActionEnterPrefixMode Action = "enter_prefix_mode"
112114 ActionExitMode Action = "exit_mode"
113115
114116 // Tab actions (global and modal)
@@ -235,6 +237,8 @@ type ShortcutSet struct {
235237 ResizeMode ShortcutTable
236238 // VimMode shortcuts are only active in vim mode.
237239 VimMode ShortcutTable
240+ // PrefixMode shortcuts are one-shot actions after the tmux-style prefix.
241+ PrefixMode ShortcutTable
238242 // vimModeSequences holds multi-key Vim mode bindings.
239243 vimModeSequences *vimkeys.Trie
240244 }
@@ -251,6 +255,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
251255 SessionMode: make(ShortcutTable),
252256 ResizeMode: make(ShortcutTable),
253257 VimMode: make(ShortcutTable),
258+ PrefixMode: make(ShortcutTable),
254259 }
255260
256261 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
@@ -259,6 +264,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
259264 set.buildPaneModeShortcuts(ctx, workspace)
260265 set.buildResizeModeShortcuts(ctx, workspace)
261266 set.buildVimModeShortcuts(ctx, workspace)
267+ set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix")
262268 }
263269 if session != nil {
264270 set.buildSessionModeShortcuts(ctx, session)
@@ -271,6 +277,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
271277 Int("resize", len(set.ResizeMode)).
272278 Int("session", len(set.SessionMode)).
273279 Int("vim", len(set.VimMode)).
280+ Int("prefix", len(set.PrefixMode)).
274281 Msg("shortcuts registered")
275282
276283 return set
@@ -278,11 +285,12 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
278285
279286 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
280287 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288+ s.registerBuiltinShortcutFallbacks(workspace)
281289 s.registerActivationShortcutsFromParts(ctx, workspace, session)
282290 s.registerConfiguredShortcuts(workspace)
283- s.registerStandardShortcuts()
284- s.registerPaneNavigationShortcuts()
285- s.registerTabSwitchShortcuts()
291+ // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;
292+ // Alt+Shift+Tab stays here as a fallback binding.
293+ s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
286294 s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
287295 }
288296
@@ -330,77 +338,63 @@ func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.Wor
330338 }
331339 }
332340
341+// registerActivationShortcutsFromParts registers mode activation shortcuts.
342+// The zellij keymap binds one shortcut per mode; the tmux keymap binds a single
343+// one-shot prefix instead of the pane, tab, resize, and session activations.
344+// Vim Mode keeps its own activation in both keymaps.
333345 func (s *ShortcutSet) registerActivationShortcutsFromParts(
334346 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
335347 ) {
336- log := logging.FromContext(ctx)
337- if workspace == nil {
338- // Still register session mode if available.
339- if session != nil {
340- if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
341- s.Global[binding] = ActionEnterSessionMode
342- log.Trace().
343- Str("shortcut", session.SessionMode.ActivationShortcut).
344- Uint("keyval", binding.Keyval).
345- Uint("mod", uint(binding.Modifiers)).
346- Msg("session mode activation registered")
347- } else {
348- log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
349- }
348+ if workspace != nil {
349+ s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode)
350+ if workspace.Keymap == entity.KeymapTmux {
351+ s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode)
352+ return
350353 }
351- return
354+ s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode)
355+ s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode)
356+ s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode)
352357 }
353- if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok {
354- s.Global[binding] = ActionEnterVimMode
355- log.Trace().
356- Str("shortcut", workspace.VimMode.ActivationShortcut).
357- Uint("keyval", binding.Keyval).
358- Uint("mod", uint(binding.Modifiers)).
359- Msg("vim mode activation registered")
360- } else {
361- log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut")
362- }
363- if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
364- s.Global[binding] = ActionEnterTabMode
365- log.Trace().
366- Str("shortcut", workspace.TabMode.ActivationShortcut).
367- Uint("keyval", binding.Keyval).
368- Uint("mod", uint(binding.Modifiers)).
369- Msg("tab mode activation registered")
370- } else {
371- log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
358+ if session != nil {
359+ s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode)
372360 }
373- if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
374- s.Global[binding] = ActionEnterPaneMode
375- log.Trace().
376- Str("shortcut", workspace.PaneMode.ActivationShortcut).
377- Uint("keyval", binding.Keyval).
378- Uint("mod", uint(binding.Modifiers)).
379- Msg("pane mode activation registered")
380- } else {
381- log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
361+}
362+
363+func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) {
364+ log := logging.FromContext(ctx)
365+ binding, ok := ParseKeyString(shortcut)
366+ if !ok {
367+ log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut")
368+ return
382369 }
383- if session != nil {
384- if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
385- s.Global[binding] = ActionEnterSessionMode
386- log.Trace().
387- Str("shortcut", session.SessionMode.ActivationShortcut).
388- Uint("keyval", binding.Keyval).
389- Uint("mod", uint(binding.Modifiers)).
390- Msg("session mode activation registered")
391- } else {
392- log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
370+ s.Global[binding] = action
371+ log.Trace().
372+ Str("shortcut", shortcut).
373+ Str("action", string(action)).
374+ Uint("keyval", binding.Keyval).
375+ Uint("mod", uint(binding.Modifiers)).
376+ Msg("mode activation registered")
377+}
378+
379+// registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380+// action is missing from the configured global shortcuts, so configs written
381+// before these actions became configurable keep working until migrated.
382+func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383+ for name, binding := range entity.BuiltinGlobalShortcuts() {
384+ if cfg != nil {
385+ if _, configured := cfg.Shortcuts.Actions[name]; configured {
386+ continue
387+ }
388+ }
389+ action, ok := configActionToAction[name]
390+ if !ok {
391+ continue
392+ }
393+ for _, key := range binding.Keys {
394+ if kb, ok := ParseKeyString(key); ok {
395+ s.Global[kb] = action
396+ }
393397 }
394- }
395- if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
396- s.Global[binding] = ActionEnterResizeMode
397- log.Trace().
398- Str("shortcut", workspace.ResizeMode.ActivationShortcut).
399- Uint("keyval", binding.Keyval).
400- Uint("mod", uint(binding.Modifiers)).
401- Msg("resize mode activation registered")
402- } else {
403- log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
404398 }
405399 }
406400
@@ -408,22 +402,12 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
408402 if cfg == nil {
409403 return
410404 }
411- // Note: Ctrl+T is NOT registered globally - it enters tab mode.
412- // In tab mode, use:
413- // n = new tab
414- // x = close tab
415- // l/tab = next tab
416- // h/shift+tab = previous tab
417- // r = rename tab
418- // This follows Zellij-style modal keyboard interface.
419- //
420- // However, these standard browser shortcuts ARE global.
421- for actionName, actionBinding := range cfg.Shortcuts.Actions {
405+ for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
422406 action, ok := configActionToAction[actionName]
423407 if !ok {
424408 continue
425409 }
426- for _, keyStr := range actionBinding.Keys {
410+ for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys {
427411 if binding, ok := ParseKeyString(keyStr); ok {
428412 s.Global[binding] = action
429413 }
@@ -431,6 +415,28 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
431415 }
432416 }
433417
418+// orderedGlobalShortcutActions returns action names in registration order:
419+// sorted, with built-in browser actions last so they win a shared key, as
420+// they did when they were hard-coded. A stable order keeps the winner of a
421+// key conflict the same across restarts.
422+func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423+ builtins := entity.BuiltinGlobalShortcuts()
424+ names := slices.Sorted(maps.Keys(actions))
425+ slices.SortStableFunc(names, func(a, b string) int {
426+ _, aBuiltin := builtins[a]
427+ _, bBuiltin := builtins[b]
428+ switch {
429+ case aBuiltin == bBuiltin:
430+ return 0
431+ case aBuiltin:
432+ return 1
433+ default:
434+ return -1
435+ }
436+ })
437+ return names
438+}
439+
434440 func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
435441 occupied := make(map[KeyBinding]Action, len(s.Global))
436442 maps.Copy(occupied, s.Global)
@@ -537,54 +543,6 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
537543 }
538544 }
539545
540-func (s *ShortcutSet) registerStandardShortcuts() {
541- s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
542- s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
543- s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
544- s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
545- s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
546- s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
547- s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
548- s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
549- s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
550- s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
551- s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
552- s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
553- s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
554- s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
555- s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
556- s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
557- s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
558- s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
559- s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
560- s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
561- s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
562- // Session management - direct shortcut to open session manager
563- s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
564-}
565-
566-func (s *ShortcutSet) registerPaneNavigationShortcuts() {
567- s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
568- s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
569- s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
570- s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
571-
572- s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
573- s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
574- s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
575- s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
576-}
577-
578-func (s *ShortcutSet) registerTabSwitchShortcuts() {
579- // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
580- // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
581- // This is necessary because WebKitGTK's WebView consumes these key events
582- // before they reach the EventControllerKey in capture phase.
583- //
584- // Only Alt+Shift+Tab remains here as a fallback binding.
585- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
586-}
587-
588546 func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
589547 log := logging.FromContext(ctx)
590548 var registered, parseErrors, unknownActions int
@@ -695,6 +653,31 @@ var configActionToAction = map[string]Action{
695653 // Session actions
696654 "session-manager": ActionOpenSessionManager,
697655
656+ // Browser actions
657+ "open-omnibox": ActionOpenOmnibox,
658+ "open-find": ActionOpenFind,
659+ "find-next": ActionFindNext,
660+ "find-prev": ActionFindPrev,
661+ "reload": ActionReload,
662+ "hard-reload": ActionHardReload,
663+ "open-devtools": ActionOpenDevTools,
664+ "go-back": ActionGoBack,
665+ "go-forward": ActionGoForward,
666+ "zoom-in": ActionZoomIn,
667+ "zoom-out": ActionZoomOut,
668+ "zoom-reset": ActionZoomReset,
669+ "quit": ActionQuit,
670+ "toggle-fullscreen": ActionToggleFullscreen,
671+ "copy-url": ActionCopyURL,
672+ "print-page": ActionPrintPage,
673+
674+ // Mode activation (used by the tmux prefix)
675+ "enter-pane-mode": ActionEnterPaneMode,
676+ "enter-tab-mode": ActionEnterTabMode,
677+ "enter-resize-mode": ActionEnterResizeMode,
678+ "enter-session-mode": ActionEnterSessionMode,
679+ "enter-vim-mode": ActionEnterVimMode,
680+
698681 // Vim mode scroll actions
699682 "vim_scroll_left": ActionVimScrollLeft,
700683 "vim-scroll-left": ActionVimScrollLeft,
@@ -854,9 +837,9 @@ func stringToKeyval(s string) (uint, bool) {
854837 return keyval, true
855838 }
856839
857- // Single letter keys (a-z)
858- if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
859- // ASCII lowercase a=97, which matches gdk.KEY_a
840+ // Single printable ASCII keys (a-z, symbols such as %, ", &).
841+ // GDK keyvals for printable ASCII match their character codes.
842+ if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {
860843 return uint(s[0]), true
861844 }
862845
@@ -884,12 +867,27 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
884867 modeTable = s.SessionMode
885868 case ModeVim:
886869 modeTable = s.VimMode
870+ case ModePrefix:
871+ modeTable = s.PrefixMode
887872 }
888873
889874 if modeTable != nil {
890875 if action, ok := modeTable[binding]; ok {
891876 return action, true
892877 }
878+ // Shifted symbols (%, ", {, ...) arrive with Shift held but are
879+ // configured without it, since the symbol already implies Shift.
880+ if isShiftedSymbol(binding) {
881+ if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok {
882+ return action, true
883+ }
884+ }
885+ // International layouts deliver some symbols as dead keys.
886+ if symbol, ok := deadKeySymbols[binding.Keyval]; ok {
887+ if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok {
888+ return action, true
889+ }
890+ }
893891 if mode == ModeVim {
894892 return "", false
895893 }
@@ -903,6 +901,22 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
903901 return "", false
904902 }
905903
904+// deadKeySymbols maps dead keysyms to the ASCII symbol they stand for.
905+var deadKeySymbols = map[uint]uint{
906+ uint(gdk.KEY_dead_diaeresis): '"',
907+ uint(gdk.KEY_dead_acute): '\'',
908+ uint(gdk.KEY_dead_grave): '`',
909+ uint(gdk.KEY_dead_circumflex): '^',
910+ uint(gdk.KEY_dead_tilde): '~',
911+}
912+
913+// isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol.
914+func isShiftedSymbol(binding KeyBinding) bool {
915+ k := binding.Keyval
916+ isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z')
917+ return binding.Modifiers == ModShift && isSymbol
918+}
919+
906920 // ShouldAutoExitMode returns true if the action should cause modal mode to exit.
907921 func ShouldAutoExitMode(action Action) bool {
908922 switch action {
@@ -4,6 +4,7 @@ package input
4 import (4 import (
5 "context"5 "context"
6 "maps"6 "maps"
7+ "slices"
7 "sort"8 "sort"
8 "strings"9 "strings"
9 10
@@ -109,6 +110,7 @@ const (
109 ActionEnterPaneMode Action = "enter_pane_mode"110 ActionEnterPaneMode Action = "enter_pane_mode"
110 ActionEnterSessionMode Action = "enter_session_mode"111 ActionEnterSessionMode Action = "enter_session_mode"
111 ActionEnterResizeMode Action = "enter_resize_mode"112 ActionEnterResizeMode Action = "enter_resize_mode"
113+ ActionEnterPrefixMode Action = "enter_prefix_mode"
112 ActionExitMode Action = "exit_mode"114 ActionExitMode Action = "exit_mode"
113 115
114 // Tab actions (global and modal)116 // Tab actions (global and modal)
@@ -235,6 +237,8 @@ type ShortcutSet struct {
235 ResizeMode ShortcutTable237 ResizeMode ShortcutTable
236 // VimMode shortcuts are only active in vim mode.238 // VimMode shortcuts are only active in vim mode.
237 VimMode ShortcutTable239 VimMode ShortcutTable
240+ // PrefixMode shortcuts are one-shot actions after the tmux-style prefix.
241+ PrefixMode ShortcutTable
238 // vimModeSequences holds multi-key Vim mode bindings.242 // vimModeSequences holds multi-key Vim mode bindings.
239 vimModeSequences *vimkeys.Trie243 vimModeSequences *vimkeys.Trie
240 }244 }
@@ -251,6 +255,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
251 SessionMode: make(ShortcutTable),255 SessionMode: make(ShortcutTable),
252 ResizeMode: make(ShortcutTable),256 ResizeMode: make(ShortcutTable),
253 VimMode: make(ShortcutTable),257 VimMode: make(ShortcutTable),
258+ PrefixMode: make(ShortcutTable),
254 }259 }
255 260
256 set.buildGlobalShortcutsFromParts(ctx, workspace, session)261 set.buildGlobalShortcutsFromParts(ctx, workspace, session)
@@ -259,6 +264,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
259 set.buildPaneModeShortcuts(ctx, workspace)264 set.buildPaneModeShortcuts(ctx, workspace)
260 set.buildResizeModeShortcuts(ctx, workspace)265 set.buildResizeModeShortcuts(ctx, workspace)
261 set.buildVimModeShortcuts(ctx, workspace)266 set.buildVimModeShortcuts(ctx, workspace)
267+ set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix")
262 }268 }
263 if session != nil {269 if session != nil {
264 set.buildSessionModeShortcuts(ctx, session)270 set.buildSessionModeShortcuts(ctx, session)
@@ -271,6 +277,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
271 Int("resize", len(set.ResizeMode)).277 Int("resize", len(set.ResizeMode)).
272 Int("session", len(set.SessionMode)).278 Int("session", len(set.SessionMode)).
273 Int("vim", len(set.VimMode)).279 Int("vim", len(set.VimMode)).
280+ Int("prefix", len(set.PrefixMode)).
274 Msg("shortcuts registered")281 Msg("shortcuts registered")
275 282
276 return set283 return set
@@ -278,11 +285,12 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
278 285
279 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.286 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
280 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {287 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288+ s.registerBuiltinShortcutFallbacks(workspace)
281 s.registerActivationShortcutsFromParts(ctx, workspace, session)289 s.registerActivationShortcutsFromParts(ctx, workspace, session)
282 s.registerConfiguredShortcuts(workspace)290 s.registerConfiguredShortcuts(workspace)
283- s.registerStandardShortcuts()291+ // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;
284- s.registerPaneNavigationShortcuts()292+ // Alt+Shift+Tab stays here as a fallback binding.
285- s.registerTabSwitchShortcuts()293+ s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
286 s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)294 s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
287 }295 }
288 296
@@ -330,77 +338,63 @@ func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.Wor
330 }338 }
331 }339 }
332 340
341+// registerActivationShortcutsFromParts registers mode activation shortcuts.
342+// The zellij keymap binds one shortcut per mode; the tmux keymap binds a single
343+// one-shot prefix instead of the pane, tab, resize, and session activations.
344+// Vim Mode keeps its own activation in both keymaps.
333 func (s *ShortcutSet) registerActivationShortcutsFromParts(345 func (s *ShortcutSet) registerActivationShortcutsFromParts(
334 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,346 ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
335 ) {347 ) {
336- log := logging.FromContext(ctx)348+ if workspace != nil {
337- if workspace == nil {349+ s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode)
338- // Still register session mode if available.350+ if workspace.Keymap == entity.KeymapTmux {
339- if session != nil {351+ s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode)
340- if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {352+ return
341- s.Global[binding] = ActionEnterSessionMode
342- log.Trace().
343- Str("shortcut", session.SessionMode.ActivationShortcut).
344- Uint("keyval", binding.Keyval).
345- Uint("mod", uint(binding.Modifiers)).
346- Msg("session mode activation registered")
347- } else {
348- log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
349- }
350 }353 }
351- return354+ s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode)
355+ s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode)
356+ s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode)
352 }357 }
353- if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok {358+ if session != nil {
354- s.Global[binding] = ActionEnterVimMode359+ s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode)
355- log.Trace().
356- Str("shortcut", workspace.VimMode.ActivationShortcut).
357- Uint("keyval", binding.Keyval).
358- Uint("mod", uint(binding.Modifiers)).
359- Msg("vim mode activation registered")
360- } else {
361- log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut")
362- }
363- if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
364- s.Global[binding] = ActionEnterTabMode
365- log.Trace().
366- Str("shortcut", workspace.TabMode.ActivationShortcut).
367- Uint("keyval", binding.Keyval).
368- Uint("mod", uint(binding.Modifiers)).
369- Msg("tab mode activation registered")
370- } else {
371- log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
372 }360 }
373- if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {361+}
374- s.Global[binding] = ActionEnterPaneMode362+
375- log.Trace().363+func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) {
376- Str("shortcut", workspace.PaneMode.ActivationShortcut).364+ log := logging.FromContext(ctx)
377- Uint("keyval", binding.Keyval).365+ binding, ok := ParseKeyString(shortcut)
378- Uint("mod", uint(binding.Modifiers)).366+ if !ok {
379- Msg("pane mode activation registered")367+ log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut")
380- } else {368+ return
381- log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
382 }369 }
383- if session != nil {370+ s.Global[binding] = action
384- if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {371+ log.Trace().
385- s.Global[binding] = ActionEnterSessionMode372+ Str("shortcut", shortcut).
386- log.Trace().373+ Str("action", string(action)).
387- Str("shortcut", session.SessionMode.ActivationShortcut).374+ Uint("keyval", binding.Keyval).
388- Uint("keyval", binding.Keyval).375+ Uint("mod", uint(binding.Modifiers)).
389- Uint("mod", uint(binding.Modifiers)).376+ Msg("mode activation registered")
390- Msg("session mode activation registered")377+}
391- } else {378+
392- log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")379+// registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380+// action is missing from the configured global shortcuts, so configs written
381+// before these actions became configurable keep working until migrated.
382+func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383+ for name, binding := range entity.BuiltinGlobalShortcuts() {
384+ if cfg != nil {
385+ if _, configured := cfg.Shortcuts.Actions[name]; configured {
386+ continue
387+ }
388+ }
389+ action, ok := configActionToAction[name]
390+ if !ok {
391+ continue
392+ }
393+ for _, key := range binding.Keys {
394+ if kb, ok := ParseKeyString(key); ok {
395+ s.Global[kb] = action
396+ }
393 }397 }
394- }
395- if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
396- s.Global[binding] = ActionEnterResizeMode
397- log.Trace().
398- Str("shortcut", workspace.ResizeMode.ActivationShortcut).
399- Uint("keyval", binding.Keyval).
400- Uint("mod", uint(binding.Modifiers)).
401- Msg("resize mode activation registered")
402- } else {
403- log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
404 }398 }
405 }399 }
406 400
@@ -408,22 +402,12 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
408 if cfg == nil {402 if cfg == nil {
409 return403 return
410 }404 }
411- // Note: Ctrl+T is NOT registered globally - it enters tab mode.405+ for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
412- // In tab mode, use:
413- // n = new tab
414- // x = close tab
415- // l/tab = next tab
416- // h/shift+tab = previous tab
417- // r = rename tab
418- // This follows Zellij-style modal keyboard interface.
419- //
420- // However, these standard browser shortcuts ARE global.
421- for actionName, actionBinding := range cfg.Shortcuts.Actions {
422 action, ok := configActionToAction[actionName]406 action, ok := configActionToAction[actionName]
423 if !ok {407 if !ok {
424 continue408 continue
425 }409 }
426- for _, keyStr := range actionBinding.Keys {410+ for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys {
427 if binding, ok := ParseKeyString(keyStr); ok {411 if binding, ok := ParseKeyString(keyStr); ok {
428 s.Global[binding] = action412 s.Global[binding] = action
429 }413 }
@@ -431,6 +415,28 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
431 }415 }
432 }416 }
433 417
418+// orderedGlobalShortcutActions returns action names in registration order:
419+// sorted, with built-in browser actions last so they win a shared key, as
420+// they did when they were hard-coded. A stable order keeps the winner of a
421+// key conflict the same across restarts.
422+func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423+ builtins := entity.BuiltinGlobalShortcuts()
424+ names := slices.Sorted(maps.Keys(actions))
425+ slices.SortStableFunc(names, func(a, b string) int {
426+ _, aBuiltin := builtins[a]
427+ _, bBuiltin := builtins[b]
428+ switch {
429+ case aBuiltin == bBuiltin:
430+ return 0
431+ case aBuiltin:
432+ return 1
433+ default:
434+ return -1
435+ }
436+ })
437+ return names
438+}
439+
434 func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {440 func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
435 occupied := make(map[KeyBinding]Action, len(s.Global))441 occupied := make(map[KeyBinding]Action, len(s.Global))
436 maps.Copy(occupied, s.Global)442 maps.Copy(occupied, s.Global)
@@ -537,54 +543,6 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
537 }543 }
538 }544 }
539 545
540-func (s *ShortcutSet) registerStandardShortcuts() {
541- s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
542- s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
543- s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
544- s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
545- s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
546- s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
547- s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
548- s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
549- s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
550- s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
551- s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
552- s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
553- s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
554- s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
555- s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
556- s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
557- s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
558- s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
559- s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
560- s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
561- s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
562- // Session management - direct shortcut to open session manager
563- s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
564-}
565-
566-func (s *ShortcutSet) registerPaneNavigationShortcuts() {
567- s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
568- s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
569- s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
570- s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
571-
572- s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
573- s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
574- s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
575- s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
576-}
577-
578-func (s *ShortcutSet) registerTabSwitchShortcuts() {
579- // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
580- // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
581- // This is necessary because WebKitGTK's WebView consumes these key events
582- // before they reach the EventControllerKey in capture phase.
583- //
584- // Only Alt+Shift+Tab remains here as a fallback binding.
585- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
586-}
587-
588 func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {546 func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
589 log := logging.FromContext(ctx)547 log := logging.FromContext(ctx)
590 var registered, parseErrors, unknownActions int548 var registered, parseErrors, unknownActions int
@@ -695,6 +653,31 @@ var configActionToAction = map[string]Action{
695 // Session actions653 // Session actions
696 "session-manager": ActionOpenSessionManager,654 "session-manager": ActionOpenSessionManager,
697 655
656+ // Browser actions
657+ "open-omnibox": ActionOpenOmnibox,
658+ "open-find": ActionOpenFind,
659+ "find-next": ActionFindNext,
660+ "find-prev": ActionFindPrev,
661+ "reload": ActionReload,
662+ "hard-reload": ActionHardReload,
663+ "open-devtools": ActionOpenDevTools,
664+ "go-back": ActionGoBack,
665+ "go-forward": ActionGoForward,
666+ "zoom-in": ActionZoomIn,
667+ "zoom-out": ActionZoomOut,
668+ "zoom-reset": ActionZoomReset,
669+ "quit": ActionQuit,
670+ "toggle-fullscreen": ActionToggleFullscreen,
671+ "copy-url": ActionCopyURL,
672+ "print-page": ActionPrintPage,
673+
674+ // Mode activation (used by the tmux prefix)
675+ "enter-pane-mode": ActionEnterPaneMode,
676+ "enter-tab-mode": ActionEnterTabMode,
677+ "enter-resize-mode": ActionEnterResizeMode,
678+ "enter-session-mode": ActionEnterSessionMode,
679+ "enter-vim-mode": ActionEnterVimMode,
680+
698 // Vim mode scroll actions681 // Vim mode scroll actions
699 "vim_scroll_left": ActionVimScrollLeft,682 "vim_scroll_left": ActionVimScrollLeft,
700 "vim-scroll-left": ActionVimScrollLeft,683 "vim-scroll-left": ActionVimScrollLeft,
@@ -854,9 +837,9 @@ func stringToKeyval(s string) (uint, bool) {
854 return keyval, true837 return keyval, true
855 }838 }
856 839
857- // Single letter keys (a-z)840+ // Single printable ASCII keys (a-z, symbols such as %, ", &).
858- if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {841+ // GDK keyvals for printable ASCII match their character codes.
859- // ASCII lowercase a=97, which matches gdk.KEY_a842+ if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {
860 return uint(s[0]), true843 return uint(s[0]), true
861 }844 }
862 845
@@ -884,12 +867,27 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
884 modeTable = s.SessionMode867 modeTable = s.SessionMode
885 case ModeVim:868 case ModeVim:
886 modeTable = s.VimMode869 modeTable = s.VimMode
870+ case ModePrefix:
871+ modeTable = s.PrefixMode
887 }872 }
888 873
889 if modeTable != nil {874 if modeTable != nil {
890 if action, ok := modeTable[binding]; ok {875 if action, ok := modeTable[binding]; ok {
891 return action, true876 return action, true
892 }877 }
878+ // Shifted symbols (%, ", {, ...) arrive with Shift held but are
879+ // configured without it, since the symbol already implies Shift.
880+ if isShiftedSymbol(binding) {
881+ if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok {
882+ return action, true
883+ }
884+ }
885+ // International layouts deliver some symbols as dead keys.
886+ if symbol, ok := deadKeySymbols[binding.Keyval]; ok {
887+ if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok {
888+ return action, true
889+ }
890+ }
893 if mode == ModeVim {891 if mode == ModeVim {
894 return "", false892 return "", false
895 }893 }
@@ -903,6 +901,22 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
903 return "", false901 return "", false
904 }902 }
905 903
904+// deadKeySymbols maps dead keysyms to the ASCII symbol they stand for.
905+var deadKeySymbols = map[uint]uint{
906+ uint(gdk.KEY_dead_diaeresis): '"',
907+ uint(gdk.KEY_dead_acute): '\'',
908+ uint(gdk.KEY_dead_grave): '`',
909+ uint(gdk.KEY_dead_circumflex): '^',
910+ uint(gdk.KEY_dead_tilde): '~',
911+}
912+
913+// isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol.
914+func isShiftedSymbol(binding KeyBinding) bool {
915+ k := binding.Keyval
916+ isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z')
917+ return binding.Modifiers == ModShift && isSymbol
918+}
919+
906 // ShouldAutoExitMode returns true if the action should cause modal mode to exit.920 // ShouldAutoExitMode returns true if the action should cause modal mode to exit.
907 func ShouldAutoExitMode(action Action) bool {921 func ShouldAutoExitMode(action Action) bool {
908 switch action {922 switch action {
M internal/ui/mode_frame.go +5 -1
@@ -304,6 +304,8 @@ func modeFrameClass(mode input.Mode) string {
304304 return "tab-mode-active"
305305 case input.ModeSession:
306306 return "session-mode-active"
307+ case input.ModePrefix:
308+ return "prefix-mode-active"
307309 default:
308310 return ""
309311 }
@@ -622,6 +624,8 @@ func modeLegendGroup(mode input.Mode, name string) string {
622624 return "EXIT"
623625 case strings.HasPrefix(name, "split-"):
624626 return "SPLIT"
627+ case strings.HasPrefix(name, "enter-"):
628+ return "MODES"
625629 case strings.HasPrefix(name, "focus-"):
626630 return "FOCUS"
627631 case strings.HasPrefix(name, "resize-"):
@@ -630,7 +634,7 @@ func modeLegendGroup(mode input.Mode, name string) string {
630634 return "SCROLL"
631635 case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632636 return "JUMP"
633- case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):
637+ case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
634638 return "SWITCH"
635639 default:
636640 return "MANAGE"
@@ -304,6 +304,8 @@ func modeFrameClass(mode input.Mode) string {
304 return "tab-mode-active"304 return "tab-mode-active"
305 case input.ModeSession:305 case input.ModeSession:
306 return "session-mode-active"306 return "session-mode-active"
307+ case input.ModePrefix:
308+ return "prefix-mode-active"
307 default:309 default:
308 return ""310 return ""
309 }311 }
@@ -622,6 +624,8 @@ func modeLegendGroup(mode input.Mode, name string) string {
622 return "EXIT"624 return "EXIT"
623 case strings.HasPrefix(name, "split-"):625 case strings.HasPrefix(name, "split-"):
624 return "SPLIT"626 return "SPLIT"
627+ case strings.HasPrefix(name, "enter-"):
628+ return "MODES"
625 case strings.HasPrefix(name, "focus-"):629 case strings.HasPrefix(name, "focus-"):
626 return "FOCUS"630 return "FOCUS"
627 case strings.HasPrefix(name, "resize-"):631 case strings.HasPrefix(name, "resize-"):
@@ -630,7 +634,7 @@ func modeLegendGroup(mode input.Mode, name string) string {
630 return "SCROLL"634 return "SCROLL"
631 case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):635 case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632 return "JUMP"636 return "JUMP"
633- case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):637+ case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
634 return "SWITCH"638 return "SWITCH"
635 default:639 default:
636 return "MANAGE"640 return "MANAGE"
M internal/ui/mode_frame_legend.go +3 -1
@@ -25,6 +25,8 @@ func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session en
2525 return workspace.TabMode.Actions
2626 case input.ModeSession:
2727 return session.SessionMode.Actions
28+ case input.ModePrefix:
29+ return workspace.PrefixMode.Actions
2830 default:
2931 return nil
3032 }
@@ -47,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
4749 group := modeLegendGroup(f.mode, name)
4850 groups[group] = append(groups[group], name)
4951 }
50- order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"}
52+ order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"}
5153 index := 0
5254 for _, groupName := range order {
5355 names := groups[groupName]
@@ -25,6 +25,8 @@ func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session en
25 return workspace.TabMode.Actions25 return workspace.TabMode.Actions
26 case input.ModeSession:26 case input.ModeSession:
27 return session.SessionMode.Actions27 return session.SessionMode.Actions
28+ case input.ModePrefix:
29+ return workspace.PrefixMode.Actions
28 default:30 default:
29 return nil31 return nil
30 }32 }
@@ -47,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
47 group := modeLegendGroup(f.mode, name)49 group := modeLegendGroup(f.mode, name)
48 groups[group] = append(groups[group], name)50 groups[group] = append(groups[group], name)
49 }51 }
50- order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"}52+ order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"}
51 index := 053 index := 0
52 for _, groupName := range order {54 for _, groupName := range order {
53 names := groups[groupName]55 names := groups[groupName]
M internal/ui/theme/css.go +9 -0
@@ -937,6 +937,10 @@ func generatePaneCSS(p Palette) string {
937937 .mode-frame-border.resize-mode-active {
938938 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
939939 }
940+
941+.mode-frame-border.prefix-mode-active {
942+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
943+}
940944 `
941945 }
942946
@@ -1431,6 +1435,11 @@ func generateToasterCSS(p Palette) string {
14311435 background-color: var(--resize-mode-color);
14321436 color: var(--resize-mode-text);
14331437 }
1438+
1439+.toast-prefix-mode {
1440+ background-color: var(--pane-mode-color);
1441+ color: var(--pane-mode-text);
1442+}
14341443 `
14351444 }
14361445
@@ -937,6 +937,10 @@ func generatePaneCSS(p Palette) string {
937 .mode-frame-border.resize-mode-active {937 .mode-frame-border.resize-mode-active {
938 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);938 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
939 }939 }
940+
941+.mode-frame-border.prefix-mode-active {
942+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
943+}
940 `944 `
941 }945 }
942 946
@@ -1431,6 +1435,11 @@ func generateToasterCSS(p Palette) string {
1431 background-color: var(--resize-mode-color);1435 background-color: var(--resize-mode-color);
1432 color: var(--resize-mode-text);1436 color: var(--resize-mode-text);
1433 }1437 }
1438+
1439+.toast-prefix-mode {
1440+ background-color: var(--pane-mode-color);
1441+ color: var(--pane-mode-text);
1442+}
1434 `1443 `
1435 }1444 }
1436 1445
M internal/ui/theme/mode_legend_css.go +1 -0
@@ -12,6 +12,7 @@ var modeLegendColors = []struct{ mode, color, text string }{
1212 {"resize", "--resize-mode-color", "--resize-mode-text"},
1313 {"tab", "--tab-mode-color", "--tab-mode-text"},
1414 {"session", "--session-mode-color", "--session-mode-text"},
15+ {"prefix", "--pane-mode-color", "--pane-mode-text"},
1516 }
1617
1718 func generateModeLegendCSS() string {
@@ -12,6 +12,7 @@ var modeLegendColors = []struct{ mode, color, text string }{
12 {"resize", "--resize-mode-color", "--resize-mode-text"},12 {"resize", "--resize-mode-color", "--resize-mode-text"},
13 {"tab", "--tab-mode-color", "--tab-mode-text"},13 {"tab", "--tab-mode-color", "--tab-mode-text"},
14 {"session", "--session-mode-color", "--session-mode-text"},14 {"session", "--session-mode-color", "--session-mode-text"},
15+ {"prefix", "--pane-mode-color", "--pane-mode-text"},
15 }16 }
16 17
17 func generateModeLegendCSS() string {18 func generateModeLegendCSS() string {

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 8

Tags 42

↑↓ moveenter openesc close