@tooee/commands 0.1.20 → 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 +28 -3
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +189 -233
  9. package/dist/context.js.map +1 -1
  10. package/dist/index.d.ts +3 -1
  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 +20 -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 +252 -276
  39. package/src/index.ts +21 -0
  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 +22 -3
  45. package/src/use-actions.ts +5 -3
  46. package/src/use-command.ts +13 -4
package/src/context.tsx CHANGED
@@ -5,10 +5,10 @@ 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
13
  ActiveCommandSurface,
14
14
  Command,
@@ -16,21 +16,23 @@ import type {
16
16
  CommandGroup,
17
17
  CommandRegistry,
18
18
  CommandSequenceState,
19
- CommandSurfaceEntry,
20
19
  CommandSurfaceRole,
21
- ParsedHotkey,
22
- ParsedStep,
23
20
  RegisteredCommandGroup,
24
21
  } from "./types.js"
25
22
  import type { Mode } from "./mode.js"
26
23
  import { ModeProvider, useMode, useSetMode } from "./mode.js"
27
24
  import { parseHotkey } from "./parse.js"
28
- import { matchStep } from "./match.js"
29
- import { SequenceTracker } from "./sequence.js"
30
-
31
- const DEFAULT_MODES: Mode[] = ["cursor"]
32
-
33
- 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"
34
36
 
35
37
  interface CommandContextValue {
36
38
  registry: CommandRegistry
@@ -39,45 +41,37 @@ interface CommandContextValue {
39
41
  groups: Map<string, RegisteredCommandGroup>
40
42
  }
41
43
 
42
- interface CommandSurfaceStackValue {
43
- /** Register a surface; returns an unregister function. Mutates entry.order. */
44
- register: (entry: CommandSurfaceEntry) => () => void
45
- /** Resolve the topmost modal surface (deepest, then most-recently registered). */
46
- getActiveModalSurface: () => CommandSurfaceEntry | null
47
- /** Reactive metadata for the active modal surface (for which-key/help). */
48
- activeModalSurface: ActiveCommandSurface | 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
49
50
  }
50
51
 
51
- const CommandContext = createContext<CommandContextValue | null>(null)
52
+ const CommandContext = createContext<CommandStoreContextValue | null>(null)
52
53
  const CommandSequenceContext = createContext<CommandSequenceState | null>(null)
53
- const CommandSurfaceStackContext = createContext<CommandSurfaceStackValue | null>(null)
54
54
  /** Nesting depth of the nearest command surface (0 at the root app). */
55
55
  const CommandSurfaceDepthContext = createContext(0)
56
56
 
57
57
  /**
58
- * Build a command registry whose `invoke` resolves the command context lazily
59
- * via `getCtx`, so a surface always dispatches with its own (local) mode and
60
- * the current shared context sources.
58
+ * Fallback store for hooks that must not throw outside a CommandProvider
59
+ * (useActiveCommandSurface, useSurfaceCommands). Never dispatched to.
61
60
  */
62
- function createRegistry(getCtx: () => CommandContext): CommandRegistry {
63
- const commands = new Map<string, Command>()
64
- return {
65
- commands,
66
- register(command: Command) {
67
- commands.set(command.id, command)
68
- return () => {
69
- commands.delete(command.id)
70
- }
71
- },
72
- invoke(id: string) {
73
- const ctx = getCtx()
74
- const cmd = commands.get(id)
75
- if (cmd && (!cmd.when || cmd.when(ctx))) {
76
- cmd.handler(ctx)
77
- }
78
- },
79
- }
80
- }
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
+ })
81
75
 
