@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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withProgress - Wrap callback-based progress functions
|
|
3
|
+
*
|
|
4
|
+
* @deprecated Use `steps()` from `@silvery/ui/progress` instead.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // OLD (deprecated):
|
|
9
|
+
* import { withProgress } from "@silvery/ui/wrappers";
|
|
10
|
+
* const result = await withProgress(
|
|
11
|
+
* (onProgress) => manager.syncFromFs(onProgress),
|
|
12
|
+
* { phases: SYNC_PHASES }
|
|
13
|
+
* );
|
|
14
|
+
*
|
|
15
|
+
* // NEW:
|
|
16
|
+
* import { steps } from "@silvery/ui/progress";
|
|
17
|
+
* const results = await steps({ syncFiles: () => manager.syncFromFs() }).run();
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ProgressInfo, ProgressCallback, WithProgressOptions } from "../types.js"
|
|
22
|
+
import { ProgressBar } from "../cli/progress-bar"
|
|
23
|
+
import { Spinner } from "../cli/spinner"
|
|
24
|
+
import { CURSOR_HIDE, CURSOR_SHOW, write, isTTY } from "../cli/ansi"
|
|
25
|
+
|
|
26
|
+
// Declare timer globals (not exposed by bun-types)
|
|
27
|
+
declare function setTimeout(callback: () => void, ms: number): unknown
|
|
28
|
+
declare function clearTimeout(id: unknown): void
|
|
29
|
+
|
|
30
|
+
// Timer type - opaque handle, we only store and clear it
|
|
31
|
+
type TimerId = unknown
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap a function that takes a progress callback
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* // Wrap existing km sync API
|
|
39
|
+
* const result = await withProgress(
|
|
40
|
+
* (onProgress) => manager.syncFromFs(onProgress),
|
|
41
|
+
* {
|
|
42
|
+
* phases: {
|
|
43
|
+
* scanning: "Scanning files",
|
|
44
|
+
* reconciling: "Reconciling changes",
|
|
45
|
+
* rules: "Evaluating rules"
|
|
46
|
+
* }
|
|
47
|
+
* }
|
|
48
|
+
* );
|
|
49
|
+
*
|
|
50
|
+
* // Simple usage without phases
|
|
51
|
+
* await withProgress((onProgress) => rebuildState(onProgress));
|
|
52
|
+
*
|
|
53
|
+
* // With custom format
|
|
54
|
+
* await withProgress(
|
|
55
|
+
* (p) => processFiles(p),
|
|
56
|
+
* { format: ":phase :bar :percent" }
|
|
57
|
+
* );
|
|
58
|
+
*
|
|
59
|
+
* // Show loading immediately (showAfter: 0) or after delay
|
|
60
|
+
* await withProgress(
|
|
61
|
+
* (p) => slowOperation(p),
|
|
62
|
+
* { showAfter: 1000, initialMessage: "Loading..." }
|
|
63
|
+
* );
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export async function withProgress<T>(
|
|
67
|
+
fn: (onProgress: ProgressCallback) => T | Promise<T>,
|
|
68
|
+
options: WithProgressOptions = {},
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const stream = process.stdout
|
|
71
|
+
const isTty = isTTY(stream)
|
|
72
|
+
|
|
73
|
+
// Determine format
|
|
74
|
+
const format =
|
|
75
|
+
options.format ?? (options.phases ? ":phase [:bar] :current/:total" : "[:bar] :current/:total :percent")
|
|
76
|
+
|
|
77
|
+
const bar = new ProgressBar({
|
|
78
|
+
format,
|
|
79
|
+
phases: options.phases ?? {},
|
|
80
|
+
hideCursor: true,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
let lastPhase: string | null = null
|
|
84
|
+
let started = false
|
|
85
|
+
|
|
86
|
+
// Initial spinner (shown before progress starts)
|
|
87
|
+
const showAfter = options.showAfter ?? 1000
|
|
88
|
+
const initialMessage = options.initialMessage ?? "Loading..."
|
|
89
|
+
let spinner: Spinner | null = null
|
|
90
|
+
let spinnerTimerId: TimerId | null = null
|
|
91
|
+
|
|
92
|
+
// Hide cursor
|
|
93
|
+
if (isTty) {
|
|
94
|
+
write(CURSOR_HIDE, stream)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Schedule initial spinner if configured
|
|
98
|
+
if (isTty && showAfter >= 0) {
|
|
99
|
+
spinnerTimerId = setTimeout(() => {
|
|
100
|
+
if (!started) {
|
|
101
|
+
spinner = new Spinner({ text: initialMessage })
|
|
102
|
+
spinner.start()
|
|
103
|
+
}
|
|
104
|
+
}, showAfter)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const onProgress: ProgressCallback = (info: ProgressInfo) => {
|
|
108
|
+
// Stop initial spinner if it was shown
|
|
109
|
+
if (spinner) {
|
|
110
|
+
spinner.stop()
|
|
111
|
+
spinner = null
|
|
112
|
+
}
|
|
113
|
+
if (spinnerTimerId !== null) {
|
|
114
|
+
clearTimeout(spinnerTimerId)
|
|
115
|
+
spinnerTimerId = null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle phase transitions
|
|
119
|
+
if (info.phase && info.phase !== lastPhase) {
|
|
120
|
+
if (lastPhase !== null && isTty) {
|
|
121
|
+
// Print newline before switching phases
|
|
122
|
+
write("\n", stream)
|
|
123
|
+
}
|
|
124
|
+
lastPhase = info.phase
|
|
125
|
+
|
|
126
|
+
// Start or update bar with new phase
|
|
127
|
+
if (!started) {
|
|
128
|
+
bar.start(info.current, info.total)
|
|
129
|
+
started = true
|
|
130
|
+
}
|
|
131
|
+
bar.setPhase(info.phase, { current: info.current, total: info.total })
|
|
132
|
+
} else {
|
|
133
|
+
if (!started) {
|
|
134
|
+
bar.start(info.current, info.total)
|
|
135
|
+
started = true
|
|
136
|
+
}
|
|
137
|
+
bar.update(info.current)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const result = await fn(onProgress)
|
|
143
|
+
|
|
144
|
+
// Clean up spinner if still pending
|
|
145
|
+
if (spinnerTimerId !== null) {
|
|
146
|
+
clearTimeout(spinnerTimerId)
|
|
147
|
+
}
|
|
148
|
+
// Note: spinner may be set by setTimeout callback - TS can't track this
|
|
149
|
+
const pendingSpinner = spinner as unknown as Spinner | null
|
|
150
|
+
if (pendingSpinner) {
|
|
151
|
+
pendingSpinner.stop()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Stop and show cursor
|
|
155
|
+
if (started) {
|
|
156
|
+
bar.stop(options.clearOnComplete)
|
|
157
|
+
}
|
|
158
|
+
if (isTty) {
|
|
159
|
+
write(CURSOR_SHOW, stream)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Clean up spinner
|
|
165
|
+
if (spinnerTimerId !== null) {
|
|
166
|
+
clearTimeout(spinnerTimerId)
|
|
167
|
+
}
|
|
168
|
+
// Note: spinner may be set by setTimeout callback - TS can't track this
|
|
169
|
+
const errorSpinner = spinner as unknown as Spinner | null
|
|
170
|
+
if (errorSpinner) {
|
|
171
|
+
errorSpinner.stop()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Restore cursor on error
|
|
175
|
+
if (started) {
|
|
176
|
+
bar.stop()
|
|
177
|
+
}
|
|
178
|
+
if (isTty) {
|
|
179
|
+
write(CURSOR_SHOW, stream)
|
|
180
|
+
}
|
|
181
|
+
throw error
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create a progress callback that can be passed to existing APIs
|
|
187
|
+
* Returns [callback, complete] tuple
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* const [onProgress, complete] = createProgressCallback({
|
|
192
|
+
* phases: { scanning: "Scanning", reconciling: "Reconciling" }
|
|
193
|
+
* });
|
|
194
|
+
*
|
|
195
|
+
* const result = await manager.syncFromFs(onProgress);
|
|
196
|
+
* complete();
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function createProgressCallback(options: WithProgressOptions = {}): [ProgressCallback, () => void] {
|
|
200
|
+
const stream = process.stdout
|
|
201
|
+
const isTty = isTTY(stream)
|
|
202
|
+
|
|
203
|
+
const format =
|
|
204
|
+
options.format ?? (options.phases ? ":phase [:bar] :current/:total" : "[:bar] :current/:total :percent")
|
|
205
|
+
|
|
206
|
+
const bar = new ProgressBar({
|
|
207
|
+
format,
|
|
208
|
+
phases: options.phases ?? {},
|
|
209
|
+
hideCursor: true,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
let lastPhase: string | null = null
|
|
213
|
+
let started = false
|
|
214
|
+
|
|
215
|
+
if (isTty) {
|
|
216
|
+
write(CURSOR_HIDE, stream)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const callback: ProgressCallback = (info: ProgressInfo) => {
|
|
220
|
+
if (info.phase && info.phase !== lastPhase) {
|
|
221
|
+
if (lastPhase !== null && isTty) {
|
|
222
|
+
write("\n", stream)
|
|
223
|
+
}
|
|
224
|
+
lastPhase = info.phase
|
|
225
|
+
|
|
226
|
+
if (!started) {
|
|
227
|
+
bar.start(info.current, info.total)
|
|
228
|
+
started = true
|
|
229
|
+
}
|
|
230
|
+
bar.setPhase(info.phase, { current: info.current, total: info.total })
|
|
231
|
+
} else {
|
|
232
|
+
if (!started) {
|
|
233
|
+
bar.start(info.current, info.total)
|
|
234
|
+
started = true
|
|
235
|
+
}
|
|
236
|
+
bar.update(info.current)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const complete = () => {
|
|
241
|
+
if (started) {
|
|
242
|
+
bar.stop(options.clearOnComplete)
|
|
243
|
+
}
|
|
244
|
+
if (isTty) {
|
|
245
|
+
write(CURSOR_SHOW, stream)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return [callback, complete]
|
|
250
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withSelect - Interactive CLI selection list
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from "chalk"
|
|
6
|
+
import type { SelectOption, WithSelectOptions } from "../types.js"
|
|
7
|
+
import { CURSOR_HIDE, CURSOR_SHOW, CURSOR_TO_START, CLEAR_LINE_END, cursorUp, write, isTTY } from "../cli/ansi"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Display an interactive selection list in the terminal
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // Simple usage
|
|
15
|
+
* const color = await withSelect("Choose a color:", [
|
|
16
|
+
* { label: "Red", value: "red" },
|
|
17
|
+
* { label: "Green", value: "green" },
|
|
18
|
+
* { label: "Blue", value: "blue" },
|
|
19
|
+
* ]);
|
|
20
|
+
*
|
|
21
|
+
* // With options
|
|
22
|
+
* const result = await withSelect(
|
|
23
|
+
* "Select item:",
|
|
24
|
+
* options,
|
|
25
|
+
* { initial: 2, maxVisible: 5 }
|
|
26
|
+
* );
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export async function withSelect<T>(
|
|
30
|
+
prompt: string,
|
|
31
|
+
options: SelectOption<T>[],
|
|
32
|
+
selectOptions: WithSelectOptions = {},
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
const { initial = 0, maxVisible = 10 } = selectOptions
|
|
35
|
+
const stream = process.stdout
|
|
36
|
+
const stdin = process.stdin
|
|
37
|
+
|
|
38
|
+
if (!isTTY(stream) || !stdin.isTTY) {
|
|
39
|
+
// Non-interactive mode: return first option or initial
|
|
40
|
+
return options[initial]?.value ?? options[0]!.value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let highlightIndex = Math.min(Math.max(0, initial), options.length - 1)
|
|
45
|
+
let linesRendered = 0
|
|
46
|
+
|
|
47
|
+
// Enable raw mode for character-by-character input
|
|
48
|
+
stdin.setRawMode(true)
|
|
49
|
+
stdin.resume()
|
|
50
|
+
stdin.setEncoding("utf8")
|
|
51
|
+
|
|
52
|
+
// Hide cursor
|
|
53
|
+
write(CURSOR_HIDE, stream)
|
|
54
|
+
|
|
55
|
+
function render() {
|
|
56
|
+
// Clear previously rendered lines
|
|
57
|
+
if (linesRendered > 0) {
|
|
58
|
+
write(cursorUp(linesRendered), stream)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Calculate scroll window
|
|
62
|
+
const scrollOffset = Math.max(
|
|
63
|
+
0,
|
|
64
|
+
Math.min(highlightIndex - Math.floor(maxVisible / 2), options.length - maxVisible),
|
|
65
|
+
)
|
|
66
|
+
const visibleCount = Math.min(maxVisible, options.length)
|
|
67
|
+
const visibleOptions = options.slice(scrollOffset, scrollOffset + visibleCount)
|
|
68
|
+
const hasMoreAbove = scrollOffset > 0
|
|
69
|
+
const hasMoreBelow = scrollOffset + visibleCount < options.length
|
|
70
|
+
|
|
71
|
+
// Render prompt
|
|
72
|
+
write(`${CURSOR_TO_START}${chalk.bold(prompt)}${CLEAR_LINE_END}\n`, stream)
|
|
73
|
+
|
|
74
|
+
let lines = 1
|
|
75
|
+
|
|
76
|
+
// Render scroll indicator (above)
|
|
77
|
+
if (hasMoreAbove) {
|
|
78
|
+
write(`${CURSOR_TO_START} ${chalk.dim("...")}${CLEAR_LINE_END}\n`, stream)
|
|
79
|
+
lines++
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Render options
|
|
83
|
+
for (let i = 0; i < visibleOptions.length; i++) {
|
|
84
|
+
const option = visibleOptions[i]
|
|
85
|
+
const actualIndex = scrollOffset + i
|
|
86
|
+
const isHighlighted = actualIndex === highlightIndex
|
|
87
|
+
|
|
88
|
+
const indicator = isHighlighted ? chalk.cyan(">") : " "
|
|
89
|
+
const label = isHighlighted ? chalk.cyan(option!.label) : option!.label
|
|
90
|
+
|
|
91
|
+
write(`${CURSOR_TO_START}${indicator} ${label}${CLEAR_LINE_END}\n`, stream)
|
|
92
|
+
lines++
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Render scroll indicator (below)
|
|
96
|
+
if (hasMoreBelow) {
|
|
97
|
+
write(`${CURSOR_TO_START} ${chalk.dim("...")}${CLEAR_LINE_END}\n`, stream)
|
|
98
|
+
lines++
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
linesRendered = lines
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cleanup() {
|
|
105
|
+
stdin.setRawMode(false)
|
|
106
|
+
stdin.pause()
|
|
107
|
+
stdin.removeListener("data", onKeypress)
|
|
108
|
+
write(CURSOR_SHOW, stream)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onKeypress(key: string) {
|
|
112
|
+
// Handle key sequences
|
|
113
|
+
const keyCode = key.charCodeAt(0)
|
|
114
|
+
|
|
115
|
+
// Ctrl+C
|
|
116
|
+
if (key === "\x03") {
|
|
117
|
+
cleanup()
|
|
118
|
+
reject(new Error("User cancelled"))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Enter/Return
|
|
123
|
+
if (key === "\r" || key === "\n") {
|
|
124
|
+
cleanup()
|
|
125
|
+
resolve(options[highlightIndex]!.value)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Escape
|
|
130
|
+
if (key === "\x1b" && key.length === 1) {
|
|
131
|
+
cleanup()
|
|
132
|
+
reject(new Error("User cancelled"))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Arrow keys (escape sequences)
|
|
137
|
+
if (key.startsWith("\x1b[")) {
|
|
138
|
+
const code = key.slice(2)
|
|
139
|
+
if (code === "A") {
|
|
140
|
+
// Up arrow
|
|
141
|
+
highlightIndex = Math.max(0, highlightIndex - 1)
|
|
142
|
+
render()
|
|
143
|
+
} else if (code === "B") {
|
|
144
|
+
// Down arrow
|
|
145
|
+
highlightIndex = Math.min(options.length - 1, highlightIndex + 1)
|
|
146
|
+
render()
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// j/k vim keys
|
|
152
|
+
if (key === "j" || key === "J") {
|
|
153
|
+
highlightIndex = Math.min(options.length - 1, highlightIndex + 1)
|
|
154
|
+
render()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (key === "k" || key === "K") {
|
|
158
|
+
highlightIndex = Math.max(0, highlightIndex - 1)
|
|
159
|
+
render()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Space to select (optional alternative to Enter)
|
|
164
|
+
if (key === " ") {
|
|
165
|
+
cleanup()
|
|
166
|
+
resolve(options[highlightIndex]!.value)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
stdin.on("data", onKeypress)
|
|
172
|
+
render()
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Create a reusable select instance for multiple selections
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* const select = createSelect({
|
|
182
|
+
* maxVisible: 5,
|
|
183
|
+
* });
|
|
184
|
+
*
|
|
185
|
+
* const first = await select("Choose first:", options1);
|
|
186
|
+
* const second = await select("Choose second:", options2);
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export function createSelect(
|
|
190
|
+
defaultOptions: WithSelectOptions = {},
|
|
191
|
+
): <T>(prompt: string, options: SelectOption<T>[], overrides?: WithSelectOptions) => Promise<T> {
|
|
192
|
+
return <T>(prompt: string, options: SelectOption<T>[], overrides: WithSelectOptions = {}) =>
|
|
193
|
+
withSelect(prompt, options, { ...defaultOptions, ...overrides })
|
|
194
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* withSpinner - Wrap promises with an animated spinner
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WithSpinnerOptions } from "../types.js"
|
|
6
|
+
import { Spinner } from "../cli/spinner"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wrap a promise with an animated spinner
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // Simple usage
|
|
14
|
+
* const data = await withSpinner(fetchData(), "Loading data...");
|
|
15
|
+
*
|
|
16
|
+
* // With options
|
|
17
|
+
* const result = await withSpinner(
|
|
18
|
+
* processFiles(),
|
|
19
|
+
* "Processing...",
|
|
20
|
+
* { style: "arc", clearOnComplete: true }
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* // With dynamic text
|
|
24
|
+
* const result = await withSpinner(
|
|
25
|
+
* longOperation(),
|
|
26
|
+
* (elapsed) => `Processing... (${elapsed}s)`
|
|
27
|
+
* );
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export async function withSpinner<T>(
|
|
31
|
+
promise: Promise<T> | (() => T | Promise<T>),
|
|
32
|
+
text: string | ((elapsedSeconds: number) => string),
|
|
33
|
+
options: WithSpinnerOptions = {},
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const spinner = new Spinner({
|
|
36
|
+
text: typeof text === "string" ? text : text(0),
|
|
37
|
+
style: options.style,
|
|
38
|
+
color: options.color,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
42
|
+
const startTime = Date.now()
|
|
43
|
+
|
|
44
|
+
spinner.start()
|
|
45
|
+
|
|
46
|
+
// Update text if dynamic
|
|
47
|
+
if (typeof text === "function") {
|
|
48
|
+
timer = setInterval(() => {
|
|
49
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000)
|
|
50
|
+
spinner.currentText = text(elapsed)
|
|
51
|
+
}, 1000)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = await (typeof promise === "function" ? promise() : promise)
|
|
56
|
+
|
|
57
|
+
if (timer) clearInterval(timer)
|
|
58
|
+
|
|
59
|
+
if (options.clearOnComplete) {
|
|
60
|
+
spinner.stop()
|
|
61
|
+
} else {
|
|
62
|
+
spinner.succeed()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (timer) clearInterval(timer)
|
|
68
|
+
spinner.fail(error instanceof Error ? error.message : "Failed")
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Attach a spinner to a promise for manual control
|
|
75
|
+
* Returns [result, spinner] tuple for custom control
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* const [promise, spinner] = attachSpinner(fetchData(), "Loading...");
|
|
80
|
+
* spinner.text = "Still loading...";
|
|
81
|
+
* const result = await promise;
|
|
82
|
+
* spinner.succeed("Loaded!");
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function attachSpinner<T>(
|
|
86
|
+
promise: Promise<T>,
|
|
87
|
+
text: string,
|
|
88
|
+
options: WithSpinnerOptions = {},
|
|
89
|
+
): [Promise<T>, Spinner] {
|
|
90
|
+
const spinner = new Spinner({
|
|
91
|
+
text,
|
|
92
|
+
style: options.style,
|
|
93
|
+
color: options.color,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
spinner.start()
|
|
97
|
+
|
|
98
|
+
async function wrapPromise(): Promise<T> {
|
|
99
|
+
try {
|
|
100
|
+
return await promise
|
|
101
|
+
} catch (error) {
|
|
102
|
+
spinner.fail(error instanceof Error ? error.message : "Failed")
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [wrapPromise(), spinner]
|
|
108
|
+
}
|