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