@tooee/commands 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +33 -0
  2. package/dist/command-store.d.ts +147 -0
  3. package/dist/command-store.d.ts.map +1 -0
  4. package/dist/command-store.js +367 -0
  5. package/dist/command-store.js.map +1 -0
  6. package/dist/context.d.ts +52 -3
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +244 -153
  9. package/dist/context.js.map +1 -1
  10. package/dist/index.d.ts +5 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/match.d.ts.map +1 -1
  15. package/dist/match.js +2 -0
  16. package/dist/match.js.map +1 -1
  17. package/dist/mode.d.ts +7 -1
  18. package/dist/mode.d.ts.map +1 -1
  19. package/dist/mode.js +12 -3
  20. package/dist/mode.js.map +1 -1
  21. package/dist/parse.d.ts.map +1 -1
  22. package/dist/parse.js +17 -5
  23. package/dist/parse.js.map +1 -1
  24. package/dist/sequence.d.ts +17 -2
  25. package/dist/sequence.d.ts.map +1 -1
  26. package/dist/sequence.js +66 -45
  27. package/dist/sequence.js.map +1 -1
  28. package/dist/types.d.ts +51 -1
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/use-actions.d.ts.map +1 -1
  31. package/dist/use-actions.js +6 -3
  32. package/dist/use-actions.js.map +1 -1
  33. package/dist/use-command.d.ts.map +1 -1
  34. package/dist/use-command.js +12 -4
  35. package/dist/use-command.js.map +1 -1
  36. package/package.json +5 -1
  37. package/src/command-store.ts +554 -0
  38. package/src/context.tsx +353 -165
  39. package/src/index.ts +27 -1
  40. package/src/match.ts +1 -0
  41. package/src/mode.tsx +31 -3
  42. package/src/parse.ts +20 -5
  43. package/src/sequence.ts +80 -52
  44. package/src/types.ts +56 -3
  45. package/src/use-actions.ts +5 -3
  46. package/src/use-command.ts +13 -4
package/src/context.tsx CHANGED
@@ -5,29 +5,34 @@ import {
5
5
  useCallback,
6
6
  useEffect,
7
7
  useMemo,
8
- useState,
9
8
  type ReactNode,
10
9
  } from "react"
11
10
  import { useKeyboard } from "@opentui/react"
11
+ import { useSelector } from "@xstate/store-react"
12
12
  import type {
13
+ ActiveCommandSurface,
13
14
  Command,
14
15
  CommandContext,
15
16
  CommandGroup,
16
17
  CommandRegistry,
17
18
  CommandSequenceState,
18
- ParsedHotkey,
19
- ParsedStep,
19
+ CommandSurfaceRole,
20
20
  RegisteredCommandGroup,
21
21
  } from "./types.js"
22
22
  import type { Mode } from "./mode.js"
23
23
  import { ModeProvider, useMode, useSetMode } from "./mode.js"
24
24
  import { parseHotkey } from "./parse.js"
25
- import { matchStep } from "./match.js"
26
- import { SequenceTracker } from "./sequence.js"
27
-
28
- const DEFAULT_MODES: Mode[] = ["cursor"]
29
-
30
- type ContextGetter = () => Partial<CommandContext>
25
+ import {
26
+ ROOT_SURFACE_ID,
27
+ createCommandStore,
28
+ selectActiveModalSurface,
29
+ selectSequence,
30
+ selectSurfaceCommandMap,
31
+ stepsKey,
32
+ type CommandStore,
33
+ type ContextGetter,
34
+ type SurfaceRecord,
35
+ } from "./command-store.js"
31
36
 
