@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,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
|
+
}
|
package/src/cli/ansi.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -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 }
|