Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/hotkeys-keymap.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion .claude/skills/acture-hotkeys/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
17 changes: 10 additions & 7 deletions docs/hand-written-keymap-override.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions packages/hotkeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>`, `<textarea>`, `<select>`, or `contentEditable` element — so users typing in a search box don't accidentally trigger the `g` key. Override via `shouldIgnoreEvent`.
Expand Down
44 changes: 31 additions & 13 deletions packages/hotkeys/src/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
WhenClause,
} from 'acture';
import { evaluateWhen } from 'acture';
import { resolveKeys, EMPTY_KEYMAP, type UserKeymap } from './keymap.js';

/** Function that returns the current context for when-clause evaluation
* at hotkey-fire time. Kept as a provider (not a snapshot) so binding
Expand Down Expand Up @@ -47,13 +48,24 @@ export interface BindHotkeysOptions {

/** Tier filter applied to candidate commands. Default: `['stable']`. */
tiers?: readonly Tier[] | 'all';

/** Optional end-user keymap layered over the record defaults (research-10):
* a sparse `commandId → { replace | add | remove }` override map. Captured
* at bind time; to apply a *changed* keymap, `stop()` then re-`bindHotkeys`.
* Default: an empty keymap (every command uses its record `keybinding`), so
* existing callers are unaffected. See `./keymap.js`. */
keymap?: UserKeymap;
}

/** Internal: a binding-table entry. */
export interface HotkeyBindingDescriptor {
readonly keySequence: string;
readonly commandId: string;
readonly when?: WhenClause;
/** True when this command carries a user keymap override. User-touched
* descriptors sort BEFORE untouched defaults on the same key, so a user
* rebinding wins the key (research-10 §5.2). Absent ⇒ default. */
readonly userTouched?: boolean;
}

const DEFAULT_IGNORE: (e: KeyboardEvent) => boolean = (event) => {
Expand All @@ -78,14 +90,15 @@ export function bindHotkeys(
const contextProvider = options.contextProvider ?? (() => ({}));
const shouldIgnoreEvent = options.shouldIgnoreEvent ?? DEFAULT_IGNORE;
const tiers = options.tiers;
const keymap = options.keymap ?? EMPTY_KEYMAP;

let teardown: (() => void) | null = null;
let disposed = false;

function rebind(): void {
teardown?.();
if (disposed) return;
const table = collectBindings(registry, tiers);
const table = collectBindings(registry, tiers, keymap);
if (table.size === 0) {
teardown = () => {};
return;
Expand Down Expand Up @@ -128,10 +141,17 @@ export function bindHotkeys(
/**
* Build the binding table: key-sequence → ordered list of candidates.
* Exported for tests / debugging; not part of the day-to-day surface.
*
* `keymap` (default: empty) layers a user override over each record's default
* `keybinding` via {@link resolveKeys} — with an empty keymap this is exactly
* the record defaults, so the behaviour is unchanged. User-touched commands
* sort BEFORE untouched defaults on the same key, so a user rebinding wins the
* key while the fire-time when-clause scan (in `bindHotkeys`) is untouched.
*/
export function collectBindings(
registry: Registry,
tiers?: readonly Tier[] | 'all',
keymap: UserKeymap = EMPTY_KEYMAP,
): Map<string, HotkeyBindingDescriptor[]> {
const table = new Map<string, HotkeyBindingDescriptor[]>();
// We intentionally do NOT pass `context` to `list()` — the when-clause
Expand All @@ -140,20 +160,26 @@ export function collectBindings(
// when-clauses that depend on dynamic state (selection, focus, etc.).
const list = registry.list(tiers !== undefined ? { tiers } : undefined);
for (const cmd of list) {
const kbs = normalizeKeybinding(cmd.keybinding);
for (const kb of kbs) {
const userTouched = keymap.overrides[cmd.id] !== undefined;
for (const kb of resolveKeys(cmd, keymap)) {
const key = parseKeybinding(kb);
let arr = table.get(key);
if (!arr) {
arr = [];
table.set(key, arr);
}
const desc: HotkeyBindingDescriptor = cmd.when !== undefined
? { keySequence: key, commandId: cmd.id, when: cmd.when }
: { keySequence: key, commandId: cmd.id };
? { keySequence: key, commandId: cmd.id, when: cmd.when, userTouched }
: { keySequence: key, commandId: cmd.id, userTouched };
arr.push(desc);
}
}
// User-touched bindings win the key (research-10 §5.2). Array.sort is stable,
// so registration order is preserved within each group — and this is a no-op
// when no keymap is applied (every descriptor has userTouched === false).
for (const arr of table.values()) {
arr.sort((a, b) => Number(b.userTouched ?? false) - Number(a.userTouched ?? false));
}
return table;
}

Expand All @@ -166,11 +192,3 @@ export function collectBindings(
export function parseKeybinding(kb: string): string {
return kb.trim();
}

function normalizeKeybinding(
kb: AnyCommandRecord['keybinding'],
): readonly string[] {
if (kb === undefined) return [];
if (typeof kb === 'string') return [kb];
return kb;
}
26 changes: 25 additions & 1 deletion packages/hotkeys/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,34 @@
* useHotkeys(registry, { context });
*/

export { bindHotkeys, parseKeybinding } from './bind.js';
export { bindHotkeys, parseKeybinding, collectBindings } from './bind.js';
export type {
BindHotkeysOptions,
HotkeyBindingDescriptor,
HotkeyContextProvider,
HotkeyDispatchListener,
} from './bind.js';

/**
* End-user keymap customization (research-10; `docs/hand-written-keymap-override.md`).
* Layer a sparse `UserKeymap` over the record defaults via `bindHotkeys`'s
* `keymap` option, and build a remap UI from the capture / conflict / display
* primitives:
*
* import { detectConflicts, tokenFromEvent, formatKeybinding } from 'acture-hotkeys';
*/
export {
resolveKeys,
detectConflicts,
tokenFromEvent,
isReservedCombo,
formatKeybinding,
layoutLabel,
EMPTY_KEYMAP,
RESERVED_COMBOS,
} from './keymap.js';
export type {
UserKeymap,
KeybindingOverride,
KeymapConflict,
} from './keymap.js';
Loading
Loading