Skip to content

feat(keymap): user-definable keybindings, with squash-selected-commits - #62

Merged
noahbclarkson merged 16 commits into
noahbclarkson:mainfrom
adehad:feat/keybindings-polish
Aug 3, 2026
Merged

feat(keymap): user-definable keybindings, with squash-selected-commits#62
noahbclarkson merged 16 commits into
noahbclarkson:mainfrom
adehad:feat/keybindings-polish

Conversation

@adehad

@adehad adehad commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 with secondary-.

What a user gets

  • keymap.json beside settings.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
      }}
    ]
  • Conflicts are reported, never silent. Two bindings on one keystroke in overlapping contexts, or a prefix binding making a chord unreachable, produce a named warning: later wins, the ignored one is called out, and for a chord the prefix loses so the chord stays reachable. One bad entry never discards the rest of the file.
  • The ? 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).
  • Squash selected commits: shift-click or ctrl-click in the graph to select a run of commits, then 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.md and docs/keymap.schema.json are 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 the CommandId variant, 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:

commands! {
    view GraphView in graph context "GraphView && !TextInput" {
        /// Squash the selected commits into one.
        SquashSelected "s" if has_multi_commit_selection [hidden];
    }
}

All 36 hand-rolled on_key_down handlers are gone. key_handler.rs goes from 615 lines to 36 (only any_overlay_active survives, driving the modal key context), which closes its own TODO(audit): QUAL-10. The ten-branch Esc cascade is one menu::Cancel action resolved by gpui's context depth, so dismissal order now falls out of the element tree. Blame and file history keep a distinct SwitchToDiff, since their Esc navigates rather than dismisses.

rgitui_graph and rgitui_diff don't gain a dependency: their commands are declared in the workspace registry under graph::/diff:: and handled on the workspace root, with the views contributing only .key_context(...).

Bugs found and fixed on the way

  • space never activated a sidebar row — the old handler matched " ", but gpui names that key space.
  • The help panel advertised Ctrl+Shift+F for Fetch; the real binding is Ctrl+Shift+R.
  • The sidebar's discard tooltip said Ctrl+Z, which never discarded anything.
  • Typing ? into any text field both inserted the character and opened the help.
  • In the search/issues/PR panels, Enter opened the highlighted row and submitted the query.
  • Several lists had g but no home/end.
  • Fetch/pull/push toolbar buttons had no shortcut tooltips at all.

Behaviour changes worth reviewing

  • Bare d/b/h/y and the !panel_has_focus gate on j/k are gone. Those letters now act only on the focused panel; Blame and FileHistory remain 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-, and secondary-o are now shift-sensitive; previously Ctrl+Shift+, also opened settings.
  • The graph context menu's last separator moved from above "Copy date" to above "Copy SHA" — the old comment said "before clipboard ops" but the hardcoded index disagreed. Easy to put back.

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 before on_key_down, so an unscoped bare-letter binding silently eats typing. This test makes that class of bug unrepresentable.
  • no_surface_hardcodes_a_chord scans the user-facing files for any literal Ctrl+/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; null unbinds; 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, h each 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 (escape in the graph/blame/history views, / and secondary-f in the detail panel and sidebar). Detection asks gpui's own KeyBindingContextPredicate::depth_of rather 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 replaces key_handler.rs, which #47 extends:

Happy to rework whichever parts you'd prefer done differently.

Verification

cargo test --workspace                                 -> 1100 passed, 6 ignored
cargo clippy --workspace --all-targets -- -D warnings  -> clean
cargo fmt --all -- --check                             -> clean
cargo build --package rgitui                           -> ok
cargo run -- .                                         -> "keymap: applied 112 key bindings", no warnings

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

@noahbclarkson

Copy link
Copy Markdown
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.

adehad and others added 16 commits August 3, 2026 20:44
`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
noahbclarkson force-pushed the feat/keybindings-polish branch from 0b8d122 to 400fc50 Compare August 3, 2026 09:00
@noahbclarkson
noahbclarkson merged commit 0a7992a into noahbclarkson:main Aug 3, 2026
2 of 3 checks passed
@adehad

adehad commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

No problem!
Really appreciate this project, its something that has been on my list since the AI boom but never had the time to make a start. (Wanting to build upon Sublime Merge)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyboard shortcuts cannot be customised, and the shortcut reference drifts from the code

2 participants