@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fluent sequential task builder
|
|
3
|
+
*
|
|
4
|
+
* @deprecated Use `steps()` from `@silvery/ui/progress` instead.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // OLD (deprecated):
|
|
9
|
+
* import { tasks } from "@silvery/ui/progress";
|
|
10
|
+
* const results = await tasks()
|
|
11
|
+
* .add("Loading", loadModules)
|
|
12
|
+
* .add("Processing", processData)
|
|
13
|
+
* .run({ clear: true });
|
|
14
|
+
*
|
|
15
|
+
* // NEW:
|
|
16
|
+
* import { steps } from "@silvery/ui/progress";
|
|
17
|
+
* const results = await steps({
|
|
18
|
+
* loadModules,
|
|
19
|
+
* processData,
|
|
20
|
+
* }).run({ clear: true });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ProgressInfo } from "../types.js"
|
|
25
|
+
import { MultiProgress, type TaskHandle } from "../cli/multi-progress"
|
|
26
|
+
|
|
27
|
+
// Node.js globals for yielding to event loop
|
|
28
|
+
declare function setImmediate(callback: (value?: unknown) => void): unknown
|
|
29
|
+
declare function setTimeout(callback: (value?: unknown) => void, ms: number): unknown
|
|
30
|
+
|
|
31
|
+
/** Phase labels for common operations */
|
|
32
|
+
const PHASE_LABELS: Record<string, string> = {
|
|
33
|
+
// Repo loading phases
|
|
34
|
+
discover: "Discovering files",
|
|
35
|
+
parse: "Parsing markdown",
|
|
36
|
+
apply: "Applying changes",
|
|
37
|
+
resolve: "Resolving links",
|
|
38
|
+
materialize: "Evaluating rules",
|
|
39
|
+
// Board building
|
|
40
|
+
board: "Building view",
|
|
41
|
+
// Legacy/alternative names
|
|
42
|
+
reading: "Reading events",
|
|
43
|
+
applying: "Applying events",
|
|
44
|
+
rules: "Evaluating rules",
|
|
45
|
+
scanning: "Scanning files",
|
|
46
|
+
reconciling: "Reconciling changes",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Task definition */
|
|
50
|
+
interface TaskDef<T = unknown> {
|
|
51
|
+
title: string
|
|
52
|
+
work: () => T | PromiseLike<T> | Generator<ProgressInfo, T, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options for run() */
|
|
56
|
+
export interface RunOptions {
|
|
57
|
+
/** Clear progress display after completion (default: false) */
|
|
58
|
+
clear?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TaskBuilder {
|
|
62
|
+
/**
|
|
63
|
+
* Add a task to the sequence
|
|
64
|
+
* @param title - Display title
|
|
65
|
+
* @param work - Function, async function, or generator
|
|
66
|
+
*/
|
|
67
|
+
add<T>(title: string, work: () => T | PromiseLike<T> | Generator<ProgressInfo, T, unknown>): TaskBuilder
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run all tasks in sequence
|
|
71
|
+
* @param options - Run options
|
|
72
|
+
* @returns Results keyed by task title
|
|
73
|
+
*/
|
|
74
|
+
run(options?: RunOptions): Promise<Record<string, unknown>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a sequential task builder
|
|
79
|
+
*
|
|
80
|
+
* @returns TaskBuilder with add() and run() methods
|
|
81
|
+
*/
|
|
82
|
+
export function tasks(): TaskBuilder {
|
|
83
|
+
const taskList: TaskDef[] = []
|
|
84
|
+
|
|
85
|
+
const builder: TaskBuilder = {
|
|
86
|
+
add<T>(title: string, work: () => T | PromiseLike<T> | Generator<ProgressInfo, T, unknown>): TaskBuilder {
|
|
87
|
+
taskList.push({ title, work })
|
|
88
|
+
return builder
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async run(options?: RunOptions): Promise<Record<string, unknown>> {
|
|
92
|
+
const multi = new MultiProgress()
|
|
93
|
+
const handles = new Map<string, TaskHandle>()
|
|
94
|
+
const results: Record<string, unknown> = {}
|
|
95
|
+
|
|
96
|
+
// Register all tasks upfront (shows pending state)
|
|
97
|
+
for (const task of taskList) {
|
|
98
|
+
handles.set(task.title, multi.add(task.title, { type: "spinner" }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
multi.start()
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
for (const task of taskList) {
|
|
105
|
+
const handle = handles.get(task.title)!
|
|
106
|
+
|
|
107
|
+
// Force render before potentially blocking operation
|
|
108
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
109
|
+
|
|
110
|
+
const result = task.work()
|
|
111
|
+
|
|
112
|
+
if (isGenerator(result)) {
|
|
113
|
+
// Generator: parent stays static, phases animate underneath
|
|
114
|
+
results[task.title] = await runGenerator(result, handle, task.title, multi)
|
|
115
|
+
} else if (isPromiseLike(result)) {
|
|
116
|
+
handle.start()
|
|
117
|
+
results[task.title] = await result
|
|
118
|
+
handle.complete()
|
|
119
|
+
} else {
|
|
120
|
+
handle.start()
|
|
121
|
+
results[task.title] = result
|
|
122
|
+
handle.complete()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
multi.stop(options?.clear ?? false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return results
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return builder
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run a generator task with progress updates
|
|
138
|
+
* Parent task stays visible while sub-phases are indented below
|
|
139
|
+
*/
|
|
140
|
+
async function runGenerator<T>(
|
|
141
|
+
gen: Generator<ProgressInfo, T, unknown>,
|
|
142
|
+
parentHandle: TaskHandle,
|
|
143
|
+
baseTitle: string,
|
|
144
|
+
multi: MultiProgress,
|
|
145
|
+
): Promise<T> {
|
|
146
|
+
let result = gen.next()
|
|
147
|
+
let currentPhase: string | undefined
|
|
148
|
+
let currentPhaseHandle: TaskHandle | null = null
|
|
149
|
+
let lastInsertId = parentHandle.id // Insert phases after parent (then after each other)
|
|
150
|
+
let phaseStartTime = Date.now()
|
|
151
|
+
const taskStartTime = Date.now()
|
|
152
|
+
|
|
153
|
+
while (!result.done) {
|
|
154
|
+
const info = result.value
|
|
155
|
+
const phase = info.phase ?? ""
|
|
156
|
+
|
|
157
|
+
// When phase changes, complete current phase and start new one (indented)
|
|
158
|
+
if (phase && phase !== currentPhase) {
|
|
159
|
+
if (currentPhaseHandle && currentPhase) {
|
|
160
|
+
// Complete previous phase with timing
|
|
161
|
+
const elapsed = Date.now() - phaseStartTime
|
|
162
|
+
const prevLabel = PHASE_LABELS[currentPhase] ?? currentPhase
|
|
163
|
+
currentPhaseHandle.complete(`${prevLabel} (${elapsed}ms)`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Start new phase line (indented under parent, inserted after last phase)
|
|
167
|
+
currentPhase = phase
|
|
168
|
+
phaseStartTime = Date.now()
|
|
169
|
+
const phaseLabel = PHASE_LABELS[phase] ?? phase
|
|
170
|
+
currentPhaseHandle = multi.add(phaseLabel, {
|
|
171
|
+
type: "spinner",
|
|
172
|
+
indent: 1,
|
|
173
|
+
insertAfter: lastInsertId,
|
|
174
|
+
})
|
|
175
|
+
lastInsertId = currentPhaseHandle.id
|
|
176
|
+
currentPhaseHandle.start()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Update progress count on current phase line
|
|
180
|
+
if (currentPhaseHandle && info.total && info.total > 0) {
|
|
181
|
+
const phaseLabel = PHASE_LABELS[phase] ?? phase
|
|
182
|
+
currentPhaseHandle.setTitle(`${phaseLabel} (${info.current}/${info.total})`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Yield to event loop for animation
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
187
|
+
|
|
188
|
+
result = gen.next()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Complete final phase
|
|
192
|
+
if (currentPhaseHandle && currentPhase) {
|
|
193
|
+
const elapsed = Date.now() - phaseStartTime
|
|
194
|
+
const finalLabel = PHASE_LABELS[currentPhase] ?? currentPhase
|
|
195
|
+
currentPhaseHandle.complete(`${finalLabel} (${elapsed}ms)`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Complete parent task with total timing
|
|
199
|
+
const totalElapsed = Date.now() - taskStartTime
|
|
200
|
+
parentHandle.complete(`${baseTitle} (${totalElapsed}ms)`)
|
|
201
|
+
|
|
202
|
+
return result.value
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isGenerator(value: unknown): value is Generator<ProgressInfo, unknown, unknown> {
|
|
206
|
+
return (
|
|
207
|
+
value !== null &&
|
|
208
|
+
typeof value === "object" &&
|
|
209
|
+
typeof (value as Generator).next === "function" &&
|
|
210
|
+
typeof (value as Generator).throw === "function"
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
215
|
+
return value !== null && typeof value === "object" && typeof (value as PromiseLike<unknown>).then === "function"
|
|
216
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React ProgressBar component for silvery/Ink TUI apps
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect, useRef } from "react"
|
|
6
|
+
import type { ProgressBarProps } from "../types.js"
|
|
7
|
+
import { getETA, DEFAULT_ETA_BUFFER_SIZE, type ETASample } from "../utils/eta"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Progress bar component for React TUI apps
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { ProgressBar } from "@silvery/ui/react";
|
|
15
|
+
*
|
|
16
|
+
* function DownloadProgress({ current, total }) {
|
|
17
|
+
* return (
|
|
18
|
+
* <ProgressBar
|
|
19
|
+
* value={current}
|
|
20
|
+
* total={total}
|
|
21
|
+
* showPercentage
|
|
22
|
+
* showETA
|
|
23
|
+
* />
|
|
24
|
+
* );
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function ProgressBar({
|
|
29
|
+
value,
|
|
30
|
+
total,
|
|
31
|
+
width = 40,
|
|
32
|
+
showPercentage = true,
|
|
33
|
+
showETA = false,
|
|
34
|
+
label,
|
|
35
|
+
color = "cyan",
|
|
36
|
+
}: ProgressBarProps): React.ReactElement {
|
|
37
|
+
// ETA calculation state
|
|
38
|
+
const [eta, setEta] = useState<string>("--:--")
|
|
39
|
+
const etaBuffer = useRef<ETASample[]>([])
|
|
40
|
+
|
|
41
|
+
// Update ETA buffer when value changes
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
etaBuffer.current.push({ time: now, value })
|
|
45
|
+
|
|
46
|
+
if (etaBuffer.current.length > DEFAULT_ETA_BUFFER_SIZE) {
|
|
47
|
+
etaBuffer.current.shift()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Calculate ETA using shared utility
|
|
51
|
+
const result = getETA(etaBuffer.current, value, total)
|
|
52
|
+
setEta(result.formatted)
|
|
53
|
+
}, [value, total])
|
|
54
|
+
|
|
55
|
+
const percent = total > 0 ? value / total : 0
|
|
56
|
+
const percentDisplay = `${Math.round(percent * 100)}%`
|
|
57
|
+
|
|
58
|
+
const filledWidth = Math.round(width * percent)
|
|
59
|
+
const emptyWidth = width - filledWidth
|
|
60
|
+
|
|
61
|
+
const bar = "█".repeat(filledWidth) + "░".repeat(emptyWidth)
|
|
62
|
+
|
|
63
|
+
// Build the display parts
|
|
64
|
+
const parts: string[] = []
|
|
65
|
+
|
|
66
|
+
if (label) {
|
|
67
|
+
parts.push(label)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
parts.push(`[${bar}]`)
|
|
71
|
+
|
|
72
|
+
if (showPercentage) {
|
|
73
|
+
parts.push(percentDisplay.padStart(4))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (showETA) {
|
|
77
|
+
parts.push(`ETA: ${eta}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<span data-progressx-bar data-color={color} data-percent={percent}>
|
|
82
|
+
{parts.join(" ")}
|
|
83
|
+
</span>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hook for progress bar state management
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```tsx
|
|
92
|
+
* function MyProgress() {
|
|
93
|
+
* const { value, total, update, increment, eta, percent } = useProgressBar(100);
|
|
94
|
+
*
|
|
95
|
+
* useEffect(() => {
|
|
96
|
+
* const timer = setInterval(() => increment(), 100);
|
|
97
|
+
* return () => clearInterval(timer);
|
|
98
|
+
* }, []);
|
|
99
|
+
*
|
|
100
|
+
* return <Text>{percent}% - ETA: {eta}</Text>;
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function useProgressBar(initialTotal: number) {
|
|
105
|
+
const [value, setValue] = useState(0)
|
|
106
|
+
const [total, setTotal] = useState(initialTotal)
|
|
107
|
+
const etaBuffer = useRef<ETASample[]>([])
|
|
108
|
+
const [eta, setEta] = useState<string>("--:--")
|
|
109
|
+
|
|
110
|
+
const update = (newValue: number) => {
|
|
111
|
+
setValue(newValue)
|
|
112
|
+
|
|
113
|
+
// Update ETA buffer
|
|
114
|
+
const now = Date.now()
|
|
115
|
+
etaBuffer.current.push({ time: now, value: newValue })
|
|
116
|
+
if (etaBuffer.current.length > DEFAULT_ETA_BUFFER_SIZE) {
|
|
117
|
+
etaBuffer.current.shift()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Calculate ETA using shared utility
|
|
121
|
+
const result = getETA(etaBuffer.current, newValue, total)
|
|
122
|
+
setEta(result.formatted)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const increment = (amount = 1) => update(value + amount)
|
|
126
|
+
|
|
127
|
+
const reset = (newTotal?: number) => {
|
|
128
|
+
setValue(0)
|
|
129
|
+
if (newTotal !== undefined) setTotal(newTotal)
|
|
130
|
+
etaBuffer.current = []
|
|
131
|
+
setEta("--:--")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const percent = total > 0 ? Math.round((value / total) * 100) : 0
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
value,
|
|
138
|
+
total,
|
|
139
|
+
percent,
|
|
140
|
+
eta,
|
|
141
|
+
update,
|
|
142
|
+
increment,
|
|
143
|
+
reset,
|
|
144
|
+
setTotal,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Spinner component for silvery/Ink TUI apps
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect } from "react"
|
|
6
|
+
import type { SpinnerProps, SpinnerStyle } from "../types.js"
|
|
7
|
+
import { SPINNER_FRAMES, SPINNER_INTERVALS } from "../cli/spinner"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Animated spinner component for React TUI apps
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { Spinner } from "@silvery/ui/react";
|
|
15
|
+
*
|
|
16
|
+
* function LoadingView() {
|
|
17
|
+
* return <Spinner label="Loading..." />;
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* // With style
|
|
21
|
+
* <Spinner label="Processing..." style="arc" color="yellow" />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function Spinner({ label, style = "dots", color = "cyan" }: SpinnerProps): React.ReactElement {
|
|
25
|
+
const [frameIndex, setFrameIndex] = useState(0)
|
|
26
|
+
const frames = SPINNER_FRAMES[style]
|
|
27
|
+
const interval = SPINNER_INTERVALS[style]
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
setFrameIndex((i) => (i + 1) % frames.length)
|
|
32
|
+
}, interval)
|
|
33
|
+
|
|
34
|
+
return () => clearInterval(timer)
|
|
35
|
+
}, [frames.length, interval])
|
|
36
|
+
|
|
37
|
+
const frame = frames[frameIndex]
|
|
38
|
+
|
|
39
|
+
// Note: In a real silvery app, you'd use <Text color={color}> etc.
|
|
40
|
+
// This is a generic React component that can be styled by the consumer
|
|
41
|
+
return (
|
|
42
|
+
<span data-progressx-spinner data-color={color}>
|
|
43
|
+
{frame}
|
|
44
|
+
{label && <span> {label}</span>}
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook for using spinner state in custom components
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* function CustomSpinner() {
|
|
55
|
+
* const frame = useSpinnerFrame("dots");
|
|
56
|
+
* return <Text color="cyan">{frame}</Text>;
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useSpinnerFrame(style: SpinnerStyle = "dots"): string {
|
|
61
|
+
const [frameIndex, setFrameIndex] = useState(0)
|
|
62
|
+
const frames = SPINNER_FRAMES[style]
|
|
63
|
+
const interval = SPINNER_INTERVALS[style]
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const timer = setInterval(() => {
|
|
67
|
+
setFrameIndex((i) => (i + 1) % frames.length)
|
|
68
|
+
}, interval)
|
|
69
|
+
|
|
70
|
+
return () => clearInterval(timer)
|
|
71
|
+
}, [frames.length, interval])
|
|
72
|
+
|
|
73
|
+
return frames[frameIndex]!
|
|
74
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Tasks component - listr2-style task list for TUI apps
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from "react"
|
|
6
|
+
import type { TaskProps, TaskStatus } from "../types.js"
|
|
7
|
+
import { useSpinnerFrame } from "./Spinner"
|
|
8
|
+
|
|
9
|
+
/** Status icons for tasks */
|
|
10
|
+
const STATUS_ICONS: Record<TaskStatus, string> = {
|
|
11
|
+
pending: "○",
|
|
12
|
+
running: "", // Will use spinner
|
|
13
|
+
completed: "✔",
|
|
14
|
+
failed: "✖",
|
|
15
|
+
skipped: "⊘",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Status colors */
|
|
19
|
+
const STATUS_COLORS: Record<TaskStatus, string> = {
|
|
20
|
+
pending: "gray",
|
|
21
|
+
running: "cyan",
|
|
22
|
+
completed: "green",
|
|
23
|
+
failed: "red",
|
|
24
|
+
skipped: "yellow",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Single task component
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <Task title="Downloading files" status="running">
|
|
33
|
+
* <ProgressBar value={50} total={100} />
|
|
34
|
+
* </Task>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function Task({ title, status, children }: TaskProps): React.ReactElement {
|
|
38
|
+
const spinnerFrame = useSpinnerFrame("dots")
|
|
39
|
+
const icon = status === "running" ? spinnerFrame : STATUS_ICONS[status]
|
|
40
|
+
const color = STATUS_COLORS[status]
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div data-progressx-task data-status={status} data-color={color}>
|
|
44
|
+
<span data-icon>{icon}</span>
|
|
45
|
+
<span data-title> {title}</span>
|
|
46
|
+
{children != null ? <div data-children>{children as React.ReactNode}</div> : null}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Container for multiple tasks
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```tsx
|
|
56
|
+
* <Tasks>
|
|
57
|
+
* <Task title="Scanning files" status="completed" />
|
|
58
|
+
* <Task title="Processing" status="running">
|
|
59
|
+
* <ProgressBar value={current} total={total} />
|
|
60
|
+
* </Task>
|
|
61
|
+
* <Task title="Cleanup" status="pending" />
|
|
62
|
+
* </Tasks>
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function Tasks({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
66
|
+
return <div data-progressx-tasks>{children}</div>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Hook for managing task state
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```tsx
|
|
74
|
+
* function MyTasks() {
|
|
75
|
+
* const { tasks, start, complete, fail, updateProgress } = useTasks([
|
|
76
|
+
* { id: 'scan', title: 'Scanning' },
|
|
77
|
+
* { id: 'process', title: 'Processing' },
|
|
78
|
+
* ]);
|
|
79
|
+
*
|
|
80
|
+
* useEffect(() => {
|
|
81
|
+
* start('scan');
|
|
82
|
+
* doScan().then(() => {
|
|
83
|
+
* complete('scan');
|
|
84
|
+
* start('process');
|
|
85
|
+
* });
|
|
86
|
+
* }, []);
|
|
87
|
+
*
|
|
88
|
+
* return (
|
|
89
|
+
* <Tasks>
|
|
90
|
+
* {tasks.map(t => <Task key={t.id} title={t.title} status={t.status} />)}
|
|
91
|
+
* </Tasks>
|
|
92
|
+
* );
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function useTasks(initialTasks: Array<{ id: string; title: string }>) {
|
|
97
|
+
const [tasks, setTasks] = React.useState<
|
|
98
|
+
Array<{
|
|
99
|
+
id: string
|
|
100
|
+
title: string
|
|
101
|
+
status: TaskStatus
|
|
102
|
+
progress?: { current: number; total: number }
|
|
103
|
+
}>
|
|
104
|
+
>(
|
|
105
|
+
initialTasks.map((t) => ({
|
|
106
|
+
...t,
|
|
107
|
+
status: "pending" as TaskStatus,
|
|
108
|
+
})),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const updateTask = (
|
|
112
|
+
id: string,
|
|
113
|
+
updates: Partial<{
|
|
114
|
+
status: TaskStatus
|
|
115
|
+
title: string
|
|
116
|
+
progress: { current: number; total: number }
|
|
117
|
+
}>,
|
|
118
|
+
) => {
|
|
119
|
+
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, ...updates } : t)))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const start = (id: string) => updateTask(id, { status: "running" })
|
|
123
|
+
const complete = (id: string, title?: string) => updateTask(id, { status: "completed", ...(title && { title }) })
|
|
124
|
+
const fail = (id: string, title?: string) => updateTask(id, { status: "failed", ...(title && { title }) })
|
|
125
|
+
const skip = (id: string, title?: string) => updateTask(id, { status: "skipped", ...(title && { title }) })
|
|
126
|
+
const updateProgress = (id: string, progress: { current: number; total: number }) => updateTask(id, { progress })
|
|
127
|
+
|
|
128
|
+
const getTask = (id: string) => tasks.find((t) => t.id === id)
|
|
129
|
+
const allCompleted = tasks.every((t) => t.status === "completed" || t.status === "skipped")
|
|
130
|
+
const hasFailed = tasks.some((t) => t.status === "failed")
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
tasks,
|
|
134
|
+
start,
|
|
135
|
+
complete,
|
|
136
|
+
fail,
|
|
137
|
+
skip,
|
|
138
|
+
updateProgress,
|
|
139
|
+
updateTask,
|
|
140
|
+
getTask,
|
|
141
|
+
allCompleted,
|
|
142
|
+
hasFailed,
|
|
143
|
+
}
|
|
144
|
+
}
|