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