82
76
  export interface CommandProviderProps {
83
77
  children: ReactNode
@@ -87,6 +81,11 @@ export interface CommandProviderProps {
87
81
  sequenceTimeoutMs?: number
88
82
  }
89
83
 
84
+ interface RootAccess {
85
+ getMode: () => Mode
86
+ buildCtx: () => CommandContext
87
+ }
88
+
90
89
  export function CommandProvider({
91
90
  children,
92
91
  leader,
@@ -94,9 +93,52 @@ export function CommandProvider({
94
93
  initialMode,
95
94
  sequenceTimeoutMs,
96
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
+
97
133
  return (
98
- <ModeProvider initialMode={initialMode}>
99
- <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
+ >
100
142
  {children}
101
143
  </CommandDispatcher>
102
144
  </ModeProvider>
@@ -105,227 +147,77 @@ export function CommandProvider({
105
147
 
106
148
  function CommandDispatcher({
107
149
  children,
150
+ commandStore,
151
+ rootAccess,
108
152
  leader,
109
153
  keymap,
110
154
  sequenceTimeoutMs,
111
155
  }: {
112
156
  children: ReactNode
157
+ commandStore: CommandStore
158
+ rootAccess: { current: RootAccess }
113
159
  leader?: string
114
160
  keymap?: Record<string, string>
115
161
  sequenceTimeoutMs?: number
116
162
  }) {
117
- const registryRef = useRef<CommandRegistry | null>(null)
118
- const contextSourcesRef = useRef(new Map<string, ContextGetter>())
119
- const groupsRef = useRef(new Map<string, RegisteredCommandGroup>())
120
163
  const mode = useMode()
121
164
  const modeRef = useRef(mode)
122
165
  modeRef.current = mode
123
166
  const setMode = useSetMode()
124
- const [sequenceState, setSequenceState] = useState<CommandSequenceState | null>(null)
125
- const clearSequenceStateRef = useRef(() => setSequenceState(null))
126
- clearSequenceStateRef.current = () => setSequenceState(null)
127
167
 
128
168
  const buildCtx = useCallback((): CommandContext => {
169
+ const registry = commandStore.registryFor(commandStore.rootRecord)
129
170
  const ctx: Record<string, any> = {
130
171
  mode: modeRef.current,
131
172
  setMode,
132
173
  commands: {
133
- invoke: (id: string) => registryRef.current?.invoke(id),
134
- list: () => Array.from(registryRef.current?.commands.values() ?? []),
174
+ invoke: (id: string) => registry.invoke(id),
175
+ list: () => Array.from(registry.commands.values()),
135
176
  },
136
177
  exit: () => {},
137
178
  }
138
- for (const getter of contextSourcesRef.current.values()) {
179
+ for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
139
180
  Object.assign(ctx, getter())
140
181
  }
141
182
  return ctx as CommandContext
142
- }, [setMode])
183
+ }, [commandStore, setMode])
143
184
 
144
185
  const buildCtxRef = useRef(buildCtx)
145
186
  buildCtxRef.current = buildCtx
146
187
 
147
- if (registryRef.current === null) {
148
- registryRef.current = createRegistry(() => buildCtxRef.current())
149
- }
150
-
151
- // --- Command surface stack ----------------------------------------------
152
- // The root registry above is the implicit base surface. Modal overlays push
153
- // surfaces on top of it; while a modal surface is topmost, key dispatch is
154
- // arbitrated to that surface only and the root (and lower surfaces) are
155
- // suspended — even for keys the surface does not handle.
156
- const surfaceEntriesRef = useRef<CommandSurfaceEntry[]>([])
157
- const surfaceOrderRef = useRef(0)
158
- const [surfaceVersion, setSurfaceVersion] = useState(0)
159
-
160
- const registerSurface = useCallback((entry: CommandSurfaceEntry) => {
161
- entry.order = surfaceOrderRef.current++
162
- surfaceEntriesRef.current = [...surfaceEntriesRef.current, entry]
163
- setSurfaceVersion((v) => v + 1)
164
- return () => {
165
- surfaceEntriesRef.current = surfaceEntriesRef.current.filter((e) => e !== entry)
166
- setSurfaceVersion((v) => v + 1)
167
- }
168
- }, [])
169
-
170
- const getActiveModalSurface = useCallback((): CommandSurfaceEntry | null => {
171
- let best: CommandSurfaceEntry | null = null
172
- for (const entry of surfaceEntriesRef.current) {
173
- if (entry.role !== "modal") continue
174
- if (
175
- best === null ||
176
- entry.depth > best.depth ||
177
- (entry.depth === best.depth && entry.order > best.order)
178
- ) {
179
- best = entry
180
- }
181
- }
182
- return best
183
- }, [])
184
-
185
- const activeModalSurface = useMemo<ActiveCommandSurface | null>(() => {
186
- const surface = getActiveModalSurface()
187
- return surface ? { id: surface.id, role: surface.role } : null
188
- // eslint-disable-next-line react-hooks/exhaustive-deps
189
- }, [getActiveModalSurface, surfaceVersion])
190
-
191
- const surfaceStackValue = useMemo<CommandSurfaceStackValue>(
192
- () => ({ register: registerSurface, getActiveModalSurface, activeModalSurface }),
193
- [registerSurface, getActiveModalSurface, activeModalSurface],
194
- )
195
-
196
- const trackerRef = useRef(
197
- new SequenceTracker({
198
- timeout: sequenceTimeoutMs,
199
- onReset: () => clearSequenceStateRef.current(),
200
- }),
201
- )
202
- const parseCacheRef = useRef(new Map<string, ParsedHotkey>())
203
-
204
- const getParsedHotkey = useCallback(
205
- (hotkey: string) => {
206
- const cache = parseCacheRef.current
207
- const cacheKey = `${hotkey}:${leader ?? ""}`
208
- let parsed = cache.get(cacheKey)
209
- if (!parsed) {
210
- parsed = parseHotkey(hotkey, leader)
211
- cache.set(cacheKey, parsed)
212
- }
213
- return parsed
214
- },
215
- [leader],
216
- )
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 })
217
193
 
218
194
  useKeyboard((event) => {
219
195
  if (event.defaultPrevented) return
220
-
221
- const rootRegistry = registryRef.current
222
- if (!rootRegistry) return
223
-
224
- // Arbitration: a topmost modal surface owns input; otherwise the root app.
225
- const activeSurface = getActiveModalSurface()
226
- const registry = activeSurface ? activeSurface.registry : rootRegistry
227
- const currentMode = activeSurface ? activeSurface.getMode() : modeRef.current
228
- const ctx = activeSurface ? activeSurface.buildCtx() : buildCtx()
229
-
230
- // Collect eligible commands with their parsed hotkeys
231
- const singleStepCandidates: { command: Command; parsed: ParsedHotkey }[] = []
232
- const multiStepCandidates: { command: Command; hotkey: string; parsed: ParsedHotkey }[] = []
233
-
234
- for (const command of registry.commands.values()) {
235
- const commandModes = command.modes ?? DEFAULT_MODES
236
- if (!commandModes.includes(currentMode)) continue
237
- if (command.when && !command.when(ctx)) continue
238
-
239
- const hotkey = keymap?.[command.id] ?? command.defaultHotkey
240
- if (!hotkey) continue
241
-
242
- const parsed = getParsedHotkey(hotkey)
243
-
244
- if (parsed.steps.length === 1) {
245
- singleStepCandidates.push({ command, parsed })
246
- } else {
247
- multiStepCandidates.push({ command, hotkey, parsed })
248
- }
196
+ const result = commandStore.key(event)
197
+ if (result.handled) {
198
+ event.preventDefault()
199
+ result.invoke?.()
249
200
  }
250
-
251
- // Check multi-step sequences first (they consume buffer state)
252
- if (multiStepCandidates.length > 0) {
253
- const multiStepHotkeys = multiStepCandidates.map((candidate) => candidate.parsed)
254
- const result = trackerRef.current.feedWithState(event, multiStepHotkeys)
255
- if (result.matchedIndex >= 0) {
256
- event.preventDefault()
257
- setSequenceState(null)
258
- multiStepCandidates[result.matchedIndex]!.command.handler(ctx)
259
- return
260
- }
261
-
262
- if (result.pending) {
263
- const firstCandidate = multiStepCandidates[result.pending.indexes[0]!]!
264
- setSequenceState({
265
- prefix: firstCandidate.parsed.steps.slice(0, result.pending.prefixLength),
266
- candidates: result.pending.indexes
267
- .map((idx) => multiStepCandidates[idx]!)
268
- .filter(({ command }) => !command.hidden)
269
- .map(({ command, hotkey, parsed }) => ({
270
- command,
271
- hotkey,
272
- steps: parsed.steps,
273
- remainingSteps: parsed.steps.slice(result.pending!.prefixLength),
274
- nextStep: parsed.steps[result.pending!.prefixLength]!,
275
- group: groupsRef.current.get(
276
- stepsKey(parsed.steps.slice(0, result.pending!.prefixLength + 1)),
277
- ),
278
- })),
279
- })
280
- event.preventDefault()
281
- return
282
- }
283
-
284
- setSequenceState(null)
285
- }
286
-
287
- // Check single-step matches
288
- for (const { command, parsed } of singleStepCandidates) {
289
- if (matchStep(event, parsed.steps[0]!)) {
290
- event.preventDefault()
291
- setSequenceState(null)
292
- command.handler(ctx)
293
- return
294
- }
295
- }
296
-
297
- // A modal surface swallows unhandled keys: dispatch never falls through to
298
- // the root app while a modal surface is topmost.
299
201
  })
300
202
 
301
- useEffect(() => {
302
- trackerRef.current.reset()
303
- setSequenceState(null)
304
- }, [mode])
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])
305
206
 
306
- // Reset any in-flight sequence when keyboard ownership changes (e.g. a modal
307
- // surface opens/closes) so partial chords don't leak across surfaces. Passive
308
- // surfaces such as which-key must not clear the sequence they are displaying.
309
- useEffect(() => {
310
- trackerRef.current.reset()
311
- setSequenceState(null)
312
- }, [activeModalSurface?.id])
207
+ const sequenceState = useSelector(commandStore.store, (s) => selectSequence(s.context))
313
208
 
314
- const ctxValue = useMemo(
209
+ const ctxValue = useMemo<CommandStoreContextValue>(
315
210
  () => ({
316
- registry: registryRef.current!,
211
+ commandStore,
212
+ surface: commandStore.rootRecord,
317
213
  leaderKey: leader,
318
- contextSources: contextSourcesRef.current,
319
- groups: groupsRef.current,
320
214
  }),
321
- [leader],
215
+ [commandStore, leader],
322
216
  )
323
217
 
324
218
  return (
325
219
  <CommandContext.Provider value={ctxValue}>
326
- <CommandSurfaceStackContext.Provider value={surfaceStackValue}>
327
- <CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
328
- </CommandSurfaceStackContext.Provider>
220
+ <CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
329
221
  </CommandContext.Provider>
330
222
  )
331
223
  }
@@ -354,8 +246,18 @@ export function CommandSurfaceProvider({
354
246
  role = "modal",
355
247
  initialMode = "cursor",
356
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
254
+
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])
258
+
357
259
  return (
358
- <ModeProvider initialMode={initialMode}>
260
+ <ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
359
261
  <CommandSurfaceInner id={id} role={role}>
360
262
  {children}
361
263
  </CommandSurfaceInner>
@@ -373,10 +275,10 @@ function CommandSurfaceInner({
373
275
  role: CommandSurfaceRole
374
276
  }) {
375
277
  const parent = useContext(CommandContext)
376
- const stack = useContext(CommandSurfaceStackContext)
377
- if (!parent || !stack) {
278
+ if (!parent) {
378
279
  throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
379
280
  }
281
+ const { commandStore } = parent
380
282
 
381
283
  const parentDepth = useContext(CommandSurfaceDepthContext)
382
284
  const depth = parentDepth + 1
@@ -386,53 +288,55 @@ function CommandSurfaceInner({
386
288
  const modeRef = useRef(mode)
387
289
  modeRef.current = mode
388
290
 
389
- const registryRef = useRef<CommandRegistry | null>(null)
390
-
391
291
  const buildCtx = useCallback((): CommandContext => {
292
+ const registry = commandStore.registryFor(recordRef.current!)
392
293
  const ctx: Record<string, any> = {
393
294
  mode: modeRef.current,
394
295
  setMode,
395
296
  commands: {
396
- invoke: (cmdId: string) => registryRef.current?.invoke(cmdId),
397
- list: () => Array.from(registryRef.current?.commands.values() ?? []),
297
+ invoke: (cmdId: string) => registry.invoke(cmdId),
298
+ list: () => Array.from(registry.commands.values()),
398
299
  },
399
300
  exit: () => {},
400
301
  }
401
- for (const getter of parent.contextSources.values()) {
302
+ for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
402
303
  Object.assign(ctx, getter())
403
304
  }
404
305
  return ctx as CommandContext
405
- }, [parent.contextSources, setMode])
306
+ }, [commandStore, setMode])
406
307
 
407
308
  const buildCtxRef = useRef(buildCtx)
408
309
  buildCtxRef.current = buildCtx
409
310
 
410
- if (registryRef.current === null) {
411
- registryRef.current = createRegistry(() => buildCtxRef.current())
412
- }
413
-
414
- const { register } = stack
415
- useEffect(() => {
416
- const entry: CommandSurfaceEntry = {
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 = {
417
319
  id,
418
320
  role,
419
321
  depth,
420
322
  order: 0,
421
- registry: registryRef.current!,
422
323
  getMode: () => modeRef.current,
423
324
  buildCtx: () => buildCtxRef.current(),
424
325
  }
425
- return register(entry)
426
- }, [register, id, role, depth])
326
+ }
327
+ const record = recordRef.current
427
328
 
428
- const ctxValue = useMemo<CommandContextValue>(
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])
332
+
333
+ const ctxValue = useMemo<CommandStoreContextValue>(
429
334
  () => ({
430
- registry: registryRef.current!,
335
+ commandStore,
336
+ surface: record,
431
337
  leaderKey: parent.leaderKey,
432
- contextSources: parent.contextSources,
433
- groups: parent.groups,
434
338
  }),
435
- [parent.leaderKey, parent.contextSources, parent.groups],
339
+ [commandStore, record, parent.leaderKey],
436
340
  )
437
341
 
438
342
  return (
@@ -449,14 +353,36 @@ export function useCommandContext(): { commands: Command[]; invoke: (id: string)
449
353
  if (!ctx) {
450
354
  throw new Error("useCommandContext must be used within a CommandProvider")
451
355
  }
356
+ const { commandStore, surface } = ctx
357
+
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)
452
364
 
453
- const { registry } = ctx
454
- return {
455
- get commands() {
456
- return Array.from(registry.commands.values())
457
- },
458
- invoke: registry.invoke,
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")
459
384
  }
385
+ return ctx.commandStore.registryFor(ctx.surface)
460
386
  }
461
387
 
462
388
  export function useCommandRegistry(): CommandContextValue {
@@ -464,7 +390,23 @@ export function useCommandRegistry(): CommandContextValue {
464
390
  if (!ctx) {
465
391
  throw new Error("useCommandRegistry must be used within a CommandProvider")
466
392
  }
467
- 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
+ )
468
410
  }
469
411
 
470
412
  export function useCommandSequenceState(): CommandSequenceState | null {
@@ -474,11 +416,55 @@ export function useCommandSequenceState(): CommandSequenceState | null {
474
416
  /**
475
417
  * Metadata for the topmost modal command surface, or null when the root app is
476
418
  * the active surface. Intended for which-key/help to read shortcuts from the
477
- * active interaction surface.
419
+ * active interaction surface. `commands` is reactive (F-13).
478
420
  */
479
421
  export function useActiveCommandSurface(): ActiveCommandSurface | null {
480
- const ctx = useContext(CommandSurfaceStackContext)
481
- return ctx ? ctx.activeModalSurface : 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
482
468
  }
483
469
 
484
470
  export function useCommandGroup(group: CommandGroup): void {
@@ -489,7 +475,7 @@ export function useCommandGroup(group: CommandGroup): void {
489
475
 
490
476
  const groupRef = useRef(group)
491
477
  groupRef.current = group
492
- const { groups, leaderKey } = ctx
478
+ const { commandStore, leaderKey } = ctx
493
479
 
494
480
  useEffect(() => {
495
481
  const parsed = parseHotkey(groupRef.current.prefix, leaderKey)
@@ -497,9 +483,10 @@ export function useCommandGroup(group: CommandGroup): void {
497
483
  ...groupRef.current,
498
484
  prefixKey: stepsKey(parsed.steps),
499
485
  }
500
- groups.set(registered.prefixKey, registered)
486
+ commandStore.store.trigger.groupRegistered({ group: registered })
501
487
  return () => {
502
- groups.delete(registered.prefixKey)
488
+ // Unregistration is identity-guarded in the store transition (R-05).
489
+ commandStore.store.trigger.groupUnregistered({ group: registered })
503
490
  }
504
491
  }, [
505
492
  group.id,
@@ -508,25 +495,11 @@ export function useCommandGroup(group: CommandGroup): void {
508
495
  group.description,
509
496
  group.icon,
510
497
  group.order,
511
- groups,
498
+ commandStore,
512
499
  leaderKey,
513
500
  ])
514
501
  }
515
502
 
516
- function stepsKey(steps: ParsedStep[]): string {
517
- return steps.map(formatStepKey).join(" ")
518
- }
519
-
520
- function formatStepKey(step: ParsedStep): string {
521
- const modifiers = []
522
- if (step.ctrl) modifiers.push("ctrl")
523
- if (step.meta) modifiers.push("meta")
524
- if (step.option) modifiers.push("option")
525
- if (step.shift) modifiers.push("shift")
526
- modifiers.push(step.key)
527
- return modifiers.join("+")
528
- }
529
-
530
503
  let nextContextSourceId = 0
531
504
 
532
505
  export function useProvideCommandContext(getter: () => Partial<CommandContext>): void {
@@ -543,13 +516,16 @@ export function useProvideCommandContext(getter: () => Partial<CommandContext>):
543
516
  const getterRef = useRef(getter)
544
517
  getterRef.current = getter
545
518
 
546
- const { contextSources } = ctx
519
+ const { commandStore } = ctx
547
520
 
548
521
  useEffect(() => {
549
522
  const id = idRef.current!
550
- contextSources.set(id, () => getterRef.current())
523
+ commandStore.store.trigger.contextSourceRegistered({
524
+ id,
525
+ getter: () => getterRef.current(),
526
+ })
551
527
  return () => {
552
- contextSources.delete(id)
528
+ commandStore.store.trigger.contextSourceUnregistered({ id })
553
529
  }
554
- }, [contextSources])
530
+ }, [commandStore])
555
531
  }