@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.
- package/README.md +5 -0
- package/dist/context.d.ts +25 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +161 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/match.d.ts +7 -0
- package/dist/match.d.ts.map +1 -0
- package/dist/match.js +17 -0
- package/dist/match.js.map +1 -0
- package/dist/mode.d.ts +10 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +23 -0
- package/dist/mode.js.map +1 -0
- package/dist/parse.d.ts +11 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +68 -0
- package/dist/parse.js.map +1 -0
- package/dist/sequence.d.ts +21 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +56 -0
- package/dist/sequence.js.map +1 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-actions.d.ts +12 -0
- package/dist/use-actions.d.ts.map +1 -0
- package/dist/use-actions.js +30 -0
- package/dist/use-actions.js.map +1 -0
- package/dist/use-command.d.ts +16 -0
- package/dist/use-command.d.ts.map +1 -0
- package/dist/use-command.js +33 -0
- package/dist/use-command.js.map +1 -0
- package/package.json +40 -0
- package/src/context.tsx +221 -0
- package/src/index.ts +22 -0
- package/src/match.ts +14 -0
- package/src/mode.tsx +38 -0
- package/src/parse.ts +71 -0
- package/src/sequence.ts +70 -0
- package/src/types.ts +46 -0
- package/src/use-actions.ts +47 -0
- 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
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/sequence.ts
ADDED
|
@@ -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
|
+
}
|