From 02b2195461b3abeafd6282b12fee5d9eefa217e2 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:13:01 +0200 Subject: [PATCH] feat(hotkeys): end-user keymap customization (user remapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second v1.14-deferred accelerator. Lets a USER (not just the developer) remap shortcuts and have the choice persist (research-10) — pure composition over the record defaults, NO change to the closed CommandRecord. The reproducible zero-dependency equivalent already shipped as docs/hand-written-keymap-override.md (v1.14); this is the tested package layer. - bindHotkeys(registry, { keymap }) — an optional UserKeymap (sparse commandId → { replace | add | remove }) layered over each record's default keybinding at bind time. Default empty → existing callers unaffected. useHotkeys (React) forwards it and re-binds on keymap identity change (live remap UI). - Pure keymap.ts: resolveKeys (override resolution); collectBindings gained a keymap param and orders user-touched bindings first on a shared key; and detectConflicts (definite/possible same-key clashes). - Capture/display primitives: tokenFromEvent (press-to-record), isReservedCombo / RESERVED_COMBOS, formatKeybinding, layoutLabel (getLayoutMap w/ fallback). - keymap.ts is tinykeys-free; bind.ts composes it (two-layer discipline). - +14 tests (23 in the package; the 9 pre-existing bind tests still pass → backward-compatible). minor changeset (1.0.1 → 1.1.0). Left to the app (primitives cover it): the full remap-UI React component, preset packs, cloud sync, the WCAG "disable character-key shortcuts" toggle. Claude-Session: https://claude.ai/code/session_016teVFousRsWNhiiPNpG1p9 --- .changeset/hotkeys-keymap.md | 26 +++ .claude/skills/acture-hotkeys/SKILL.md | 2 +- docs/hand-written-keymap-override.md | 17 +- docs/roadmap.md | 15 +- packages/hotkeys/README.md | 30 ++++ packages/hotkeys/src/bind.ts | 44 +++-- packages/hotkeys/src/index.ts | 26 ++- packages/hotkeys/src/keymap.test.ts | 135 ++++++++++++++++ packages/hotkeys/src/keymap.ts | 215 +++++++++++++++++++++++++ packages/hotkeys/src/react.ts | 8 +- 10 files changed, 491 insertions(+), 27 deletions(-) create mode 100644 .changeset/hotkeys-keymap.md create mode 100644 packages/hotkeys/src/keymap.test.ts create mode 100644 packages/hotkeys/src/keymap.ts diff --git a/.changeset/hotkeys-keymap.md b/.changeset/hotkeys-keymap.md new file mode 100644 index 0000000..eab8db2 --- /dev/null +++ b/.changeset/hotkeys-keymap.md @@ -0,0 +1,26 @@ +--- +"acture-hotkeys": minor +--- + +Add **end-user keymap customization** — let a *user* (not just the developer) +remap shortcuts and have the choice persist (research-10). Pure composition over +the record defaults: no change to the closed `CommandRecord`. + +- **`bindHotkeys(registry, { keymap })`** — an optional `UserKeymap` (a sparse + `commandId → { replace | add | remove }` override map) layered over each + record's default `keybinding` at bind time. Default: an empty keymap, so + existing callers are unaffected. `useHotkeys` (React) forwards it and re-binds + on keymap identity change, so a live remap UI takes effect. +- **`resolveKeys(cmd, keymap)`** — the pure override resolution; `collectBindings` + now accepts a keymap and orders user-touched bindings first on a shared key + (VS Code's "user override wins, scope still respected"). +- **`detectConflicts(registry, keymap?)`** — reports same-key clashes + (`definite` when no sharer is `when`-scoped, else `possible`) — the + highest-value remap affordance. +- **Capture / display primitives:** `tokenFromEvent` (press-to-record), + `isReservedCombo` / `RESERVED_COMBOS` (reject browser-owned combos), + `formatKeybinding` (⌘/Ctrl labels), `layoutLabel` (`getLayoutMap` for physical + keys, with fallback). + +The reproducible zero-dependency equivalent is `docs/hand-written-keymap-override.md`; +the full remap-UI React component stays the app's to build (the primitives cover it). diff --git a/.claude/skills/acture-hotkeys/SKILL.md b/.claude/skills/acture-hotkeys/SKILL.md index 842896c..683725f 100644 --- a/.claude/skills/acture-hotkeys/SKILL.md +++ b/.claude/skills/acture-hotkeys/SKILL.md @@ -45,7 +45,7 @@ This is a distinct, *composed* layer on top of the binder above — added when a - **Press-to-record capture** is the loved UX (Obsidian/JetBrains/games): listen → build the tinykeys token → reject browser/OS-reserved combos (`Cmd+W`, `Ctrl+T`, …) → conflict-check → commit to the keymap store. Persist the `UserKeymap` as JSON (localStorage/IndexedDB/server row); `$mod` keeps tokens portable across a user's machines. - **WCAG 2.1.4 (Level A) is mandatory once you ship single-key bindings** (`"d"`, `"g i"`): ship a global "disable character-key shortcuts" toggle or default single-key bindings off/focus-scoped. This is a legal requirement, not a nicety (research-10 §3.7). -**Agent-written vs package-reuse** applies here too, decided per-piece: the `UserKeymap` + `resolveKeys` + keymap-aware `collectBindings` + conflict pass is ~50 hand-writable lines (the reference doc) that a project can own outright; the UI-and-browser-bound pieces — the press-to-record capture component, the `getLayoutMap()` display layer, preset loaders — are where an `acture-hotkeys` helper would earn its keep *if one ships*. Surface that split; it's the user's call (dev-tool-first). Handle every item on the research-10 §5.4 web-gotcha checklist (IME composition, macOS ⌘ keyup, `keydown` not `keypress`, `preventDefault` discipline). +**Agent-written vs package-reuse** applies here too, decided per-piece — and the package accelerator **now ships**. The `UserKeymap` + `resolveKeys` + keymap-aware `collectBindings` + conflict pass is ~50 hand-writable lines (the reference doc) a project can own outright; OR install `acture-hotkeys`, which now exports the whole layer: `bindHotkeys(registry, { keymap })` (+ React `useHotkeys` re-binds on keymap change), `resolveKeys`, `detectConflicts`, and the capture/display primitives `tokenFromEvent` / `isReservedCombo` / `RESERVED_COMBOS` / `formatKeybinding` / `layoutLabel`. The **full remap-UI React component** stays the app's to build (the primitives cover it). Surface the split; it's the user's call (dev-tool-first). Handle every item on the research-10 §5.4 web-gotcha checklist (IME composition, macOS ⌘ keyup, `keydown` not `keypress`, `preventDefault` discipline). ## When working ON `acture-hotkeys` diff --git a/docs/hand-written-keymap-override.md b/docs/hand-written-keymap-override.md index 49db4c8..dd0f6b7 100644 --- a/docs/hand-written-keymap-override.md +++ b/docs/hand-written-keymap-override.md @@ -279,13 +279,16 @@ YAGNI applied softly — add these only when a real need appears: ## Faithfulness note The shapes here — `UserKeymap`, `KeybindingOverride`, `resolveKeys`, -`collectBindings`, `detectConflicts` — are deliberately the shapes an -`acture-hotkeys` customization helper would export, and `collectBindings` is a -superset of the one already in `packages/hotkeys/src/bind.ts` (append a `km` -argument *after* `tiers`; default it to `EMPTY_KEYMAP` so existing -`collectBindings(registry, tiers)` calls are unaffected). An agent -that hand-writes from this doc and later installs the helper finds the migration -mechanical. If the package contract changes, this doc changes with it. +`collectBindings`, `detectConflicts` — are deliberately the shapes +**`acture-hotkeys` now exports** (`bindHotkeys({ keymap })`, `resolveKeys`, +`detectConflicts`, plus the capture/display primitives `tokenFromEvent` / +`isReservedCombo` / `formatKeybinding` / `layoutLabel`), and its `collectBindings` +is a superset of this one (`collectBindings(registry, tiers?, keymap?)`; the +`keymap` argument defaults to `EMPTY_KEYMAP` so existing +`collectBindings(registry, tiers)` calls are unaffected). Hand-write from this +doc, or install the package — the per-consumer choice (dev-tool-first). An agent +that hand-writes and later installs finds the migration mechanical. If the +package contract changes, this doc changes with it. ## See also diff --git a/docs/roadmap.md b/docs/roadmap.md index eb03402..2004805 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ The live forward-planning surface. `docs/v1_plan.md` and `docs/implementation_pl **How work proceeds:** phases are over. Work is small, tracked increments. Each picks one or two items from "Next" or "Deferred", ships them, updates this file, and replaces `docs/next_session.md` with the following handoff. -Last updated: **2026-07-06** (v1.16 — acture-mcp read side completed with the portable `getState` tool; follows v1.15's resources projection). +Last updated: **2026-07-06** (v1.17 — acture-hotkeys end-user keymap customization, the second v1.14-deferred accelerator). --- @@ -147,6 +147,19 @@ Completes the read side started in v1.15. MCP **resources** are the correct read --- +### v1.17 — acture-hotkeys: end-user keymap customization — complete (this increment) + +The second v1.14-deferred accelerator. Lets a **user** (not just the developer) remap shortcuts and have the choice persist (research-10) — pure composition over the record defaults, **no change to the closed `CommandRecord`**. The reproducible zero-dependency equivalent already shipped as `docs/hand-written-keymap-override.md` (v1.14); this is the tested `acture-hotkeys` accelerator. + +- **`bindHotkeys(registry, { keymap })`** — an optional `UserKeymap` (sparse `commandId → { replace | add | remove }`) layered over each record's default `keybinding` at bind time. Default empty → existing callers unaffected. `useHotkeys` (React) forwards it and re-binds on keymap identity change, so a live remap UI takes effect. +- **Pure layer (`keymap.ts`):** `resolveKeys(cmd, keymap)` (override resolution); `collectBindings` gained a keymap param and orders user-touched bindings first on a shared key (VS Code's "user override wins, scope still respected"); `detectConflicts(registry, keymap?)` (`definite`/`possible` same-key clashes — the highest-value remap affordance). +- **Capture / display primitives:** `tokenFromEvent` (press-to-record), `isReservedCombo` / `RESERVED_COMBOS` (reject browser-owned combos), `formatKeybinding` (⌘/Ctrl labels), `layoutLabel` (`getLayoutMap` for physical keys, with fallback). +- **`McpToolDescriptor`-style discipline held:** the pure `keymap.ts` is tinykeys-free; `bind.ts` composes it. +14 tests (23 in the package; the 9 pre-existing bind tests still pass — backward-compatible). `minor` changeset (`acture-hotkeys` 1.0.1 → 1.1.0). +- **Left to the app (the primitives cover it):** the full remap-UI React component, preset packs, cloud sync, WCAG "disable character-key shortcuts" toggle — documented, YAGNI-gated (research-10 §5 "deliberately omits"). +- **Still deferred:** the `sideEffect`/`requiresConfirmation` closed-surface question (the confirmation-gate work). + +--- + ## Next **The autonomous v1.12 + v1.13 chain is complete.** The remaining post-v1 items need user direction; the rewritten `docs/next_session.md` surfaces them. The candidates are: **`acture-state-jotai`** (atom-tree ↔ flat-state bridge is non-trivial per research-3; the adapter may not implement `PatchCapableAdapter` cleanly), **`acture-state-valtio`** (proxy-to-patch translation is non-trivial). (**`acture-sandbox`** is no longer a candidate — its isolation-only seam shipped in the extension-system increment; see below.) Pull-forward decisions are the user's; surface options with honest trade-offs when scheduled. diff --git a/packages/hotkeys/README.md b/packages/hotkeys/README.md index ce16e58..58bdadb 100644 --- a/packages/hotkeys/README.md +++ b/packages/hotkeys/README.md @@ -59,6 +59,36 @@ registry.register(commandB); // keybinding: 'g', when: '!editor.focused' If you want to *override* a base binding from a plugin, explicitly `unregister(id)` the base command first. +## End-user customization — remapping shortcuts + +Let a **user** (not just the developer) remap shortcuts and have the choice persist. The record's `keybinding` stays the *developer default*; a sparse **`UserKeymap`** overrides it at bind time — pure composition, **no change to the `CommandRecord`** ([research-10](https://github.com/thorwhalen/acture/blob/main/docs/research/acture_research_10%20--%20End-User%20Keyboard-Shortcut%20Customization.md); reproducible core in [`docs/hand-written-keymap-override.md`](https://github.com/thorwhalen/acture/blob/main/docs/hand-written-keymap-override.md)). + +```ts +import { bindHotkeys, type UserKeymap } from 'acture-hotkeys'; + +const keymap: UserKeymap = { + version: 1, + overrides: { + 'editor.save': { kind: 'replace', keys: ['$mod+s'] }, // rebind + 'editor.format': { kind: 'add', keys: ['$mod+Shift+f'] }, // add a second binding + 'app.help': { kind: 'remove' }, // unbind + }, +}; + +const stop = bindHotkeys(registry, { keymap }); // omit → record defaults, unchanged +``` + +`useHotkeys` forwards `keymap` and re-binds on its identity change, so a live settings UI takes effect. Persist the `UserKeymap` as JSON (localStorage / a server row); `$mod` keeps tokens portable across a user's machines. + +Build the remap UI from the exported primitives: + +- **`detectConflicts(registry, keymap?)`** → same-key clashes (`definite` / `possible`), for the "Already assigned to X — Reassign / Keep both / Cancel" flow. +- **`tokenFromEvent(event)`** → a tinykeys token from a `keydown` (press-to-record); **`isReservedCombo(token)`** rejects browser-owned combos (`$mod+w`, `$mod+t`, …) that `preventDefault` can't reclaim. +- **`formatKeybinding(token)`** → a ⌘/Ctrl label; **`layoutLabel(code)`** → a layout-correct label for physical (`event.code`) bindings via `navigator.keyboard.getLayoutMap()` (with fallback). +- **`resolveKeys(cmd, keymap)`** / **`collectBindings(registry, tiers, keymap)`** → the resolution, if you build a custom binder. + +> **WCAG 2.1.4 (Level A):** if you ship single-key bindings (`"g i"`, `"d"`), also ship a "disable character-key shortcuts" toggle or default them off/focus-scoped — a legal requirement, not a nicety. + ## Input-aware default By default, `bindHotkeys` skips firing when the target is an ``, `