@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.
- package/package.json +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- 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
|
+
}
|