@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/index.ts CHANGED
@@ -33,8 +33,29 @@ export {
33
33
  useCommandGroup,
34
34
  useCommandRegistry,
35
35
  useCommandSequenceState,
36
+ useCommandStore,
36
37
  useProvideCommandContext,
38
+ useSurfaceCommands,
37
39
  } 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"
38
59
  export type { CommandProviderProps, CommandSurfaceProviderProps } from "./context.js"
39
60
  export { useCommand } from "./use-command.js"
40
61
  export type { UseCommandOptions } from "./use-command.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 {
@@ -77,6 +91,11 @@ export interface CommandSurfaceEntry {
77
91
  export interface ActiveCommandSurface {
78
92
  id: string
79
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[]
80
99
  }
81
100
 
82
101
  export interface CommandGroup {
@@ -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,