@silvery/ui 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.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Step node tree structure for declarative steps
3
+ *
4
+ * Parses the user's declarative object structure into an internal tree
5
+ * that can be rendered and executed.
6
+ */
7
+
8
+ /**
9
+ * A single step in the tree
10
+ */
11
+ export interface StepNode {
12
+ /** Display label (auto-generated or custom) */
13
+ label: string
14
+
15
+ /** Object key from the declaration */
16
+ key: string
17
+
18
+ /** Work function (if leaf node) */
19
+ work?: (...args: unknown[]) => unknown
20
+
21
+ /** Child steps (if group node) */
22
+ children?: StepNode[]
23
+
24
+ /** Indentation level for display */
25
+ indent: number
26
+ }
27
+
28
+ /**
29
+ * What users can declare as a step value
30
+ */
31
+ export type StepValue =
32
+ | ((...args: unknown[]) => unknown) // Function (auto-named)
33
+ | [string, (...args: unknown[]) => unknown] // [label, function]
34
+ | StepsDef // Nested group
35
+
36
+ /**
37
+ * The declarative structure users provide
38
+ */
39
+ export type StepsDef = {
40
+ [key: string]: StepValue
41
+ }
42
+
43
+ /**
44
+ * Parse a declarative steps definition into a tree of StepNodes
45
+ *
46
+ * @param def - The declarative structure
47
+ * @param indent - Current indentation level (internal)
48
+ * @returns Array of StepNodes
49
+ */
50
+ export function parseStepsDef(def: StepsDef, indent = 0): StepNode[] {
51
+ const nodes: StepNode[] = []
52
+
53
+ for (const [key, value] of Object.entries(def)) {
54
+ if (typeof value === "function") {
55
+ // Function: auto-generate label from key
56
+ nodes.push({
57
+ key,
58
+ label: generateLabel(key),
59
+ work: value,
60
+ indent,
61
+ })
62
+ } else if (Array.isArray(value) && value.length === 2) {
63
+ // Tuple: [label, function]
64
+ const [label, work] = value as [string, (...args: unknown[]) => unknown]
65
+ nodes.push({
66
+ key,
67
+ label,
68
+ work,
69
+ indent,
70
+ })
71
+ } else if (typeof value === "object" && value !== null) {
72
+ // Nested group
73
+ const children = parseStepsDef(value as StepsDef, indent + 1)
74
+ nodes.push({
75
+ key,
76
+ label: generateLabel(key),
77
+ children,
78
+ indent,
79
+ })
80
+ }
81
+ }
82
+
83
+ return nodes
84
+ }
85
+
86
+ /**
87
+ * Flatten the tree for sequential execution
88
+ *
89
+ * Returns nodes in depth-first order, with groups followed by their children.
90
+ */
91
+ export function flattenStepNodes(nodes: StepNode[]): StepNode[] {
92
+ const result: StepNode[] = []
93
+
94
+ for (const node of nodes) {
95
+ result.push(node)
96
+ if (node.children) {
97
+ result.push(...flattenStepNodes(node.children))
98
+ }
99
+ }
100
+
101
+ return result
102
+ }
103
+
104
+ /**
105
+ * Get only leaf nodes (nodes with work functions)
106
+ */
107
+ export function getLeafNodes(nodes: StepNode[]): StepNode[] {
108
+ const result: StepNode[] = []
109
+
110
+ for (const node of nodes) {
111
+ if (node.work) {
112
+ result.push(node)
113
+ }
114
+ if (node.children) {
115
+ result.push(...getLeafNodes(node.children))
116
+ }
117
+ }
118
+
119
+ return result
120
+ }
121
+
122
+ /**
123
+ * Generate a display label from a camelCase function name
124
+ *
125
+ * @example
126
+ * generateLabel("loadModules") // "Load modules"
127
+ * generateLabel("parseMarkdown") // "Parse markdown"
128
+ * generateLabel("initBoardStateGenerator") // "Init board state generator"
129
+ */
130
+ export function generateLabel(fnName: string): string {
131
+ return fnName
132
+ .replace(/([A-Z])/g, " $1") // Insert space before capitals
133
+ .replace(/(\d+)/g, " $1") // Insert space before numbers
134
+ .toLowerCase() // Convert all to lowercase
135
+ .trim() // Remove leading/trailing spaces
136
+ .replace(/\s+/g, " ") // Collapse multiple spaces
137
+ .replace(/^./, (s) => s.toUpperCase()) // Capitalize only first letter
138
+ }
139
+
140
+ /**
141
+ * Check if a value is a StepsDef (nested group)
142
+ */
143
+ export function isStepsDef(value: unknown): value is StepsDef {
144
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value !== "function"
145
+ }
146
+
147
+ /**
148
+ * Check if a value is a tuple [label, function]
149
+ */
150
+ export function isLabelTuple(value: unknown): value is [string, (...args: unknown[]) => unknown] {
151
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "function"
152
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Sequential step runner with progress display
3
+ *
4
+ * Supports two modes:
5
+ *
6
+ * ## Declarative Mode (recommended)
7
+ *
8
+ * Pass an object to declare all steps upfront:
9
+ *
10
+ * ```typescript
11
+ * import { steps, step } from "@silvery/ui/progress";
12
+ *
13
+ * const loader = steps({
14
+ * loadModules, // Auto-named: "Load modules"
15
+ * loadRepo: { // Group: "Load repo"
16
+ * discover, // "Discover"
17
+ * parse, // "Parse"
18
+ * },
19
+ * });
20
+ *
21
+ * const results = await loader.run({ clear: true });
22
+ *
23
+ * // Inside work functions, use step() to report progress:
24
+ * async function discover() {
25
+ * const files = await glob("**\/*.md");
26
+ * for (let i = 0; i < files.length; i++) {
27
+ * step().progress(i + 1, files.length);
28
+ * await process(files[i]);
29
+ * }
30
+ * return files;
31
+ * }
32
+ * ```
33
+ *
34
+ * ## Fluent Mode (legacy)
35
+ *
36
+ * Chain steps with `.run()` and execute with `.execute()`:
37
+ *
38
+ * ```typescript
39
+ * await steps()
40
+ * .run("Loading modules", () => import("./app"))
41
+ * .run("Building view", async () => buildView())
42
+ * .execute();
43
+ * ```
44
+ *
45
+ * Generators can yield sub-step progress:
46
+ * - Yield a **string** to create a new sub-step
47
+ * - Yield an **object** `{ current, total }` to update progress on current sub-step
48
+ */
49
+
50
+ import { MultiProgress, type TaskHandle } from "../cli/multi-progress"
51
+ import { stepsDeclarative, type StepsRunner } from "./declarative"
52
+ import type { StepsDef } from "./step-node"
53
+
54
+ // Re-export step() context helper
55
+ export { step } from "./als-context"
56
+
57
+ // Re-export types from declarative
58
+ export type { StepsRunner } from "./declarative"
59
+ export type { StepsDef, StepNode } from "./step-node"
60
+ export type { StepContext } from "./als-context"
61
+
62
+ // Node.js globals for yielding to event loop
63
+ declare function setImmediate(callback: (value?: unknown) => void): unknown
64
+ declare function setTimeout(callback: (value?: unknown) => void, ms: number): unknown
65
+
66
+ /** Progress update (object form) */
67
+ interface ProgressUpdate {
68
+ current?: number
69
+ total?: number
70
+ }
71
+
72
+ /** Declare all sub-steps upfront (optional, yield as first value) */
73
+ interface DeclareSteps {
74
+ declare: string[]
75
+ }
76
+
77
+ /** What generators can yield */
78
+ type StepYield = string | ProgressUpdate | DeclareSteps
79
+
80
+ /**
81
+ * Controller for creating and updating sub-steps
82
+ *
83
+ * Passed to work functions that need to report sub-step progress.
84
+ */
85
+ export interface StepController {
86
+ /** Create a new sub-step (auto-completes previous sub-step) */
87
+ new (label: string): void
88
+
89
+ /** Update progress on current sub-step */
90
+ progress(current: number, total: number): void
91
+
92
+ /** Explicitly complete current sub-step (optional - new() auto-completes) */
93
+ done(): void
94
+ }
95
+
96
+ /** Work function types */
97
+ type SyncWork<T> = () => T
98
+ type AsyncWork<T> = () => PromiseLike<T>
99
+ type SyncGeneratorWork<T> = () => Generator<StepYield, T, unknown>
100
+ type AsyncGeneratorWork<T> = () => AsyncGenerator<StepYield, T, unknown>
101
+ /** Work function with step controller for sub-step progress */
102
+ type WorkWithStep<T> = (step: StepController) => T | PromiseLike<T>
103
+
104
+ type WorkFn<T> = SyncWork<T> | AsyncWork<T> | SyncGeneratorWork<T> | AsyncGeneratorWork<T> | WorkWithStep<T>
105
+
106
+ /** Step definition */
107
+ interface StepDef<T = unknown> {
108
+ title: string
109
+ work: WorkFn<T>
110
+ }
111
+
112
+ /** Options for execute() */
113
+ export interface ExecuteOptions {
114
+ /** Clear progress display after completion (default: false) */
115
+ clear?: boolean
116
+ }
117
+
118
+ export interface StepBuilder {
119
+ /**
120
+ * Add a step to run
121
+ *
122
+ * @param title - Display title for this step
123
+ * @param work - Function to execute. Can be:
124
+ * - Sync function: `() => result`
125
+ * - Async function: `async () => result`
126
+ * - Sync generator: `function* () { yield "sub-step"; return result; }`
127
+ * - Async generator: `async function* () { yield "sub-step"; return result; }`
128
+ *
129
+ * Generators can yield:
130
+ * - `"string"` - Creates a new sub-step with that label
131
+ * - `{ current: N, total: M }` - Updates progress on current sub-step
132
+ */
133
+ run<T>(title: string, work: WorkFn<T>): StepBuilder
134
+
135
+ /**
136
+ * Execute all steps in sequence
137
+ * @param options - Execution options
138
+ * @returns Results keyed by step title
139
+ */
140
+ execute(options?: ExecuteOptions): Promise<Record<string, unknown>>
141
+ }
142
+
143
+ /**
144
+ * Create a step runner
145
+ *
146
+ * @overload Declarative mode - pass an object to declare steps upfront
147
+ * @overload Fluent mode - chain steps with .run() and execute with .execute()
148
+ *
149
+ * @example Declarative mode (recommended)
150
+ * ```typescript
151
+ * const loader = steps({
152
+ * loadModules,
153
+ * loadRepo: { discover, parse },
154
+ * });
155
+ * const results = await loader.run({ clear: true });
156
+ * ```
157
+ *
158
+ * @example Fluent mode
159
+ * ```typescript
160
+ * await steps()
161
+ * .run("Step 1", doStep1)
162
+ * .run("Step 2", doStep2)
163
+ * .execute();
164
+ * ```
165
+ */
166
+ export function steps<T extends StepsDef>(def: T): StepsRunner<T>
167
+ export function steps(): StepBuilder
168
+ export function steps<T extends StepsDef>(def?: T): StepsRunner<T> | StepBuilder {
169
+ // Declarative mode: object passed
170
+ if (def !== undefined) {
171
+ return stepsDeclarative(def)
172
+ }
173
+
174
+ // Fluent mode: no arguments
175
+ return createFluentBuilder()
176
+ }
177
+
178
+ /**
179
+ * Create a no-op StepController for work functions that don't use sub-steps.
180
+ * This satisfies the type union while being harmless if not used.
181
+ */
182
+ function createNoopStepController(): StepController {
183
+ const controller = (_label: string) => {}
184
+ controller.progress = (_current: number, _total: number) => {}
185
+ controller.done = () => {}
186
+ // StepController uses `new` as a callable signature, not a constructor
187
+ return controller as unknown as StepController
188
+ }
189
+
190
+ /**
191
+ * Create the fluent step builder (legacy mode)
192
+ */
193
+ function createFluentBuilder(): StepBuilder {
194
+ const stepList: StepDef[] = []
195
+
196
+ const builder: StepBuilder = {
197
+ run<T>(title: string, work: WorkFn<T>): StepBuilder {
198
+ stepList.push({ title, work: work as WorkFn<unknown> })
199
+ return builder
200
+ },
201
+
202
+ async execute(options?: ExecuteOptions): Promise<Record<string, unknown>> {
203
+ const multi = new MultiProgress()
204
+ const handles = new Map<string, TaskHandle>()
205
+ const results: Record<string, unknown> = {}
206
+
207
+ // Register all steps upfront (shows pending state)
208
+ for (const step of stepList) {
209
+ handles.set(step.title, multi.add(step.title, { type: "spinner" }))
210
+ }
211
+
212
+ multi.start()
213
+
214
+ try {
215
+ for (const step of stepList) {
216
+ const handle = handles.get(step.title)!
217
+
218
+ // Yield to event loop before potentially blocking work
219
+ await new Promise((resolve) => setImmediate(resolve))
220
+
221
+ const result = step.work(createNoopStepController())
222
+
223
+ if (isAsyncGenerator(result)) {
224
+ // Async generator: parent stays static while sub-steps animate
225
+ results[step.title] = await runAsyncGenerator(result, handle, step.title, multi)
226
+ } else if (isSyncGenerator(result)) {
227
+ // Sync generator: same handling
228
+ results[step.title] = await runSyncGenerator(result, handle, step.title, multi)
229
+ } else if (isPromiseLike(result)) {
230
+ handle.start()
231
+ results[step.title] = await result
232
+ handle.complete()
233
+ } else {
234
+ handle.start()
235
+ results[step.title] = result
236
+ handle.complete()
237
+ }
238
+ }
239
+ } finally {
240
+ multi.stop(options?.clear ?? false)
241
+ }
242
+
243
+ return results
244
+ },
245
+ }
246
+
247
+ return builder
248
+ }
249
+
250
+ /**
251
+ * Process a yielded value:
252
+ * - { declare: [...] } = declare all sub-steps upfront (show as pending)
253
+ * - string = start/create a sub-step with that label
254
+ * - { current, total } = update progress on current sub-step
255
+ */
256
+ function processYield(value: StepYield, state: GeneratorState, multi: MultiProgress): void {
257
+ // Handle declaration of all sub-steps upfront
258
+ if (isDeclareSteps(value)) {
259
+ for (const label of value.declare) {
260
+ const handle = multi.add(label, {
261
+ type: "spinner",
262
+ indent: 1,
263
+ insertAfter: state.lastInsertId,
264
+ })
265
+ state.lastInsertId = handle.id
266
+ state.declaredSteps.set(label, handle)
267
+ }
268
+ return
269
+ }
270
+
271
+ if (typeof value === "string") {
272
+ // String = start a sub-step with this label
273
+ if (state.currentHandle && state.currentLabel) {
274
+ const elapsed = Date.now() - state.subStepStartTime
275
+ state.currentHandle.complete(elapsed)
276
+ }
277
+
278
+ state.currentLabel = value
279
+ state.subStepStartTime = Date.now()
280
+
281
+ // Use pre-declared handle if available, otherwise create new one
282
+ const declared = state.declaredSteps.get(value)
283
+ if (declared) {
284
+ state.currentHandle = declared
285
+ state.currentHandle.start()
286
+ } else {
287
+ state.currentHandle = multi.add(value, {
288
+ type: "spinner",
289
+ indent: 1,
290
+ insertAfter: state.lastInsertId,
291
+ })
292
+ state.lastInsertId = state.currentHandle.id
293
+ state.currentHandle.start()
294
+ }
295
+ } else if (value && typeof value === "object") {
296
+ // Object = progress update on current sub-step
297
+ const { current, total } = value as ProgressUpdate
298
+ if (state.currentHandle && total && total > 0) {
299
+ state.currentHandle.setTitle(`${state.currentLabel} (${current ?? 0}/${total})`)
300
+ }
301
+ }
302
+ }
303
+
304
+ function isDeclareSteps(value: StepYield): value is DeclareSteps {
305
+ return (
306
+ value !== null && typeof value === "object" && "declare" in value && Array.isArray((value as DeclareSteps).declare)
307
+ )
308
+ }
309
+
310
+ /** State for generator processing */
311
+ interface GeneratorState {
312
+ currentLabel: string | undefined
313
+ currentHandle: TaskHandle | null
314
+ lastInsertId: string
315
+ subStepStartTime: number
316
+ startTime: number
317
+ /** Pre-declared sub-steps (pending until started) */
318
+ declaredSteps: Map<string, TaskHandle>
319
+ }
320
+
321
+ /**
322
+ * Run an async generator step
323
+ */
324
+ async function runAsyncGenerator<T>(
325
+ gen: AsyncGenerator<StepYield, T, unknown>,
326
+ parentHandle: TaskHandle,
327
+ parentTitle: string,
328
+ multi: MultiProgress,
329
+ ): Promise<T> {
330
+ const state: GeneratorState = {
331
+ currentLabel: undefined,
332
+ currentHandle: null,
333
+ lastInsertId: parentHandle.id,
334
+ subStepStartTime: Date.now(),
335
+ startTime: Date.now(),
336
+ declaredSteps: new Map(),
337
+ }
338
+
339
+ let result = await gen.next()
340
+
341
+ while (!result.done) {
342
+ processYield(result.value, state, multi)
343
+
344
+ // Yield to event loop for animation
345
+ await new Promise((resolve) => setTimeout(resolve, 0))
346
+
347
+ result = await gen.next()
348
+ }
349
+
350
+ // Complete final sub-step
351
+ if (state.currentHandle && state.currentLabel) {
352
+ const elapsed = Date.now() - state.subStepStartTime
353
+ state.currentHandle.complete(elapsed)
354
+ }
355
+
356
+ // Complete parent step
357
+ const totalElapsed = Date.now() - state.startTime
358
+ parentHandle.complete(totalElapsed)
359
+
360
+ return result.value
361
+ }
362
+
363
+ /**
364
+ * Run a sync generator step
365
+ */
366
+ async function runSyncGenerator<T>(
367
+ gen: Generator<StepYield, T, unknown>,
368
+ parentHandle: TaskHandle,
369
+ parentTitle: string,
370
+ multi: MultiProgress,
371
+ ): Promise<T> {
372
+ const state: GeneratorState = {
373
+ currentLabel: undefined,
374
+ currentHandle: null,
375
+ lastInsertId: parentHandle.id,
376
+ subStepStartTime: Date.now(),
377
+ startTime: Date.now(),
378
+ declaredSteps: new Map(),
379
+ }
380
+
381
+ let result = gen.next()
382
+
383
+ while (!result.done) {
384
+ processYield(result.value, state, multi)
385
+
386
+ // Yield to event loop for animation
387
+ await new Promise((resolve) => setTimeout(resolve, 0))
388
+
389
+ result = gen.next()
390
+ }
391
+
392
+ // Complete final sub-step
393
+ if (state.currentHandle && state.currentLabel) {
394
+ const elapsed = Date.now() - state.subStepStartTime
395
+ state.currentHandle.complete(elapsed)
396
+ }
397
+
398
+ // Complete parent step
399
+ const totalElapsed = Date.now() - state.startTime
400
+ parentHandle.complete(totalElapsed)
401
+
402
+ return result.value
403
+ }
404
+
405
+ function isAsyncGenerator(value: unknown): value is AsyncGenerator<StepYield, unknown, unknown> {
406
+ return (
407
+ value !== null &&
408
+ typeof value === "object" &&
409
+ typeof (value as AsyncGenerator).next === "function" &&
410
+ typeof (value as AsyncGenerator)[Symbol.asyncIterator] === "function"
411
+ )
412
+ }
413
+
414
+ function isSyncGenerator(value: unknown): value is Generator<StepYield, unknown, unknown> {
415
+ return (
416
+ value !== null &&
417
+ typeof value === "object" &&
418
+ typeof (value as Generator).next === "function" &&
419
+ typeof (value as Generator)[Symbol.iterator] === "function"
420
+ )
421
+ }
422
+
423
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
424
+ return value !== null && typeof value === "object" && typeof (value as PromiseLike<unknown>).then === "function"
425
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Fluent single-task wrapper
3
+ *
4
+ * @deprecated Use `steps()` from `@silvery/ui/progress` instead.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * // OLD (deprecated):
9
+ * import { task } from "@silvery/ui/progress";
10
+ * const data = await task("Loading data").wrap(fetchData());
11
+ *
12
+ * // NEW:
13
+ * import { steps } from "@silvery/ui/progress";
14
+ * const results = await steps({ loadData: fetchData }).run();
15
+ * ```
16
+ */
17
+
18
+ import type { ProgressInfo } from "../types.js"
19
+ import { createSpinner } from "../cli/spinner"
20
+
21
+ /** Phase labels for common operations */
22
+ const PHASE_LABELS: Record<string, string> = {
23
+ reading: "Reading events",
24
+ applying: "Applying events",
25
+ rules: "Evaluating rules",
26
+ scanning: "Scanning files",
27
+ reconciling: "Reconciling changes",
28
+ board: "Building view",
29
+ }
30
+
31
+ export interface TaskWrapper {
32
+ /**
33
+ * Wrap work with a spinner indicator
34
+ * @param work - Promise, function, or generator
35
+ */
36
+ wrap<T>(
37
+ work: T | PromiseLike<T> | (() => T | PromiseLike<T>) | (() => Generator<ProgressInfo, T, unknown>),
38
+ ): Promise<T>
39
+ }
40
+
41
+ /**
42
+ * Create a task wrapper with spinner
43
+ *
44
+ * @param title - Display title for the task
45
+ * @returns TaskWrapper with wrap() method
46
+ */
47
+ export function task(title: string): TaskWrapper {
48
+ return {
49
+ async wrap<T>(
50
+ work: T | PromiseLike<T> | (() => T | PromiseLike<T>) | (() => Generator<ProgressInfo, T, unknown>),
51
+ ): Promise<T> {
52
+ const spinner = createSpinner()
53
+ spinner(title)
54
+
55
+ try {
56
+ // If it's a function, call it
57
+ if (typeof work === "function") {
58
+ const result = (work as () => unknown)()
59
+
60
+ // Check if it's a generator
61
+ if (isGenerator(result)) {
62
+ return await runGenerator(result as Generator<ProgressInfo, T, unknown>, spinner, title)
63
+ }
64
+
65
+ // Check if it's a promise
66
+ if (isPromiseLike(result)) {
67
+ const value = await result
68
+ spinner.succeed(title)
69
+ return value as T
70
+ }
71
+
72
+ // Sync function
73
+ spinner.succeed(title)
74
+ return result as T
75
+ }
76
+
77
+ // If it's a promise-like, await it
78
+ if (isPromiseLike(work)) {
79
+ const value = await work
80
+ spinner.succeed(title)
81
+ return value as T
82
+ }
83
+
84
+ // Otherwise it's a direct value
85
+ spinner.succeed(title)
86
+ return work as T
87
+ } catch (error) {
88
+ spinner.fail(title)
89
+ throw error
90
+ }
91
+ },
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Run a generator with progress updates
97
+ */
98
+ async function runGenerator<T>(
99
+ gen: Generator<ProgressInfo, T, unknown>,
100
+ spinner: ReturnType<typeof createSpinner>,
101
+ baseTitle: string,
102
+ ): Promise<T> {
103
+ let result = gen.next()
104
+
105
+ while (!result.done) {
106
+ const info = result.value
107
+ const phase = info.phase ?? ""
108
+ const phaseLabel = PHASE_LABELS[phase] ?? (phase || baseTitle)
109
+
110
+ // Update spinner with phase and progress count
111
+ if (info.total && info.total > 0) {
112
+ spinner(`${phaseLabel} (${info.current}/${info.total})`)
113
+ } else {
114
+ spinner(phaseLabel)
115
+ }
116
+
117
+ // Yield to event loop for animation
118
+ await new Promise((resolve) => setImmediate(resolve))
119
+
120
+ result = gen.next()
121
+ }
122
+
123
+ spinner.succeed(baseTitle)
124
+ return result.value
125
+ }
126
+
127
+ function isGenerator(value: unknown): value is Generator<ProgressInfo, unknown, unknown> {
128
+ return (
129
+ value !== null &&
130
+ typeof value === "object" &&
131
+ typeof (value as Generator).next === "function" &&
132
+ typeof (value as Generator).throw === "function"
133
+ )
134
+ }
135
+
136
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
137
+ return value !== null && typeof value === "object" && typeof (value as PromiseLike<unknown>).then === "function"
138
+ }