Skip to content

Commit 2e57243

Browse files
alicodingclaude
andcommitted
feat: ⌘?/⌘/ multi-binding keybinding aliases (BACKLOG Standing #6)
Command grows an optional extraBindings: KeyCombo[] alongside the existing 1:1 defaultBinding (shared/commands.ts, backward-compatible). shared/keybinding.ts's keyFromEventCode gains Slash support -- the '/' key is shift-independent like every other key here, so ⌘/ vs ⌘? (the Shift+/ glyph) is captured purely as a mod difference on the same physical key. palette.open carries both as extraBindings, checked against the full registry + RESERVED_COMBOS first (no collision -- nothing else uses '/'). dispatchCommandForEvent checks a command's effective (override- aware) primary plus its extras on every dispatch; extras themselves are deliberately not override-checked this pass -- Settings' recorder- based rebinding UI still edits only the primary, and extras render as read-only secondary KeyComboChips next to it (views/KeyboardShortcutsSection.tsx). Vitest covers dispatch-matches-either-binding, override-doesn't- disable-extras, and the no-extraBindings backward-compat case; keymap.spec.ts gained a live ⌘/ and ⌘⇧/ -> palette-opens case and a Settings-renders-the-two-read-only-chips case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh
1 parent af6efdb commit 2e57243

10 files changed

Lines changed: 207 additions & 30 deletions

File tree

docs/SPEC.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2729,9 +2729,9 @@ findings) and the build rationale are in
27292729
Wails window, can't share App.tsx's). This ⌘K palette's own
27302730
still-open gap — the *inline per-workflow hotkey/armed-state
27312731
detail* the Acceptance sentence's "showed them the hotkey" half
2732-
names — and a ⌘?/⌘/ alias (blocked on the 1:1 command↔binding
2733-
registry shape) both stay recorded in the goal file, not silently
2734-
dropped. **Update (2026-08-12, session 2)**: the COMMAND-shortcut
2732+
names — stays recorded in the goal file, not silently dropped; the
2733+
⌘?/⌘/ alias (previously blocked on the 1:1 command↔binding registry
2734+
shape) shipped later, see the Update below. **Update (2026-08-12, session 2)**: the COMMAND-shortcut
27352735
half of that inline-hotkey-hint gap (as opposed to the still-open
27362736
per-workflow-TRIGGER half above) is now built and shared, not
27372737
per-surface: `app/HotkeyHint.tsx` (`resolveHotkeyLabel`/
@@ -2787,7 +2787,18 @@ findings) and the build rationale are in
27872787
ever called); a clash against another OVERRIDDEN command or a
27882788
workflow's trigger hotkey (`TriggerService.ClaimedCombos`, reusing
27892789
`trigger.CheckConflict` verbatim) is caught server-side, naming the
2790-
conflicting command or workflow.
2790+
conflicting command or workflow. **Update (2026-08-13, BACKLOG.md
2791+
Standing #6)**: `Command` gained an optional `extraBindings:
2792+
KeyCombo[]` alongside `defaultBinding` — always-on additional
2793+
bindings for the same command, deliberately NOT run through the
2794+
override/rebind machinery above (Settings still only rebinds the
2795+
primary; extras render as read-only secondary `KeyComboChip`s next to
2796+
it). `palette.open` carries ⌘/ and ⌘? as its two extras (the ⌘K
2797+
palette's own "owner reinforcement" request from goal 0015, deferred
2798+
at the time as a cross-cutting registry change) — both land on the
2799+
physical `/` key (`keyFromEventCode`, `shared/keybinding.ts`, gained
2800+
`Slash` support), distinguished by the Shift mod exactly the way
2801+
every other key here already is shift-independent.
27912802
- **Per-view hotkeys** — Cmd+1 through Cmd+4 jump to a top-level view
27922803
(Composition/Configure/Activity/Spec, matching the sidebar order,
27932804
down from an original five once Runs stopped being a top-level view —

docs/goals/BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ live-review material, interleaved during owner reviews, not a lane.**
200200
3. [x] [0031 — AI node family](archive/0031-ai-node-family.md) — DELIVERED 2026-08-12, the flagship capability, shipped as two sequential PRs for reviewability. PR1: `internal/domain/aiprovider` (the `AIProvider` Configure entity, mirrors MCPServer's recipe) + `internal/adapters/aiclient` (`openaicompat` covers Ollama's own `/v1` shim + LM Studio/vLLM/any BYO endpoint, `anthropic` speaks the native Messages API not its OpenAI-compat shim — both httptest-proven) + `process-ai-completion`, plus a pre-flight audit of every registered NodeType's ConfigFields against the Configure-vs-workflow split (verdict: already fully consistent — codified in `.claude/rules/architecture.md`). PR2: `process-ai-extract-structured` (own typed output-field editor, `AIExtractFieldsEditor.tsx`) + `process-ai-classify` (node-local category list, fail-safe on an out-of-enum response) + the "Example: AI classify -> branch" seed — THE decisioning composition (AI writes a category Attribute, Branch routes on it). Effect: static `ClassExternal` on all three, `EffectForNode` downgrades to `ClassLocal` for a loopback AIProvider BaseURL (owner-ratified 2026-08-12: "you're the boss") — remote asks by default, local Ollama frictionless. Conforms to node-standard.md from birth; `docs/SPEC.md` §3.3 flipped OPEN → LOCKED.
201201
4. [x] Copy-management migration ×4 (`app/``composition/``configure/``views/`) then the `eslint-plugin-i18next` revisit — DELIVERED 2026-08-12, see item 10's own writeup above ([0032](archive/0032-copy-management.md)).
202202
5. [x] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — DELIVERED 2026-08-13: `pinnedWorkflowIds: string[]` + `togglePinnedWorkflow` on `shared/store.ts`'s existing zustand `persist` (same localStorage tier as `activeWorkTabKey`, goal 0033's precedent — no new Go surface). `app/workflowFrecency.ts`'s new `sortWorkflowsByPinnedAndFrecency` partitions pinned (in pin-order) above the existing frecency-sorted unpinned tail, reusing `sortWorkflowsByFrecency` rather than a second algorithm. A Primer `PinIcon` `IconButton` trailing-visual pin toggle on both the Quick Panel's and ⌘K palette's workflow rows (muted outline unpinned, accent-colored "filled" once pinned) — found and fixed a real Primer interaction bug along the way: `ActionList.Item`'s own `TrailingVisual` wraps children in a `VisualWrap` span with `pointer-events: none` (trailing visuals are decorative-only by the library's own convention), which silently ate every click on the toggle until `pointer-events: auto` was added back on the button itself. Vitest covers the pinned-above-frecency/pin-order/unpinned-id-dropped/no-mutation cases; `quick-panel.spec.ts` gained a full pin→sort→unpin→revert→reload-persists e2e case.
203-
6. [ ] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — schema DECIDED at prioritization (orchestrator, 2026-08-12): `Command` grows an optional `extraBindings: KeyCombo[]` alongside the existing 1:1 `defaultBinding` (backward-compatible; Settings' rebinding UI edits the primary, extras render as secondary chips; dispatch checks all).
203+
6. [x] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — DELIVERED 2026-08-13: `Command` grew an optional `extraBindings: KeyCombo[]` alongside `defaultBinding` (`shared/commands.ts`, backward-compatible); `shared/keybinding.ts`'s `keyFromEventCode` gained `/` support (shift-independent, same as every other key — the Shift mod is what distinguishes ⌘/ from ⌘?, both on the physical Slash key). `palette.open` carries both as `extraBindings`, checked against the full registry + `RESERVED_COMBOS` first (no collision — nothing else uses `/`). `dispatchCommandForEvent` checks a command's effective (override-aware) primary plus its extras every dispatch; extras themselves are deliberately NOT override-checked this pass (Settings' recorder-based rebinding UI still edits only the primary). `views/KeyboardShortcutsSection.tsx` renders extras as read-only secondary `KeyComboChip`s next to the primary's click-to-rebind button. Vitest covers dispatch-matches-either-binding + override-doesn't-disable-extras + no-extraBindings-backward-compat; `keymap.spec.ts` gained both a live ⌘//⌘⇧+/ → palette-opens case and a Settings-renders-the-two-read-only-chips case.
204204
7. [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) Phase 2: orchestrator-driven live MCP probing against the locked-down-enterprise use cases (the mandate names the orchestrator as the prober — self-driveable, exploratory; produces the next ranked gap list). Phase 3 judgments that need the owner surface as they're found.
205205
8. [ ] Dev-loop instance guards (tech debt, owner-hit 2026-08-12 evening: THREE concurrent `mill.dev.app` instances in the dock, real crash risk on the 16GB machine) — two confirmed root causes, both get mechanical fixes: (a) `wails3 dev`'s Go-rebuild cycle relaunches the app WITHOUT killing the previous instance, so orphans accumulate one per rebuild during heavy agent waves — extend goal 0029's start-sweep into a per-rebuild reap (kill any existing `bin/mill.dev.app` process before the new launch; find the right hook in the Taskfile's dev target or wails3 dev's own lifecycle); (b) `task dev` ran twice concurrently (two backgrounded watchers, each with its own app+vite) — the dev target refuses to start when an instance is already running (vite-port 9245 check + wails3-dev process check, clear message naming the existing PID). This makes the standing never-two-data-sharing-instances rule ENFORCED instead of remembered.
206206
9. [ ] Dock-bounce on parked approvals (small, unlocked by wails beta.6's Flash() gaining macOS support via NSApp requestUserAttention — PR #44's changelog finding; Mill calls Flash nowhere today) — the attention stack (goal 0023/ADR-0032's away-user layer) gains a one-shot dock bounce when an approval parks while the user is away; kernel attention-layer surface per ADR-0035 (same class as the dock badge), NOT a new composition path. Tiny: one call site in the existing NotifyPendingApproval flow + manual-only registry entry (real dock behavior isn't CI-testable).

frontend/e2e/keymap.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,44 @@ test('Settings: rebinding a command persists, the new combo works, and a conflic
201201
await expect(saveRowAgain.getByTestId('keymap-row-reset')).toHaveCount(0)
202202
await expect(saveRowAgain.getByTestId('keymap-row-combo')).toHaveText('⌘S')
203203
})
204+
205+
// docs/goals/BACKLOG.md Standing #6 -- ⌘?/⌘/ multi-binding aliases on
206+
// palette.open. commands.test.ts already covers dispatchCommandForEvent
207+
// matching an extraBinding in isolation; this proves the live wiring
208+
// (App.tsx's real keydown listener, the real Dialog toggling) for both
209+
// aliases, not just Cmd+K.
210+
test('Cmd+/ and Cmd+Shift+/ (the extra palette.open bindings) both open the command palette, same as Cmd+K', async ({ page }) => {
211+
await page.goto('/')
212+
const paletteDialog = page.getByRole('dialog', { name: 'Command palette' })
213+
214+
await page.keyboard.press('Meta+/')
215+
await expect(paletteDialog).toBeVisible()
216+
await page.keyboard.press('Escape')
217+
await expect(paletteDialog).toHaveCount(0)
218+
219+
await page.keyboard.press('Meta+Shift+/')
220+
await expect(paletteDialog).toBeVisible()
221+
await page.keyboard.press('Escape')
222+
await expect(paletteDialog).toHaveCount(0)
223+
})
224+
225+
// Settings' Keyboard Shortcuts list renders the extras as read-only
226+
// secondary chips (views/KeyboardShortcutsSection.tsx) -- distinct from
227+
// the primary combo button above it, which stays the only
228+
// click-to-rebind target.
229+
test('Settings shows palette.open\'s extra bindings as read-only secondary chips', async ({ page }) => {
230+
await page.goto('/')
231+
await page.getByRole('link', { name: 'Settings' }).click()
232+
233+
const paletteRow = page.locator('[data-testid="keymap-row"][data-command-id="palette.open"]')
234+
await expect(paletteRow.getByTestId('keymap-row-combo')).toHaveText('⌘K')
235+
236+
const extraChips = paletteRow.getByTestId('keymap-row-extra-binding')
237+
await expect(extraChips).toHaveCount(2)
238+
await expect(extraChips.nth(0)).toHaveText('⌘/')
239+
await expect(extraChips.nth(1)).toHaveText('⌘⇧/')
240+
241+
// A command with no extraBindings (e.g. workflow.save) renders none.
242+
const saveRow = page.locator('[data-testid="keymap-row"][data-command-id="workflow.save"]')
243+
await expect(saveRow.getByTestId('keymap-row-extra-binding')).toHaveCount(0)
244+
})

frontend/src/app/CommandPalette.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,15 @@ import styles from './CommandPalette.module.css'
4040
// onClose('escape') and preventDefault()s, so Escape-to-close needs no
4141
// extra wiring here.
4242
//
43-
// ⌘? / ⌘/ aliases (the goal's "owner reinforcement" note) are
44-
// deliberately NOT built: shared/commands.ts's Command shape is one
45-
// binding per command (`defaultBinding: KeyCombo | null`), and both
46-
// dispatchCommandForEvent and the Settings "Keyboard Shortcuts" rebind
47-
// UI (KeyboardShortcutsSection.tsx) key off that 1:1 assumption, as
48-
// does the Go side's persisted-override map (settingsservice_keymap.go,
49-
// one KeyCombo per command id). Adding a second real `palette.open`
50-
// command row instead would show two identically-labelled, independently
51-
// rebindable rows in Settings -- not a clean alias. A real alias needs
52-
// either `defaultBinding: KeyCombo[]` threaded through all of the above,
53-
// or teaching shared/keybinding.ts's keyFromEventCode a bare '/'/'?' key
54-
// outside the registry entirely -- both cross-cutting enough that this
55-
// stays ⌘K-only per this goal's own "don't restructure the registry,
56-
// ship ⌘K only and note the deferral" instruction.
43+
// ⌘? / ⌘/ aliases (the goal's "owner reinforcement" note, deferred at
44+
// the time as a cross-cutting registry change) landed later as
45+
// docs/goals/BACKLOG.md Standing #6: shared/commands.ts's Command grew
46+
// an optional `extraBindings: KeyCombo[]` alongside `defaultBinding`
47+
// (backward-compatible), and shared/keybinding.ts's keyFromEventCode
48+
// now recognizes the '/' key. dispatchCommandForEvent checks every
49+
// command's extras too; this Dialog itself needs no changes -- it
50+
// still just renders off the store's paletteOpen flag regardless of
51+
// which bound combo flipped it.
5752

5853
type PaletteGroupId = 'commands' | 'workflows' | 'tabs'
5954

frontend/src/locales/en/views.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
"pressCombo": "Press a combo… (Esc to cancel)",
6161
"clickToChange": "Click to change",
6262
"reset": "Reset",
63-
"unbound": "Unbound"
63+
"unbound": "Unbound",
64+
"alsoLabel": "Also:",
65+
"extraBindingTitle": "Additional shortcut (not editable here)"
6466
},
6567
"placeholderView": {
6668
"notBuiltYet": "Not built yet",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { dispatchCommandForEvent, findCommand } from './commands'
3+
4+
// docs/goals/BACKLOG.md Standing #6 (⌘?/⌘/ palette aliases): a
5+
// Command's optional extraBindings (shared/commands.ts) must dispatch
6+
// the SAME command as its primary defaultBinding, and an override on
7+
// the primary must never affect extras (they're deliberately not
8+
// override-checked -- see Command.extraBindings' own doc comment).
9+
describe('dispatchCommandForEvent with extraBindings', () => {
10+
const event = (init: Partial<KeyboardEvent>) => init as KeyboardEvent
11+
12+
it('palette.open has the two documented extra bindings registered', () => {
13+
const command = findCommand('palette.open')
14+
expect(command?.extraBindings).toEqual([
15+
{ mods: ['cmd'], key: '/' },
16+
{ mods: ['cmd', 'shift'], key: '/' },
17+
])
18+
})
19+
20+
it('Cmd+K (the primary default) still opens the palette', () => {
21+
const ran = dispatchCommandForEvent(event({ code: 'KeyK', metaKey: true }), {})
22+
expect(ran).toBe(true)
23+
})
24+
25+
it('Cmd+/ (an extra binding) also dispatches palette.open', () => {
26+
const ran = dispatchCommandForEvent(event({ code: 'Slash', metaKey: true }), {})
27+
expect(ran).toBe(true)
28+
})
29+
30+
it('Cmd+Shift+/ (the ⌘? glyph, the second extra binding) also dispatches palette.open', () => {
31+
const ran = dispatchCommandForEvent(event({ code: 'Slash', metaKey: true, shiftKey: true }), {})
32+
expect(ran).toBe(true)
33+
})
34+
35+
it('Ctrl+/ is not bound to anything -- extras match on their exact mods, not just the "/" key', () => {
36+
const ran = dispatchCommandForEvent(event({ code: 'Slash', ctrlKey: true }), {})
37+
expect(ran).toBe(false)
38+
})
39+
40+
it('an override on the primary binding does not disable the extras', () => {
41+
// palette.open rebound to Cmd+P in Settings -- Cmd+K itself no
42+
// longer runs it, but the two extras (never override-checked, per
43+
// Command.extraBindings' own doc comment) still do.
44+
const overrides = { 'palette.open': { mods: ['cmd'], key: 'P' } }
45+
expect(dispatchCommandForEvent(event({ code: 'KeyK', metaKey: true }), overrides)).toBe(false)
46+
expect(dispatchCommandForEvent(event({ code: 'Slash', metaKey: true }), overrides)).toBe(true)
47+
expect(dispatchCommandForEvent(event({ code: 'KeyP', metaKey: true }), overrides)).toBe(true)
48+
})
49+
50+
it('a command with no extraBindings is unaffected (backward-compatible)', () => {
51+
// tab.close has no extras -- only its own Cmd+W default dispatches
52+
// it, same behavior as before this feature existed.
53+
expect(dispatchCommandForEvent(event({ code: 'KeyW', metaKey: true }), {})).toBe(true)
54+
expect(dispatchCommandForEvent(event({ code: 'Slash', metaKey: true, ctrlKey: true }), {})).toBe(false)
55+
})
56+
})

frontend/src/shared/commands.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ export interface Command {
3131
// palette.open, whose binding is reserved ahead of goal 0015 actually
3232
// building the palette.
3333
defaultBinding: KeyCombo | null
34+
// Additional, always-on bindings for the SAME command (docs/goals/
35+
// BACKLOG.md Standing #6) -- backward-compatible (every existing
36+
// command simply omits it). Deliberately NOT user-rebindable this
37+
// pass: Settings' recorder-based rebinding UI
38+
// (views/KeyboardShortcutsSection.tsx) edits `defaultBinding` only
39+
// (via keybindingOverrides, same as before); extras render as
40+
// read-only secondary KeyComboChips there and are never looked up in
41+
// keybindingOverrides by dispatchCommandForEvent below -- a real
42+
// "edit an alias" feature (its own override storage keyed by
43+
// command+index, its own Go-side persistence) is more than this
44+
// item's scope covers, named as a future extension rather than half-
45+
// built here.
46+
extraBindings?: KeyCombo[]
3447
run: () => void
3548
}
3649

@@ -177,6 +190,19 @@ export const COMMANDS: Command[] = [
177190
// shared signal the same way workflow.save/workflow.run already
178191
// do via canvasCommandRequest.
179192
defaultBinding: { mods: ['cmd'], key: 'K' },
193+
// ⌘? / ⌘/ aliases (docs/goals/BACKLOG.md Standing #6, the "owner
194+
// reinforcement" note CommandPalette.tsx used to document as
195+
// deliberately not built): both land on the same physical '/' key
196+
// (keyFromEventCode is shift-independent, shared/keybinding.ts),
197+
// distinguished by the Shift mod -- ⌘/ is the bare combo, ⌘? adds
198+
// Shift (what actually produces the '?' glyph). Checked against
199+
// every other command's defaultBinding above and RESERVED_COMBOS
200+
// (shared/keybinding.ts): neither uses the '/' key on macOS, no
201+
// collision.
202+
extraBindings: [
203+
{ mods: ['cmd'], key: '/' },
204+
{ mods: ['cmd', 'shift'], key: '/' },
205+
],
180206
run: () => useAppStore.getState().togglePalette(),
181207
},
182208
{
@@ -245,21 +271,24 @@ export function effectiveBinding(command: Command, overrides: Record<string, Key
245271
}
246272

247273
// dispatchCommandForEvent resolves a keydown against every command's
248-
// current effective binding and runs the first match -- called from
249-
// App.tsx's one window keydown listener, folding in what used to be a
250-
// separate, hardcoded Cmd+1-4/Cmd+, handler (view.*/settings.open are
251-
// now just ordinary commands in COMMANDS above, same dispatch path).
252-
// Returns whether a command actually ran, so the caller knows whether
253-
// to preventDefault (never swallow an unbound combo -- native
254-
// editing shortcuts, browser devtools, etc. must keep working).
274+
// current effective binding (its primary, override-aware) PLUS every
275+
// extraBindings entry (docs/goals/BACKLOG.md Standing #6 -- always-on,
276+
// never override-checked, see Command.extraBindings' own doc comment)
277+
// and runs the first match -- called from App.tsx's one window keydown
278+
// listener, folding in what used to be a separate, hardcoded Cmd+1-4/
279+
// Cmd+, handler (view.*/settings.open are now just ordinary commands
280+
// in COMMANDS above, same dispatch path). Returns whether a command
281+
// actually ran, so the caller knows whether to preventDefault (never
282+
// swallow an unbound combo -- native editing shortcuts, browser
283+
// devtools, etc. must keep working).
255284
export function dispatchCommandForEvent(e: KeyboardEvent, overrides: Record<string, KeyCombo>): boolean {
256285
const pressed = comboFromEvent(e)
257286
if (!pressed) return false
258287
const want = comboKey(pressed.mods, pressed.key)
259288
for (const command of COMMANDS) {
260289
const binding = effectiveBinding(command, overrides)
261-
if (!binding) continue
262-
if (comboKey(binding.mods, binding.key) === want) {
290+
const bindings = binding ? [binding, ...(command.extraBindings ?? [])] : (command.extraBindings ?? [])
291+
if (bindings.some((b) => comboKey(b.mods, b.key) === want)) {
263292
command.run()
264293
return true
265294
}

0 commit comments

Comments
 (0)