@tooee/commands 0.1.20 → 0.1.22
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.
- package/README.md +54 -0
- package/dist/command-store.d.ts +147 -0
- package/dist/command-store.d.ts.map +1 -0
- package/dist/command-store.js +367 -0
- package/dist/command-store.js.map +1 -0
- package/dist/context.d.ts +29 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +192 -233
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/match.d.ts.map +1 -1
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -1
- package/dist/mode.d.ts +7 -1
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +12 -3
- package/dist/mode.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +17 -5
- package/dist/parse.js.map +1 -1
- package/dist/sequence.d.ts +17 -2
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +66 -45
- package/dist/sequence.js.map +1 -1
- package/dist/types.d.ts +20 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-actions.d.ts.map +1 -1
- package/dist/use-actions.js +6 -3
- package/dist/use-actions.js.map +1 -1
- package/dist/use-command.d.ts.map +1 -1
- package/dist/use-command.js +12 -4
- package/dist/use-command.js.map +1 -1
- package/package.json +5 -1
- package/src/command-store.ts +554 -0
- package/src/context.tsx +259 -276
- package/src/index.ts +22 -0
- package/src/match.ts +1 -0
- package/src/mode.tsx +31 -3
- package/src/parse.ts +20 -5
- package/src/sequence.ts +80 -52
- package/src/types.ts +22 -3
- package/src/use-actions.ts +5 -3
- 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
|
+
}
|