@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.
- package/package.json +54 -0
- package/src/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
|
@@ -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
|
+
}
|