Skip to content

Commit 0929fa0

Browse files
authored
refactor(tui): keybinding precedence - promote hardcoded keys to actions (#333)
## Summary - Refactors TUI key dispatch so the configurable `KeybindingResolver` is the single source of truth for overridable keys, eliminating the silent-shadow bug where hardcoded `switch keyStr` blocks short-circuited user-configured bindings. - Promotes every previously-hardcoded overridable key (`q`, `?`, `g`, `v`, up/k, down/j, `/`, `:`, `G`) into a proper `action.Type` registered as a default `UserCommand` and bound via `defaultViewsConfig`. Layer-1 system keys (`ctrl+c`, `esc`, `tab`, `shift+tab`) and modal-dismissal keys remain hardcoded by design. - Adds a model-level global-scope-only fallback so a view-scoped binding for `q`/`?` cannot leak into the global `Quit`/`ShowHelp` handler. ## Phases 1. `0393dfd` Phase 1 — Action enums (`Sessions*`, `GoToTop`/`GoToBottom`, `Quit`, `ShowHelp`), default `UserCommand` registrations, `handleGlobalAction` extension. 2. `44fb686` Phase 2 — Sessions view: register defaults, delete hardcoded blocks, wire view-internal `IsAction` dispatch. 3. `e6d082e` Phase 3 — Global `q`/`?` migration with `commandFor` helper and global-scope-only model fallback. 4. `9df2818` Phase 4 — Tasks tree-pane + review view `g`/`G` migration (detail-pane viewport navigation left hardcoded). 5. `4c7377c` Phase 5 — Precedence regression tests including `TestPromotedKeysAreOverridable` driving `Model.Update` end-to-end (resolver-only tests would have been GREEN on the original bug). 6. `2cd968f` Phase 6 — `AGENTS.md` precedence rule + help-text regression test. 7. `9b8670f` Post-review polish — godocs, why-comments, additional coverage (review-focus `?` carve-out, default-binding e2e, modal `q`-under-override, out-of-scope-binding case). ## Test plan - [ ] `mise run test` — full suite passes - [ ] `env GOTOOLCHAIN=go1.26.2 golangci-lint run ./internal/core/config ./internal/tui ./internal/tui/views/tasks ./internal/tui/views/review` — scoped lint clean - [ ] Manually verify default keybindings still behave: in TUI, `q` quits, `?` opens help, `g`/`v` work in sessions, `g`/`G` jump in tasks/review, `/` enters filter mode, `:` opens command palette. - [ ] Manually verify user override: configure a custom `g` binding in sessions scope and confirm it takes precedence over `SessionsRefreshGitStatuses`. - [ ] Manually verify modal dismissal: open help modal, press `q` (or `esc`) — closes regardless of user binding.
1 parent 1dd9acd commit 0929fa0

14 files changed

Lines changed: 948 additions & 146 deletions

File tree

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ Available variables vary by context - see `internal/core/config/validate.go` for
198198

199199
Never silently discard errors. If an error cannot be presented to the user (e.g., in background polling, cache refresh, or TUI status fetching), log it at an appropriate level (`debug` for expected/transient failures, `warn` for configuration problems). Prefer degraded behavior with logging over silent fallbacks — for example, show a `StatusMissing` indicator instead of dropping an item from the UI.
200200

201+
### Keybinding Precedence
202+
203+
The TUI dispatches keystrokes through three layers, in this order:
204+
205+
1. **Layer 1: hardcoded, non-overridable** - `ctrl+c`, `esc`, `tab`, and `shift+tab` in normal-mode handling, plus modal-lifecycle dismissal keys (`esc` / `q`) inside dialogs and modals.
206+
2. **Layer 2: configurable via `KeybindingResolver`** - every other user-overridable key. Default bindings live in `defaultViewsConfig` (`internal/core/config/config_views.go`), and user config in `cfg.Views.{Global,Sessions,Tasks,Review}.Keybindings` overrides those defaults because `maps.Copy(merged, user)` overlays user values onto the merged map.
207+
3. **Layer 3: bubbles list internals** - the underlying list component claims keys like `g`, `G`, `j`, `k`, `h`, `l`, `u`, `d`, `f`, `b`, `/`, `?`, `q`, and `esc`. This is intentionally out of scope for hive keybinding configuration: the resolver consumes configured bindings before the list sees them.
208+
209+
When adding a new overridable key, do not add a new `if keyStr == "X"` block in view code. Register an `action.Type` in `internal/core/action/type.go`, add a default `UserCommand` in `defaultUserCommands` (`internal/core/config/config.go`), bind it in `defaultViewsConfig`, and dispatch it from `internal/tui/model_handlers.go`.
210+
201211
### Session States
202212

203213
```

internal/core/action/type.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ package action
4747
// DocsTogglePreview
4848
// DocsToggleTree
4949
// DocsSelectRepo
50+
// SessionsRefreshGitStatuses
51+
// SessionsTogglePreview
52+
// SessionsNavigateUp
53+
// SessionsNavigateDown
54+
// SessionsFilterStart
55+
// SessionsCommandPaletteOpen
56+
// GoToTop
57+
// GoToBottom
58+
// Quit
59+
// ShowHelp
5060
//
5161
// )
5262
type Type string

internal/core/action/type_enum.go

Lines changed: 136 additions & 86 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/core/config/config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,64 @@ var defaultUserCommands = map[string]UserCommand{
303303
Silent: true,
304304
Scope: []string{"review"},
305305
},
306+
"SessionsRefreshGitStatuses": {
307+
Action: action.TypeSessionsRefreshGitStatuses,
308+
Help: "refresh git status",
309+
Silent: true,
310+
Scope: []string{"sessions"},
311+
},
312+
"SessionsTogglePreview": {
313+
Action: action.TypeSessionsTogglePreview,
314+
Help: "toggle preview pane",
315+
Silent: true,
316+
Scope: []string{"sessions"},
317+
},
318+
"SessionsNavigateUp": {
319+
Action: action.TypeSessionsNavigateUp,
320+
Help: "move selection up",
321+
Silent: true,
322+
Scope: []string{"sessions"},
323+
},
324+
"SessionsNavigateDown": {
325+
Action: action.TypeSessionsNavigateDown,
326+
Help: "move selection down",
327+
Silent: true,
328+
Scope: []string{"sessions"},
329+
},
330+
"SessionsFilterStart": {
331+
Action: action.TypeSessionsFilterStart,
332+
Help: "start filter",
333+
Silent: true,
334+
Scope: []string{"sessions"},
335+
},
336+
"SessionsCommandPaletteOpen": {
337+
Action: action.TypeSessionsCommandPaletteOpen,
338+
Help: "open command palette",
339+
Silent: true,
340+
Scope: []string{"sessions"},
341+
},
342+
"GoToTop": {
343+
Action: action.TypeGoToTop,
344+
Help: "jump to top",
345+
Silent: true,
346+
Scope: []string{"tasks", "review"},
347+
},
348+
"GoToBottom": {
349+
Action: action.TypeGoToBottom,
350+
Help: "jump to bottom",
351+
Silent: true,
352+
Scope: []string{"tasks", "review"},
353+
},
354+
"Quit": {
355+
Action: action.TypeQuit,
356+
Help: "quit",
357+
Silent: true,
358+
},
359+
"ShowHelp": {
360+
Action: action.TypeShowHelp,
361+
Help: "show help",
362+
Silent: true,
363+
},
306364
"SendBatch": {
307365
Sh: `{{ range .Form.targets }}
308366
{{ agentSend }} {{ .Name | shq }}:claude {{ $.Form.message | shq }}

internal/core/config/config_views.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,24 @@ func (m MessagesViewConfig) SplitRatioOrDefault(defaultPct int) int {
8585
// defaultViewsConfig provides built-in per-view keybindings that users can override.
8686
var defaultViewsConfig = ViewsConfig{
8787
Global: GlobalViewConfig{
88-
Keybindings: map[string]Keybinding{},
88+
Keybindings: map[string]Keybinding{
89+
"q": {Cmd: "Quit"},
90+
"?": {Cmd: "ShowHelp"},
91+
},
8992
},
9093
Sessions: SessionsViewConfig{
9194
Keybindings: map[string]Keybinding{
9295
"r": {Cmd: "Recycle"},
9396
"d": {Cmd: "Delete"},
9497
"n": {Cmd: "NewSession"},
98+
"g": {Cmd: "SessionsRefreshGitStatuses"},
99+
"v": {Cmd: "SessionsTogglePreview"},
100+
"up": {Cmd: "SessionsNavigateUp"},
101+
"k": {Cmd: "SessionsNavigateUp"},
102+
"down": {Cmd: "SessionsNavigateDown"},
103+
"j": {Cmd: "SessionsNavigateDown"},
104+
"/": {Cmd: "SessionsFilterStart"},
105+
":": {Cmd: "SessionsCommandPaletteOpen"},
95106
"enter": {Cmd: "TmuxOpen"},
96107
"ctrl+d": {Cmd: "TmuxKill"},
97108
"A": {Cmd: "AgentSend"},
@@ -108,6 +119,8 @@ var defaultViewsConfig = ViewsConfig{
108119
Keybindings: map[string]Keybinding{
109120
"r": {Cmd: "TasksRefresh"},
110121
"f": {Cmd: "TasksFilter"},
122+
"g": {Cmd: "GoToTop"},
123+
"G": {Cmd: "GoToBottom"},
111124
"y": {Cmd: "TasksCopyID"},
112125
"v": {Cmd: "TasksTogglePreview"},
113126
"s": {Cmd: "TasksSelectRepo"},
@@ -127,6 +140,8 @@ var defaultViewsConfig = ViewsConfig{
127140
"o": {Cmd: "DocsOpen"},
128141
"v": {Cmd: "DocsTogglePreview"},
129142
"r": {Cmd: "DocsSelectRepo"},
143+
"g": {Cmd: "GoToTop"},
144+
"G": {Cmd: "GoToBottom"},
130145
},
131146
},
132147
}

0 commit comments

Comments
 (0)