@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,388 @@
1
+ /**
2
+ * withTextInput - CLI wrapper for text input prompts
3
+ */
4
+
5
+ import chalk from "chalk"
6
+ import type { TextInputOptions } from "../types.js"
7
+ import { CURSOR_HIDE, CURSOR_SHOW, CURSOR_TO_START, CLEAR_LINE_END, write, isTTY } from "../cli/ansi"
8
+
9
+ /**
10
+ * Prompt for text input in the terminal
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // Simple usage
15
+ * const name = await withTextInput("What is your name?");
16
+ *
17
+ * // With options
18
+ * const password = await withTextInput("Password:", { mask: "*" });
19
+ *
20
+ * // With validation
21
+ * const email = await withTextInput("Email:", {
22
+ * validate: (v) => v.includes("@") ? undefined : "Invalid email"
23
+ * });
24
+ *
25
+ * // With autocomplete
26
+ * const fruit = await withTextInput("Pick a fruit:", {
27
+ * autocomplete: ["apple", "banana", "cherry"]
28
+ * });
29
+ * ```
30
+ */
31
+ export async function withTextInput(prompt: string, options: TextInputOptions = {}): Promise<string> {
32
+ const stream = options.stream ?? process.stdout
33
+ const inputStream = options.inputStream ?? process.stdin
34
+ const isTty = isTTY(stream)
35
+
36
+ // Initialize state
37
+ let value = options.defaultValue ?? ""
38
+ let cursorPosition = value.length
39
+ let errorMessage: string | undefined
40
+
41
+ // Setup raw mode for character-by-character input
42
+ if (inputStream.isTTY) {
43
+ inputStream.setRawMode(true)
44
+ }
45
+ inputStream.resume()
46
+
47
+ // Render the current state
48
+ const render = () => {
49
+ const displayValue = options.mask ? options.mask.repeat(value.length) : value
50
+
51
+ const suggestion = getAutocompleteSuggestion(value, options.autocomplete)
52
+ const suggestionSuffix = suggestion ? chalk.dim(suggestion.slice(value.length)) : ""
53
+
54
+ // Build cursor display
55
+ const beforeCursor = displayValue.slice(0, cursorPosition)
56
+ const cursorChar = displayValue[cursorPosition] ?? " "
57
+ const afterCursor = displayValue.slice(cursorPosition + 1)
58
+
59
+ // Placeholder when empty
60
+ const showPlaceholder = !value && options.placeholder
61
+ const inputDisplay = showPlaceholder
62
+ ? chalk.dim(options.placeholder) + chalk.inverse(" ")
63
+ : beforeCursor + chalk.inverse(cursorChar) + afterCursor + suggestionSuffix
64
+
65
+ // Error message
66
+ const errorDisplay = errorMessage ? chalk.red(` (${errorMessage})`) : ""
67
+
68
+ const line = `${chalk.cyan("?")} ${chalk.bold(prompt)} ${inputDisplay}${errorDisplay}`
69
+
70
+ if (isTty) {
71
+ write(`${CURSOR_TO_START}${line}${CLEAR_LINE_END}`, stream)
72
+ }
73
+ }
74
+
75
+ // Hide cursor during input (we show our own)
76
+ if (isTty) {
77
+ write(CURSOR_HIDE, stream)
78
+ }
79
+
80
+ render()
81
+
82
+ return new Promise<string>((resolve, reject) => {
83
+ const cleanup = () => {
84
+ inputStream.removeListener("data", onData)
85
+ inputStream.removeListener("error", onError)
86
+ if (inputStream.isTTY) {
87
+ inputStream.setRawMode(false)
88
+ }
89
+ inputStream.pause()
90
+ if (isTty) {
91
+ write(CURSOR_SHOW, stream)
92
+ }
93
+ }
94
+
95
+ const submit = () => {
96
+ // Validate before accepting
97
+ if (options.validate) {
98
+ const error = options.validate(value)
99
+ if (error) {
100
+ errorMessage = error
101
+ render()
102
+ return
103
+ }
104
+ }
105
+
106
+ cleanup()
107
+
108
+ // Show final value
109
+ const displayValue = options.mask ? options.mask.repeat(value.length) : value
110
+ write(
111
+ `${CURSOR_TO_START}${chalk.green("✔")} ${chalk.bold(prompt)} ${chalk.dim(displayValue)}${CLEAR_LINE_END}\n`,
112
+ stream,
113
+ )
114
+
115
+ resolve(value)
116
+ }
117
+
118
+ const onError = (err: Error) => {
119
+ cleanup()
120
+ reject(err)
121
+ }
122
+
123
+ const onData = (data: Buffer) => {
124
+ const input = data.toString()
125
+ errorMessage = undefined // Clear error on any input
126
+
127
+ // Handle special keys
128
+ for (let i = 0; i < input.length; i++) {
129
+ const char = input[i]!
130
+ const code = char.charCodeAt(0)
131
+
132
+ // Enter (CR or LF)
133
+ if (code === 13 || code === 10) {
134
+ submit()
135
+ return
136
+ }
137
+
138
+ // Ctrl+C - abort
139
+ if (code === 3) {
140
+ cleanup()
141
+ write("\n", stream)
142
+ reject(new Error("User aborted"))
143
+ return
144
+ }
145
+
146
+ // Escape - clear or abort
147
+ if (code === 27) {
148
+ // Check for arrow key sequences
149
+ if (input[i + 1] === "[") {
150
+ const arrowCode = input[i + 2]
151
+ if (arrowCode === "D") {
152
+ // Left arrow
153
+ cursorPosition = Math.max(0, cursorPosition - 1)
154
+ i += 2
155
+ continue
156
+ }
157
+ if (arrowCode === "C") {
158
+ // Right arrow
159
+ cursorPosition = Math.min(value.length, cursorPosition + 1)
160
+ i += 2
161
+ continue
162
+ }
163
+ if (arrowCode === "H") {
164
+ // Home
165
+ cursorPosition = 0
166
+ i += 2
167
+ continue
168
+ }
169
+ if (arrowCode === "F") {
170
+ // End
171
+ cursorPosition = value.length
172
+ i += 2
173
+ continue
174
+ }
175
+ // Skip other escape sequences
176
+ i += 2
177
+ continue
178
+ }
179
+ // Plain escape - clear input
180
+ value = ""
181
+ cursorPosition = 0
182
+ continue
183
+ }
184
+
185
+ // Backspace (127 or 8)
186
+ if (code === 127 || code === 8) {
187
+ if (cursorPosition > 0) {
188
+ value = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
189
+ cursorPosition--
190
+ }
191
+ continue
192
+ }
193
+
194
+ // Delete (escape sequence handled above)
195
+ if (code === 4) {
196
+ // Ctrl+D acts as delete
197
+ if (cursorPosition < value.length) {
198
+ value = value.slice(0, cursorPosition) + value.slice(cursorPosition + 1)
199
+ }
200
+ continue
201
+ }
202
+
203
+ // Tab - accept autocomplete suggestion
204
+ if (code === 9) {
205
+ const suggestion = getAutocompleteSuggestion(value, options.autocomplete)
206
+ if (suggestion) {
207
+ value = suggestion
208
+ cursorPosition = value.length
209
+ }
210
+ continue
211
+ }
212
+
213
+ // Ctrl+A - beginning of line
214
+ if (code === 1) {
215
+ cursorPosition = 0
216
+ continue
217
+ }
218
+
219
+ // Ctrl+E - end of line
220
+ if (code === 5) {
221
+ cursorPosition = value.length
222
+ continue
223
+ }
224
+
225
+ // Ctrl+U - clear to beginning
226
+ if (code === 21) {
227
+ value = value.slice(cursorPosition)
228
+ cursorPosition = 0
229
+ continue
230
+ }
231
+
232
+ // Ctrl+K - clear to end
233
+ if (code === 11) {
234
+ value = value.slice(0, cursorPosition)
235
+ continue
236
+ }
237
+
238
+ // Ctrl+W - delete word backward
239
+ if (code === 23) {
240
+ const before = value.slice(0, cursorPosition)
241
+ const after = value.slice(cursorPosition)
242
+ const trimmed = before.trimEnd()
243
+ const lastSpace = trimmed.lastIndexOf(" ")
244
+ const newBefore = lastSpace === -1 ? "" : trimmed.slice(0, lastSpace + 1)
245
+ value = newBefore + after
246
+ cursorPosition = newBefore.length
247
+ continue
248
+ }
249
+
250
+ // Regular printable character
251
+ if (code >= 32 && code < 127) {
252
+ value = value.slice(0, cursorPosition) + char + value.slice(cursorPosition)
253
+ cursorPosition++
254
+ continue
255
+ }
256
+
257
+ // Handle UTF-8 characters (multi-byte)
258
+ if (code > 127) {
259
+ value = value.slice(0, cursorPosition) + char + value.slice(cursorPosition)
260
+ cursorPosition++
261
+ continue
262
+ }
263
+ }
264
+
265
+ render()
266
+ }
267
+
268
+ inputStream.on("data", onData)
269
+ inputStream.on("error", onError)
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Create a text input instance for manual control
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * const input = createTextInput("Name:", { placeholder: "Enter name" });
279
+ * input.render();
280
+ *
281
+ * // Later, get the value
282
+ * const value = await input.waitForSubmit();
283
+ * ```
284
+ */
285
+ export function createTextInput(prompt: string, options: TextInputOptions = {}): TextInputInstance {
286
+ const stream = options.stream ?? process.stdout
287
+ const isTty = isTTY(stream)
288
+
289
+ let value = options.defaultValue ?? ""
290
+ let cursorPosition = value.length
291
+
292
+ const render = () => {
293
+ const displayValue = options.mask ? options.mask.repeat(value.length) : value
294
+
295
+ const suggestion = getAutocompleteSuggestion(value, options.autocomplete)
296
+ const suggestionSuffix = suggestion ? chalk.dim(suggestion.slice(value.length)) : ""
297
+
298
+ const beforeCursor = displayValue.slice(0, cursorPosition)
299
+ const cursorChar = displayValue[cursorPosition] ?? " "
300
+ const afterCursor = displayValue.slice(cursorPosition + 1)
301
+
302
+ const showPlaceholder = !value && options.placeholder
303
+ const inputDisplay = showPlaceholder
304
+ ? chalk.dim(options.placeholder) + chalk.inverse(" ")
305
+ : beforeCursor + chalk.inverse(cursorChar) + afterCursor + suggestionSuffix
306
+
307
+ const line = `${chalk.cyan("?")} ${chalk.bold(prompt)} ${inputDisplay}`
308
+
309
+ if (isTty) {
310
+ write(`${CURSOR_TO_START}${line}${CLEAR_LINE_END}`, stream)
311
+ }
312
+ }
313
+
314
+ return {
315
+ get value() {
316
+ return value
317
+ },
318
+ set value(v: string) {
319
+ value = v
320
+ cursorPosition = Math.min(cursorPosition, v.length)
321
+ },
322
+ get cursorPosition() {
323
+ return cursorPosition
324
+ },
325
+ set cursorPosition(pos: number) {
326
+ cursorPosition = Math.max(0, Math.min(value.length, pos))
327
+ },
328
+ render,
329
+ insert(char: string) {
330
+ value = value.slice(0, cursorPosition) + char + value.slice(cursorPosition)
331
+ cursorPosition += char.length
332
+ },
333
+ backspace() {
334
+ if (cursorPosition > 0) {
335
+ value = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition)
336
+ cursorPosition--
337
+ }
338
+ },
339
+ delete() {
340
+ if (cursorPosition < value.length) {
341
+ value = value.slice(0, cursorPosition) + value.slice(cursorPosition + 1)
342
+ }
343
+ },
344
+ clear() {
345
+ value = ""
346
+ cursorPosition = 0
347
+ },
348
+ acceptSuggestion() {
349
+ const suggestion = getAutocompleteSuggestion(value, options.autocomplete)
350
+ if (suggestion) {
351
+ value = suggestion
352
+ cursorPosition = value.length
353
+ }
354
+ },
355
+ }
356
+ }
357
+
358
+ /** Instance returned by createTextInput for manual control */
359
+ export interface TextInputInstance {
360
+ /** Current input value */
361
+ value: string
362
+ /** Current cursor position */
363
+ cursorPosition: number
364
+ /** Render the current state */
365
+ render(): void
366
+ /** Insert text at cursor */
367
+ insert(char: string): void
368
+ /** Delete character before cursor */
369
+ backspace(): void
370
+ /** Delete character at cursor */
371
+ delete(): void
372
+ /** Clear all input */
373
+ clear(): void
374
+ /** Accept autocomplete suggestion */
375
+ acceptSuggestion(): void
376
+ }
377
+
378
+ /**
379
+ * Find a matching autocomplete suggestion for the current input
380
+ */
381
+ function getAutocompleteSuggestion(value: string, autocomplete?: string[]): string | undefined {
382
+ if (!value || !autocomplete?.length) {
383
+ return undefined
384
+ }
385
+
386
+ const lowerValue = value.toLowerCase()
387
+ return autocomplete.find((item) => item.toLowerCase().startsWith(lowerValue) && item.length > value.length)
388
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * wrapEmitter - Track EventEmitter state changes with progress indicators
3
+ */
4
+
5
+ import type { EventEmitter } from "events"
6
+ import { Spinner } from "../cli/spinner"
7
+
8
+ /** Event handler configuration */
9
+ interface EventConfig {
10
+ /** Display text for this event */
11
+ text?: string
12
+ /** Dynamic text based on event data */
13
+ getText?: (data: unknown) => string
14
+ /** Mark spinner as succeeded */
15
+ succeed?: boolean
16
+ /** Mark spinner as failed */
17
+ fail?: boolean
18
+ /** Stop tracking */
19
+ stop?: boolean
20
+ }
21
+
22
+ /** Configuration for wrapEmitter */
23
+ interface WrapEmitterConfig {
24
+ /** Event handlers */
25
+ events: Record<string, EventConfig>
26
+ /** Initial text */
27
+ initialText?: string
28
+ }
29
+
30
+ /**
31
+ * Track EventEmitter state changes with a spinner
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const stop = wrapEmitter(syncManager, {
36
+ * initialText: "Starting sync...",
37
+ * events: {
38
+ * 'ready': { text: "Watcher ready", succeed: true },
39
+ * 'state-change': { getText: (s) => `State: ${s}` },
40
+ * 'error': { fail: true },
41
+ * 'idle': { stop: true }
42
+ * }
43
+ * });
44
+ *
45
+ * // Later, to stop manually
46
+ * stop();
47
+ * ```
48
+ */
49
+ export function wrapEmitter(emitter: EventEmitter, config: WrapEmitterConfig): () => void {
50
+ const spinner = new Spinner(config.initialText ?? "")
51
+ const handlers: Map<string, (...args: unknown[]) => void> = new Map()
52
+
53
+ spinner.start()
54
+
55
+ // Set up event handlers
56
+ for (const [eventName, eventConfig] of Object.entries(config.events)) {
57
+ const handler = (data: unknown) => {
58
+ // Update text
59
+ if (eventConfig.getText) {
60
+ spinner.currentText = eventConfig.getText(data)
61
+ } else if (eventConfig.text) {
62
+ spinner.currentText = eventConfig.text
63
+ }
64
+
65
+ // Handle terminal states
66
+ if (eventConfig.succeed) {
67
+ spinner.succeed()
68
+ cleanup()
69
+ } else if (eventConfig.fail) {
70
+ const message = data instanceof Error ? data.message : String(data ?? "Failed")
71
+ spinner.fail(message)
72
+ cleanup()
73
+ } else if (eventConfig.stop) {
74
+ spinner.stop()
75
+ cleanup()
76
+ }
77
+ }
78
+
79
+ handlers.set(eventName, handler)
80
+ emitter.on(eventName, handler)
81
+ }
82
+
83
+ // Cleanup function
84
+ function cleanup() {
85
+ for (const [eventName, handler] of handlers) {
86
+ emitter.off(eventName, handler)
87
+ }
88
+ handlers.clear()
89
+ }
90
+
91
+ // Return stop function
92
+ return () => {
93
+ spinner.stop()
94
+ cleanup()
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Wait for an EventEmitter to emit a specific event
100
+ * Shows a spinner while waiting
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * await waitForEvent(syncManager, "ready", "Waiting for watcher...");
105
+ * ```
106
+ */
107
+ export async function waitForEvent(
108
+ emitter: EventEmitter,
109
+ eventName: string,
110
+ text: string,
111
+ options: {
112
+ errorEvent?: string
113
+ timeout?: number
114
+ } = {},
115
+ ): Promise<unknown> {
116
+ return new Promise((resolve, reject) => {
117
+ const spinner = new Spinner(text)
118
+ spinner.start()
119
+
120
+ let timer: ReturnType<typeof setTimeout> | null = null
121
+
122
+ const cleanup = () => {
123
+ emitter.off(eventName, successHandler)
124
+ if (options.errorEvent) {
125
+ emitter.off(options.errorEvent, errorHandler)
126
+ }
127
+ if (timer) {
128
+ clearTimeout(timer)
129
+ }
130
+ }
131
+
132
+ const successHandler = (data: unknown) => {
133
+ cleanup()
134
+ spinner.succeed()
135
+ resolve(data)
136
+ }
137
+
138
+ const errorHandler = (error: unknown) => {
139
+ cleanup()
140
+ spinner.fail(error instanceof Error ? error.message : "Error")
141
+ reject(error instanceof Error ? error : new Error(String(error)))
142
+ }
143
+
144
+ emitter.once(eventName, successHandler)
145
+
146
+ if (options.errorEvent) {
147
+ emitter.once(options.errorEvent, errorHandler)
148
+ }
149
+
150
+ if (options.timeout) {
151
+ timer = setTimeout(() => {
152
+ cleanup()
153
+ spinner.fail("Timeout")
154
+ reject(new Error(`Timeout waiting for ${eventName}`))
155
+ }, options.timeout)
156
+ }
157
+ })
158
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * wrapGenerator - Consume a generator while showing progress
3
+ */
4
+
5
+ import type { ProgressGenerator } from "../types.js"
6
+ import { ProgressBar } from "../cli/progress-bar"
7
+ import { CURSOR_HIDE, CURSOR_SHOW, write, isTTY } from "../cli/ansi"
8
+
9
+ /**
10
+ * Consume a progress generator while displaying progress
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // Wrap existing generator (like evaluateAllRules())
15
+ * await wrapGenerator(evaluateAllRules(), "Evaluating rules");
16
+ *
17
+ * // With custom format
18
+ * await wrapGenerator(
19
+ * processItems(),
20
+ * ({ current, total }) => `Processing: ${current}/${total}`
21
+ * );
22
+ *
23
+ * // Get the generator's return value
24
+ * const result = await wrapGenerator(generatorWithReturn(), "Processing");
25
+ * ```
26
+ */
27
+ export async function wrapGenerator<T>(
28
+ generator: ProgressGenerator<T>,
29
+ textOrFormat: string | ((progress: { current: number; total: number }) => string),
30
+ options: { clearOnComplete?: boolean } = {},
31
+ ): Promise<T> {
32
+ const stream = process.stdout
33
+ const isTty = isTTY(stream)
34
+
35
+ const isCustomFormat = typeof textOrFormat === "function"
36
+ const label = isCustomFormat ? "" : textOrFormat
37
+
38
+ const bar = new ProgressBar({
39
+ format: label ? `${label} [:bar] :current/:total :percent` : ":bar :current/:total :percent",
40
+ hideCursor: true,
41
+ })
42
+
43
+ if (isTty) {
44
+ write(CURSOR_HIDE, stream)
45
+ }
46
+
47
+ let started = false
48
+ let result: IteratorResult<{ current: number; total: number }, T>
49
+
50
+ try {
51
+ // Consume the generator
52
+ while (true) {
53
+ result = generator.next()
54
+
55
+ if (result.done) {
56
+ break
57
+ }
58
+
59
+ const { current, total } = result.value
60
+
61
+ if (!started) {
62
+ bar.start(current, total)
63
+ started = true
64
+ } else {
65
+ bar.update(current)
66
+ }
67
+ }
68
+
69
+ // Stop bar
70
+ if (started) {
71
+ bar.stop(options.clearOnComplete)
72
+ }
73
+ if (isTty) {
74
+ write(CURSOR_SHOW, stream)
75
+ }
76
+
77
+ return result.value
78
+ } catch (error) {
79
+ if (started) {
80
+ bar.stop()
81
+ }
82
+ if (isTty) {
83
+ write(CURSOR_SHOW, stream)
84
+ }
85
+ throw error
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create an async iterable wrapper that shows progress
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const items = [1, 2, 3, 4, 5];
95
+ * for await (const item of withIterableProgress(items, "Processing")) {
96
+ * await processItem(item);
97
+ * }
98
+ * ```
99
+ */
100
+ export async function* withIterableProgress<T>(
101
+ iterable: Iterable<T> | AsyncIterable<T>,
102
+ label: string,
103
+ options: { clearOnComplete?: boolean } = {},
104
+ ): AsyncGenerator<T, void, unknown> {
105
+ const stream = process.stdout
106
+ const isTty = isTTY(stream)
107
+
108
+ // Try to get length if array
109
+ const items = Array.isArray(iterable) ? iterable : null
110
+ const total = items?.length ?? 0
111
+
112
+ const bar = new ProgressBar({
113
+ format: `${label} [:bar] :current/:total :percent`,
114
+ total,
115
+ hideCursor: true,
116
+ })
117
+
118
+ if (isTty) {
119
+ write(CURSOR_HIDE, stream)
120
+ }
121
+
122
+ let current = 0
123
+ bar.start(0, total)
124
+
125
+ try {
126
+ for await (const item of iterable as AsyncIterable<T>) {
127
+ yield item
128
+ current++
129
+ bar.update(current)
130
+ }
131
+
132
+ bar.stop(options.clearOnComplete)
133
+ if (isTty) {
134
+ write(CURSOR_SHOW, stream)
135
+ }
136
+ } catch (error) {
137
+ bar.stop()
138
+ if (isTty) {
139
+ write(CURSOR_SHOW, stream)
140
+ }
141
+ throw error
142
+ }
143
+ }