Skip to content

Custom keybindings: registry, live rebinding, config UI - #52

Merged
iTerminate merged 4 commits into
mainfrom
custom-keybindings-config
Aug 19, 2026
Merged

Custom keybindings: registry, live rebinding, config UI#52
iTerminate merged 4 commits into
mainfrom
custom-keybindings-config

Conversation

@iTerminate

Copy link
Copy Markdown
Owner

Summary

  • Replace 224 hard-coded BINDINGS entries across 25 screens with a single controllers/keybindings.py registry (KeySpec/REGISTRY) plus app.keys_ctl, which pushes a user's overrides onto Textual's App.set_keymap.
  • Every screen's BINDINGS becomes bindings_for(scope); ConfigScreen is the one deliberate exception, so a bad rebind can never lock the config screen itself.
  • Keys shown to the user (help pages, docked-log/search/mode-banner hints) now read the live keymap instead of hard-coded literals — this also fixed a latent Rich-markup crash where adjacent bracket-key hint fragments could form a bogus [/] close tag.
  • New Configuration ▸ Keybindings category: pick a row, press the new key (KeyCapturePopup), validated against conflicts (keybindings.validate) before it's accepted; ctrl+c cancels, delete resets to default.
  • Fixed a real conflict-detection bug along the way: two curated ids that share a key by default in the same scope (e.g. dashboard's Use-mode log.toggle and Edit-mode-only dashboard.edit_slot, both "a") were flagged as conflicting with each other even when just resetting to that shared default — the exact mode-gated overlap check_action already keeps mutually exclusive. TWINS now exempts known baseline pairs.

Test plan

  • pytest tests/unit/test_keybindings.py — registry migration guarded by a golden pre-migration snapshot (tests/unit/binding_snapshot.json), plus registry invariants and pure-function coverage
  • pytest tests/test_keybindings_config.py — end-to-end: rebind via popup + save takes effect live, conflicting/reserved keys re-prompt inline, delete resets to default, reset-all clears overrides
  • uv run ruff check . / uv run pyright — clean
  • Full suite: 1382 passed, 1 skipped
  • Manual smoke test via uv run hatty --demo (tmux-driven): boots clean, Ctrl+P → Configuration → Keybindings renders correctly with live data

224 hard-coded BINDINGS entries across 25 screens become one KeySpec
registry (controllers/keybindings.py) plus app.keys_ctl, which pushes
a user's overrides onto Textual's App.set_keymap — the foundation for
per-user rebinding. Guarded by a golden snapshot of every screen's
pre-migration BINDINGS list.
Every BINDINGS list becomes bindings_for(scope), except ConfigScreen's
(kept literal so a bad rebind can never lock the config screen itself).
keys_ctl.apply() runs on boot/demo/onboarding-restart and after a config
save, so a rebind takes effect without a restart; HACLI.handle_bindings_clash
is a documented no-op for Textual's expected false-positive clashes when
several bindings share one id on purpose (e.g. nav.back).

Every place a key is shown to the user now reads the live keymap instead
of a hard-coded literal: the help screen's inactive-page bindings, and
every docked-log/search/mode-banner hint string. That surfaced a Rich
markup crash — hint text built from the "[" / "]" bracket-key display
strings can look like a markup close tag once adjacent fragments combine
("[" + "/" + "]" -> "[/]") — fixed by escaping ActivityLogPanel.set_hint's
input.
KeyCapturePopup swallows the next raw keypress (ctrl+c cancels, delete
resets to default) and validates it against the working overrides via
keybindings.validate() before accepting — a conflict re-prompts inline
instead of dismissing. The config screen keeps its own uncommitted
working copy, only merged into app_config on Save.

Fixed a real bug validate() surfaced: two curated ids that share a key
by default in the same scope (e.g. dashboard's Use-mode log.toggle and
Edit-mode-only dashboard.edit_slot, both "a") were flagged as conflicting
with each other even when resetting to that shared default — the exact
mode-gated overlap check_action already keeps mutually exclusive. TWINS
now exempts these known baseline pairs from the conflict check.
@iTerminate
iTerminate merged commit a7e633d into main Aug 19, 2026
1 check passed
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.

1 participant