@tooee/commands 0.1.0

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 (47) hide show
  1. package/README.md +5 -0
  2. package/dist/context.d.ts +25 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +161 -0
  5. package/dist/context.js.map +1 -0
  6. package/dist/index.d.ts +15 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +8 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/match.d.ts +7 -0
  11. package/dist/match.d.ts.map +1 -0
  12. package/dist/match.js +17 -0
  13. package/dist/match.js.map +1 -0
  14. package/dist/mode.d.ts +10 -0
  15. package/dist/mode.d.ts.map +1 -0
  16. package/dist/mode.js +23 -0
  17. package/dist/mode.js.map +1 -0
  18. package/dist/parse.d.ts +11 -0
  19. package/dist/parse.d.ts.map +1 -0
  20. package/dist/parse.js +68 -0
  21. package/dist/parse.js.map +1 -0
  22. package/dist/sequence.d.ts +21 -0
  23. package/dist/sequence.d.ts.map +1 -0
  24. package/dist/sequence.js +56 -0
  25. package/dist/sequence.js.map +1 -0
  26. package/dist/types.d.ts +43 -0
  27. package/dist/types.d.ts.map +1 -0
  28. package/dist/types.js +2 -0
  29. package/dist/types.js.map +1 -0
  30. package/dist/use-actions.d.ts +12 -0
  31. package/dist/use-actions.d.ts.map +1 -0
  32. package/dist/use-actions.js +30 -0
  33. package/dist/use-actions.js.map +1 -0
  34. package/dist/use-command.d.ts +16 -0
  35. package/dist/use-command.d.ts.map +1 -0
  36. package/dist/use-command.js +33 -0
  37. package/dist/use-command.js.map +1 -0
  38. package/package.json +40 -0
  39. package/src/context.tsx +221 -0
  40. package/src/index.ts +22 -0
  41. package/src/match.ts +14 -0
  42. package/src/mode.tsx +38 -0
  43. package/src/parse.ts +71 -0
  44. package/src/sequence.ts +70 -0
  45. package/src/types.ts +46 -0
  46. package/src/use-actions.ts +47 -0
  47. package/src/use-command.ts +49 -0
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useCommandRegistry } from "./context.jsx";
3
+ export function useCommand(options) {
4
+ const { registry } = useCommandRegistry();
5
+ const optionsRef = useRef(options);
6
+ optionsRef.current = options;
7
+ useEffect(() => {
8
+ const command = {
9
+ id: options.id,
10
+ title: options.title,
11
+ handler: (...args) => optionsRef.current.handler(...args),
12
+ defaultHotkey: options.hotkey,
13
+ modes: options.modes,
14
+ category: options.category,
15
+ group: options.group,
16
+ icon: options.icon,
17
+ when: optionsRef.current.when ? (ctx) => optionsRef.current.when(ctx) : undefined,
18
+ hidden: options.hidden,
19
+ };
20
+ return registry.register(command);
21
+ }, [
22
+ options.id,
23
+ options.title,
24
+ options.hotkey,
25
+ options.modes,
26
+ options.category,
27
+ options.group,
28
+ options.icon,
29
+ options.hidden,
30
+ registry,
31
+ ]);
32
+ }
33
+ //# sourceMappingURL=use-command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-command.js","sourceRoot":"","sources":["../src/use-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAGzC,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAelD,MAAM,UAAU,UAAU,CAAC,OAA0B;IACnD,MAAM,EAAE,QAAQ,EAAE,GAAG,kBAAkB,EAAE,CAAA;IACzC,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,CAAA;IAClC,UAAU,CAAC,OAAO,GAAG,OAAO,CAAA;IAE5B,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAY;YACvB,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,OAAO,EAAE,CAAC,GAAG,IAAoC,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;YACzF,aAAa,EAAE,OAAO,CAAC,MAAM;YAC7B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,QAAQ,EAAE,OAAO,CAAC,QAAQ;YAC1B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,IAAI,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;YAClF,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAA;QACD,OAAO,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;IACnC,CAAC,EAAE;QACD,OAAO,CAAC,EAAE;QACV,OAAO,CAAC,KAAK;QACb,OAAO,CAAC,MAAM;QACd,OAAO,CAAC,KAAK;QACb,OAAO,CAAC,QAAQ;QAChB,OAAO,CAAC,KAAK;QACb,OAAO,CAAC,IAAI;QACZ,OAAO,CAAC,MAAM;QACd,QAAQ;KACT,CAAC,CAAA;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tooee/commands",
3
+ "version": "0.1.0",
4
+ "description": "Vim-inspired modal command system for Tooee",
5
+ "license": "MIT",
6
+ "author": "Gareth Andrew",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/gingerhendrix/tooee.git",
10
+ "directory": "packages/commands"
11
+ },
12
+ "homepage": "https://github.com/gingerhendrix/tooee",
13
+ "bugs": "https://github.com/gingerhendrix/tooee/issues",
14
+ "keywords": ["tui", "terminal", "cli", "opentui", "vim", "keybindings"],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "@tooee/source": "./src/index.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ }
23
+ },
24
+ "files": ["dist", "src"],
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "@opentui/core": "^0.1.67",
30
+ "@opentui/react": "^0.1.67",
31
+ "@types/bun": "^1.3.5",
32
+ "@types/react": "^19.1.10",
33
+ "typescript": "^5.8.3"
34
+ },
35
+ "peerDependencies": {
36
+ "@opentui/core": "^0.1.67",
37
+ "@opentui/react": "^0.1.67",
38
+ "react": "^18.0.0 || ^19.0.0"
39
+ }
40
+ }
@@ -0,0 +1,221 @@
1
+ import { createContext, useContext, useRef, useCallback, useEffect, type ReactNode } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { Command, CommandContext, CommandRegistry, ParsedHotkey } from "./types.js"
4
+ import type { Mode } from "./mode.jsx"
5
+ import { ModeProvider, useMode, useSetMode } from "./mode.jsx"
6
+ import { parseHotkey } from "./parse.js"
7
+ import { matchStep } from "./match.js"
8
+ import { SequenceTracker } from "./sequence.js"
9
+
10
+ const DEFAULT_MODES: Mode[] = ["cursor"]
11
+
12
+ type ContextGetter = () => Partial<CommandContext>
13
+
14
+ interface CommandContextValue {
15
+ registry: CommandRegistry
16
+ leaderKey?: string
17
+ contextSources: Map<string, ContextGetter>
18
+ }
19
+
20
+ const CommandContext = createContext<CommandContextValue | null>(null)
21
+
22
+ export interface CommandProviderProps {
23
+ children: ReactNode
24
+ leader?: string
25
+ keymap?: Record<string, string>
26
+ initialMode?: Mode
27
+ }
28
+
29
+ export function CommandProvider({ children, leader, keymap, initialMode }: CommandProviderProps) {
30
+ return (
31
+ <ModeProvider initialMode={initialMode}>
32
+ <CommandDispatcher leader={leader} keymap={keymap}>
33
+ {children}
34
+ </CommandDispatcher>
35
+ </ModeProvider>
36
+ )
37
+ }
38
+
39
+ function CommandDispatcher({
40
+ children,
41
+ leader,
42
+ keymap,
43
+ }: {
44
+ children: ReactNode
45
+ leader?: string
46
+ keymap?: Record<string, string>
47
+ }) {
48
+ const registryRef = useRef<CommandRegistry | null>(null)
49
+ const contextSourcesRef = useRef(new Map<string, ContextGetter>())
50
+ const mode = useMode()
51
+ const modeRef = useRef(mode)
52
+ modeRef.current = mode
53
+ const setMode = useSetMode()
54
+
55
+ const buildCtx = useCallback((): CommandContext => {
56
+ const ctx: Record<string, any> = {
57
+ mode: modeRef.current,
58
+ setMode,
59
+ commands: {
60
+ invoke: (id: string) => registryRef.current?.invoke(id),
61
+ list: () => Array.from(registryRef.current?.commands.values() ?? []),
62
+ },
63
+ exit: () => {},
64
+ }
65
+ for (const getter of contextSourcesRef.current.values()) {
66
+ Object.assign(ctx, getter())
67
+ }
68
+ return ctx as CommandContext
69
+ }, [setMode])
70
+
71
+ if (registryRef.current === null) {
72
+ const commands = new Map<string, Command>()
73
+ registryRef.current = {
74
+ commands,
75
+ register(command: Command) {
76
+ commands.set(command.id, command)
77
+ return () => {
78
+ commands.delete(command.id)
79
+ }
80
+ },
81
+ invoke(id: string) {
82
+ const ctx = buildCtx()
83
+ const cmd = commands.get(id)
84
+ if (cmd && (!cmd.when || cmd.when(ctx))) {
85
+ cmd.handler(ctx)
86
+ }
87
+ },
88
+ }
89
+ }
90
+
91
+ const trackerRef = useRef(new SequenceTracker())
92
+ const parseCacheRef = useRef(new Map<string, ParsedHotkey>())
93
+
94
+ const getParsedHotkey = useCallback(
95
+ (hotkey: string) => {
96
+ const cache = parseCacheRef.current
97
+ const cacheKey = `${hotkey}:${leader ?? ""}`
98
+ let parsed = cache.get(cacheKey)
99
+ if (!parsed) {
100
+ parsed = parseHotkey(hotkey, leader)
101
+ cache.set(cacheKey, parsed)
102
+ }
103
+ return parsed
104
+ },
105
+ [leader],
106
+ )
107
+
108
+ useKeyboard((event) => {
109
+ if (event.defaultPrevented) return
110
+
111
+ const registry = registryRef.current
112
+ if (!registry) return
113
+
114
+ const currentMode = mode
115
+ const ctx = buildCtx()
116
+
117
+ // Collect eligible commands with their parsed hotkeys
118
+ const singleStepCandidates: { command: Command; parsed: ParsedHotkey }[] = []
119
+ const multiStepHotkeys: ParsedHotkey[] = []
120
+ const multiStepCommands: Command[] = []
121
+
122
+ for (const command of registry.commands.values()) {
123
+ const commandModes = command.modes ?? DEFAULT_MODES
124
+ if (!commandModes.includes(currentMode)) continue
125
+ if (command.when && !command.when(ctx)) continue
126
+
127
+ const hotkey = keymap?.[command.id] ?? command.defaultHotkey
128
+ if (!hotkey) continue
129
+
130
+ const parsed = getParsedHotkey(hotkey)
131
+
132
+ if (parsed.steps.length === 1) {
133
+ singleStepCandidates.push({ command, parsed })
134
+ } else {
135
+ multiStepHotkeys.push(parsed)
136
+ multiStepCommands.push(command)
137
+ }
138
+ }
139
+
140
+ // Check multi-step sequences first (they consume buffer state)
141
+ if (multiStepHotkeys.length > 0) {
142
+ const idx = trackerRef.current.feed(event, multiStepHotkeys)
143
+ if (idx >= 0) {
144
+ event.preventDefault()
145
+ multiStepCommands[idx]!.handler(ctx)
146
+ return
147
+ }
148
+ }
149
+
150
+ // Check single-step matches
151
+ for (const { command, parsed } of singleStepCandidates) {
152
+ if (matchStep(event, parsed.steps[0]!)) {
153
+ event.preventDefault()
154
+ command.handler(ctx)
155
+ return
156
+ }
157
+ }
158
+ })
159
+
160
+ return (
161
+ <CommandContext.Provider
162
+ value={{
163
+ registry: registryRef.current,
164
+ leaderKey: leader,
165
+ contextSources: contextSourcesRef.current,
166
+ }}
167
+ >
168
+ {children}
169
+ </CommandContext.Provider>
170
+ )
171
+ }
172
+
173
+ export function useCommandContext(): { commands: Command[]; invoke: (id: string) => void } {
174
+ const ctx = useContext(CommandContext)
175
+ if (!ctx) {
176
+ throw new Error("useCommandContext must be used within a CommandProvider")
177
+ }
178
+
179
+ const { registry } = ctx
180
+ return {
181
+ get commands() {
182
+ return Array.from(registry.commands.values())
183
+ },
184
+ invoke: registry.invoke,
185
+ }
186
+ }
187
+
188
+ export function useCommandRegistry(): CommandContextValue {
189
+ const ctx = useContext(CommandContext)
190
+ if (!ctx) {
191
+ throw new Error("useCommandRegistry must be used within a CommandProvider")
192
+ }
193
+ return ctx
194
+ }
195
+
196
+ let nextContextSourceId = 0
197
+
198
+ export function useProvideCommandContext(getter: () => Partial<CommandContext>): void {
199
+ const ctx = useContext(CommandContext)
200
+ if (!ctx) {
201
+ throw new Error("useProvideCommandContext must be used within a CommandProvider")
202
+ }
203
+
204
+ const idRef = useRef<string | null>(null)
205
+ if (idRef.current === null) {
206
+ idRef.current = `ctx-${nextContextSourceId++}`
207
+ }
208
+
209
+ const getterRef = useRef(getter)
210
+ getterRef.current = getter
211
+
212
+ const { contextSources } = ctx
213
+
214
+ useEffect(() => {
215
+ const id = idRef.current!
216
+ contextSources.set(id, () => getterRef.current())
217
+ return () => {
218
+ contextSources.delete(id)
219
+ }
220
+ }, [contextSources])
221
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type {
2
+ Command,
3
+ CommandContext,
4
+ CommandContextBase,
5
+ CommandHandler,
6
+ CommandWhen,
7
+ ParsedHotkey,
8
+ ParsedStep,
9
+ } from "./types.js"
10
+ export type { Mode } from "./mode.jsx"
11
+ export { ModeProvider, useMode, useSetMode } from "./mode.jsx"
12
+ export type { ModeProviderProps } from "./mode.jsx"
13
+ export { parseHotkey } from "./parse.js"
14
+ export { matchStep } from "./match.js"
15
+ export { SequenceTracker } from "./sequence.js"
16
+ export type { SequenceTrackerOptions } from "./sequence.js"
17
+ export { CommandProvider, useCommandContext, useProvideCommandContext } from "./context.jsx"
18
+ export type { CommandProviderProps } from "./context.jsx"
19
+ export { useCommand } from "./use-command.js"
20
+ export type { UseCommandOptions } from "./use-command.js"
21
+ export { useActions } from "./use-actions.js"
22
+ export type { ActionDefinition } from "./use-actions.js"
package/src/match.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+ import type { ParsedStep } from "./types.js"
3
+
4
+ /**
5
+ * Check if a KeyEvent matches a ParsedStep.
6
+ */
7
+ export function matchStep(event: KeyEvent, step: ParsedStep): boolean {
8
+ if (event.name !== step.key) return false
9
+ if (event.ctrl !== step.ctrl) return false
10
+ if (event.meta !== step.meta) return false
11
+ if (event.shift !== step.shift) return false
12
+ if (event.option !== step.option) return false
13
+ return true
14
+ }
package/src/mode.tsx ADDED
@@ -0,0 +1,38 @@
1
+ import { createContext, useContext, useState, useCallback, type ReactNode } from "react"
2
+
3
+ export type Mode = "cursor" | "insert" | "select"
4
+
5
+ interface ModeContextValue {
6
+ mode: Mode
7
+ setMode: (mode: Mode) => void
8
+ }
9
+
10
+ const ModeContext = createContext<ModeContextValue | null>(null)
11
+
12
+ export interface ModeProviderProps {
13
+ children: ReactNode
14
+ initialMode?: Mode
15
+ }
16
+
17
+ export function ModeProvider({ children, initialMode = "cursor" }: ModeProviderProps) {
18
+ const [mode, setModeState] = useState<Mode>(initialMode)
19
+ const setMode = useCallback((m: Mode) => setModeState(m), [])
20
+
21
+ return <ModeContext.Provider value={{ mode, setMode }}>{children}</ModeContext.Provider>
22
+ }
23
+
24
+ export function useMode(): Mode {
25
+ const ctx = useContext(ModeContext)
26
+ if (!ctx) {
27
+ throw new Error("useMode must be used within a ModeProvider")
28
+ }
29
+ return ctx.mode
30
+ }
31
+
32
+ export function useSetMode(): (mode: Mode) => void {
33
+ const ctx = useContext(ModeContext)
34
+ if (!ctx) {
35
+ throw new Error("useSetMode must be used within a ModeProvider")
36
+ }
37
+ return ctx.setMode
38
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { ParsedHotkey, ParsedStep } from "./types.js"
2
+
3
+ const KEY_ALIASES: Record<string, string> = {
4
+ esc: "escape",
5
+ enter: "return",
6
+ cr: "return",
7
+ del: "delete",
8
+ space: " ",
9
+ tab: "tab",
10
+ backspace: "backspace",
11
+ }
12
+
13
+ function normalizeKey(key: string): string {
14
+ const lower = key.toLowerCase()
15
+ return KEY_ALIASES[lower] ?? lower
16
+ }
17
+
18
+ function parseStep(step: string): ParsedStep {
19
+ const parts = step.toLowerCase().split("+")
20
+ let key = ""
21
+ let ctrl = false
22
+ let meta = false
23
+ let shift = false
24
+ let option = false
25
+
26
+ for (const part of parts) {
27
+ const trimmed = part.trim()
28
+ if (trimmed === "ctrl" || trimmed === "control") {
29
+ ctrl = true
30
+ } else if (trimmed === "meta" || trimmed === "alt") {
31
+ meta = true
32
+ } else if (trimmed === "shift") {
33
+ shift = true
34
+ } else if (trimmed === "option") {
35
+ option = true
36
+ } else if (trimmed === "super") {
37
+ // super modifier — not tracked in ParsedStep currently
38
+ } else {
39
+ key = normalizeKey(trimmed)
40
+ }
41
+ }
42
+
43
+ return { key, ctrl, meta, shift, option }
44
+ }
45
+
46
+ /**
47
+ * Parse a hotkey string into a ParsedHotkey.
48
+ *
49
+ * Supports:
50
+ * - Modifier combos: "ctrl+s", "ctrl+shift+p"
51
+ * - Sequences (space-separated): "g g", "d d"
52
+ * - Leader prefix: "<leader>n" expands to leaderKey + "n"
53
+ */
54
+ export function parseHotkey(hotkey: string, leaderKey?: string): ParsedHotkey {
55
+ const trimmed = hotkey.trim()
56
+
57
+ // Handle leader prefix
58
+ const leaderMatch = trimmed.match(/^<leader>(.+)$/)
59
+ if (leaderMatch) {
60
+ const leaderStep = leaderKey
61
+ ? parseStep(leaderKey)
62
+ : { key: "x", ctrl: true, meta: false, shift: false, option: false }
63
+ const followStep = parseStep(leaderMatch[1]!)
64
+ return { steps: [leaderStep, followStep] }
65
+ }
66
+
67
+ // Space-separated = sequence
68
+ const parts = trimmed.split(/\s+/)
69
+ const steps = parts.map(parseStep)
70
+ return { steps }
71
+ }
@@ -0,0 +1,70 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+ import type { ParsedHotkey } from "./types.js"
3
+ import { matchStep } from "./match.js"
4
+
5
+ export interface SequenceTrackerOptions {
6
+ timeout?: number // ms, default 500
7
+ }
8
+
9
+ export class SequenceTracker {
10
+ private buffer: KeyEvent[] = []
11
+ private timer: ReturnType<typeof setTimeout> | null = null
12
+ private timeout: number
13
+
14
+ constructor(options?: SequenceTrackerOptions) {
15
+ this.timeout = options?.timeout ?? 500
16
+ }
17
+
18
+ /**
19
+ * Feed a key event and check against registered hotkeys.
20
+ * Returns the index of the matched hotkey, or -1 if no match.
21
+ */
22
+ feed(event: KeyEvent, hotkeys: ParsedHotkey[]): number {
23
+ this.buffer.push(event)
24
+ this.resetTimer()
25
+
26
+ for (let i = 0; i < hotkeys.length; i++) {
27
+ const hotkey = hotkeys[i]!
28
+ if (this.matchesBuffer(hotkey)) {
29
+ this.reset()
30
+ return i
31
+ }
32
+ }
33
+
34
+ // Prune buffer if no hotkey could possibly match
35
+ const maxLen = Math.max(...hotkeys.map((h) => h.steps.length))
36
+ if (this.buffer.length > maxLen) {
37
+ this.buffer.shift()
38
+ }
39
+
40
+ return -1
41
+ }
42
+
43
+ private matchesBuffer(hotkey: ParsedHotkey): boolean {
44
+ const { steps } = hotkey
45
+ if (this.buffer.length < steps.length) return false
46
+
47
+ const start = this.buffer.length - steps.length
48
+ for (let i = 0; i < steps.length; i++) {
49
+ if (!matchStep(this.buffer[start + i]!, steps[i]!)) return false
50
+ }
51
+ return true
52
+ }
53
+
54
+ reset(): void {
55
+ this.buffer = []
56
+ this.clearTimer()
57
+ }
58
+
59
+ private resetTimer(): void {
60
+ this.clearTimer()
61
+ this.timer = setTimeout(() => this.reset(), this.timeout)
62
+ }
63
+
64
+ private clearTimer(): void {
65
+ if (this.timer !== null) {
66
+ clearTimeout(this.timer)
67
+ this.timer = null
68
+ }
69
+ }
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { Mode } from "./mode.jsx"
2
+
3
+ export interface CommandContextBase {
4
+ mode: Mode
5
+ setMode: (mode: Mode) => void
6
+ commands: { invoke: (id: string) => void; list: () => Command[] }
7
+ exit: () => void
8
+ }
9
+
10
+ export interface CommandContext extends CommandContextBase {
11
+ [key: string]: any
12
+ }
13
+
14
+ export type CommandHandler = (ctx: CommandContext) => void | Promise<void>
15
+ export type CommandWhen = (ctx: CommandContext) => boolean
16
+
17
+ export interface Command {
18
+ id: string
19
+ title: string
20
+ handler: CommandHandler
21
+ defaultHotkey?: string
22
+ modes?: Mode[]
23
+ when?: CommandWhen
24
+ category?: string
25
+ group?: string
26
+ icon?: string
27
+ hidden?: boolean
28
+ }
29
+
30
+ export interface ParsedHotkey {
31
+ steps: ParsedStep[]
32
+ }
33
+
34
+ export interface ParsedStep {
35
+ key: string
36
+ ctrl: boolean
37
+ meta: boolean
38
+ shift: boolean
39
+ option: boolean
40
+ }
41
+
42
+ export interface CommandRegistry {
43
+ commands: Map<string, Command>
44
+ register: (command: Command) => () => void
45
+ invoke: (id: string) => void
46
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, useMemo, useRef } from "react"
2
+ import { useCommandRegistry } from "./context.jsx"
3
+ import type { Command, CommandHandler, CommandWhen } from "./types.js"
4
+ import type { Mode } from "./mode.jsx"
5
+
6
+ export interface ActionDefinition {
7
+ id: string
8
+ title: string
9
+ hotkey?: string
10
+ modes?: Mode[]
11
+ handler: CommandHandler
12
+ when?: CommandWhen
13
+ }
14
+
15
+ export function useActions(actions: ActionDefinition[] | undefined): void {
16
+ const { registry } = useCommandRegistry()
17
+ const actionsRef = useRef(actions)
18
+ actionsRef.current = actions
19
+
20
+ const key = useMemo(
21
+ () => actions?.map((a) => `${a.id}:${a.hotkey ?? ""}`).join(",") ?? "",
22
+ [actions],
23
+ )
24
+
25
+ useEffect(() => {
26
+ const current = actionsRef.current
27
+ if (!current || current.length === 0) return
28
+
29
+ const unregisters = current.map((action, i) => {
30
+ const command: Command = {
31
+ id: action.id,
32
+ title: action.title,
33
+ defaultHotkey: action.hotkey,
34
+ modes: action.modes,
35
+ handler: (ctx) => actionsRef.current?.[i]?.handler(ctx),
36
+ when: action.when ? (ctx) => actionsRef.current?.[i]?.when?.(ctx) ?? false : undefined,
37
+ }
38
+ return registry.register(command)
39
+ })
40
+
41
+ return () => {
42
+ for (const unregister of unregisters) {
43
+ unregister()
44
+ }
45
+ }
46
+ }, [key, registry])
47
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef } from "react"
2
+ import type { Command, CommandHandler, CommandWhen } from "./types.js"
3
+ import type { Mode } from "./mode.jsx"
4
+ import { useCommandRegistry } from "./context.jsx"
5
+
6
+ export interface UseCommandOptions {
7
+ id: string
8
+ title: string
9
+ handler: CommandHandler
10
+ hotkey?: string
11
+ modes?: Mode[]
12
+ category?: string
13
+ group?: string
14
+ icon?: string
15
+ when?: CommandWhen
16
+ hidden?: boolean
17
+ }
18
+
19
+ export function useCommand(options: UseCommandOptions): void {
20
+ const { registry } = useCommandRegistry()
21
+ const optionsRef = useRef(options)
22
+ optionsRef.current = options
23
+
24
+ useEffect(() => {
25
+ const command: Command = {
26
+ id: options.id,
27
+ title: options.title,
28
+ handler: (...args: Parameters<Command["handler"]>) => optionsRef.current.handler(...args),
29
+ defaultHotkey: options.hotkey,
30
+ modes: options.modes,
31
+ category: options.category,
32
+ group: options.group,
33
+ icon: options.icon,
34
+ when: optionsRef.current.when ? (ctx) => optionsRef.current.when!(ctx) : undefined,
35
+ hidden: options.hidden,
36
+ }
37
+ return registry.register(command)
38
+ }, [
39
+ options.id,
40
+ options.title,
41
+ options.hotkey,
42
+ options.modes,
43
+ options.category,
44
+ options.group,
45
+ options.icon,
46
+ options.hidden,
47
+ registry,
48
+ ])
49
+ }