@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,169 @@
1
+ /**
2
+ * Canvas Entry Point
3
+ *
4
+ * Provides a browser-friendly API for rendering silvery components to HTML5 Canvas.
5
+ * This module sets up the canvas adapter and provides render functions.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { renderToCanvas, Box, Text, useContentRect } from '@silvery/ui/canvas';
10
+ *
11
+ * function App() {
12
+ * const { width, height } = useContentRect();
13
+ * return (
14
+ * <Box flexDirection="column">
15
+ * <Text>Canvas size: {width}px × {height}px</Text>
16
+ * </Box>
17
+ * );
18
+ * }
19
+ *
20
+ * const canvas = document.getElementById('canvas');
21
+ * renderToCanvas(<App />, canvas);
22
+ * ```
23
+ */
24
+
25
+ import type { ReactElement } from "react"
26
+ import {
27
+ type CanvasAdapterConfig,
28
+ CanvasRenderBuffer,
29
+ createCanvasAdapter,
30
+ } from "@silvery/term/adapters/canvas-adapter"
31
+ import { createBrowserRenderer, initBrowserRenderer, renderOnce } from "@silvery/term/browser-renderer"
32
+ import type { RenderBuffer } from "@silvery/term/render-adapter"
33
+
34
+ // Re-export components and hooks for convenience
35
+ export { Box, type BoxProps } from "@silvery/react/components/Box"
36
+ export { Text, type TextProps } from "@silvery/react/components/Text"
37
+ export { useContentRect, useScreenRect } from "@silvery/react/hooks/useLayout"
38
+ export { useApp } from "@silvery/react/hooks/useApp"
39
+
40
+ // Re-export adapter utilities
41
+ export {
42
+ createCanvasAdapter,
43
+ CanvasRenderBuffer,
44
+ type CanvasAdapterConfig,
45
+ } from "@silvery/term/adapters/canvas-adapter"
46
+
47
+ // ============================================================================
48
+ // Types
49
+ // ============================================================================
50
+
51
+ export interface CanvasRenderOptions extends CanvasAdapterConfig {
52
+ /** Width of the canvas (default: canvas.width) */
53
+ width?: number
54
+ /** Height of the canvas (default: canvas.height) */
55
+ height?: number
56
+ }
57
+
58
+ export interface CanvasInstance {
59
+ /** Re-render with a new element */
60
+ rerender: (element: ReactElement) => void
61
+ /** Unmount and clean up */
62
+ unmount: () => void
63
+ /** Dispose (alias for unmount) — enables `using` */
64
+ [Symbol.dispose](): void
65
+ /** Get the current buffer */
66
+ getBuffer: () => RenderBuffer | null
67
+ /** Force a re-render */
68
+ refresh: () => void
69
+ }
70
+
71
+ // ============================================================================
72
+ // Initialization
73
+ // ============================================================================
74
+
75
+ const canvasAdapterFactory = {
76
+ createAdapter: (config: CanvasAdapterConfig) => createCanvasAdapter(config),
77
+ }
78
+
79
+ /**
80
+ * Initialize the canvas rendering system.
81
+ * Called automatically by renderToCanvas, but can be called manually.
82
+ */
83
+ export function initCanvasRenderer(config: CanvasAdapterConfig = {}): void {
84
+ initBrowserRenderer(canvasAdapterFactory, config)
85
+ }
86
+
87
+ // ============================================================================
88
+ // Render Functions
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Render a React element to an HTML5 Canvas.
93
+ *
94
+ * @param element - React element to render
95
+ * @param canvas - Target canvas element
96
+ * @param options - Render options (font size, colors, etc.)
97
+ * @returns CanvasInstance for controlling the render
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * const canvas = document.getElementById('canvas');
102
+ * const instance = renderToCanvas(<App />, canvas, { fontSize: 16 });
103
+ *
104
+ * // Later: update the component
105
+ * instance.rerender(<App newProps />);
106
+ *
107
+ * // Clean up
108
+ * instance.unmount();
109
+ * ```
110
+ */
111
+ export function renderToCanvas(
112
+ element: ReactElement,
113
+ canvas: HTMLCanvasElement,
114
+ options: CanvasRenderOptions = {},
115
+ ): CanvasInstance {
116
+ initCanvasRenderer(options)
117
+
118
+ const pixelWidth = options.width ?? canvas.width
119
+ const pixelHeight = options.height ?? canvas.height
120
+
121
+ // Ensure canvas dimensions match
122
+ if (canvas.width !== pixelWidth) canvas.width = pixelWidth
123
+ if (canvas.height !== pixelHeight) canvas.height = pixelHeight
124
+
125
+ // Convert pixel dimensions to cell dimensions for the layout engine.
126
+ // The layout engine operates in cell units (columns x rows), not pixels.
127
+ const fontSize = options.fontSize ?? 14
128
+ const lineHeightMultiplier = options.lineHeight ?? 1.2
129
+ const charWidth = fontSize * 0.6
130
+ const lineHeight = fontSize * lineHeightMultiplier
131
+ const cols = Math.floor(pixelWidth / charWidth)
132
+ const rows = Math.floor(pixelHeight / lineHeight)
133
+
134
+ return createBrowserRenderer<CanvasRenderBuffer>(element, cols, rows, (buffer) => {
135
+ const ctx = canvas.getContext("2d")
136
+ if (ctx) {
137
+ ctx.drawImage(buffer.canvas, 0, 0)
138
+ }
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Render a React element to a canvas and return the buffer.
144
+ * One-shot render without ongoing updates.
145
+ *
146
+ * @param element - React element to render
147
+ * @param width - Canvas width in pixels
148
+ * @param height - Canvas height in pixels
149
+ * @param options - Render options
150
+ * @returns The rendered buffer
151
+ */
152
+ export function renderCanvasOnce(
153
+ element: ReactElement,
154
+ width: number,
155
+ height: number,
156
+ options: CanvasAdapterConfig = {},
157
+ ): CanvasRenderBuffer {
158
+ initCanvasRenderer(options)
159
+
160
+ // Convert pixel dimensions to cell dimensions for the layout engine
161
+ const fontSize = options.fontSize ?? 14
162
+ const lineHeightMultiplier = options.lineHeight ?? 1.2
163
+ const charWidth = fontSize * 0.6
164
+ const lineHeight = fontSize * lineHeightMultiplier
165
+ const cols = Math.floor(width / charWidth)
166
+ const rows = Math.floor(height / lineHeight)
167
+
168
+ return renderOnce<CanvasRenderBuffer>(element, cols, rows)
169
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ANSI escape code utilities for terminal control
3
+ */
4
+
5
+ /** Hide the cursor */
6
+ export const CURSOR_HIDE = "\x1b[?25l"
7
+
8
+ /** Show the cursor */
9
+ export const CURSOR_SHOW = "\x1b[?25h"
10
+
11
+ /** Move cursor to beginning of line */
12
+ export const CURSOR_TO_START = "\r"
13
+
14
+ /** Clear from cursor to end of line */
15
+ export const CLEAR_LINE_END = "\x1b[K"
16
+
17
+ /** Clear entire line */
18
+ export const CLEAR_LINE = "\x1b[2K"
19
+
20
+ /** Clear screen and move to top-left */
21
+ export const CLEAR_SCREEN = "\x1b[2J\x1b[H"
22
+
23
+ /** Move cursor up N lines */
24
+ export const cursorUp = (n: number = 1): string => `\x1b[${n}A`
25
+
26
+ /** Move cursor down N lines */
27
+ export const cursorDown = (n: number = 1): string => `\x1b[${n}B`
28
+
29
+ /** Save cursor position */
30
+ export const CURSOR_SAVE = "\x1b[s"
31
+
32
+ /** Restore cursor position */
33
+ export const CURSOR_RESTORE = "\x1b[u"
34
+
35
+ /**
36
+ * Write to stream with proper handling
37
+ */
38
+ export function write(text: string, stream: NodeJS.WriteStream = process.stdout): void {
39
+ stream.write(text)
40
+ }
41
+
42
+ /**
43
+ * Clear the current line and write new text
44
+ */
45
+ export function writeLine(text: string, stream: NodeJS.WriteStream = process.stdout): void {
46
+ stream.write(`${CURSOR_TO_START}${text}${CLEAR_LINE_END}`)
47
+ }
48
+
49
+ /**
50
+ * Wrap a function to handle cursor visibility
51
+ * Hides cursor on start, shows on completion/error
52
+ */
53
+ export function withCursor<T>(fn: () => T | Promise<T>, stream: NodeJS.WriteStream = process.stdout): Promise<T> {
54
+ stream.write(CURSOR_HIDE)
55
+
56
+ const restore = () => stream.write(CURSOR_SHOW)
57
+
58
+ try {
59
+ const result = fn()
60
+ if (result instanceof Promise) {
61
+ return result.finally(restore)
62
+ }
63
+ restore()
64
+ return Promise.resolve(result)
65
+ } catch (error) {
66
+ restore()
67
+ throw error
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Check if stream is a TTY (supports ANSI codes)
73
+ * Also respects FORCE_TTY environment variable for testing
74
+ */
75
+ export function isTTY(stream: NodeJS.WriteStream = process.stdout): boolean {
76
+ if (process.env.FORCE_TTY === "1") return true
77
+ return stream.isTTY ?? false
78
+ }
79
+
80
+ /**
81
+ * Get terminal width
82
+ */
83
+ export function getTerminalWidth(stream: NodeJS.WriteStream = process.stdout): number {
84
+ return stream.columns ?? 80
85
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * CLI progress indicators - direct stdout usage (no React)
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { Spinner, ProgressBar, MultiProgress } from "@silvery/ui/cli";
7
+ *
8
+ * // Quick spinner
9
+ * const stop = Spinner.start("Loading...");
10
+ * await work();
11
+ * stop();
12
+ *
13
+ * // Spinner with result
14
+ * const spinner = new Spinner("Processing...");
15
+ * spinner.start();
16
+ * spinner.succeed("Done!");
17
+ *
18
+ * // Progress bar
19
+ * const bar = new ProgressBar({ total: 100 });
20
+ * bar.start();
21
+ * bar.update(50);
22
+ * bar.stop();
23
+ *
24
+ * // Multiple tasks
25
+ * const multi = new MultiProgress();
26
+ * const task1 = multi.add("Download", { type: "bar", total: 100 });
27
+ * const task2 = multi.add("Process", { type: "spinner" });
28
+ * multi.start();
29
+ * task1.start();
30
+ * task1.update(50);
31
+ * task1.complete();
32
+ * multi.stop();
33
+ * ```
34
+ */
35
+
36
+ export { Spinner, SPINNER_FRAMES, createSpinner, type CallableSpinner } from "./spinner"
37
+ export { ProgressBar } from "./progress-bar"
38
+ export { MultiProgress, type TaskHandle } from "./multi-progress"
39
+ export * from "./ansi"
@@ -0,0 +1,340 @@
1
+ /**
2
+ * MultiProgress - Container for managing multiple concurrent progress indicators
3
+ */
4
+
5
+ import chalk from "chalk"
6
+ import type { SpinnerStyle, TaskStatus } from "../types.js"
7
+ import { CURSOR_HIDE, CURSOR_SHOW, CLEAR_LINE, cursorUp, write, isTTY } from "./ansi"
8
+ import { Spinner, SPINNER_FRAMES } from "./spinner"
9
+ import { ProgressBar } from "./progress-bar"
10
+
11
+ /** Status icons */
12
+ const STATUS_ICONS: Record<TaskStatus, string> = {
13
+ pending: chalk.gray("○"),
14
+ running: "", // Will be replaced with spinner frame
15
+ completed: chalk.green("✔"),
16
+ failed: chalk.red("✖"),
17
+ skipped: chalk.yellow("⊘"),
18
+ }
19
+
20
+ /** Task configuration */
21
+ interface TaskConfig {
22
+ title: string
23
+ type: "spinner" | "bar" | "group"
24
+ status: TaskStatus
25
+ total?: number
26
+ current?: number
27
+ spinnerStyle?: SpinnerStyle
28
+ indent?: number
29
+ }
30
+
31
+ /** Internal task state */
32
+ interface TaskState extends TaskConfig {
33
+ id: string
34
+ /** Completion time in ms (shown dimmed after title on completion) */
35
+ completionTime?: number
36
+ }
37
+
38
+ /**
39
+ * MultiProgress - Manage multiple concurrent progress indicators
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const multi = new MultiProgress();
44
+ *
45
+ * const download = multi.add("Downloading files", { type: "bar", total: 100 });
46
+ * const process = multi.add("Processing", { type: "spinner" });
47
+ *
48
+ * download.start();
49
+ * download.update(50);
50
+ * download.complete();
51
+ *
52
+ * process.start();
53
+ * process.complete();
54
+ *
55
+ * multi.stop();
56
+ * ```
57
+ */
58
+ export class MultiProgress {
59
+ private tasks: Map<string, TaskState> = new Map()
60
+ private taskOrder: string[] = []
61
+ private stream: NodeJS.WriteStream
62
+ private isActive = false
63
+ private timer: ReturnType<typeof setInterval> | null = null
64
+ private frameIndex = 0
65
+ private renderedLines = 0
66
+
67
+ constructor(stream: NodeJS.WriteStream = process.stdout) {
68
+ this.stream = stream
69
+ }
70
+
71
+ /**
72
+ * Add a new task
73
+ * @param insertAfter - ID of task to insert after (for hierarchical display)
74
+ */
75
+ add(
76
+ title: string,
77
+ options: {
78
+ type?: "spinner" | "bar" | "group"
79
+ total?: number
80
+ spinnerStyle?: SpinnerStyle
81
+ indent?: number
82
+ insertAfter?: string
83
+ } = {},
84
+ ): TaskHandle {
85
+ const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
86
+
87
+ const task: TaskState = {
88
+ id,
89
+ title,
90
+ type: options.type ?? "spinner",
91
+ status: "pending",
92
+ total: options.total,
93
+ current: 0,
94
+ spinnerStyle: options.spinnerStyle ?? "dots",
95
+ indent: options.indent ?? 0,
96
+ }
97
+
98
+ this.tasks.set(id, task)
99
+
100
+ // Insert after specified task, or append to end
101
+ if (options.insertAfter) {
102
+ const afterIndex = this.taskOrder.indexOf(options.insertAfter)
103
+ if (afterIndex >= 0) {
104
+ this.taskOrder.splice(afterIndex + 1, 0, id)
105
+ } else {
106
+ this.taskOrder.push(id)
107
+ }
108
+ } else {
109
+ this.taskOrder.push(id)
110
+ }
111
+
112
+ if (this.isActive) {
113
+ this.render()
114
+ }
115
+
116
+ return new TaskHandle(this, id)
117
+ }
118
+
119
+ /**
120
+ * Start the multi-progress display
121
+ */
122
+ start(): this {
123
+ if (this.isActive) {
124
+ return this
125
+ }
126
+
127
+ this.isActive = true
128
+
129
+ if (isTTY(this.stream)) {
130
+ write(CURSOR_HIDE, this.stream)
131
+ }
132
+
133
+ this.render()
134
+
135
+ // Start animation timer
136
+ this.timer = setInterval(() => {
137
+ this.frameIndex = (this.frameIndex + 1) % 10
138
+ this.render()
139
+ }, 80)
140
+
141
+ return this
142
+ }
143
+
144
+ /**
145
+ * Dispose the multi-progress display (calls stop)
146
+ */
147
+ [Symbol.dispose](): void {
148
+ this.stop()
149
+ }
150
+
151
+ /**
152
+ * Stop the multi-progress display
153
+ * @param clear - If true, clear all task lines from terminal
154
+ */
155
+ stop(clear = false): this {
156
+ if (!this.isActive) {
157
+ return this
158
+ }
159
+
160
+ this.isActive = false
161
+
162
+ if (this.timer) {
163
+ clearInterval(this.timer)
164
+ this.timer = null
165
+ }
166
+
167
+ if (clear && isTTY(this.stream)) {
168
+ // Clear all rendered lines
169
+ if (this.renderedLines > 0) {
170
+ write(cursorUp(this.renderedLines), this.stream)
171
+ for (let i = 0; i < this.renderedLines; i++) {
172
+ write(`${CLEAR_LINE}\n`, this.stream)
173
+ }
174
+ write(cursorUp(this.renderedLines), this.stream)
175
+ }
176
+ } else {
177
+ // Final render
178
+ this.render()
179
+ write("\n", this.stream)
180
+ }
181
+
182
+ if (isTTY(this.stream)) {
183
+ write(CURSOR_SHOW, this.stream)
184
+ }
185
+
186
+ return this
187
+ }
188
+
189
+ /** @internal Update task state */
190
+ _updateTask(id: string, updates: Partial<TaskState>): void {
191
+ const task = this.tasks.get(id)
192
+ if (task) {
193
+ Object.assign(task, updates)
194
+ // Only render immediately for status changes (complete/fail/etc.)
195
+ // Progress updates (current/total) are debounced by the 80ms animation timer
196
+ if (this.isActive && updates.status) {
197
+ this.render()
198
+ }
199
+ }
200
+ }
201
+
202
+ /** @internal Get task state */
203
+ _getTask(id: string): TaskState | undefined {
204
+ return this.tasks.get(id)
205
+ }
206
+
207
+ private render(): void {
208
+ if (!isTTY(this.stream)) {
209
+ return
210
+ }
211
+
212
+ // Move cursor up to clear previous render
213
+ if (this.renderedLines > 0) {
214
+ write(cursorUp(this.renderedLines), this.stream)
215
+ }
216
+
217
+ const lines: string[] = []
218
+
219
+ for (const id of this.taskOrder) {
220
+ const task = this.tasks.get(id)
221
+ if (!task) continue
222
+
223
+ let icon: string
224
+ if (task.status === "running") {
225
+ if (task.type === "group") {
226
+ // Groups don't animate - keep pending icon while running
227
+ icon = STATUS_ICONS.pending
228
+ } else {
229
+ const frames = SPINNER_FRAMES[task.spinnerStyle ?? "dots"]
230
+ icon = chalk.cyan(frames[this.frameIndex % frames.length])
231
+ }
232
+ } else {
233
+ icon = STATUS_ICONS[task.status]
234
+ }
235
+
236
+ const indent = " ".repeat(task.indent ?? 0)
237
+ let line = `${indent}${icon} ${task.title}`
238
+
239
+ // Add progress bar for bar type
240
+ if (task.type === "bar" && task.total && task.total > 0) {
241
+ const percent = task.current! / task.total
242
+ const barWidth = 20
243
+ const filled = Math.round(barWidth * percent)
244
+ const empty = barWidth - filled
245
+ const bar = chalk.cyan("█".repeat(filled)) + chalk.gray("░".repeat(empty))
246
+ line += ` ${bar} ${Math.round(percent * 100)}%`
247
+ }
248
+
249
+ // Add completion time in dimmed text
250
+ if (task.status === "completed" && task.completionTime !== undefined) {
251
+ line += chalk.dim(` ${task.completionTime}ms`)
252
+ }
253
+
254
+ lines.push(line)
255
+ }
256
+
257
+ // Clear and write each line
258
+ for (const line of lines) {
259
+ write(`${CLEAR_LINE}${line}\n`, this.stream)
260
+ }
261
+
262
+ this.renderedLines = lines.length
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Handle for controlling an individual task
268
+ */
269
+ class TaskHandle {
270
+ constructor(
271
+ private multi: MultiProgress,
272
+ private _id: string,
273
+ ) {}
274
+
275
+ /** Get task ID (for insertAfter) */
276
+ get id(): string {
277
+ return this._id
278
+ }
279
+
280
+ /** Start the task (set status to running) */
281
+ start(): this {
282
+ this.multi._updateTask(this._id, { status: "running" })
283
+ return this
284
+ }
285
+
286
+ /** Update progress (for bar type) */
287
+ update(current: number): this {
288
+ this.multi._updateTask(this._id, { current })
289
+ return this
290
+ }
291
+
292
+ /** Mark task as completed */
293
+ complete(titleOrTime?: string | number): this {
294
+ const updates: Partial<TaskState> = { status: "completed" }
295
+ if (typeof titleOrTime === "number") {
296
+ // Numeric = completion time in ms (preserves current title)
297
+ updates.completionTime = titleOrTime
298
+ } else if (titleOrTime) {
299
+ // String = new title (legacy behavior)
300
+ updates.title = titleOrTime
301
+ }
302
+ this.multi._updateTask(this._id, updates)
303
+ return this
304
+ }
305
+
306
+ /** Mark task as failed */
307
+ fail(title?: string): this {
308
+ const updates: Partial<TaskState> = { status: "failed" }
309
+ if (title) updates.title = title
310
+ this.multi._updateTask(this._id, updates)
311
+ return this
312
+ }
313
+
314
+ /** Mark task as skipped */
315
+ skip(title?: string): this {
316
+ const updates: Partial<TaskState> = { status: "skipped" }
317
+ if (title) updates.title = title
318
+ this.multi._updateTask(this._id, updates)
319
+ return this
320
+ }
321
+
322
+ /** Update task title */
323
+ setTitle(title: string): this {
324
+ this.multi._updateTask(this._id, { title })
325
+ return this
326
+ }
327
+
328
+ /** Change task type (e.g., from spinner to group when sub-steps are added) */
329
+ setType(type: "spinner" | "bar" | "group"): this {
330
+ this.multi._updateTask(this._id, { type })
331
+ return this
332
+ }
333
+
334
+ /** Get current status */
335
+ get status(): TaskStatus {
336
+ return this.multi._getTask(this._id)?.status ?? "pending"
337
+ }
338
+ }
339
+
340
+ export type { TaskHandle }