@silvery/tea 0.3.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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * withCommands - SlateJS-style plugin for command system
3
+ *
4
+ * Adds a `cmd` object to the App for direct command invocation with metadata.
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * const app = withCommands(render(<Board />), {
9
+ * registry: commandRegistry,
10
+ * getContext: () => buildCommandContext(state),
11
+ * handleAction: (action) => dispatch(action),
12
+ * getKeybindings: () => keybindings,
13
+ * })
14
+ *
15
+ * // Direct command invocation
16
+ * await app.cmd.down()
17
+ * await app.cmd['cursor_down']()
18
+ *
19
+ * // Command metadata
20
+ * app.cmd.down.id // 'cursor_down'
21
+ * app.cmd.down.name // 'Move Down'
22
+ * app.cmd.down.help // 'Move cursor down'
23
+ * app.cmd.down.keys // ['j', 'ArrowDown']
24
+ *
25
+ * // Introspection
26
+ * app.cmd.all() // All commands with metadata
27
+ * app.getState() // { screen, commands, focus } for AI
28
+ * ```
29
+ *
30
+ * See docs/future/silvery-command-api-research.md for design rationale.
31
+ */
32
+
33
+ import type { App } from "@silvery/term/app"
34
+
35
+ // =============================================================================
36
+ // Types
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Generic command definition interface.
41
+ * Compatible with @km/commands CommandDef but doesn't require the dependency.
42
+ */
43
+ export interface CommandDef<TContext = unknown, TAction = unknown> {
44
+ id: string
45
+ name: string
46
+ description: string
47
+ shortcuts?: string[]
48
+ execute: (ctx: TContext) => TAction | TAction[] | null
49
+ }
50
+
51
+ /**
52
+ * Generic keybinding interface.
53
+ * Compatible with @km/commands Keybinding.
54
+ */
55
+ export interface KeybindingDef {
56
+ key: string
57
+ commandId: string
58
+ ctrl?: boolean
59
+ alt?: boolean
60
+ opt?: boolean
61
+ shift?: boolean
62
+ cmd?: boolean
63
+ }
64
+
65
+ /**
66
+ * Generic command registry interface.
67
+ */
68
+ export interface CommandRegistryLike<TContext = unknown, TAction = unknown> {
69
+ get(id: string): CommandDef<TContext, TAction> | undefined
70
+ getAll(): CommandDef<TContext, TAction>[]
71
+ }
72
+
73
+ /**
74
+ * Command metadata exposed on the cmd object.
75
+ */
76
+ export interface CommandInfo {
77
+ id: string
78
+ name: string
79
+ description: string
80
+ keys: readonly string[]
81
+ }
82
+
83
+ /**
84
+ * A callable command with metadata.
85
+ */
86
+ export interface Command {
87
+ (): Promise<void>
88
+ readonly id: string
89
+ readonly name: string
90
+ readonly help: string
91
+ readonly keys: readonly string[]
92
+ }
93
+
94
+ /**
95
+ * The cmd object added to the app.
96
+ *
97
+ * Provides both method-style (`cmd.down()`) and index-style (`cmd['cursor_down']()`)
98
+ * access to commands. Uses Proxy for dynamic lookup.
99
+ */
100
+ export interface Cmd {
101
+ [key: string]: Command | (() => CommandInfo[]) | (() => string) | undefined
102
+ /** Get all commands with metadata */
103
+ all(): CommandInfo[]
104
+ /** Get human/AI readable description of all commands */
105
+ describe(): string
106
+ }
107
+
108
+ /**
109
+ * Options for withCommands.
110
+ *
111
+ * @typeParam TContext - The context type passed to command execute()
112
+ * @typeParam TAction - The action type returned by command execute()
113
+ */
114
+ export interface WithCommandsOptions<TContext, TAction> {
115
+ /** Command registry with get() and getAll() */
116
+ registry: CommandRegistryLike<TContext, TAction>
117
+ /** Build context for command execution */
118
+ getContext: () => TContext
119
+ /** Handle actions returned by command execution */
120
+ handleAction: (action: TAction) => void
121
+ /** Get keybindings for command metadata (optional) */
122
+ getKeybindings?: () => KeybindingDef[]
123
+ }
124
+
125
+ /**
126
+ * App state for AI introspection.
127
+ */
128
+ export interface AppState {
129
+ screen: string
130
+ commands: CommandInfo[]
131
+ focus?: { id: string; text: string }
132
+ }
133
+
134
+ /**
135
+ * App with command system.
136
+ */
137
+ export type AppWithCommands = App & {
138
+ cmd: Cmd
139
+ getState(): AppState
140
+ }
141
+
142
+ // =============================================================================
143
+ // Implementation
144
+ // =============================================================================
145
+
146
+ /**
147
+ * Find command by short name or full id.
148
+ *
149
+ * Supports both:
150
+ * - Exact id match: `cmd['cursor_down']`
151
+ * - Short name match: `cmd.down` (matches 'cursor_down', 'navigation_down', etc.)
152
+ */
153
+ function findCommand<TContext, TAction>(
154
+ registry: CommandRegistryLike<TContext, TAction>,
155
+ key: string,
156
+ ): CommandDef<TContext, TAction> | undefined {
157
+ // Try exact id match first
158
+ const byId = registry.get(key)
159
+ if (byId) return byId
160
+
161
+ // Try short name (last segment after underscore or dot)
162
+ const all = registry.getAll()
163
+ return all.find((c) => {
164
+ const shortName = c.id.split(/[._]/).pop()
165
+ return shortName === key
166
+ })
167
+ }
168
+
169
+ /**
170
+ * Get keys bound to a command.
171
+ */
172
+ function getKeysForCommand(commandId: string, keybindings?: KeybindingDef[]): readonly string[] {
173
+ if (!keybindings) return []
174
+ return keybindings
175
+ .filter((kb) => kb.commandId === commandId)
176
+ .map((kb) => {
177
+ const parts: string[] = []
178
+ if (kb.cmd) parts.push("Cmd")
179
+ if (kb.ctrl) parts.push("Ctrl")
180
+ if (kb.alt || kb.opt) parts.push("Alt")
181
+ if (kb.shift) parts.push("Shift")
182
+ parts.push(kb.key)
183
+ return parts.join("+")
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Format help text for all commands.
189
+ */
190
+ function formatHelp<TContext, TAction>(
191
+ registry: CommandRegistryLike<TContext, TAction>,
192
+ keybindings?: KeybindingDef[],
193
+ ): string {
194
+ const commands = registry.getAll()
195
+ const lines = commands.map((cmd) => {
196
+ const keys = getKeysForCommand(cmd.id, keybindings)
197
+ const keyStr = keys.length > 0 ? ` [${keys.join(", ")}]` : ""
198
+ return `${cmd.id}${keyStr}: ${cmd.description}`
199
+ })
200
+ return lines.join("\n")
201
+ }
202
+
203
+ /**
204
+ * Add command system to an App.
205
+ *
206
+ * Supports two calling styles:
207
+ * - Direct: `withCommands(app, options)` — returns enhanced app immediately
208
+ * - Curried: `withCommands(options)` — returns a plugin for pipe() composition
209
+ *
210
+ * @example Direct
211
+ * ```tsx
212
+ * const app = withCommands(render(<Board />), {
213
+ * registry: commandRegistry,
214
+ * getContext: () => buildContext(state),
215
+ * handleAction: (action) => dispatch(action),
216
+ * })
217
+ * ```
218
+ *
219
+ * @example Curried (pipe)
220
+ * ```tsx
221
+ * const app = pipe(
222
+ * baseApp,
223
+ * withCommands({
224
+ * registry: commandRegistry,
225
+ * getContext: () => buildContext(state),
226
+ * handleAction: (action) => dispatch(action),
227
+ * }),
228
+ * )
229
+ * ```
230
+ */
231
+ // Curried form: withCommands(options) => plugin
232
+ export function withCommands<TContext, TAction>(
233
+ options: WithCommandsOptions<TContext, TAction>,
234
+ ): (app: App) => AppWithCommands
235
+ // Direct form: withCommands(app, options) => enhancedApp
236
+ export function withCommands<TContext, TAction>(
237
+ app: App,
238
+ options: WithCommandsOptions<TContext, TAction>,
239
+ ): AppWithCommands
240
+ export function withCommands<TContext, TAction>(
241
+ appOrOptions: App | WithCommandsOptions<TContext, TAction>,
242
+ maybeOptions?: WithCommandsOptions<TContext, TAction>,
243
+ ): AppWithCommands | ((app: App) => AppWithCommands) {
244
+ // Curried form: first arg is options (no press/text/ansi = not an App)
245
+ if (maybeOptions === undefined) {
246
+ const options = appOrOptions as WithCommandsOptions<TContext, TAction>
247
+ return (app: App) => applyCommands(app, options)
248
+ }
249
+ // Direct form: first arg is app
250
+ return applyCommands(appOrOptions as App, maybeOptions)
251
+ }
252
+
253
+ function applyCommands<TContext, TAction>(app: App, options: WithCommandsOptions<TContext, TAction>): AppWithCommands {
254
+ const { registry, getContext, handleAction, getKeybindings } = options
255
+
256
+ const cmd = new Proxy({} as Cmd, {
257
+ get(_, prop: string | symbol): unknown {
258
+ // Handle symbol access (for JS internals)
259
+ if (typeof prop === "symbol") return undefined
260
+
261
+ // Introspection methods
262
+ if (prop === "all") {
263
+ return () => {
264
+ const commands = registry.getAll()
265
+ const keybindings = getKeybindings?.()
266
+ return commands.map((c) => ({
267
+ id: c.id,
268
+ name: c.name,
269
+ description: c.description,
270
+ keys: getKeysForCommand(c.id, keybindings),
271
+ }))
272
+ }
273
+ }
274
+
275
+ if (prop === "describe") {
276
+ return () => formatHelp(registry, getKeybindings?.())
277
+ }
278
+
279
+ // Look up command by short name or full id
280
+ const def = findCommand(registry, prop)
281
+ if (!def) return undefined
282
+
283
+ // Build callable with metadata
284
+ const fn = async () => {
285
+ const ctx = getContext()
286
+ const result = def.execute(ctx)
287
+ if (result) {
288
+ const actions = Array.isArray(result) ? result : [result]
289
+ for (const action of actions) {
290
+ handleAction(action)
291
+ }
292
+ }
293
+ // Allow microtask to flush for test synchronization
294
+ await Promise.resolve()
295
+ }
296
+
297
+ // Attach metadata
298
+ const keybindings = getKeybindings?.()
299
+ Object.defineProperties(fn, {
300
+ id: { value: def.id, enumerable: true },
301
+ name: { value: def.name, enumerable: true },
302
+ help: { value: def.description, enumerable: true },
303
+ keys: {
304
+ value: getKeysForCommand(def.id, keybindings),
305
+ enumerable: true,
306
+ },
307
+ })
308
+
309
+ return fn as Command
310
+ },
311
+
312
+ has(_, prop): boolean {
313
+ if (typeof prop === "symbol") return false
314
+ if (prop === "all" || prop === "describe") return true
315
+ return !!findCommand(registry, prop)
316
+ },
317
+ })
318
+
319
+ // Build getState for AI introspection
320
+ const getState = (): AppState => {
321
+ const commands = registry.getAll()
322
+ const keybindings = getKeybindings?.()
323
+ return {
324
+ screen: app.text,
325
+ commands: commands.map((c) => ({
326
+ id: c.id,
327
+ name: c.name,
328
+ description: c.description,
329
+ keys: getKeysForCommand(c.id, keybindings),
330
+ })),
331
+ // Focus info would require DOM query - leave undefined for now
332
+ focus: undefined,
333
+ }
334
+ }
335
+
336
+ return Object.assign(app, { cmd, getState }) as AppWithCommands
337
+ }