@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/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ ActiveCommandSurface,
2
3
  Command,
3
4
  CommandContext,
4
5
  CommandContextBase,
@@ -6,6 +7,8 @@ export type {
6
7
  CommandHandler,
7
8
  CommandSequenceCandidate,
8
9
  CommandSequenceState,
10
+ CommandSurfaceEntry,
11
+ CommandSurfaceRole,
9
12
  CommandWhen,
10
13
  ParsedHotkey,
11
14
  ParsedStep,
@@ -24,13 +27,36 @@ export type {
24
27
  } from "./sequence.js"
25
28
  export {
26
29
  CommandProvider,
30
+ CommandSurfaceProvider,
31
+ useActiveCommandSurface,
27
32
  useCommandContext,
28
33
  useCommandGroup,
29
34
  useCommandRegistry,
30
35
  useCommandSequenceState,
36
+ useCommandStore,
31
37
  useProvideCommandContext,
38
+ useSurfaceCommands,
32
39
  } from "./context.js"
33
- export type { CommandProviderProps } from "./context.js"
40
+ export {
41
+ ROOT_SURFACE_ID,
42
+ createCommandStore,
43
+ selectActiveModalSurface,
44
+ selectGroups,
45
+ selectSequence,
46
+ selectSurfaceCommandMap,
47
+ selectSurfaceCommands,
48
+ } from "./command-store.js"
49
+ export type {
50
+ CommandStore,
51
+ CommandStoreConfig,
52
+ CommandStoreContext,
53
+ CommandStoreInstance,
54
+ ContextGetter,
55
+ CreateCommandStoreOptions,
56
+ KeyDispatchResult,
57
+ SurfaceRecord,
58
+ } from "./command-store.js"
59
+ export type { CommandProviderProps, CommandSurfaceProviderProps } from "./context.js"
34
60
  export { useCommand } from "./use-command.js"
35
61
  export type { UseCommandOptions } from "./use-command.js"
36
62
  export { useActions } from "./use-actions.js"
package/src/match.ts CHANGED
@@ -10,5 +10,6 @@ export function matchStep(event: KeyEvent, step: ParsedStep): boolean {
10
10
  if (event.meta !== step.meta) return false
11
11
  if (event.shift !== step.shift) return false
12
12
  if (event.option !== step.option) return false
13
+ if ((event.super ?? false) !== (step.super ?? false)) return false
13
14
  return true
14
15
  }
package/src/mode.tsx CHANGED
@@ -1,4 +1,12 @@
1
- import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react"
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useMemo,
7
+ useRef,
8
+ type ReactNode,
9
+ } from "react"
2
10
 
3
11
  export type Mode = "cursor" | "insert" | "select"
4
12
 
@@ -12,11 +20,31 @@ const ModeContext = createContext<ModeContextValue | null>(null)
12
20
  export interface ModeProviderProps {
13
21
  children: ReactNode
14
22
  initialMode?: Mode
23
+ /**
24
+ * Internal: called synchronously whenever `setMode` changes the mode (not
25
+ * for same-value calls). Used by the command dispatcher to treat mode
26
+ * changes as transitions (sequence reset) instead of post-render repairs.
27
+ */
28
+ onModeChange?: (mode: Mode) => void
15
29
  }
16
30
 
