Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eaf0663
bugs
Kitenite Apr 28, 2026
6b06a68
feat(desktop): layout-aware hotkey display via native-keymap
Kitenite Apr 28, 2026
5fa4410
chore(desktop): tidy hotkeys module — barrels, single-helper file
Kitenite Apr 28, 2026
ba4e634
feat(desktop): introduce dual-mode binding type (Phase 2 foundation)
Kitenite Apr 28, 2026
2aa79e7
feat(desktop): logical-mode dispatch via layout translation
Kitenite Apr 28, 2026
b07c124
feat(desktop): logical-mode recorder + Settings UI + cross-mode confl…
Kitenite Apr 28, 2026
5749f28
chore(desktop): drop mode badge from keyboard settings
Kitenite Apr 28, 2026
3f69b41
fix(desktop): layout-aware glyphs in keyboard conflict dialog
Kitenite Apr 28, 2026
976615a
fix(desktop): translate logical bindings before display, not just dis…
Kitenite Apr 28, 2026
bf10082
docs(desktop): add keyboard system QA plan
Kitenite Apr 28, 2026
e738b86
chore(desktop): tighten hotkey module comments
Kitenite Apr 28, 2026
16f0da8
chore(desktop): remove v1→v2 hotkey migration code
Kitenite Apr 28, 2026
e0dd8d8
fix(desktop): SearchBarTrigger dispatches the layout-translated chord
Kitenite Apr 28, 2026
44554d6
fix(desktop): drop '+' from logical keyChord (collides with separator)
Kitenite Apr 28, 2026
cd82f9e
fix(desktop): only uppercase ASCII letters in keymap glyph display
Kitenite Apr 28, 2026
5b303ac
chore(desktop): canonicalize translateLogicalChord early return
Kitenite Apr 28, 2026
c1d99ed
chore(desktop): consolidate NAMED_KEYS / NAMED_CODES into one set
Kitenite Apr 28, 2026
0f13d59
fix(desktop): retry keyboardLayout subscription on error
Kitenite Apr 28, 2026
1326bec
docs(desktop): fix QA plan + plan doc inconsistencies
Kitenite Apr 28, 2026
ca17024
ci(desktop): install libx11-dev / libxkbfile-dev for native-keymap
Kitenite Apr 28, 2026
10eb001
build(desktop): register native-keymap as a runtime-external module
Kitenite Apr 28, 2026
e9b1d20
docs(desktop): consolidate keyboard system into a single architecture…
Kitenite Apr 28, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen --ignore-scripts

- name: Install Linux native build deps
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev

- name: Install desktop native dependencies
working-directory: apps/desktop
run: bun run install:deps
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen --ignore-scripts

- name: Install Linux native build deps
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev

- name: Install desktop native dependencies
working-directory: apps/desktop
run: bun run install:deps
Expand Down Expand Up @@ -135,6 +138,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen --ignore-scripts

- name: Install Linux native build deps
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev

- name: Install desktop native dependencies
working-directory: apps/desktop
run: bun run install:deps
Expand Down
194 changes: 194 additions & 0 deletions apps/desktop/docs/KEYBOARD_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Keyboard Shortcut System

Layout-aware hotkey dispatch + display for the desktop renderer.

## What it does

