@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 ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@silvery/tea",
3
+ "version": "0.3.0",
4
+ "description": "TEA (The Elm Architecture) state machine store for silvery",
5
+ "license": "MIT",
6
+ "author": "Bjørn Stabell <bjorn@stabell.org>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/beorn/silvery.git",
10
+ "directory": "packages/tea"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "main": "src/index.ts",
17
+ "types": "src/index.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "import": "./src/index.ts"
22
+ },
23
+ "./core": {
24
+ "types": "./src/core/index.ts",
25
+ "import": "./src/core/index.ts"
26
+ },
27
+ "./store": {
28
+ "types": "./src/store/index.ts",
29
+ "import": "./src/store/index.ts"
30
+ },
31
+ "./tea": {
32
+ "types": "./src/tea/index.ts",
33
+ "import": "./src/tea/index.ts"
34
+ },
35
+ "./streams": {
36
+ "types": "./src/streams/index.ts",
37
+ "import": "./src/streams/index.ts"
38
+ },
39
+ "./plugins": {
40
+ "types": "./src/plugins.ts",
41
+ "import": "./src/plugins.ts"
42
+ },
43
+ "./*": "./src/*.ts"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "@silvery/compat": "workspace:*",
50
+ "@silvery/react": "workspace:*",
51
+ "@silvery/term": "workspace:*",
52
+ "@silvery/test": "workspace:*"
53
+ }
54
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @silvery/core — TEA runtime, React reconciler, store, and effect system.
3
+ *
4
+ * Portable core that doesn't depend on terminal specifics. Can target
5
+ * terminal, canvas, DOM, or any custom render backend.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ /**
11
+ * silvery/core — Pure functions and types, NO React dependency.
12
+ *
13
+ * This sub-path export provides:
14
+ * - TEA (The Elm Architecture) types: SilveryModel, SilveryMsg, Effect, Sub, Plugin
15
+ * - Focus manager: createFocusManager + types
16
+ * - Focus events: event factories + dispatch functions
17
+ * - Focus queries: tree query functions
18
+ * - Plugin composition: compose() for middleware-style plugin chaining
19
+ *
20
+ * Everything here is pure TypeScript — no React import anywhere in the
21
+ * dependency graph. Safe for use in non-React contexts (CLI tools, tests,
22
+ * server-side logic).
23
+ *
24
+ * @packageDocumentation
25
+ */
26
+
27
+ // =============================================================================
28
+ // TEA Types (The Elm Architecture)
29
+ // =============================================================================
30
+
31
+ import type { FocusOrigin } from "../focus-manager.js"
32
+ export type { FocusOrigin } from "../focus-manager.js"
33
+
34
+ /**
35
+ * The model type that silvery manages for focus state.
36
+ *
37
+ * Applications extend this with their own model fields.
38
+ * The store's update function receives the full model and returns
39
+ * a new model + effects tuple.
40
+ */
41
+ export interface SilveryModel {
42
+ focus: {
43
+ activeId: string | null
44
+ previousId: string | null
45
+ origin: FocusOrigin | null
46
+ scopeStack: string[]
47
+ scopeMemory: Record<string, string>
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Direction type used in spatial navigation messages.
53
+ */
54
+ export type Direction = "up" | "down" | "left" | "right"
55
+
56
+ /**
57
+ * Message types that silvery understands.
58
+ *
59
+ * Applications can extend this union with their own message types.
60
+ * The store's update function pattern-matches on `type` to decide
61
+ * how to update the model.
62
+ */
63
+ export type SilveryMsg =
64
+ | { type: "focus"; nodeId: string; origin?: FocusOrigin }
65
+ | { type: "blur" }
66
+ | { type: "focus-next" }
67
+ | { type: "focus-prev" }
68
+ | { type: "focus-direction"; direction: Direction }
69
+ | { type: "scope-enter"; scopeId: string }
70
+ | { type: "scope-exit" }
71
+ | {
72
+ type: "term:key"
73
+ key: string
74
+ input: string
75
+ ctrl: boolean
76
+ meta: boolean
77
+ shift: boolean
78
+ }
79
+ | {
80
+ type: "term:mouse"
81
+ action: "down" | "up" | "move" | "scroll"
82
+ x: number
83
+ y: number
84
+ button: number
85
+ }
86
+ | { type: "term:resize"; cols: number; rows: number }
87
+
88
+ /**
89
+ * Effect commands returned by update functions.
90
+ *
91
+ * Effects are declarative descriptions of side effects. The store
92
+ * executes them after the model update, keeping the update function pure.
93
+ *
94
+ * - `none`: No effect (useful as a default)
95
+ * - `batch`: Multiple effects to execute
96
+ * - `dispatch`: Queue another message (no re-entrant dispatch)
97
+ */
98
+ export type Effect = { type: "none" } | { type: "batch"; effects: Effect[] } | { type: "dispatch"; msg: SilveryMsg }
99
+
100
+ /**
101
+ * Subscription descriptor (for future use).
102
+ *
103
+ * Subscriptions represent long-running side effects (timers, event listeners)
104
+ * that produce messages over time. The store manages their lifecycle.
105
+ */
106
+ export type Sub = {
107
+ type: "none"
108
+ }
109
+
110
+ // =============================================================================
111
+ // Effect Constructors
112
+ // =============================================================================
113
+
114
+ /** No-op effect. */
115
+ export const none: Effect = { type: "none" }
116
+
117
+ /** Batch multiple effects. */
118
+ export function batch(...effects: Effect[]): Effect {
119
+ // Flatten nested batches and filter out none effects
120
+ const flat: Effect[] = []
121
+ for (const e of effects) {
122
+ if (e.type === "none") continue
123
+ if (e.type === "batch") {
124
+ flat.push(...e.effects)
125
+ } else {
126
+ flat.push(e)
127
+ }
128
+ }
129
+ if (flat.length === 0) return none
130
+ if (flat.length === 1) return flat[0]!
131
+ return { type: "batch", effects: flat }
132
+ }
133
+
134
+ /** Queue a message dispatch as an effect. */
135
+ export function dispatch(msg: SilveryMsg): Effect {
136
+ return { type: "dispatch", msg }
137
+ }
138
+
139
+ // =============================================================================
140
+ // Plugin Type (Middleware Composition)
141
+ // =============================================================================
142
+
143
+ /**
144
+ * A plugin wraps an update function, adding behavior before/after/around it.
145
+ *
146
+ * Plugins compose via `compose()` — the outermost plugin runs first on
147
+ * message receive, but the innermost (original) update runs first for
148
+ * model updates. This is the standard middleware pattern.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * const logging: Plugin<MyModel, MyMsg> = (inner) => (msg, model) => {
153
+ * console.log('msg:', msg.type)
154
+ * const result = inner(msg, model)
155
+ * console.log('new model:', result[0])
156
+ * return result
157
+ * }
158
+ * ```
159
+ */
160
+ export type Plugin<Model, Msg> = (
161
+ innerUpdate: (msg: Msg, model: Model) => [Model, Effect[]],
162
+ ) => (msg: Msg, model: Model) => [Model, Effect[]]
163
+
164
+ /**
165
+ * Compose multiple plugins into a single update function wrapper.
166
+ *
167
+ * Plugins are applied right-to-left (innermost first), so the first
168
+ * plugin in the array is the outermost wrapper — it sees messages first
169
+ * and model changes last.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * const update = compose(logging, focusNav, spatialNav)(baseUpdate)
174
+ * // Equivalent to: logging(focusNav(spatialNav(baseUpdate)))
175
+ * ```
176
+ */
177
+ export function compose<Model, Msg>(...plugins: Plugin<Model, Msg>[]): Plugin<Model, Msg> {
178
+ return (innerUpdate) => {
179
+ let update = innerUpdate
180
+ // Apply right-to-left so first plugin is outermost
181
+ for (let i = plugins.length - 1; i >= 0; i--) {
182
+ update = plugins[i]!(update)
183
+ }
184
+ return update
185
+ }
186
+ }
187
+
188
+ // =============================================================================
189
+ // Focus Manager (pure, no React)
190
+ // =============================================================================
191
+
192
+ export { createFocusManager } from "../focus-manager.js"
193
+ export type { FocusManager, FocusManagerOptions, FocusChangeCallback, FocusSnapshot } from "../focus-manager.js"
194
+
195
+ // =============================================================================
196
+ // Focus Events (pure, no React)
197
+ // =============================================================================
198
+
199
+ export { createKeyEvent, createFocusEvent, dispatchKeyEvent, dispatchFocusEvent } from "../focus-events.js"
200
+ export type { SilveryKeyEvent, SilveryFocusEvent, FocusEventProps } from "../focus-events.js"
201
+
202
+ // =============================================================================
203
+ // Focus Queries (pure, no React)
204
+ // =============================================================================
205
+
206
+ export {
207
+ findFocusableAncestor,
208
+ getTabOrder,
209
+ findByTestID,
210
+ findSpatialTarget,
211
+ getExplicitFocusLink,
212
+ } from "../focus-queries.js"
213
+
214
+ // =============================================================================
215
+ // Slices (ops-as-data helper)
216
+ // =============================================================================
217
+
218
+ export { createSlice } from "./slice.js"
219
+ export type { Slice, SliceWithInit, InferOp } from "./slice.js"
220
+
221
+ // =============================================================================
222
+ // Shared Types (pure)
223
+ // =============================================================================
224
+
225
+ export type { TeaNode, Rect } from "../types.js"
@@ -0,0 +1,69 @@
1
+ // --- Types ---
2
+
3
+ /** True if F accepts at least 2 args */
4
+ type HasParams<F> = F extends (a: any, b: any, ...rest: any[]) => any ? true : false
5
+
6
+ /** Extract 2nd arg type, or never for 1-arg handlers */
7
+ type HandlerParams<F> = HasParams<F> extends true ? (F extends (s: any, params: infer P) => any ? P : never) : never
8
+
9
+ /** One variant of the op union */
10
+ type OpVariant<Name extends string, F> = [HandlerParams<F>] extends [never]
11
+ ? { op: Name }
12
+ : { op: Name } & HandlerParams<F>
13
+
14
+ /** Full op union inferred from handler map */
15
+ export type InferOp<H> = {
16
+ [K in keyof H & string]: OpVariant<K, H[K]>
17
+ }[keyof H & string]
18
+
19
+ /** Union of all handler return types */
20
+ type ApplyReturn<H> = {
21
+ [K in keyof H]: H[K] extends (...args: any[]) => infer R ? R : never
22
+ }[keyof H]
23
+
24
+ /** Handlers-only slice (no state factory) */
25
+ export type Slice<S, H extends Record<string, (s: S, ...args: any[]) => any>> = H & {
26
+ apply(s: S, op: InferOp<H>): ApplyReturn<H>
27
+ readonly Op: InferOp<H>
28
+ }
29
+
30
+ /** Slice with bundled state factory */
31
+ export type SliceWithInit<S, H extends Record<string, (s: S, ...args: any[]) => any>> = Slice<S, H> & {
32
+ create(): { state: S; apply: (op: InferOp<H>) => ApplyReturn<H> }
33
+ }
34
+
35
+ // --- Implementation ---
36
+
37
+ // Shared: build the slice object from handlers
38
+ function makeSlice<S, H extends Record<string, (s: S, ...args: any[]) => any>>(handlers: H): Slice<S, H> {
39
+ const apply = (s: S, op: { op: string }) => {
40
+ const handler = handlers[op.op]
41
+ if (!handler) throw new Error(`Unknown op: ${op.op}`)
42
+ return handler(s, op)
43
+ }
44
+ return Object.assign({ apply }, handlers) as any
45
+ }
46
+
47
+ // Overload 1: state factory + handlers (primary)
48
+ export function createSlice<S, H extends Record<string, (s: S, ...args: any[]) => any>>(
49
+ init: () => S,
50
+ handlers: H,
51
+ ): SliceWithInit<S, H>
52
+
53
+ // Overload 2: curried, no state (fallback)
54
+ export function createSlice<S>(): <H extends Record<string, (s: S, ...args: any[]) => any>>(handlers: H) => Slice<S, H>
55
+
56
+ export function createSlice(...args: any[]): any {
57
+ if (args.length === 0) {
58
+ // Curried form
59
+ return <H extends Record<string, (s: any, ...args: any[]) => any>>(handlers: H) => makeSlice(handlers)
60
+ }
61
+ // State factory form
62
+ const [init, handlers] = args
63
+ const slice = makeSlice(handlers)
64
+ ;(slice as any).create = () => {
65
+ const state = init()
66
+ return { state, apply: (op: any) => slice.apply(state, op) }
67
+ }
68
+ return slice
69
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * createCommandRegistry() — Build a typed command registry from a definition object.
3
+ *
4
+ * Silvery's own command registry builder. Creates a `CommandRegistryLike`
5
+ * from a plain object of command definitions, suitable for use with
6
+ * `withCommands()`.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * const registry = createCommandRegistry({
11
+ * cursor_down: {
12
+ * name: 'Move Down',
13
+ * description: 'Move cursor down one row',
14
+ * shortcuts: ['j', 'ArrowDown'],
15
+ * execute: (ctx) => ({ type: 'moveCursor', delta: 1 }),
16
+ * },
17
+ * cursor_up: {
18
+ * name: 'Move Up',
19
+ * description: 'Move cursor up one row',
20
+ * shortcuts: ['k', 'ArrowUp'],
21
+ * execute: (ctx) => ({ type: 'moveCursor', delta: -1 }),
22
+ * },
23
+ * toggle_done: {
24
+ * name: 'Toggle Done',
25
+ * description: 'Toggle the done state of the current item',
26
+ * execute: (ctx) => ({ type: 'toggleDone', index: ctx.cursor }),
27
+ * },
28
+ * })
29
+ *
30
+ * // Use with withCommands
31
+ * const app = withCommands(baseApp, {
32
+ * registry,
33
+ * getContext: () => buildContext(state),
34
+ * handleAction: (action) => dispatch(action),
35
+ * })
36
+ * ```
37
+ */
38
+
39
+ import type { CommandDef, CommandRegistryLike } from "./with-commands"
40
+
41
+ // =============================================================================
42
+ // Types
43
+ // =============================================================================
44
+
45
+ /**
46
+ * Definition for a single command in the registry builder.
47
+ *
48
+ * Unlike the full `CommandDef`, the `id` is inferred from the object key.
49
+ */
50
+ export interface CommandDefInput<TContext = unknown, TAction = unknown> {
51
+ /** Human-readable name */
52
+ name: string
53
+ /** Description of what the command does */
54
+ description?: string
55
+ /** Default keyboard shortcuts */
56
+ shortcuts?: string[]
57
+ /** Execute the command, returning action(s) or null */
58
+ execute: (ctx: TContext) => TAction | TAction[] | null
59
+ }
60
+
61
+ /**
62
+ * A record mapping command IDs to their definitions.
63
+ */
64
+ export type CommandDefs<TContext = unknown, TAction = unknown> = Record<string, CommandDefInput<TContext, TAction>>
65
+
66
+ // =============================================================================
67
+ // Implementation
68
+ // =============================================================================
69
+
70
+ /**
71
+ * Create a command registry from a definition object.
72
+ *
73
+ * Each key in the object becomes the command ID. The returned registry
74
+ * implements `CommandRegistryLike` for use with `withCommands()`.
75
+ *
76
+ * @param defs - Object mapping command IDs to their definitions
77
+ * @returns A command registry with `get()` and `getAll()`
78
+ */
79
+ export function createCommandRegistry<TContext, TAction>(
80
+ defs: CommandDefs<TContext, TAction>,
81
+ ): CommandRegistryLike<TContext, TAction> {
82
+ // Build the full CommandDef array with IDs
83
+ const commands: CommandDef<TContext, TAction>[] = []
84
+ const byId = new Map<string, CommandDef<TContext, TAction>>()
85
+
86
+ for (const [id, def] of Object.entries(defs)) {
87
+ const command: CommandDef<TContext, TAction> = {
88
+ id,
89
+ name: def.name,
90
+ description: def.description ?? def.name,
91
+ shortcuts: def.shortcuts,
92
+ execute: def.execute,
93
+ }
94
+ commands.push(command)
95
+ byId.set(id, command)
96
+ }
97
+
98
+ return {
99
+ get(id: string): CommandDef<TContext, TAction> | undefined {
100
+ return byId.get(id)
101
+ },
102
+ getAll(): CommandDef<TContext, TAction>[] {
103
+ return commands
104
+ },
105
+ }
106
+ }
package/src/effects.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Built-in TEA effects for timers and dispatch.
3
+ *
4
+ * Effect constructors create plain data objects. Effect runners execute them.
5
+ * The update function returns effects as data — the runtime executes them.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * function update(state, msg) {
10
+ * switch (msg.type) {
11
+ * case "start":
12
+ * return [{ ...state, phase: "thinking" }, [fx.delay(1200, { type: "done" })]]
13
+ * case "tick":
14
+ * return { ...state, count: state.count + 1 } // no effects
15
+ * case "done":
16
+ * return [{ ...state, phase: "idle" }, [fx.cancel("ticker")]]
17
+ * }
18
+ * }
19
+ * ```
20
+ */
21
+
22
+ import type { EffectLike, EffectRunners } from "./tea"
23
+
24
+ // =============================================================================
25
+ // Effect Types
26
+ // =============================================================================
27
+
28
+ /** Fire a message after a delay. */
29
+ export interface DelayEffect<Msg = unknown> extends EffectLike {
30
+ type: "delay"
31
+ ms: number
32
+ msg: Msg
33
+ id?: string
34
+ }
35
+
36
+ /** Fire a message repeatedly on an interval. */
37
+ export interface IntervalEffect<Msg = unknown> extends EffectLike {
38
+ type: "interval"
39
+ ms: number
40
+ msg: Msg
41
+ id: string
42
+ }
43
+
44
+ /** Cancel a named timer (delay or interval). */
45
+ export interface CancelEffect extends EffectLike {
46
+ type: "cancel"
47
+ id: string
48
+ }
49
+
50
+ /** All built-in effect types. */
51
+ export type TimerEffect<Msg = unknown> = DelayEffect<Msg> | IntervalEffect<Msg> | CancelEffect
52
+
53
+ // =============================================================================
54
+ // Effect Constructors (the fx namespace)
55
+ // =============================================================================
56
+
57
+ /** Fire `msg` after `ms` milliseconds. Optionally named for cancellation. */
58
+ function delay<Msg>(ms: number, msg: Msg, id?: string): DelayEffect<Msg> {
59
+ return { type: "delay", ms, msg, id }
60
+ }
61
+
62
+ /** Fire `msg` every `ms` milliseconds. Must be named (for cancellation). */
63
+ function interval<Msg>(ms: number, msg: Msg, id: string): IntervalEffect<Msg> {
64
+ return { type: "interval", ms, msg, id }
65
+ }
66
+
67
+ /** Cancel a named delay or interval. */
68
+ function cancel(id: string): CancelEffect {
69
+ return { type: "cancel", id }
70
+ }
71
+
72
+ /**
73
+ * Built-in effect constructors.
74
+ *
75
+ * ```ts
76
+ * return [newState, [fx.delay(1000, { type: "tick" }), fx.cancel("old")]]
77
+ * ```
78
+ */
79
+ export const fx = { delay, interval, cancel } as const
80
+
81
+ // =============================================================================
82
+ // Effect Runners
83
+ // =============================================================================
84
+
85
+ /**
86
+ * Create timer effect runners that manage named timers.
87
+ *
88
+ * Returns runners + a cleanup function. Call cleanup on unmount to
89
+ * cancel all active timers.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * const { runners, cleanup } = createTimerRunners<MyMsg>()
94
+ * const [state, send] = useTea(init, update, runners)
95
+ * useEffect(() => cleanup, [])
96
+ * ```
97
+ */
98
+ export function createTimerRunners<Msg>(): {
99
+ runners: EffectRunners<TimerEffect<Msg>, Msg>
100
+ cleanup: () => void
101
+ } {
102
+ const timers = new Map<string, ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>()
103
+ let nextAnon = 0
104
+
105
+ function clearTimer(id: string): void {
106
+ const existing = timers.get(id)
107
+ if (existing !== undefined) {
108
+ clearTimeout(existing as ReturnType<typeof setTimeout>)
109
+ clearInterval(existing as ReturnType<typeof setInterval>)
110
+ timers.delete(id)
111
+ }
112
+ }
113
+
114
+ const runners: EffectRunners<TimerEffect<Msg>, Msg> = {
115
+ delay(effect, dispatch) {
116
+ const id = effect.id ?? `__anon_${nextAnon++}`
117
+ clearTimer(id)
118
+ const timer = setTimeout(() => {
119
+ timers.delete(id)
120
+ dispatch(effect.msg as Msg)
121
+ }, effect.ms)
122
+ timers.set(id, timer)
123
+ },
124
+
125
+ interval(effect, dispatch) {
126
+ clearTimer(effect.id)
127
+ const timer = setInterval(() => {
128
+ dispatch(effect.msg as Msg)
129
+ }, effect.ms)
130
+ timers.set(effect.id, timer)
131
+ },
132
+
133
+ cancel(effect) {
134
+ clearTimer(effect.id)
135
+ },
136
+ }
137
+
138
+ function cleanup(): void {
139
+ for (const [id] of timers) {
140
+ clearTimer(id)
141
+ }
142
+ }
143
+
144
+ return { runners, cleanup }
145
+ }