32
37
  interface CommandContextValue {
33
38
  registry: CommandRegistry
@@ -36,8 +41,37 @@ interface CommandContextValue {
36
41
  groups: Map<string, RegisteredCommandGroup>
37
42
  }
38
43
 
39
- const CommandContext = createContext<CommandContextValue | null>(null)
44
+ /** Internal provider value: the store plus the surface this subtree registers to. */
45
+ interface CommandStoreContextValue {
46
+ commandStore: CommandStore
47
+ /** The surface record `useCommand` registrations under this subtree target. */
48
+ surface: SurfaceRecord
49
+ leaderKey?: string
50
+ }
51
+
52
+ const CommandContext = createContext<CommandStoreContextValue | null>(null)
40
53
  const CommandSequenceContext = createContext<CommandSequenceState | null>(null)
54
+ /** Nesting depth of the nearest command surface (0 at the root app). */
55
+ const CommandSurfaceDepthContext = createContext(0)
56
+
57
+ /**
58
+ * Fallback store for hooks that must not throw outside a CommandProvider
59
+ * (useActiveCommandSurface, useSurfaceCommands). Never dispatched to.
60
+ */
61
+ const FALLBACK_COMMAND_STORE = createCommandStore({
62
+ root: {
63
+ getMode: () => "cursor",
64
+ // Placeholder context: augmented domain fields (overlay, view, ...) are
65
+ // only present where their providers run; dispatch never reaches this.
66
+ buildCtx: () =>
67
+ ({
68
+ mode: "cursor",
69
+ setMode: () => {},
70
+ commands: { invoke: () => {}, list: () => [] },
71
+ exit: () => {},
72
+ }) as unknown as CommandContext,
73
+ },
74
+ })
41
75
 
42
76
  export interface CommandProviderProps {
43
77
  children: ReactNode
@@ -47,6 +81,11 @@ export interface CommandProviderProps {
47
81
  sequenceTimeoutMs?: number
48
82
  }
49
83
 
84
+ interface RootAccess {
85
+ getMode: () => Mode
86
+ buildCtx: () => CommandContext
87
+ }
88
+
50
89
  export function CommandProvider({
51
90
  children,
52
91
  leader,
@@ -54,9 +93,52 @@ export function CommandProvider({
54
93
  initialMode,
55
94
  sequenceTimeoutMs,
56
95
  }: CommandProviderProps) {
96
+ // The store is created here (above the root ModeProvider) so mode changes
97
+ // can be routed into it as transitions; the dispatcher below installs the
98
+ // real root accessors before any key can dispatch.
99
+ const rootAccessRef = useRef<RootAccess>({
100
+ getMode: () => initialMode ?? "cursor",
101
+ // Placeholder until CommandDispatcher installs the real accessors on its
102
+ // first render (before any key can dispatch).
103
+ buildCtx: () =>
104
+ ({
105
+ mode: initialMode ?? "cursor",
106
+ setMode: () => {},
107
+ commands: { invoke: () => {}, list: () => [] },
108
+ exit: () => {},
109
+ }) as unknown as CommandContext,
110
+ })
111
+
112
+ const storeRef = useRef<CommandStore | null>(null)
113
+ if (storeRef.current === null) {
114
+ storeRef.current = createCommandStore({
115
+ leader,
116
+ keymap,
117
+ sequenceTimeoutMs,
118
+ root: {
119
+ getMode: () => rootAccessRef.current.getMode(),
120
+ buildCtx: () => rootAccessRef.current.buildCtx(),
121
+ },
122
+ })
123
+ }
124
+ const commandStore = storeRef.current
125
+
126
+ // Root mode changes are transitions: reset any pending chord synchronously
127
+ // (this replaces the old post-render tracker-reset effect).
128
+ const handleModeChange = useCallback(
129
+ () => commandStore.modeChanged(ROOT_SURFACE_ID),
130
+ [commandStore],
131
+ )
132
+
57
133
  return (
58
- <ModeProvider initialMode={initialMode}>
59
- <CommandDispatcher leader={leader} keymap={keymap} sequenceTimeoutMs={sequenceTimeoutMs}>
134
+ <ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
135
+ <CommandDispatcher
136
+ commandStore={commandStore}
137
+ rootAccess={rootAccessRef}
138
+ leader={leader}
139
+ keymap={keymap}
140
+ sequenceTimeoutMs={sequenceTimeoutMs}
141
+ >
60
142
  {children}
61
143
  </CommandDispatcher>
62
144
  </ModeProvider>
@@ -65,179 +147,203 @@ export function CommandProvider({
65
147
 
66
148
  function CommandDispatcher({
67
149
  children,
150
+ commandStore,
151
+ rootAccess,
68
152
  leader,
69
153
  keymap,
70
154
  sequenceTimeoutMs,
71
155
  }: {
72
156
  children: ReactNode
157
+ commandStore: CommandStore
158
+ rootAccess: { current: RootAccess }
73
159
  leader?: string
74
160
  keymap?: Record<string, string>
75
161
  sequenceTimeoutMs?: number
76
162
  }) {
77
- const registryRef = useRef<CommandRegistry | null>(null)
78
- const contextSourcesRef = useRef(new Map<string, ContextGetter>())
79
- const groupsRef = useRef(new Map<string, RegisteredCommandGroup>())
80
163
  const mode = useMode()
81
164
  const modeRef = useRef(mode)
82
165
  modeRef.current = mode
83
166
  const setMode = useSetMode()
84
- const [sequenceState, setSequenceState] = useState<CommandSequenceState | null>(null)
85
- const clearSequenceStateRef = useRef(() => setSequenceState(null))
86
- clearSequenceStateRef.current = () => setSequenceState(null)
87
167
 
88
168
  const buildCtx = useCallback((): CommandContext => {
169
+ const registry = commandStore.registryFor(commandStore.rootRecord)
89
170
  const ctx: Record<string, any> = {
90
171
  mode: modeRef.current,
91
172
  setMode,
92
173
  commands: {
93
- invoke: (id: string) => registryRef.current?.invoke(id),
94
- list: () => Array.from(registryRef.current?.commands.values() ?? []),
174
+ invoke: (id: string) => registry.invoke(id),
175
+ list: () => Array.from(registry.commands.values()),
95
176
  },
96
177
  exit: () => {},
97
178
  }
98
- for (const getter of contextSourcesRef.current.values()) {
179
+ for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
99
180
  Object.assign(ctx, getter())
100
181
  }
101
182
  return ctx as CommandContext
102
- }, [setMode])
103
-
104
- if (registryRef.current === null) {
105
- const commands = new Map<string, Command>()
106
- registryRef.current = {
107
- commands,
108
- register(command: Command) {
109
- commands.set(command.id, command)
110
- return () => {
111
- commands.delete(command.id)
112
- }
113
- },
114
- invoke(id: string) {
115
- const ctx = buildCtx()
116
- const cmd = commands.get(id)
117
- if (cmd && (!cmd.when || cmd.when(ctx))) {
118
- cmd.handler(ctx)
119
- }
120
- },
183
+ }, [commandStore, setMode])
184
+
185
+ const buildCtxRef = useRef(buildCtx)
186
+ buildCtxRef.current = buildCtx
187
+
188
+ // Install the live root accessors and config (ref writes, same pattern as
189
+ // the previous modeRef mirrors; leader/keymap are read per dispatch).
190
+ rootAccess.current.getMode = () => modeRef.current
191
+ rootAccess.current.buildCtx = () => buildCtxRef.current()
192
+ commandStore.setConfig({ leader, keymap, sequenceTimeoutMs })
193
+
194
+ useKeyboard((event) => {
195
+ if (event.defaultPrevented) return
196
+ const result = commandStore.key(event)
197
+ if (result.handled) {
198
+ event.preventDefault()
199
+ result.invoke?.()
121
200
  }
122
- }
201
+ })
202
+
203
+ // Clear the store's key buffer and pending timeout on dispatcher unmount so
204
+ // the timer cannot fire after the tree is gone.
205
+ useEffect(() => () => commandStore.dispose(), [commandStore])
123
206
 
124
- const trackerRef = useRef(
125
- new SequenceTracker({
126
- timeout: sequenceTimeoutMs,
127
- onReset: () => clearSequenceStateRef.current(),
207
+ const sequenceState = useSelector(commandStore.store, (s) => selectSequence(s.context))
208
+
209
+ const ctxValue = useMemo<CommandStoreContextValue>(
210
+ () => ({
211
+ commandStore,
212
+ surface: commandStore.rootRecord,
213
+ leaderKey: leader,
128
214
  }),
215
+ [commandStore, leader],
129
216
  )
130
- const parseCacheRef = useRef(new Map<string, ParsedHotkey>())
131
-
132
- const getParsedHotkey = useCallback(
133
- (hotkey: string) => {
134
- const cache = parseCacheRef.current
135
- const cacheKey = `${hotkey}:${leader ?? ""}`
136
- let parsed = cache.get(cacheKey)
137
- if (!parsed) {
138
- parsed = parseHotkey(hotkey, leader)
139
- cache.set(cacheKey, parsed)
140
- }
141
- return parsed
142
- },
143
- [leader],
217
+
218
+ return (
219
+ <CommandContext.Provider value={ctxValue}>
220
+ <CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
221
+ </CommandContext.Provider>
144
222
  )
223
+ }
145
224
 
146
- useKeyboard((event) => {
147
- if (event.defaultPrevented) return
225
+ export interface CommandSurfaceProviderProps {
226
+ children: ReactNode
227
+ /** Stable id for the surface (typically the overlay id). */
228
+ id: string
229
+ /** Interaction role (default "modal"). */
230
+ role?: CommandSurfaceRole
231
+ /** Initial local mode for this surface (default "cursor"). */
232
+ initialMode?: Mode
233
+ }
148
234
 
149
- const registry = registryRef.current
150
- if (!registry) return
235
+ /**
236
+ * Mounts an overlay-owned command surface. Children registered via `useCommand`
237
+ * target this surface's local registry, and `useMode`/`useSetMode` read/write
238
+ * this surface's local mode instead of the root app's mode.
239
+ *
240
+ * While a `modal` surface is topmost it owns keyboard input and suspends the
241
+ * parent app's commands; a `passive` surface never becomes the keyboard owner.
242
+ */
243
+ export function CommandSurfaceProvider({
244
+ children,
245
+ id,
246
+ role = "modal",
247
+ initialMode = "cursor",
248
+ }: CommandSurfaceProviderProps) {
249
+ const parent = useContext(CommandContext)
250
+ if (!parent) {
251
+ throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
252
+ }
253
+ const { commandStore } = parent
151
254
 
152
- const currentMode = mode
153
- const ctx = buildCtx()
255
+ // Surface-local mode changes are transitions too (F-08): a mid-chord mode
256
+ // switch on a modal surface resets the pending sequence.
257
+ const handleModeChange = useCallback(() => commandStore.modeChanged(id), [commandStore, id])
154
258
 
155
- // Collect eligible commands with their parsed hotkeys
156
- const singleStepCandidates: { command: Command; parsed: ParsedHotkey }[] = []
157
- const multiStepCandidates: { command: Command; hotkey: string; parsed: ParsedHotkey }[] = []
259
+ return (
260
+ <ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
261
+ <CommandSurfaceInner id={id} role={role}>
262
+ {children}
263
+ </CommandSurfaceInner>
264
+ </ModeProvider>
265
+ )
266
+ }
158
267
 
159
- for (const command of registry.commands.values()) {
160
- const commandModes = command.modes ?? DEFAULT_MODES
161
- if (!commandModes.includes(currentMode)) continue
162
- if (command.when && !command.when(ctx)) continue
268
+ function CommandSurfaceInner({
269
+ children,
270
+ id,
271
+ role,
272
+ }: {
273
+ children: ReactNode
274
+ id: string
275
+ role: CommandSurfaceRole
276
+ }) {
277
+ const parent = useContext(CommandContext)
278
+ if (!parent) {
279
+ throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
280
+ }
281
+ const { commandStore } = parent
163
282
 
164
- const hotkey = keymap?.[command.id] ?? command.defaultHotkey
165
- if (!hotkey) continue
283
+ const parentDepth = useContext(CommandSurfaceDepthContext)
284
+ const depth = parentDepth + 1
166
285
 
167
- const parsed = getParsedHotkey(hotkey)
286
+ const mode = useMode()
287
+ const setMode = useSetMode()
288
+ const modeRef = useRef(mode)
289
+ modeRef.current = mode
168
290
 
169
- if (parsed.steps.length === 1) {
170
- singleStepCandidates.push({ command, parsed })
171
- } else {
172
- multiStepCandidates.push({ command, hotkey, parsed })
173
- }
291
+ const buildCtx = useCallback((): CommandContext => {
292
+ const registry = commandStore.registryFor(recordRef.current!)
293
+ const ctx: Record<string, any> = {
294
+ mode: modeRef.current,
295
+ setMode,
296
+ commands: {
297
+ invoke: (cmdId: string) => registry.invoke(cmdId),
298
+ list: () => Array.from(registry.commands.values()),
299
+ },
300
+ exit: () => {},
174
301
  }
175
-
176
- // Check multi-step sequences first (they consume buffer state)
177
- if (multiStepCandidates.length > 0) {
178
- const multiStepHotkeys = multiStepCandidates.map((candidate) => candidate.parsed)
179
- const result = trackerRef.current.feedWithState(event, multiStepHotkeys)
180
- if (result.matchedIndex >= 0) {
181
- event.preventDefault()
182
- setSequenceState(null)
183
- multiStepCandidates[result.matchedIndex]!.command.handler(ctx)
184
- return
185
- }
186
-
187
- if (result.pending) {
188
- const firstCandidate = multiStepCandidates[result.pending.indexes[0]!]!
189
- setSequenceState({
190
- prefix: firstCandidate.parsed.steps.slice(0, result.pending.prefixLength),
191
- candidates: result.pending.indexes
192
- .map((idx) => multiStepCandidates[idx]!)
193
- .filter(({ command }) => !command.hidden)
194
- .map(({ command, hotkey, parsed }) => ({
195
- command,
196
- hotkey,
197
- steps: parsed.steps,
198
- remainingSteps: parsed.steps.slice(result.pending!.prefixLength),
199
- nextStep: parsed.steps[result.pending!.prefixLength]!,
200
- group: groupsRef.current.get(
201
- stepsKey(parsed.steps.slice(0, result.pending!.prefixLength + 1)),
202
- ),
203
- })),
204
- })
205
- event.preventDefault()
206
- return
207
- }
208
-
209
- setSequenceState(null)
302
+ for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
303
+ Object.assign(ctx, getter())
210
304
  }
211
-
212
- // Check single-step matches
213
- for (const { command, parsed } of singleStepCandidates) {
214
- if (matchStep(event, parsed.steps[0]!)) {
215
- event.preventDefault()
216
- setSequenceState(null)
217
- command.handler(ctx)
218
- return
219
- }
305
+ return ctx as CommandContext
306
+ }, [commandStore, setMode])
307
+
308
+ const buildCtxRef = useRef(buildCtx)
309
+ buildCtxRef.current = buildCtx
310
+
311
+ const recordRef = useRef<SurfaceRecord | null>(null)
312
+ if (
313
+ recordRef.current === null ||
314
+ recordRef.current.id !== id ||
315
+ recordRef.current.role !== role ||
316
+ recordRef.current.depth !== depth
317
+ ) {
318
+ recordRef.current = {
319
+ id,
320
+ role,
321
+ depth,
322
+ order: 0,
323
+ getMode: () => modeRef.current,
324
+ buildCtx: () => buildCtxRef.current(),
220
325
  }
221
- })
326
+ }
327
+ const record = recordRef.current
222
328
 
223
- useEffect(() => {
224
- trackerRef.current.reset()
225
- setSequenceState(null)
226
- }, [mode])
329
+ // Mount/unmount registration is a keep-effect: it synchronizes the React
330
+ // tree with the store's surface stack.
331
+ useEffect(() => commandStore.pushSurface(record), [commandStore, record])
227
332
 
228
- const ctxValue = useMemo(
333
+ const ctxValue = useMemo<CommandStoreContextValue>(
229
334
  () => ({
230
- registry: registryRef.current!,
231
- leaderKey: leader,
232
- contextSources: contextSourcesRef.current,
233
- groups: groupsRef.current,
335
+ commandStore,
336
+ surface: record,
337
+ leaderKey: parent.leaderKey,
234
338
  }),
235
- [leader],
339
+ [commandStore, record, parent.leaderKey],
236
340
  )
237
341
 
238
342
  return (
239
343
  <CommandContext.Provider value={ctxValue}>
240
- <CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
344
+ <CommandSurfaceDepthContext.Provider value={depth}>
345
+ {children}
346
+ </CommandSurfaceDepthContext.Provider>
241
347
  </CommandContext.Provider>
242
348
  )
243
349
  }
@@ -247,14 +353,36 @@ export function useCommandContext(): { commands: Command[]; invoke: (id: string)
247
353
  if (!ctx) {
248
354
  throw new Error("useCommandContext must be used within a CommandProvider")
249
355
  }
356
+ const { commandStore, surface } = ctx
250
357
 
251
- const { registry } = ctx
252
- return {
253
- get commands() {
254
- return Array.from(registry.commands.values())
255
- },
256
- invoke: registry.invoke,
358
+ // Reactive: consumers re-render when a command registers or unregisters on
359
+ // this surface (the per-surface map is identity-stable otherwise).
360
+ const commandMap = useSelector(commandStore.store, (s) =>
361
+ selectSurfaceCommandMap(s.context, surface.id),
362
+ )
363
+ const registry = commandStore.registryFor(surface)
364
+
365
+ return useMemo(
366
+ () => ({
367
+ commands: commandMap ? Array.from(commandMap.values()) : [],
368
+ invoke: registry.invoke,
369
+ }),
370
+ [commandMap, registry],
371
+ )
372
+ }
373
+
374
+ /**
375
+ * Internal: the stable registry facade for the nearest surface, without
376
+ * subscribing to store changes. Registration hooks (useCommand/useActions)
377
+ * only need the facade; subscribing them would re-render every command host
378
+ * on unrelated group/context-source registrations.
379
+ */
380
+ export function useSurfaceRegistry(): CommandRegistry {
381
+ const ctx = useContext(CommandContext)
382
+ if (!ctx) {
383
+ throw new Error("useCommandRegistry must be used within a CommandProvider")
257
384
  }
385
+ return ctx.commandStore.registryFor(ctx.surface)
258
386
  }
259
387
 
260
388
  export function useCommandRegistry(): CommandContextValue {
@@ -262,13 +390,83 @@ export function useCommandRegistry(): CommandContextValue {
262
390
  if (!ctx) {
263
391
  throw new Error("useCommandRegistry must be used within a CommandProvider")
264
392
  }
265
- return ctx
393
+ const { commandStore, surface, leaderKey } = ctx
394
+
395
+ // Subscribe to the slices so captured maps stay current across renders (the
396
+ // maps are immutable snapshots now, not shared mutable maps).
397
+ const groups = useSelector(commandStore.store, (s) => s.context.groups)
398
+ const contextSources = useSelector(commandStore.store, (s) => s.context.contextSources)
399
+ const registry = commandStore.registryFor(surface)
400
+
401
+ return useMemo(
402
+ () => ({
403
+ registry,
404
+ leaderKey,
405
+ contextSources: contextSources as Map<string, ContextGetter>,
406
+ groups: groups as Map<string, RegisteredCommandGroup>,
407
+ }),
408
+ [registry, leaderKey, contextSources, groups],
409
+ )
266
410
  }
267
411
 
268
412
  export function useCommandSequenceState(): CommandSequenceState | null {
269
413
  return useContext(CommandSequenceContext)
270
414
  }
271
415
 
416
+ /**
417
+ * Metadata for the topmost modal command surface, or null when the root app is
418
+ * the active surface. Intended for which-key/help to read shortcuts from the
419
+ * active interaction surface. `commands` is reactive (F-13).
420
+ */
421
+ export function useActiveCommandSurface(): ActiveCommandSurface | null {
422
+ const ctx = useContext(CommandContext)
423
+ const store = (ctx?.commandStore ?? FALLBACK_COMMAND_STORE).store
424
+
425
+ const record = useSelector(store, (s) => selectActiveModalSurface(s.context))
426
+ const commandMap = useSelector(store, (s) => {
427
+ const active = selectActiveModalSurface(s.context)
428
+ return active ? selectSurfaceCommandMap(s.context, active.id) : undefined
429
+ })
430
+
431
+ return useMemo(() => {
432
+ if (!record) return null
433
+ return {
434
+ id: record.id,
435
+ role: record.role as CommandSurfaceRole,
436
+ commands: commandMap ? Array.from(commandMap.values()) : [],
437
+ }
438
+ }, [record, commandMap])
439
+ }
440
+
441
+ /**
442
+ * Commands registered on a surface (reactive). Defaults to the active modal
443
+ * surface, falling back to the root surface when none is active.
444
+ */
445
+ export function useSurfaceCommands(surfaceId?: string): readonly Command[] {
446
+ const ctx = useContext(CommandContext)
447
+ const store = (ctx?.commandStore ?? FALLBACK_COMMAND_STORE).store
448
+
449
+ const commandMap = useSelector(store, (s) => {
450
+ const id = surfaceId ?? selectActiveModalSurface(s.context)?.id ?? ROOT_SURFACE_ID
451
+ return selectSurfaceCommandMap(s.context, id)
452
+ })
453
+
454
+ return useMemo(() => (commandMap ? Array.from(commandMap.values()) : []), [commandMap])
455
+ }
456
+
457
+ /**
458
+ * Advanced/internal — subject to change. The command store instance backing
459
+ * this provider tree, for bridges that must reach the dispatch machinery
460
+ * (e.g. the shell's overlay-replacement sequence reset).
461
+ */
462
+ export function useCommandStore(): CommandStore {
463
+ const ctx = useContext(CommandContext)
464
+ if (!ctx) {
465
+ throw new Error("useCommandStore must be used within a CommandProvider")
466
+ }
467
+ return ctx.commandStore
468
+ }
469
+
272
470
  export function useCommandGroup(group: CommandGroup): void {
273
471
  const ctx = useContext(CommandContext)
274
472
  if (!ctx) {
@@ -277,7 +475,7 @@ export function useCommandGroup(group: CommandGroup): void {
277
475
 
278
476
  const groupRef = useRef(group)
279
477
  groupRef.current = group
280
- const { groups, leaderKey } = ctx
478
+ const { commandStore, leaderKey } = ctx
281
479
 
282
480
  useEffect(() => {
283
481
  const parsed = parseHotkey(groupRef.current.prefix, leaderKey)
@@ -285,9 +483,10 @@ export function useCommandGroup(group: CommandGroup): void {
285
483
  ...groupRef.current,
286
484
  prefixKey: stepsKey(parsed.steps),
287
485
  }
288
- groups.set(registered.prefixKey, registered)
486
+ commandStore.store.trigger.groupRegistered({ group: registered })
289
487
  return () => {
290
- groups.delete(registered.prefixKey)
488
+ // Unregistration is identity-guarded in the store transition (R-05).
489
+ commandStore.store.trigger.groupUnregistered({ group: registered })
291
490
  }
292
491
  }, [
293
492
  group.id,
@@ -296,25 +495,11 @@ export function useCommandGroup(group: CommandGroup): void {
296
495
  group.description,
297
496
  group.icon,
298
497
  group.order,
299
- groups,
498
+ commandStore,
300
499
  leaderKey,
301
500
  ])
302
501
  }
303
502
 
304
- function stepsKey(steps: ParsedStep[]): string {
305
- return steps.map(formatStepKey).join(" ")
306
- }
307
-
308
- function formatStepKey(step: ParsedStep): string {
309
- const modifiers = []
310
- if (step.ctrl) modifiers.push("ctrl")
311
- if (step.meta) modifiers.push("meta")
312
- if (step.option) modifiers.push("option")
313
- if (step.shift) modifiers.push("shift")
314
- modifiers.push(step.key)
315
- return modifiers.join("+")
316
- }
317
-
318
503
  let nextContextSourceId = 0
319
504
 
320
505
  export function useProvideCommandContext(getter: () => Partial<CommandContext>): void {
@@ -331,13 +516,16 @@ export function useProvideCommandContext(getter: () => Partial<CommandContext>):
331
516
  const getterRef = useRef(getter)
332
517
  getterRef.current = getter
333
518
 
334
- const { contextSources } = ctx
519
+ const { commandStore } = ctx
335
520
 
336
521
  useEffect(() => {
337
522
  const id = idRef.current!
338
- contextSources.set(id, () => getterRef.current())
523
+ commandStore.store.trigger.contextSourceRegistered({
524
+ id,
525
+ getter: () => getterRef.current(),
526
+ })
339
527
  return () => {
340
- contextSources.delete(id)
528
+ commandStore.store.trigger.contextSourceUnregistered({ id })
341
529
  }
342
- }, [contextSources])
530
+ }, [commandStore])
343
531
  }