feat(keymap): user-definable keybindings, with squash-selected-commits - #62
Merged
noahbclarkson merged 16 commits intoAug 3, 2026
Merged
Conversation
This was referenced Jul 31, 2026
Owner
|
Thanks for the PR @adehad! Just reviewed these. This is a ngood use of macros and a nicely ergonomic API. This will need to be merged last as it sits on top I believe. Appreciate the work man, been meaning to rewrite this for a while. |
`CommandId` was a hand-written 56-variant enum with a parallel `as_str` match, a reverse parser, and default keystrokes scattered through `key_handler.rs` — four places to keep in sync, with no compiler help. Introduce `commands!`, a `macro_rules!` macro that takes one declaration per command and emits all of it: the `CommandId` variant, a gpui `Action` unit struct in the declared namespace, the canonical `namespace::Name` keymap name, the default keystroke(s), the key context, the palette availability predicate, palette visibility, and the doc comment reused as a description. `ALL_COMMANDS` exposes the whole set so binding, validation, docs and the shortcuts UI can be driven from it. `as_str()` is derived from the variant name by a const PascalCase to snake_case conversion, and a test pins all 56 historical strings so the values persisted in settings cannot drift. Adds `CommandPalette`, `NextTab`, `PrevTab` and `CloseTab`, which were inline `key_handler.rs` branches with no `CommandId`, so they can be rebound like everything else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read a Zed-shaped `keymap.json` from rgitui's config directory: an array
of `{ context, bindings }` sections, JSONC so it can carry comments and
trailing commas (via `serde_json_lenient`, the crate Zed uses for the
same job). `null` unbinds through gpui's `NoAction`, and an action may
carry JSON input as `[name, input]`.
Defaults are applied first and the user's file last, so gpui's later-wins
ordering makes user bindings take precedence. Saving the file reloads the
whole keymap: a `notify` watcher on the config directory debounces for
200ms, then clears and rebuilds every binding.
Errors accumulate rather than aborting: an unparseable context predicate
skips one section, an unknown action name or malformed keystroke skips
one binding, and everything else still loads. Problems land in the
`KeymapState` global, which the workspace observes so they surface as
toasts — for reloads as well as startup.
Conflict detection does not rely on gpui silently dropping the loser. It
reports two classes: the same keystroke in overlapping contexts (equal
predicates, an absent predicate, or one a superset of the other per
`KeyBindingContextPredicate::is_superset`), and a binding shadowing the
prefix of a longer chord. Overlaps resolve later-wins; a chord prefix
always loses so the chord stays reachable. A user binding replacing a
default is the feature, so it is exempt, as are explicit unbinds. The
detection is a pure function over `(keystrokes, context, action)`
triples, unit-tested without a display.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`docs/KEYBINDINGS.md` gives users a table per view of keystroke, action name and description, plus how to write `keymap.json`. `docs/keymap.schema.json` enumerates every action name with its description, so an editor pointed at it completes and validates the file. Both are rendered from `ALL_COMMANDS`, so neither can drift from the `commands!` declaration, and both are golden-tested: the tests regenerate the content and fail with a line diff when the committed file is stale. `RGITUI_BLESS=1 cargo test -p rgitui_workspace keymap::generate` rewrites them. The keymap's root is a JSON array, so there is nowhere to put a `$schema` key; the schema has to be associated by file name in the editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bind the workspace's global shortcuts through gpui actions instead of the `if key == ... && modifiers...` chain in `key_handler.rs`, so `keymap.json` can rebind every one of them. The workspace root gains a `Workspace` key context and one macro-generated `on_action` handler per command, all funnelling into `dispatch_command`. Handlers land on the root, which is in the dispatch path of whatever child holds focus, so the shortcuts keep working from any panel. `!modal` replaces the `any_overlay_active` gate for these bindings. The root adds `modal` to its own key context while an overlay is open, rather than relying on the overlays' contexts — a dialog that takes no focus would otherwise not appear in the focused element's context chain, and the gate has to hold in that case too. `TextInput` now sets a key context so `!TextInput` can exclude text fields. The `?` shortcut uses it, which also fixes typing `?` into a text field both inserting the character and opening the shortcuts help. `dispatch_command` handles the five commands that need a `Window` — toggling the palette, settings, repo opener and shortcuts help all save focus first, and switching branches focuses the sidebar — preserving what `key_handler.rs` did rather than what `execute_command` does for the same `CommandId` from the palette. Removed from `key_handler.rs`: theme editor, fetch, AI message, palette, settings, refresh, repo opener, shortcuts help, stage/unstage all, create and switch branch, commit, stash save/pop, tab next/prev/close, workspace home, and the Alt+5-8 panel toggles. Its Esc cascade, `any_overlay_active` gate, graph and global search toggles, j/k/d/b/y/C/h bare keys, panel resize and Alt+1-4 panel focus stay for phase B. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase A moved the 25 global shortcuts off `Workspace::handle_key_down`. This does the same for the list and panel views, and gives `commands!` the one thing the view blocks needed to express themselves. The macro now lets a single command bind keystrokes in *different* key contexts (`SelectNext ["down", "j" in "List && !TextInput"]`). That is not a convenience: gpui evaluates `!TextInput` false whenever a text field is anywhere on the focus path, so a vim-style letter must carry it while its arrow-key twin must not — otherwise `down` would stop moving the command palette's selection the moment the query field took focus. `CommandMeta` therefore carries `default_bindings: &[(keystrokes, context)]` in place of the old keystroke list plus single context, and `docs/KEYBINDINGS.md` grows a Context column. Six shared `menu` commands — Cancel, Confirm and the four selection moves — are bound once against a `List` key context that every view owning a row selection now sets alongside its own name. gpui dispatches an action outwards from the focused element and the first listener consumes it, so the panel that owns the selection is the one that moves; a view that does not want a command calls `cx.propagate()` and it carries on outwards, ending at the focused text field. That is what lets Enter mean "activate this row" in the sidebar and "submit" in a picker with one binding, and it removes the `is_searching` / `panel_has_focus` guards the old handlers needed. `GraphView` and `DiffViewer` live in crates that cannot depend on `rgitui_workspace` and so cannot name the generated actions. Rather than invert the dependency, their commands are declared here in the `graph` and `diff` namespaces and handled on the workspace root, which is an ancestor of both on every dispatch path; the views themselves only declare their key context, and the bindings still fire only while those panels hold focus. They keep their own navigation commands rather than joining the `List` group, because a shared `menu` command has to be handled by the element that owns the selection and neither of them can handle anything. Behaviour changes a user will notice: * The duplicate global bindings for `d`, `b`, `h` and `y` are gone, along with the `!panel_has_focus` gate on `j`/`k`. Those keys now act only on the panel that has focus — `y`/`shift-c` in the graph, `d` in the diff viewer or to leave blame, `h`/`b` to cross between blame and file history. `rgitui::Blame` and `rgitui::FileHistory` are reachable from the command palette instead of from a bare letter anywhere in the window. * Space now activates the selected sidebar row. The old handler matched `" "`, but gpui names that key `space`, so it never fired. * `secondary-f`, `/`, `secondary-shift-f`, `alt-1`–`alt-4`, `tab`, `secondary-[`/`]` and `secondary-up`/`down` are declared commands and so are rebindable from `keymap.json` like everything else. * `CommandId::display_label` is derived from the command id rather than a hand-written table, so a new command cannot be added without one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The workspace's key handler owned a ten-step `if key == "escape" && ... is_visible()` chain that had to be extended, in the right order, for every new overlay. It also duplicated the Esc handling each dialog already had, which only ever mattered when a dialog was open without focus. Now each overlay, dialog and window root declares a key context and listens for the shared `menu::Cancel`. gpui dispatches the action outwards from the focused element and the first listener consumes it, so the innermost thing that can be dismissed is the one that goes — the ordering falls out of the element tree instead of being maintained by hand. Blame and file history keep their own `escape` bindings, because there Esc is a navigation back to the diff rather than a dismissal, and a deeper context wins over `Workspace`. Thirteen of these had no key context at all; they all have one now, which is also what makes them addressable from `keymap.json`. Enter is handled the same way. Dialogs whose Enter already went through a text field's `Submit` event propagate `menu::Confirm` so it still fires exactly once; the confirm dialog and the theme editor, which have no such field to submit, listen for it directly. The create-PR dialog keeps `shift-enter` as its own command so plain Enter still inserts a newline in the multi-line body. The settings window gets a `SettingsWindow` context and its own Cancel listener: each window owns its dismissal and Esc never crosses windows. The shortcut reference is corrected while it is open: it advertised Ctrl+Shift+F as Fetch (that is now the working-tree search; Fetch is Ctrl+Shift+R) and listed `b`, `h` and `d` as window-wide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two views hand-rolled a text editor inside the same key handler that owned their command shortcuts, and both had the bug that arrangement invites. The detail panel's changed-files filter consumed every printable character in the handler that also owned `j`/`k`/`v`/`[`/`]`, and rendered the query as a `Label` with no cursor. The interactive rebase's reword mode kept `(index, message, cursor)` in a tuple, painted its own cursor out of three `Label`s, and swallowed the very `p`/`r`/`s`/`f`/`d` letters that are commands in normal mode. Both now use the shared `TextInput`, which already handles printable characters, backspace, delete, arrow keys, word motion, selection and clipboard — and which sets the `TextInput` key context. That last part is the point: the bare-letter commands are scoped `!TextInput`, which gpui evaluates false whenever a text field is anywhere on the focus path, so there is no longer an "am I typing?" branch to keep correct. It is the problem class that goes away, not just the bug. Reword state shrinks to `Option<usize>` plus the shared field, so the index bookkeeping in move-up/move-down and drag reorder no longer has to carry a message and a cursor along with it. Esc while rewording leaves reword mode and returns focus to the list before it means "dismiss the dialog", and Enter commits the message before it means "run the rebase". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The graph could only ever hold one selected commit, which rules out every operation over a range. Give it an arbitrary set instead: a set of commit indices plus the anchor a range extension grows from. Members are commit indices rather than list indices, so a virtual worktree row can never join the selection and inserting or removing one leaves it alone — the row ↔ commit mapping is now two pure functions over the sorted worktree rows, unit-tested against real `compute_worktree_row_positions` output. Reloading the commit list remaps the selection by OID, as the single selection already did. Gestures: shift-click extends from the anchor, secondary-click toggles one row, and `shift-j`/`shift-k`/`shift-down`/`shift-up` extend by one commit, stepping over worktree rows. Plain clicks and plain `j`/`k` still collapse onto one commit and re-anchor there, so single selection behaves exactly as before. `CommitSelected` is emitted only while exactly one commit is selected, so growing a selection does not fire a diff computation per row; the new `SelectionChanged` event carries the rest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`secondary-shift-s` on a multi-commit graph selection now pre-fills the interactive rebase dialog with a plan that melds the selection together. Nothing is executed here: the user reviews the plan, can adjust it, and confirms through the dialog's existing `Execute` path. `rebase_interactive` accepts a plan only when its commit set is exactly HEAD's contiguous first-parent range `base..HEAD` — it recomputes that range from HEAD rather than trusting the plan, so a cross-branch plan cannot replay commits from an unrelated branch. The graph, meanwhile, lists every ref in date order, so a run of adjacent rows can easily straddle two branches. `squash::plan_squash` therefore validates first and each rejection carries an actionable message: too few commits, off HEAD's first-parent chain, a gap in the run, a merge inside the range that would be rewritten, or a range that reaches the root commit. The plan covers HEAD down to the oldest selected commit. That commit stays a `pick` and the rest of the selection becomes `squash`, because git melds a `squash` into the todo line above it; anything newer is replayed unchanged. It is all pure, so the rules are tested without a display or a fixture repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three hand-written lists claimed to document the keybindings — the shortcut help, the command palette hints and the settings quick reference — and one had already drifted: it advertised Ctrl+Shift+F for Fetch, which the registry binds to Ctrl+Shift+R. Every shortcut the UI shows is now derived from the bindings that were actually loaded: * `keymap::display` humanises a keystroke — `secondary-shift-r` becomes `Ctrl+Shift+R`, or `⌘⇧R` on macOS. Parsing is `gpui::Keystroke::parse`, so `secondary` and the shift/uppercase normalisation cannot disagree with what gpui matches; only the spelling is ours, since gpui's own `Display` renders the keymap syntax rather than a label. The style is a parameter rather than a `cfg!`, so both spellings are unit-testable anywhere. * `keymap::summary` records what the load produced, per command: the bindings that will fire, where each came from, and anything that went wrong. It is built from the very specs handed to `bind_keys`, so it agrees with the live keymap by construction, and it is pure. It also resolves shadowing, which `Keymap::bindings_for_action` cannot: after a user binds Ctrl+S to Commit, StageAll reads as unbound instead of advertising a keystroke that can no longer reach it. * the summary is published on `KeymapState` behind an `Arc`, so any render can read it, and it is rebuilt on every `keymap.json` reload. The shortcut help now groups by `commands!` view block — which is also the key context each group is scoped to — labels every row with the command's doc comment, and lists unbound commands last rather than hiding them, so the palette-only commands stay discoverable. The palette hints, the settings quick reference, the toolbar tooltips, the title bar and the home screen all read the same accessor, as does the branch-switch toast; the sidebar's discard tooltip stops quoting Ctrl+Z, which never discarded anything. `no_surface_hardcodes_a_chord` scans those files and fails the build if one spells a keystroke out again, so the drift becomes impossible rather than merely fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A binding the user wrote and a binding rgitui shipped looked identical in the reference, and a binding that was dropped as ambiguous looked like it worked. The conflict report only reached a startup toast, which is gone by the time anyone wonders why their key does nothing. The summary already carries provenance and per-command warnings, so the help panel now shows both: * a `keymap.json` badge and a tinted keystroke border on any row whose binding came from the user's file, mirrored as a tinted hint in the command palette and a marker in the settings quick reference; * a warning row per problem, naming which binding was ignored and why — the ambiguous-keystroke and chord-prefix messages from the load, plus the keystroke a command lost to another command or to an unbind; * a header line counting the user's bindings and the problems, so a keymap with a mistake in it says so before anything is scrolled. Opening the reference is now how a user finds out which of their bindings was ignored, rather than reading the log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shortcut reference told the user every binding was rebindable but not where to do it, and `keymap.json` does not exist until someone creates one — so the first step was to find an undocumented path in the config directory and guess the format. `rgitui::OpenKeymap` opens it: a header button in the shortcut reference, a button in the settings General section and a command palette entry all call `Workspace::open_keymap_file`, which creates the file when absent and hands it to the editor configured in settings — the same `open_editor` path the repository context menu uses, so no new launch mechanism. A failure to create the file is toasted with the path, which is also the button's tooltip, so the location is recoverable either way. The file is created from `keymap_stub()` rather than left empty: a commented starter naming the schema URL and, for its examples, a real action at its real default keystroke pulled from the registry, so it cannot advertise something that does not exist. `the_starter_keymap_parses_and_binds_nothing` holds it to loading cleanly and changing no binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`graph::SquashSelected` defaulted to `secondary-shift-s`, which the graph dispatches at a deeper context than the workspace root: while the graph held focus that keystroke reached squash and never reached `rgitui::UnstageAll`, silently costing it one of its two bindings. Bare `s` matches the interactive rebase editor, where `s` already means squash, and the convention that an unmodified letter acts on the focused panel. It stays scoped `GraphView && !TextInput`, so it stands down for the graph's own search field, and it collides with nothing else the graph can reach: the other `s` bindings belong to the diff viewer, the sidebar and the rebase editor, none of which is on the graph's dispatch path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Squashing a multi-selection was only reachable by knowing `s`. Add a "Squash selected commits" row to the graph's right-click menu, emitting `GraphViewEvent::SquashSelected`, which the workspace routes to the same `squash_selected_commits` the keystroke uses — so both paths validate through `plan_squash` and refuse a bad selection in identical words. The row appears only once two commits are selected, since below that it could never succeed. The remaining rules — a contiguous run on HEAD's own first-parent chain, no merge in the way — stay in the workspace, which is where `plan_squash` lives; `rgitui_graph` sits below it and cannot call it. Adding the row meant the menu's `CONTEXT_MENU_ITEM_COUNT` needed bumping, a constant that has to be kept in step by hand with a list expressed as a `match` on row indices. Both are now derived: the rows are a table of `GraphMenuItem`, the height counts what that table yields for the current selection, and each row carries its own separator and destructive flags instead of the render loop testing indices. That also puts the last separator where its comment always said it went — above the clipboard group rather than one row into it — and drops ~200 lines of per-index click handlers in favour of one `menu_event` mapping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`conflict.rs` compares two bindings' context predicates for textual or `is_superset` overlap, which cannot see that `GraphView && !TextInput` sits *inside* `Workspace && !modal` at dispatch time. A panel binding taking a keystroke away from a global one therefore went unreported — exactly the case that had squash silently costing `rgitui::UnstageAll` its `secondary-shift-s`. It is reported now, at a severity below a conflict. Deeper-wins scoping is usually the point (the shipped defaults have seven such overlaps: Esc means "back to the diff" in the blame and history views, `/` filters the focused list rather than searching the graph), so nothing is dropped, and an info note never becomes a load-time toast or a startup WARN — it appears on the affected row in the shortcut reference and in a new table in docs/KEYBINDINGS.md. `CONTEXT_TREE` in the registry says which element each key context is set on and what encloses it. It is hand-written, because the `key_context` calls it mirrors are spread over three crates and only run at render time, but `every_registry_context_is_in_the_tree` fails when a new `commands!` block names a context nobody placed in it, so a view cannot escape the analysis unnoticed. `shadow.rs` walks the tree into focus paths and asks gpui's own `depth_of` which binding wins where, so the verdict cannot drift from what pressing the key actually does — and the `modal` flag means an overlay is not reported as shadowing a global binding that `!modal` had already stood down. `summary.rs` carries the finding on its existing per-command note list, now tagged with a `NoteSeverity`, rather than through a second channel; `warning_count` still counts warnings only, so the defaults do not tell every user their keymap has problems. While regenerating the reference: say where `keymap.json` actually lives per platform, and that the app will create and open it for you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s take Esc Three fixes on top of the keymap work, plus the rebase onto the corrected noahbclarkson#60. The workspace observes KeymapState and reported problems by taking them, but taking goes through update_global, which notifies global observers again -- including the observer doing the taking. gpui clears the notification dedup flag before running observers, so the re-notify always lands and the cycle never ends: the UI thread spins at 100% CPU on startup and on every keymap.json save, which is the reload this feature exists for. It now reads before writing and returns when there is nothing left to report. KeymapState::has_problems names the invariant and a unit test pins it, since the convergence rests entirely on take_problems draining the state. menu::Cancel dispatches along the focus path, so the three dialogs that never focused could not be dismissed with Esc once the old visibility-based Esc cascade was removed. Worst hit was this PR's own squash flow: opening the rebase editor from the graph left focus on the graph, where Esc resolved to graph::GraphCancel and did nothing, and `s` reopened the dialog instead of choosing squash. All three now take focus on the next render, matching the seven dialogs that already did. View-scoped bindings also stayed live under an open modal -- only the Workspace block carried !modal -- so j/k/y/s/x kept driving the panel behind a dialog. The six panel contexts now carry it too. Tab switching and go-home stay on Control, carried over from noahbclarkson#60: macOS reserves Cmd+Tab and Cmd+H, so the registry's secondary- bindings would never have fired there. Ports the noahbclarkson#65 provenance guards onto the new action methods, which the rebase would otherwise have reverted to the is_staged check, making a hunk staged from a historical commit edit the working tree again. Those tests move from simulated keystrokes to the action entry points, since the bindings now live in rgitui_workspace, which rgitui_diff cannot reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
noahbclarkson
force-pushed
the
feat/keybindings-polish
branch
from
August 3, 2026 09:00
0b8d122 to
400fc50
Compare
Contributor
Author
|
No problem! I hope you don't mind if some more AI merge requests come your way.... I may also suggest looking in to adding https://www.greptile.com/open-source and/or https://www.coderabbit.ai/ (also free for OSS) - I've found them quite helpful in catching issues. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63.
Makes every keyboard shortcut user-definable, and adds squash-selected-commits as the first feature built on top of it.
I noticed #47 is a draft heading for the same goal, so this is offered as an alternative rather than a competing effort — see "Relationship to #47" at the end. I'm happy to close this if you'd rather take that route, or to split it up.
Stacked on #60 (the
Modifiers::secondary()fix), since the defaults here are expressed withsecondary-.What a user gets
keymap.jsonbesidesettings.json— JSONC, hot-reloaded on save. Zed-shaped sections so gpui's own loader consumes it directly:[ { "context": "GraphView", "bindings": { "s": "graph::SquashSelected", "y": null // remove a default }} ]?panel is generated from the live keymap, so it shows what your keymap.json actually produced — user bindings badged[keymap.json], conflicts explained inline, and a header summary (bound 85 / 119 commands, 3 user bindings, 2 warnings).s(or the context menu) to pre-fill the interactive-rebase dialog with the squash plan. Nothing executes until you confirm in the dialog.docs/KEYBINDINGS.mdanddocs/keymap.schema.jsonare generated and golden-tested; point your editor at the schema for action-name completion.How it works
A
commands!macro is the single source of truth. One declaration per command generates theCommandIdvariant, the gpui action, the keymap name, the context, the default keystroke, the availability predicate and palette membership — and, for the set as a whole, the bindings, the docs page, the JSON schema and the shortcuts UI:All 36 hand-rolled
on_key_downhandlers are gone.key_handler.rsgoes from 615 lines to 36 (onlyany_overlay_activesurvives, driving themodalkey context), which closes its ownTODO(audit): QUAL-10. The ten-branch Esc cascade is onemenu::Cancelaction resolved by gpui's context depth, so dismissal order now falls out of the element tree. Blame and file history keep a distinctSwitchToDiff, since their Esc navigates rather than dismisses.rgitui_graphandrgitui_diffdon't gain a dependency: their commands are declared in the workspace registry undergraph::/diff::and handled on the workspace root, with the views contributing only.key_context(...).Bugs found and fixed on the way
spacenever activated a sidebar row — the old handler matched" ", but gpui names that keyspace.Ctrl+Shift+Ffor Fetch; the real binding isCtrl+Shift+R.Ctrl+Z, which never discarded anything.?into any text field both inserted the character and opened the help.gbut nohome/end.Behaviour changes worth reviewing
d/b/h/yand the!panel_has_focusgate onj/kare gone. Those letters now act only on the focused panel;BlameandFileHistoryremain in the palette. This is the point of context scoping, but it is user-visible.?no longer opens help while a text field has focus (that's what stops it being swallowed into the field).secondary-,andsecondary-oare now shift-sensitive; previouslyCtrl+Shift+,also opened settings.Tests
1100 passing (up from 975 on this base), all headless — no display required.
The two invariants I'd point a reviewer at:
every typeable binding carries !TextInput— gpui dispatches keymap bindings beforeon_key_down, so an unscoped bare-letter binding silently eats typing. This test makes that class of bug unrepresentable.no_surface_hardcodes_a_chordscans the user-facing files for any literalCtrl+/Cmd+/⌘and fails with file and line. A companion test plants a violation to prove the scanner can't rot into a no-op. That's what makes the Fetch drift above impossible rather than merely fixed.Plus: the shipped defaults parse and contain zero conflicts; each conflict class is detected;
nullunbinds; unknown action names are reported without aborting the load; the squash planner is a pure function tested for gaps, cross-branch selections, merges in range and root commits.Ambiguous letters are covered too —
d,s,p,b,heach mean different things in different views, and there's a test that each resolves to exactly one action per context.A note on shadowing
Because a view context dispatches deeper than
Workspace, a view binding can mask a global one. That's usually intentional, so it is reported as info, not as a conflict, and only in the?panel — never as a startup warning. The shipped defaults produce seven such notes, all deliberate (escapein the graph/blame/history views,/andsecondary-fin the detail panel and sidebar). Detection asks gpui's ownKeyBindingContextPredicate::depth_ofrather than re-implementing predicate logic.Relationship to #47
Same goal, and #47's
CommandId::as_str()snake_case ids are preserved here, so anything persisted under those names still resolves. The difference is structural, so the two can't easily be combined — this replaceskey_handler.rs, which #47 extends:CommandIdenum,COMMAND_IDS, and the dispatch array inkey_handler.rs) with nothing keeping them in sync; the macro makes that state unrepresentable.controlandplatformfor exact equality, so a"Ctrl+S"entry can't fire on macOS — the same root cause as fix: use the platform's primary modifier for shortcuts #60. Here,secondary-resolves per platform.ParsedShortcut::parseends in_ => {}, so"Ctrl+Shft+R"silently parses as Ctrl+R, and an unknown key name never fires. Here, unknown names and unparseable keystrokes are reported, and the generated schema catches them while you type.j/k,y,dand the rebase dialog's letters stay fixed.shortcuts_help.rs, so help still shows defaults once you customise.settings.json, which the settings writer thread rewrites, so hand edits are at risk and comments aren't possible.Happy to rework whichever parts you'd prefer done differently.
Verification
Verified on Windows 11 with the pinned 1.94.1 toolchain. The macOS path is verified by construction rather than on hardware — a second pair of eyes there would be welcome. The keymap error paths were exercised end to end by loading a deliberately broken
keymap.json(bad context predicate, chord shadowing, duplicate keystroke, unknown action) and confirming each produced its own warning while valid entries still applied.🤖 Generated with Claude Code