@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
@@ -0,0 +1,554 @@
1
+ import { createStore } from "@xstate/store"
2
+ import type { KeyEvent } from "@opentui/core"
3
+ import type {
4
+ Command,
5
+ CommandContext,
6
+ CommandRegistry,
7
+ CommandSequenceState,
8
+ CommandSurfaceRole,
9
+ ParsedHotkey,
10
+ ParsedStep,
11
+ RegisteredCommandGroup,
12
+ } from "./types.js"
13
+ import type { Mode } from "./mode.js"
14
+ import { parseHotkey } from "./parse.js"
15
+ import { matchStep } from "./match.js"
16
+ import {
17
+ DEFAULT_SEQUENCE_TIMEOUT_MS,
18
+ findPendingMatch,
19
+ matchesBuffer,
20
+ pruneBuffer,
21
+ } from "./sequence.js"
22
+
23
+ export const ROOT_SURFACE_ID = "__root"
24
+
25
+ const DEFAULT_MODES: Mode[] = ["cursor"]
26
+
27
+ /**
28
+ * A surface as tracked by the store. The root app is `surfaces[0]`, always
29
+ * present, with role `"root"` — it never arbitrates as modal.
30
+ *
31
+ * Commands are NOT stored on the record: React mounts children (whose
32
+ * `useCommand` effects register commands) before the surface's own register
33
+ * effect pushes the record, and unmount cleanup pops the record before the
34
+ * children unregister. Per-surface command maps therefore live in
35
+ * `commandsBySurface`, keyed by surface id, independent of the stack.
36
+ */
37
+ export interface SurfaceRecord {
38
+ id: string
39
+ role: CommandSurfaceRole | "root"
40
+ /** Nesting depth (root = 0). */
41
+ depth: number
42
+ /** Monotonic registration order (tie-break), assigned by the wrapper on push. */
43
+ order: number
44
+ /** Reads this surface's current local mode (mode stays in ModeProvider React state). */
45
+ getMode: () => Mode
46
+ /** Builds the command context handed to this surface's handlers. */
47
+ buildCtx: () => CommandContext
48
+ }
49
+
50
+ export type ContextGetter = () => Partial<CommandContext>
51
+
52
+ export interface CommandStoreContext {
53
+ /** Surface stack ordered by registration; `surfaces[0]` is the root record. */
54
+ surfaces: readonly SurfaceRecord[]
55
+ /** Per-surface command maps, keyed by surface id (see SurfaceRecord docs). */
56
+ commandsBySurface: ReadonlyMap<string, ReadonlyMap<string, Command>>
57
+ /** Command groups keyed by prefixKey. */
58
+ groups: ReadonlyMap<string, RegisteredCommandGroup>
59
+ contextSources: ReadonlyMap<string, ContextGetter>
60
+ /** Renderable pending-sequence display state (which-key input). */
61
+ sequence: CommandSequenceState | null
62
+ }
63
+
64
+ // --- Selectors ---------------------------------------------------------------
65
+
66
+ /**
67
+ * The topmost modal surface: role === "modal", max depth, then max order.
68
+ * Passive surfaces and the root never win.
69
+ */
70
+ export function selectActiveModalSurface(ctx: CommandStoreContext): SurfaceRecord | null {
71
+ let best: SurfaceRecord | null = null
72
+ for (const record of ctx.surfaces) {
73
+ if (record.role !== "modal") continue
74
+ if (
75
+ best === null ||
76
+ record.depth > best.depth ||
77
+ (record.depth === best.depth && record.order > best.order)
78
+ ) {
79
+ best = record
80
+ }
81
+ }
82
+ return best
83
+ }
84
+
85
+ /**
86
+ * Commands registered on a surface. Returns a fresh array; memoize against
87
+ * `selectSurfaceCommandMap` identity in render paths.
88
+ */
89
+ export function selectSurfaceCommands(
90
+ ctx: CommandStoreContext,
91
+ surfaceId: string,
92
+ ): readonly Command[] {
93
+ const commands = ctx.commandsBySurface.get(surfaceId)
94
+ return commands ? Array.from(commands.values()) : []
95
+ }
96
+
97
+ /** Identity-stable per-surface command map (undefined when none registered). */
98
+ export function selectSurfaceCommandMap(
99
+ ctx: CommandStoreContext,
100
+ surfaceId: string,
101
+ ): ReadonlyMap<string, Command> | undefined {
102
+ return ctx.commandsBySurface.get(surfaceId)
103
+ }
104
+
105
+ export function selectSequence(ctx: CommandStoreContext): CommandSequenceState | null {
106
+ return ctx.sequence
107
+ }
108
+
109
+ export function selectGroups(
110
+ ctx: CommandStoreContext,
111
+ ): ReadonlyMap<string, RegisteredCommandGroup> {
112
+ return ctx.groups
113
+ }
114
+
115
+ // --- Step-key helpers (shared by key dispatch and group registration) --------
116
+
117
+ export function stepsKey(steps: readonly ParsedStep[]): string {
118
+ return steps.map(formatStepKey).join(" ")
119
+ }
120
+
121
+ export function formatStepKey(step: ParsedStep): string {
122
+ const modifiers = []
123
+ if (step.ctrl) modifiers.push("ctrl")
124
+ if (step.meta) modifiers.push("meta")
125
+ if (step.option) modifiers.push("option")
126
+ if (step.shift) modifiers.push("shift")
127
+ if (step.super) modifiers.push("super")
128
+ modifiers.push(step.key)
129
+ return modifiers.join("+")
130
+ }
131
+
132
+ // --- Store -------------------------------------------------------------------
133
+
134
+ /**
135
+ * Sequence-clear rule for surface transitions: a pending chord (display state)
136
+ * is cleared exactly when the transition changes which surface record owns
137
+ * keyboard input. Pushing/popping a passive surface (e.g. which-key) keeps the
138
+ * sequence it is displaying; replacing the active modal surface with a same-id
139
+ * record clears it (F-09) because the record identity changes.
140
+ */
141
+ function sequenceAfterStackChange(
142
+ before: SurfaceRecord | null,
143
+ after: SurfaceRecord | null,
144
+ sequence: CommandSequenceState | null,
145
+ ): CommandSequenceState | null {
146
+ return before === after ? sequence : null
147
+ }
148
+
149
+ function createBaseStore(initialContext: CommandStoreContext) {
150
+ return createStore({
151
+ context: initialContext,
152
+ on: {
153
+ commandRegistered: (
154
+ ctx: CommandStoreContext,
155
+ event: { surfaceId: string; command: Command },
156
+ ): CommandStoreContext => {
157
+ const existing = ctx.commandsBySurface.get(event.surfaceId)
158
+ const commands = new Map(existing ?? [])
159
+ commands.set(event.command.id, event.command)
160
+ const commandsBySurface = new Map(ctx.commandsBySurface)
161
+ commandsBySurface.set(event.surfaceId, commands)
162
+ return { ...ctx, commandsBySurface }
163
+ },
164
+ commandUnregistered: (
165
+ ctx: CommandStoreContext,
166
+ event: { surfaceId: string; command: Command },
167
+ ): CommandStoreContext => {
168
+ // Identity-guarded (R-05): with duplicate ids the map holds the last
169
+ // writer, and the first registrant's unmount must not delete the
170
+ // second's live command.
171
+ const existing = ctx.commandsBySurface.get(event.surfaceId)
172
+ if (!existing || existing.get(event.command.id) !== event.command) return ctx
173
+ const commands = new Map(existing)
174
+ commands.delete(event.command.id)
175
+ const commandsBySurface = new Map(ctx.commandsBySurface)
176
+ if (commands.size === 0) {
177
+ commandsBySurface.delete(event.surfaceId)
178
+ } else {
179
+ commandsBySurface.set(event.surfaceId, commands)
180
+ }
181
+ return { ...ctx, commandsBySurface }
182
+ },
183
+ groupRegistered: (
184
+ ctx: CommandStoreContext,
185
+ event: { group: RegisteredCommandGroup },
186
+ ): CommandStoreContext => {
187
+ const groups = new Map(ctx.groups)
188
+ groups.set(event.group.prefixKey, event.group)
189
+ return { ...ctx, groups }
190
+ },
191
+ groupUnregistered: (
192
+ ctx: CommandStoreContext,
193
+ event: { group: RegisteredCommandGroup },
194
+ ): CommandStoreContext => {
195
+ // Identity-guarded, as for commands.
196
+ if (ctx.groups.get(event.group.prefixKey) !== event.group) return ctx
197
+ const groups = new Map(ctx.groups)
198
+ groups.delete(event.group.prefixKey)
199
+ return { ...ctx, groups }
200
+ },
201
+ contextSourceRegistered: (
202
+ ctx: CommandStoreContext,
203
+ event: { id: string; getter: ContextGetter },
204
+ ): CommandStoreContext => {
205
+ const contextSources = new Map(ctx.contextSources)
206
+ contextSources.set(event.id, event.getter)
207
+ return { ...ctx, contextSources }
208
+ },
209
+ contextSourceUnregistered: (
210
+ ctx: CommandStoreContext,
211
+ event: { id: string },
212
+ ): CommandStoreContext => {
213
+ if (!ctx.contextSources.has(event.id)) return ctx
214
+ const contextSources = new Map(ctx.contextSources)
215
+ contextSources.delete(event.id)
216
+ return { ...ctx, contextSources }
217
+ },
218
+ surfacePushed: (
219
+ ctx: CommandStoreContext,
220
+ event: { surface: SurfaceRecord },
221
+ ): CommandStoreContext => {
222
+ const before = selectActiveModalSurface(ctx)
223
+ const surfaces = [...ctx.surfaces, event.surface]
224
+ const after = selectActiveModalSurface({ ...ctx, surfaces })
225
+ return { ...ctx, surfaces, sequence: sequenceAfterStackChange(before, after, ctx.sequence) }
226
+ },
227
+ surfacePopped: (
228
+ ctx: CommandStoreContext,
229
+ event: { surface: SurfaceRecord },
230
+ ): CommandStoreContext => {
231
+ // Identity-based removal: only the exact pushed record is removed.
232
+ const surfaces = ctx.surfaces.filter((record) => record !== event.surface)
233
+ if (surfaces.length === ctx.surfaces.length) return ctx
234
+ const before = selectActiveModalSurface(ctx)
235
+ const after = selectActiveModalSurface({ ...ctx, surfaces })
236
+ return { ...ctx, surfaces, sequence: sequenceAfterStackChange(before, after, ctx.sequence) }
237
+ },
238
+ modeChanged: (
239
+ ctx: CommandStoreContext,
240
+ _event: { surfaceId: string },
241
+ ): CommandStoreContext => {
242
+ // A mode change is a transition, not a post-render repair: any pending
243
+ // chord is invalidated (F-08 — including surface-local mode changes).
244
+ return ctx.sequence === null ? ctx : { ...ctx, sequence: null }
245
+ },
246
+ sequencePending: (
247
+ ctx: CommandStoreContext,
248
+ event: { state: CommandSequenceState },
249
+ ): CommandStoreContext => {
250
+ return { ...ctx, sequence: event.state }
251
+ },
252
+ sequenceReset: (ctx: CommandStoreContext): CommandStoreContext => {
253
+ return ctx.sequence === null ? ctx : { ...ctx, sequence: null }
254
+ },
255
+ },
256
+ })
257
+ }
258
+
259
+ export type CommandStoreInstance = ReturnType<typeof createBaseStore>
260
+
261
+ // --- Wrapper -----------------------------------------------------------------
262
+
263
+ export interface KeyDispatchResult {
264
+ handled: boolean
265
+ /**
266
+ * Present on a full command match. The caller (the `useKeyboard` effect)
267
+ * calls `preventDefault()` and then `invoke()` synchronously, preserving
268
+ * today's dispatch order and `event.defaultPrevented` semantics.
269
+ */
270
+ invoke?: () => void
271
+ }
272
+
273
+ export interface CommandStoreConfig {
274
+ leader?: string
275
+ keymap?: Record<string, string>
276
+ sequenceTimeoutMs?: number
277
+ }
278
+
279
+ export interface CreateCommandStoreOptions extends CommandStoreConfig {
280
+ /** Root surface accessors, provided by the command provider/dispatcher. */
281
+ root: { getMode: () => Mode; buildCtx: () => CommandContext }
282
+ }
283
+
284
+ /**
285
+ * The single writer around the store. Owns the two pieces of IO-adjacent,
286
+ * non-render state: the key buffer (not render state — putting it in store
287
+ * context would notify subscribers on every keystroke) and the sequence
288
+ * timeout timer. The renderable `sequence` display state lives in the store.
289
+ */
290
+ export interface CommandStore {
291
+ /** The underlying @xstate/store instance (subscribe/getSnapshot/trigger). */
292
+ store: CommandStoreInstance
293
+ /** The always-present root surface record (`surfaces[0]`). */
294
+ rootRecord: SurfaceRecord
295
+
296
+ /**
297
+ * Feed a key event: arbitrates the active surface, filters candidates by
298
+ * mode/when, runs the pure sequence matchers against the internal buffer,
299
+ * triggers sequencePending/sequenceReset as needed, schedules/clears the
300
+ * timeout, and returns what happened so the caller can preventDefault and
301
+ * invoke synchronously.
302
+ */
303
+ key: (event: KeyEvent) => KeyDispatchResult
304
+
305
+ /** Clears buffer + timer and triggers sequenceReset. Idempotent. */
306
+ reset: () => void
307
+ /** Clears buffer + timer permanently, without touching store state. Called on dispatcher unmount. */
308
+ dispose: () => void
309
+
310
+ /** Push a surface record; assigns registration order. Returns the pop cleanup. */
311
+ pushSurface: (surface: SurfaceRecord) => () => void
312
+ /** Notify a (root or surface-local) mode change: clears buffer + pending sequence. */
313
+ modeChanged: (surfaceId: string) => void
314
+
315
+ /** Back-compat CommandRegistry facade for a surface record (stable per record). */
316
+ registryFor: (record: SurfaceRecord) => CommandRegistry
317
+
318
+ /** Update live config (leader/keymap/timeout are read per dispatch, as before). */
319
+ setConfig: (config: CommandStoreConfig) => void
320
+ }
321
+
322
+ export function createCommandStore(options: CreateCommandStoreOptions): CommandStore {
323
+ const rootRecord: SurfaceRecord = {
324
+ id: ROOT_SURFACE_ID,
325
+ role: "root",
326
+ depth: 0,
327
+ order: 0,
328
+ getMode: options.root.getMode,
329
+ buildCtx: options.root.buildCtx,
330
+ }
331
+
332
+ const store = createBaseStore({
333
+ surfaces: [rootRecord],
334
+ commandsBySurface: new Map(),
335
+ groups: new Map(),
336
+ contextSources: new Map(),
337
+ sequence: null,
338
+ })
339
+
340
+ let config: CommandStoreConfig = {
341
+ leader: options.leader,
342
+ keymap: options.keymap,
343
+ sequenceTimeoutMs: options.sequenceTimeoutMs,
344
+ }
345
+
346
+ // IO-adjacent wrapper state: key buffer, timer, parse cache, order counter.
347
+ let buffer: readonly KeyEvent[] = []
348
+ let timer: ReturnType<typeof setTimeout> | null = null
349
+ const parseCache = new Map<string, ParsedHotkey>()
350
+ const registries = new Map<SurfaceRecord, CommandRegistry>()
351
+ let orderCounter = 1
352
+
353
+ function clearTimer(): void {
354
+ if (timer !== null) {
355
+ clearTimeout(timer)
356
+ timer = null
357
+ }
358
+ }
359
+
360
+ function armTimer(): void {
361
+ clearTimer()
362
+ timer = setTimeout(reset, config.sequenceTimeoutMs ?? DEFAULT_SEQUENCE_TIMEOUT_MS)
363
+ }
364
+
365
+ function clearBufferAndTimer(): void {
366
+ buffer = []
367
+ clearTimer()
368
+ }
369
+
370
+ function reset(): void {
371
+ clearBufferAndTimer()
372
+ store.trigger.sequenceReset()
373
+ }
374
+
375
+ function dispose(): void {
376
+ clearBufferAndTimer()
377
+ }
378
+
379
+ function getParsedHotkey(hotkey: string): ParsedHotkey {
380
+ const cacheKey = `${hotkey}:${config.leader ?? ""}`
381
+ let parsed = parseCache.get(cacheKey)
382
+ if (!parsed) {
383
+ parsed = parseHotkey(hotkey, config.leader)
384
+ parseCache.set(cacheKey, parsed)
385
+ }
386
+ return parsed
387
+ }
388
+
389
+ function key(event: KeyEvent): KeyDispatchResult {
390
+ const ctx = store.getSnapshot().context
391
+
392
+ // Arbitration: a topmost modal surface owns input; otherwise the root app.
393
+ const surface = selectActiveModalSurface(ctx) ?? rootRecord
394
+ const currentMode = surface.getMode()
395
+ const cmdCtx = surface.buildCtx()
396
+ const commands = ctx.commandsBySurface.get(surface.id)
397
+
398
+ // Collect eligible commands with their parsed hotkeys
399
+ const singleStepCandidates: { command: Command; parsed: ParsedHotkey }[] = []
400
+ const multiStepCandidates: { command: Command; hotkey: string; parsed: ParsedHotkey }[] = []
401
+
402
+ if (commands) {
403
+ for (const command of commands.values()) {
404
+ const commandModes = command.modes ?? DEFAULT_MODES
405
+ if (!commandModes.includes(currentMode)) continue
406
+ if (command.when && !command.when(cmdCtx)) continue
407
+
408
+ const hotkey = config.keymap?.[command.id] ?? command.defaultHotkey
409
+ if (!hotkey) continue
410
+
411
+ const parsed = getParsedHotkey(hotkey)
412
+
413
+ // Unmatchable hotkeys (e.g. <leader> with no configured leader) register
414
+ // nothing rather than matching everything.
415
+ if (parsed.steps.length === 0) continue
416
+
417
+ if (parsed.steps.length === 1) {
418
+ singleStepCandidates.push({ command, parsed })
419
+ } else {
420
+ multiStepCandidates.push({ command, hotkey, parsed })
421
+ }
422
+ }
423
+ }
424
+
425
+ // Check multi-step sequences first (they consume buffer state)
426
+ if (multiStepCandidates.length > 0) {
427
+ const hotkeys = multiStepCandidates.map((candidate) => candidate.parsed)
428
+ buffer = [...buffer, event]
429
+ armTimer()
430
+
431
+ let matchedIndex = -1
432
+ for (let i = 0; i < hotkeys.length; i++) {
433
+ if (matchesBuffer(buffer, hotkeys[i]!)) {
434
+ matchedIndex = i
435
+ break
436
+ }
437
+ }
438
+
439
+ if (matchedIndex >= 0) {
440
+ clearBufferAndTimer()
441
+ store.trigger.sequenceReset()
442
+ const matched = multiStepCandidates[matchedIndex]!.command
443
+ return { handled: true, invoke: () => matched.handler(cmdCtx) }
444
+ }
445
+
446
+ buffer = pruneBuffer(buffer, hotkeys)
447
+ const pending = findPendingMatch(buffer, hotkeys)
448
+
449
+ if (pending) {
450
+ const firstCandidate = multiStepCandidates[pending.indexes[0]!]!
451
+ const state: CommandSequenceState = {
452
+ prefix: firstCandidate.parsed.steps.slice(0, pending.prefixLength),
453
+ candidates: pending.indexes
454
+ .map((idx) => multiStepCandidates[idx]!)
455
+ .filter(({ command }) => !command.hidden)
456
+ .map(({ command, hotkey, parsed }) => ({
457
+ command,
458
+ hotkey,
459
+ steps: parsed.steps,
460
+ remainingSteps: parsed.steps.slice(pending.prefixLength),
461
+ nextStep: parsed.steps[pending.prefixLength]!,
462
+ group: ctx.groups.get(stepsKey(parsed.steps.slice(0, pending.prefixLength + 1))),
463
+ })),
464
+ }
465
+ store.trigger.sequencePending({ state })
466
+ return { handled: true }
467
+ }
468
+
469
+ store.trigger.sequenceReset()
470
+ }
471
+
472
+ // Check single-step matches
473
+ for (const { command, parsed } of singleStepCandidates) {
474
+ if (matchStep(event, parsed.steps[0]!)) {
475
+ store.trigger.sequenceReset()
476
+ return { handled: true, invoke: () => command.handler(cmdCtx) }
477
+ }
478
+ }
479
+
480
+ // A modal surface swallows unhandled keys: dispatch never falls through to
481
+ // the root app while a modal surface is topmost. (The caller does not
482
+ // preventDefault unmatched keys — same as today.)
483
+ return { handled: false }
484
+ }
485
+
486
+ function pushSurface(surface: SurfaceRecord): () => void {
487
+ surface.order = orderCounter++
488
+ const before = selectActiveModalSurface(store.getSnapshot().context)
489
+ store.trigger.surfacePushed({ surface })
490
+ const after = selectActiveModalSurface(store.getSnapshot().context)
491
+ // Keep the wrapper buffer consistent with the transition's sequence-clear
492
+ // rule: clear exactly when keyboard ownership changed.
493
+ if (before !== after) clearBufferAndTimer()
494
+
495
+ return () => {
496
+ const beforePop = selectActiveModalSurface(store.getSnapshot().context)
497
+ store.trigger.surfacePopped({ surface })
498
+ const afterPop = selectActiveModalSurface(store.getSnapshot().context)
499
+ if (beforePop !== afterPop) clearBufferAndTimer()
500
+ }
501
+ }
502
+
503
+ function modeChanged(surfaceId: string): void {
504
+ clearBufferAndTimer()
505
+ store.trigger.modeChanged({ surfaceId })
506
+ }
507
+
508
+ function registryFor(record: SurfaceRecord): CommandRegistry {
509
+ let registry = registries.get(record)
510
+ if (!registry) {
511
+ registry = {
512
+ get commands() {
513
+ const current = store.getSnapshot().context.commandsBySurface.get(record.id)
514
+ // The registry contract exposes a Map; the store's per-surface maps
515
+ // are Maps at runtime (readonly-typed). Consumers must not mutate.
516
+ return (current ?? new Map()) as Map<string, Command>
517
+ },
518
+ register(command: Command) {
519
+ store.trigger.commandRegistered({ surfaceId: record.id, command })
520
+ return () => {
521
+ store.trigger.commandUnregistered({ surfaceId: record.id, command })
522
+ }
523
+ },
524
+ invoke(id: string) {
525
+ const ctx = store.getSnapshot().context
526
+ const cmd = ctx.commandsBySurface.get(record.id)?.get(id)
527
+ if (!cmd) return
528
+ const cmdCtx = record.buildCtx()
529
+ if (!cmd.when || cmd.when(cmdCtx)) {
530
+ cmd.handler(cmdCtx)
531
+ }
532
+ },
533
+ }
534
+ registries.set(record, registry)
535
+ }
536
+ return registry
537
+ }
538
+
539
+ function setConfig(next: CommandStoreConfig): void {
540
+ config = next
541
+ }
542
+
543
+ return {
544
+ store,
545
+ rootRecord,
546
+ key,
547
+ reset,
548
+ dispose,
549
+ pushSurface,
550
+ modeChanged,
551
+ registryFor,
552
+ setConfig,
553
+ }
554
+ }