- 50+ shipped default shortcuts (`apps/desktop/src/renderer/hotkeys/registry.ts`).
- User-customizable via Settings → Keyboard, persisted in `localStorage`.
- Each binding can match by **physical key position** (`event.code`) or **printed character** (`event.key`) so users on Dvorak / AZERTY / QWERTZ get shortcuts that follow the labels on their keyboard.
- Display refreshes on the fly when the user switches input source (macOS menu-bar picker, Cmd+Space).
- Terminal forwarding: app hotkeys bubble through xterm; `Ctrl+C/D/Z/S/Q/\` are reserved for the PTY.

## Public API

Everything consumers need is re-exported from `renderer/hotkeys`:

```ts
import {
// dispatch
useHotkey, // register a callback for a HotkeyId
// read
useBinding, getBinding, // current binding (string | v2 object)
getDispatchChord, // imperative event.code-form chord (use for synthesizing KeyboardEvents)
// display
useHotkeyDisplay, // formatted "⌘⇧P" for a HotkeyId
useFormatBinding, // formatted display for a binding shape (e.g. recording UI)
HotkeyLabel, // <Kbd>-rendering component
// recorder
useRecordHotkeys, // capture flow for the Settings page
// registry
HOTKEYS, HotkeyId, PLATFORM,
} from "renderer/hotkeys";
```

Stay out of `stores/keyboardLayoutStore` and `utils/binding.ts` internals unless you're extending the system.

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│ Electron Main process │
│ │
│ native-keymap (npm, Microsoft) │
│ ├─ getKeyMap() → IKeyboardMapping │
│ ├─ getCurrentKeyboardLayout() → IKeyboardLayoutInfo │
│ └─ onDidChangeKeyboardLayout(cb) │
│ └─ macOS: kTISNotifySelectedKeyboardInputSourceChanged │
│ │
│ apps/desktop/src/main/lib/keyboardLayout.ts │
│ └─ EventEmitter wrapping native-keymap, lazy-init │
│ │
│ apps/desktop/src/lib/trpc/routers/keyboardLayout.ts │
│ └─ get query + changes observable │
└──────────────────┬──────────────────────────────────────┘
│ tRPC subscription (observable per AGENTS.md)
┌─────────────────────────────────────────────────────────┐
│ Electron Renderer │
│ │
│ hotkeys/stores/keyboardLayoutStore.ts │
│ └─ Zustand store: { map, layoutId } │
│ Self-restarting on subscription error │
│ │
│ hotkeys/utils/binding.ts → bindingToDispatchChord() │
│ └─ Single source of truth for translating logical │
│ bindings to event.code form. Used by: │
│ - useHotkey (react-hotkeys-hook registration) │
│ - useHotkeyDisplay / useFormatBinding (rendering) │
│ - useRecordHotkeys (cross-mode conflict detection) │
│ - resolveHotkeyFromEvent (terminal forwarding) │
│ │
│ hotkeys/display.ts → formatHotkeyDisplay() │
│ └─ Looks up event.code in layoutMap for printable │
│ keys; falls back to KEY_DISPLAY (US-ANSI) for │
│ special keys and when map is null │
└─────────────────────────────────────────────────────────┘
```

## Binding model

Each binding is a `ShortcutBinding`:

```ts
type ShortcutBinding =
| string // legacy / shipped default — implicitly physical
| { version: 2; mode: BindingMode; chord: string };

type BindingMode = "physical" | "logical" | "named";
```

| Mode | Match against | Stored chord | Use |
|---|---|---|---|
| `physical` | `event.code` | scan-code-canonical (`"meta+p"` = physical KeyP) | Shipped registry defaults; preserves QWERTY muscle memory |
| `logical` | the produced character | the literal character (`"meta+p"` = the key labeled P) | Default for new user-recorded printable bindings; follows the printed letter across layouts |
| `named` | `event.code` (stable for named keys) | `"meta+enter"`, `"meta+arrowup"`, `"f5"` | Auto-applied to Enter/arrows/F-keys regardless of mode preference |

**Storage compactness**: physical-mode bindings serialize to bare strings (matches legacy shape, keeps the registry terse). Logical and named modes serialize to the v2 object form.

## Layout-aware translation

The single function that bridges modes is `bindingToDispatchChord(binding, layoutMap)`. For every consumer that needs the chord react-hotkeys-hook actually matches against, route through this function:

```
binding.mode === "physical" → return chord unchanged
binding.mode === "named" → return chord unchanged (event.code is stable)
binding.mode === "logical" → translateLogicalChord(chord, layoutMap)
→ find scan code whose unshifted glyph
matches the chord's letter,
return chord with key replaced.
Falls back to literal chord (US-correct)
when layoutMap is null.
```