17
- export function ModeProvider({ children, initialMode = "cursor" }: ModeProviderProps) {
31
+ export function ModeProvider({
32
+ children,
33
+ initialMode = "cursor",
34
+ onModeChange,
35
+ }: ModeProviderProps) {
18
36
  const [mode, setModeState] = useState<Mode>(initialMode)
19
- const setMode = useCallback((m: Mode) => setModeState(m), [])
37
+ const modeRef = useRef(mode)
38
+ const onModeChangeRef = useRef(onModeChange)
39
+ onModeChangeRef.current = onModeChange
40
+
41
+ const setMode = useCallback((m: Mode) => {
42
+ if (m !== modeRef.current) {
43
+ modeRef.current = m
44
+ onModeChangeRef.current?.(m)
45
+ }
46
+ setModeState(m)
47
+ }, [])
20
48
 
21
49
  const value = useMemo<ModeContextValue>(() => ({ mode, setMode }), [mode, setMode])
22
50
 
package/src/parse.ts CHANGED
@@ -15,6 +15,14 @@ function normalizeKey(key: string): string {
15
15
  return KEY_ALIASES[lower] ?? lower
16
16
  }
17
17
 
18
+ const warned = new Set<string>()
19
+
20
+ function warnOnce(message: string): void {
21
+ if (warned.has(message)) return
22
+ warned.add(message)
23
+ console.warn(message)
24
+ }
25
+
18
26
  function parseStep(step: string): ParsedStep {
19
27
  const parts = step.toLowerCase().split("+")
20
28
  let key = ""
@@ -22,6 +30,7 @@ function parseStep(step: string): ParsedStep {
22
30
  let meta = false
23
31
  let shift = false
24
32
  let option = false
33
+ let superKey = false
25
34
 
26
35
  for (const part of parts) {
27
36
  const trimmed = part.trim()
@@ -34,13 +43,13 @@ function parseStep(step: string): ParsedStep {
34
43
  } else if (trimmed === "option") {
35
44
  option = true
36
45
  } else if (trimmed === "super") {
37
- // super modifier — not tracked in ParsedStep currently
46
+ superKey = true
38
47
  } else {
39
48
  key = normalizeKey(trimmed)
40
49
  }
41
50
  }
42
51
 
43
- return { key, ctrl, meta, shift, option }
52
+ return { key, ctrl, meta, shift, option, super: superKey }
44
53
  }
45
54
 
46
55
  /**
@@ -57,9 +66,15 @@ export function parseHotkey(hotkey: string, leaderKey?: string): ParsedHotkey {
57
66
  // Handle leader prefix
58
67
  const leaderMatch = trimmed.match(/^<leader>(.+)$/)
59
68
  if (leaderMatch) {
60
- const leaderStep = leaderKey
61
- ? parseStep(leaderKey)
62
- : { key: "x", ctrl: true, meta: false, shift: false, option: false }
69
+ if (!leaderKey) {
70
+ // No leader configured: the hotkey must not spring to life on some
71
+ // invented default. Zero steps = unmatchable; the dispatcher skips it.
72
+ warnOnce(
73
+ `[tooee/commands] Hotkey "${trimmed}" uses <leader> but no leader key is configured; the hotkey is disabled.`,
74
+ )
75
+ return { steps: [] }
76
+ }
77
+ const leaderStep = parseStep(leaderKey)
63
78
  const followStep = parseStep(leaderMatch[1]!)
64
79
  return { steps: [leaderStep, followStep] }
65
80
  }
package/src/sequence.ts CHANGED
@@ -19,6 +19,82 @@ export interface SequenceFeedResult {
19
19
  pending: SequencePendingMatch | null
20
20
  }
21
21
 
22
+ // --- Pure matching helpers ---------------------------------------------------
23
+ // These are the sequence-matching primitives, extracted so a store (or any
24
+ // non-React dispatcher) can run them against its own buffer without
25
+ // instantiating SequenceTracker. SequenceTracker delegates to them.
26
+
27
+ /**
28
+ * True when the tail of `buffer` fully matches every step of `hotkey`.
29
+ * Zero-step hotkeys (e.g. a disabled leaderless `<leader>` binding) never
30
+ * match anything.
31
+ */
32
+ export function matchesBuffer(buffer: readonly KeyEvent[], hotkey: ParsedHotkey): boolean {
33
+ const { steps } = hotkey
34
+ if (steps.length === 0) return false
35
+ if (buffer.length < steps.length) return false
36
+
37
+ const start = buffer.length - steps.length
38
+ for (let i = 0; i < steps.length; i++) {
39
+ if (!matchStep(buffer[start + i]!, steps[i]!)) return false
40
+ }
41
+ return true
42
+ }
43
+
44
+ /**
45
+ * Find the longest buffer tail that is a proper prefix of at least one hotkey.
46
+ * Returns the prefix length and the indexes of the hotkeys it could still
47
+ * complete, or null when nothing is pending.
48
+ */
49
+ export function findPendingMatch(
50
+ buffer: readonly KeyEvent[],
51
+ hotkeys: readonly ParsedHotkey[],
52
+ ): SequencePendingMatch | null {
53
+ const maxPrefixLength = Math.min(
54
+ buffer.length,
55
+ Math.max(0, ...hotkeys.map((h) => h.steps.length - 1)),
56
+ )
57
+
58
+ for (let prefixLength = maxPrefixLength; prefixLength > 0; prefixLength--) {
59
+ const start = buffer.length - prefixLength
60
+ const indexes: number[] = []
61
+
62
+ for (let hotkeyIndex = 0; hotkeyIndex < hotkeys.length; hotkeyIndex++) {
63
+ const hotkey = hotkeys[hotkeyIndex]!
64
+ if (hotkey.steps.length <= prefixLength) continue
65
+
66
+ let matches = true
67
+ for (let i = 0; i < prefixLength; i++) {
68
+ if (!matchStep(buffer[start + i]!, hotkey.steps[i]!)) {
69
+ matches = false
70
+ break
71
+ }
72
+ }
73
+
74
+ if (matches) indexes.push(hotkeyIndex)
75
+ }
76
+
77
+ if (indexes.length > 0) return { prefixLength, indexes }
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * Drop buffer entries older than the longest hotkey could ever consume.
85
+ * Returns the same array when nothing needs pruning.
86
+ */
87
+ export function pruneBuffer(
88
+ buffer: readonly KeyEvent[],
89
+ hotkeys: readonly ParsedHotkey[],
90
+ ): readonly KeyEvent[] {
91
+ const maxLen = Math.max(0, ...hotkeys.map((h) => h.steps.length))
92
+ if (maxLen > 0 && buffer.length > maxLen) {
93
+ return buffer.slice(buffer.length - maxLen)
94
+ }
95
+ return buffer
96
+ }
97
+
22
98
  export class SequenceTracker {
23
99
  private buffer: KeyEvent[] = []
24
100
  private timer: ReturnType<typeof setTimeout> | null = null
@@ -46,68 +122,20 @@ export class SequenceTracker {
46
122
  this.resetTimer()
47
123
 
48
124
  for (let i = 0; i < hotkeys.length; i++) {
49
- const hotkey = hotkeys[i]!
50
- if (this.matchesBuffer(hotkey)) {
125
+ if (matchesBuffer(this.buffer, hotkeys[i]!)) {
51
126
  this.reset()
52
127
  return { matchedIndex: i, pending: null }
53
128
  }
54
129
  }
55
130
 
56
131
  // Prune buffer if no hotkey could possibly match
57
- const maxLen = Math.max(0, ...hotkeys.map((h) => h.steps.length))
58
- if (maxLen > 0) {
59
- while (this.buffer.length > maxLen) {
60
- this.buffer.shift()
61
- }
62
- }
132
+ this.buffer = [...pruneBuffer(this.buffer, hotkeys)]
63
133
 
64
- return { matchedIndex: -1, pending: this.findPendingMatch(hotkeys) }
134
+ return { matchedIndex: -1, pending: findPendingMatch(this.buffer, hotkeys) }
65
135
  }
66
136
 
67
137
  getPendingMatch(hotkeys: ParsedHotkey[]): SequencePendingMatch | null {
68
- return this.findPendingMatch(hotkeys)
69
- }
70
-
71
- private matchesBuffer(hotkey: ParsedHotkey): boolean {
72
- const { steps } = hotkey
73
- if (this.buffer.length < steps.length) return false
74
-
75
- const start = this.buffer.length - steps.length
76
- for (let i = 0; i < steps.length; i++) {
77
- if (!matchStep(this.buffer[start + i]!, steps[i]!)) return false
78
- }
79
- return true
80
- }
81
-
82
- private findPendingMatch(hotkeys: ParsedHotkey[]): SequencePendingMatch | null {
83
- const maxPrefixLength = Math.min(
84
- this.buffer.length,
85
- Math.max(0, ...hotkeys.map((h) => h.steps.length - 1)),
86
- )
87
-
88
- for (let prefixLength = maxPrefixLength; prefixLength > 0; prefixLength--) {
89
- const start = this.buffer.length - prefixLength
90
- const indexes: number[] = []
91
-
92
- for (let hotkeyIndex = 0; hotkeyIndex < hotkeys.length; hotkeyIndex++) {
93
- const hotkey = hotkeys[hotkeyIndex]!
94
- if (hotkey.steps.length <= prefixLength) continue
95
-
96
- let matches = true
97
- for (let i = 0; i < prefixLength; i++) {
98
- if (!matchStep(this.buffer[start + i]!, hotkey.steps[i]!)) {
99
- matches = false
100
- break
101
- }
102
- }
103
-
104
- if (matches) indexes.push(hotkeyIndex)
105
- }
106
-
107
- if (indexes.length > 0) return { prefixLength, indexes }
108
- }
109
-
110
- return null
138
+ return findPendingMatch(this.buffer, hotkeys)
111
139
  }
112
140
 
113
141
  reset(): void {
package/src/types.ts CHANGED
@@ -7,9 +7,21 @@ export interface CommandContextBase {
7
7
  exit: () => void
8
8
  }
9
9
 
10
- export interface CommandContext extends CommandContextBase {
11
- [key: string]: any
12
- }
10
+ /**
11
+ * The context handed to command handlers. Domain packages contribute typed
12
+ * fields via module augmentation (the mechanism the shell already uses for
13
+ * `overlay` and `toast`):
14
+ *
15
+ * ```ts
16
+ * declare module "@tooee/commands" {
17
+ * interface CommandContext {
18
+ * myDomain: MyDomainContext
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
24
+ export interface CommandContext extends CommandContextBase {}
13
25
 
14
26
  export type CommandHandler = (ctx: CommandContext) => void | Promise<void>
15
27
  export type CommandWhen = (ctx: CommandContext) => boolean
@@ -37,6 +49,8 @@ export interface ParsedStep {
37
49
  meta: boolean
38
50
  shift: boolean
39
51
  option: boolean
52
+ /** Super/Windows/Cmd-as-super modifier. Optional for back-compat. */
53
+ super?: boolean
40
54
  }
41
55
 
42
56
  export interface CommandRegistry {
@@ -45,6 +59,45 @@ export interface CommandRegistry {
45
59
  invoke: (id: string) => void
46
60
  }
47
61
 
62
+ /**
63
+ * Interaction role of a command surface.
64
+ * - `modal`: owns keyboard input while topmost and suspends every lower surface
65
+ * (including the root app) — even for keys it does not handle.
66
+ * - `passive`: never becomes the keyboard owner; purely visual.
67
+ */
68
+ export type CommandSurfaceRole = "modal" | "passive"
69
+
70
+ /**
71
+ * A command surface is an interaction surface that can own keyboard input.
72
+ * The root app is the implicit base surface; modal overlays push surfaces on
73
+ * top of it. Each surface carries its own command registry and local mode so
74
+ * the same command definitions can be rooted in a standalone app or pushed by
75
+ * an overlay without leaking mode/command state into the parent.
76
+ */
77
+ export interface CommandSurfaceEntry {
78
+ id: string
79
+ role: CommandSurfaceRole
80
+ /** Nesting depth (root children are depth 0; the first overlay surface is 1). */
81
+ depth: number
82
+ /** Monotonic registration order, used to break depth ties. */
83
+ order: number
84
+ registry: CommandRegistry
85
+ /** Reads this surface's current local mode. */
86
+ getMode: () => Mode
87
+ /** Builds the command context handed to this surface's command handlers. */
88
+ buildCtx: () => CommandContext
89
+ }
90
+
91
+ export interface ActiveCommandSurface {
92
+ id: string
93
+ role: CommandSurfaceRole
94
+ /**
95
+ * Commands registered on this surface (reactive). Enables surface-aware
96
+ * which-key/help/palette consumers.
97
+ */
98
+ commands: readonly Command[]
99
+ }
100
+
48
101
  export interface CommandGroup {
49
102
  id: string
50
103
  title: string
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef } from "react"
2
- import { useCommandRegistry } from "./context.js"
2
+ import { useSurfaceRegistry } from "./context.js"
3
3
  import type { Command, CommandHandler, CommandWhen } from "./types.js"
4
4
  import type { Mode } from "./mode.js"
5
5
 
@@ -17,7 +17,7 @@ export interface ActionDefinition {
17
17
  }
18
18
 
19
19
  export function useActions(actions: ActionDefinition[] | undefined): void {
20
- const { registry } = useCommandRegistry()
20
+ const registry = useSurfaceRegistry()
21
21
  const actionsRef = useRef(actions)
22
22
  actionsRef.current = actions
23
23
 
@@ -26,7 +26,9 @@ export function useActions(actions: ActionDefinition[] | undefined): void {
26
26
  actions
27
27
  ?.map(
28
28
  (a) =>
29
- `${a.id}:${a.title}:${a.hotkey ?? ""}:${a.category ?? ""}:${a.group ?? ""}:${a.icon ?? ""}:${a.hidden ?? false}`,
29
+ // modes and when-presence are frozen into the registration, so
30
+ // they must participate in the re-registration key.
31
+ `${a.id}:${a.title}:${a.hotkey ?? ""}:${a.category ?? ""}:${a.group ?? ""}:${a.icon ?? ""}:${a.hidden ?? false}:${a.modes?.join("|") ?? ""}:${a.when ? 1 : 0}`,
30
32
  )
31
33
  .join(",") ?? "",
32
34
  [actions],
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef } from "react"
2
2
  import type { Command, CommandHandler, CommandWhen } from "./types.js"
3
3
  import type { Mode } from "./mode.js"
4
- import { useCommandRegistry } from "./context.js"
4
+ import { useSurfaceRegistry } from "./context.js"
5
5
 
6
6
  export interface UseCommandOptions {
7
7
  id: string
@@ -17,17 +17,26 @@ export interface UseCommandOptions {
17
17
  }
18
18
 
19
19
  export function useCommand(options: UseCommandOptions): void {
20
- const { registry } = useCommandRegistry()
20
+ const registry = useSurfaceRegistry()
21
21
  const optionsRef = useRef(options)
22
22
  optionsRef.current = options
23
23
 
24
+ // Key on the modes CONTENT, not the array identity: callers pass inline
25
+ // array literals, and with a subscribable registry an identity-keyed effect
26
+ // would re-register on every render — re-notifying the subscriber that
27
+ // caused the render (an infinite update loop for components that both
28
+ // register and observe commands, e.g. the command palette provider).
29
+ const modesKey = options.modes?.join("|") ?? ""
30
+
24
31
  useEffect(() => {
25
32
  const command: Command = {
26
33
  id: options.id,
27
34
  title: options.title,
28
35
  handler: (...args: Parameters<Command["handler"]>) => optionsRef.current.handler(...args),
29
36
  defaultHotkey: options.hotkey,
30
- modes: options.modes,
37
+ // Read through the ref: the effect is keyed on modesKey (content), and
38
+ // the ref holds the same-render options when the effect runs.
39
+ modes: optionsRef.current.modes,
31
40
  category: options.category,
32
41
  group: options.group,
33
42
  icon: options.icon,
@@ -39,7 +48,7 @@ export function useCommand(options: UseCommandOptions): void {
39
48
  options.id,
40
49
  options.title,
41
50
  options.hotkey,
42
- options.modes,
51
+ modesKey,
43
52
  options.category,
44
53
  options.group,
45
54
  options.icon,