@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,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
+ }