Example: a logical `meta+z` binding on German QWERTZ resolves to `meta+y` (because German's KeyY position prints "z"), so react-hotkeys-hook fires when the user presses the key labeled Z — same letter, different physical position.

## Recording flow

`useRecordHotkeys` captures both `event.code` (codeChord) and `event.key` (keyChord) on each keystroke, plus a classification:

- **fkey** / **named** → mode forced to `named` regardless of preference.
- **printable** → caller's `preferredMode` (default `"logical"`) decides; `+` falls back to physical to avoid colliding with the chord separator.

The Settings page passes `preferredMode: "logical"`. Conflict detection compares dispatch chords (post-translation), so logical and physical bindings that collide on the current layout are flagged.

## Cross-cutting guards

| Concern | Where | Why |
|---|---|---|
| **AltGr** (Linux/Windows) | `eventToChord` and `useHotkey.shouldIgnoreEvent` | Chromium reports AltGr as ctrlKey+altKey — without suppression, AltGr-typed printables on non-US layouts (`AltGr+E = €` on German) would false-trigger any `ctrl+alt+e` binding. |
| **IME composition** (CJK / dead keys) | `eventToChord` and `useHotkey.shouldIgnoreEvent` | `event.isComposing` and Safari's `keyCode === 229` short-circuit matching. Modifier+letter chords bypass IME on macOS by OS design. |
| **Terminal-reserved chords** | `TERMINAL_RESERVED_CHORDS` set | `Ctrl+C/D/Z/S/Q/\` always go to PTY; recorder rejects them with an error. |

## Migration

The v1→v2 hotkey storage migration was shipped April 2026 and removed in commit `16f0da83e` (3 months later, after every active user had the `hotkey-overrides-migrated-v2` marker). If a user genuinely hasn't launched the app since April, they see default bindings instead of their v1 customizations; v1 overrides remain in main-process state via the `uiState.hotkeys.get` tRPC endpoint and could be recovered if anyone asks.

## Decision history (brief)

- **April 2026** — Initial refactor. Unified everything on `event.code` (recorder, dispatch, terminal forwarding). Preserved the bare-string storage shape. See `plans/done/20260412-keyboard-recorder-ctrl-binding-fix.md`.
- **April 27, 2026** — Layout audit and Phase 0–2 plan. Briefly tried `navigator.keyboard.getLayoutMap()` to avoid the native-keymap dep; switched back after discovering Chromium's `layoutchange` event doesn't fire for macOS input-source switches. native-keymap hooks `kTISNotifySelectedKeyboardInputSourceChanged` directly, which fires reliably. See `plans/done/20260427-keyboard-layout-plan.md`.
- **April 28, 2026** — Phase 1 (native-keymap) + Phase 2 (dual-mode bindings) shipped. v1 migration removed.

## Known gaps / future work

| Item | Status |
|---|---|
| **Menu accelerator sync** — `main/lib/menu.ts` hardcodes `CmdOrCtrl+R/,//Shift+Q`; they shadow user rebinds | Demand-driven. The single concrete user-visible gap. |
| **v1 terminal handler** uses catch-all `ctrl/meta` skip → starves TUIs of unbound chords like Ctrl+R | Tracked in `plans/20260409-tui-hotkey-forwarding.md`; v2 already correct. |
| **AltGr first-class binding token** | Reserved but never wired. AltGr is suppressed at match time, but a user can't *record* `AltGr+E` as their own chord. Drop or implement on demand. |
| **Numpad / Digit disambiguation** | Collapsed: `Numpad1` and `Digit1` both canonicalize to `"1"`. No current need for separate bindings. |
| **Shifted-layer display** | We use the unshifted glyph + ⇧ symbol convention (macOS). `native-keymap` exposes `withShift` / `withAltGr` data we don't read. |
| **Physical/logical mode toggle in Settings UI** | Backend supports both modes; UI defaults new printable recordings to logical with no opt-in to physical. Add a toggle if a user requests it. |
| **Layout-id telemetry** | `layoutId` is in the store but never reported. Cheap if product wants the data. |
| **Multi-stroke chords** (`Ctrl+K Ctrl+S`) | No demand. |
| **When-clauses / context system** | No demand; per-component `useHotkey` registration is sufficient. |

## Out of scope

- VSCode-style `KeybindingResolver` / context engine.
- `globalShortcut` (system-wide hotkeys).
- Per-extension keybinding contributions.
- Vendoring VSCode's static layout files (only if `native-keymap` ever proves insufficient).

## Key files

```
apps/desktop/src/main/lib/keyboardLayout.ts # native-keymap wrapper
apps/desktop/src/lib/trpc/routers/keyboardLayout.ts # tRPC bridge
apps/desktop/src/renderer/hotkeys/
├── registry.ts # shipped defaults
├── types.ts # ShortcutBinding, BindingMode
├── display.ts # formatHotkeyDisplay, glyphForCode
├── stores/
│ ├── hotkeyOverridesStore.ts # localStorage user overrides
│ └── keyboardLayoutStore.ts # tRPC mirror with retry
├── hooks/
│ ├── useBinding/ # binding + dispatch chord
│ ├── useHotkey/ # register a callback
│ ├── useHotkeyDisplay/ # formatted display
│ └── useRecordHotkeys/ # Settings recording flow
├── utils/
│ ├── binding.ts # parse / serialize / translate
│ └── resolveHotkeyFromEvent.ts # canonicalization, terminal index
└── components/HotkeyLabel/ # <Kbd>-rendering component

apps/desktop/src/main/lib/menu.ts # ⚠ hardcoded; see "Known gaps"
apps/desktop/src/renderer/lib/terminal/ # terminal forwarding integration
```

## References

- VSCode keyboardLayoutMainService: https://github.com/microsoft/vscode/blob/main/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts
- `native-keymap`: https://github.com/microsoft/node-native-keymap
- MDN `KeyboardEvent.code`: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
"lucide-react": "^0.563.0",
"mastracode": "0.15.0-alpha.3",
"nanoid": "^5.1.6",
"native-keymap": "^3.3.9",
"node-addon-api": "^7.1.0",
"node-pty": "1.1.0",
"os-locale": "^6.0.2",
Expand Down
Loading
Loading