@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,227 @@
1
+ /**
2
+ * React TextInput component for silvery/Ink TUI apps
3
+ */
4
+
5
+ import React, { useState, useCallback } from "react"
6
+ import type { TextInputProps } from "../types.js"
7
+
8
+ /**
9
+ * Single-line text input component for React TUI apps
10
+ *
11
+ * This is a controlled component that renders the current input state.
12
+ * It does NOT handle keyboard input directly - that's the caller's responsibility
13
+ * (via useInput hook in Ink, or similar).
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * import { TextInput } from "@silvery/ui/input";
18
+ * import { useInput } from "ink";
19
+ *
20
+ * function MyForm() {
21
+ * const [value, setValue] = useState("");
22
+ *
23
+ * useInput((input, key) => {
24
+ * if (key.backspace || key.delete) {
25
+ * setValue(v => v.slice(0, -1));
26
+ * } else if (!key.ctrl && !key.meta && input) {
27
+ * setValue(v => v + input);
28
+ * }
29
+ * });
30
+ *
31
+ * return <TextInput value={value} onChange={setValue} placeholder="Type here..." />;
32
+ * }
33
+ *
34
+ * // With password masking
35
+ * <TextInput value={password} onChange={setPassword} mask="*" />
36
+ *
37
+ * // With autocomplete
38
+ * <TextInput
39
+ * value={query}
40
+ * onChange={setQuery}
41
+ * autocomplete={["apple", "apricot", "avocado"]}
42
+ * />
43
+ * ```
44
+ */
45
+ export function TextInput({
46
+ value,
47
+ onChange,
48
+ placeholder,
49
+ mask,
50
+ autocomplete,
51
+ onAutocomplete,
52
+ onSubmit,
53
+ cursorPosition,
54
+ focused = true,
55
+ }: TextInputProps): React.ReactElement {
56
+ // Calculate display value (masked or plain)
57
+ const displayValue = mask ? mask.repeat(value.length) : value
58
+
59
+ // Find matching autocomplete suggestion
60
+ const suggestion = getAutocompleteSuggestion(value, autocomplete)
61
+
62
+ // Cursor position defaults to end of input
63
+ const cursor = cursorPosition ?? value.length
64
+
65
+ // Build the display: value + cursor + suggestion suffix
66
+ const beforeCursor = displayValue.slice(0, cursor)
67
+ const afterCursor = displayValue.slice(cursor)
68
+ const suggestionSuffix = suggestion ? suggestion.slice(value.length) : ""
69
+
70
+ // Show placeholder if empty and not focused or no value
71
+ const showPlaceholder = !value && placeholder
72
+
73
+ return (
74
+ <span data-silvery-ui-text-input data-focused={focused}>
75
+ {showPlaceholder ? (
76
+ <span data-color="dim">{placeholder}</span>
77
+ ) : (
78
+ <>
79
+ <span>{beforeCursor}</span>
80
+ {focused && (
81
+ <span data-cursor data-inverse>
82
+ {afterCursor[0] || " "}
83
+ </span>
84
+ )}
85
+ <span>{afterCursor.slice(1)}</span>
86
+ {suggestionSuffix && <span data-color="dim">{suggestionSuffix}</span>}
87
+ </>
88
+ )}
89
+ </span>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Hook for managing text input state with autocomplete support
95
+ *
96
+ * @example
97
+ * ```tsx
98
+ * function SearchInput() {
99
+ * const { value, displayValue, suggestion, handleInput, acceptSuggestion, clear } =
100
+ * useTextInput({ autocomplete: ["apple", "banana", "cherry"] });
101
+ *
102
+ * useInput((input, key) => {
103
+ * if (key.tab && suggestion) {
104
+ * acceptSuggestion();
105
+ * } else {
106
+ * handleInput(input, key);
107
+ * }
108
+ * });
109
+ *
110
+ * return <Text>{displayValue}</Text>;
111
+ * }
112
+ * ```
113
+ */
114
+ export function useTextInput(
115
+ options: {
116
+ initialValue?: string
117
+ mask?: string
118
+ autocomplete?: string[]
119
+ onSubmit?: (value: string) => void
120
+ } = {},
121
+ ): {
122
+ value: string
123
+ setValue: (value: string) => void
124
+ displayValue: string
125
+ suggestion: string | undefined
126
+ cursorPosition: number
127
+ setCursorPosition: (pos: number) => void
128
+ handleInput: (input: string, key: InputKey) => void
129
+ acceptSuggestion: () => void
130
+ clear: () => void
131
+ } {
132
+ const [value, setValue] = useState(options.initialValue ?? "")
133
+ const [cursorPosition, setCursorPosition] = useState(value.length)
134
+
135
+ const displayValue = options.mask ? options.mask.repeat(value.length) : value
136
+
137
+ const suggestion = getAutocompleteSuggestion(value, options.autocomplete)
138
+
139
+ const handleInput = useCallback(
140
+ (input: string, key: InputKey) => {
141
+ if (key.return) {
142
+ options.onSubmit?.(value)
143
+ return
144
+ }
145
+
146
+ if (key.backspace || key.delete) {
147
+ if (cursorPosition > 0) {
148
+ setValue((v) => v.slice(0, cursorPosition - 1) + v.slice(cursorPosition))
149
+ setCursorPosition((p) => Math.max(0, p - 1))
150
+ }
151
+ return
152
+ }
153
+
154
+ if (key.leftArrow) {
155
+ setCursorPosition((p) => Math.max(0, p - 1))
156
+ return
157
+ }
158
+
159
+ if (key.rightArrow) {
160
+ setCursorPosition((p) => Math.min(value.length, p + 1))
161
+ return
162
+ }
163
+
164
+ // Ignore control characters
165
+ if (key.ctrl || key.meta || !input) {
166
+ return
167
+ }
168
+
169
+ // Insert character at cursor position
170
+ setValue((v) => v.slice(0, cursorPosition) + input + v.slice(cursorPosition))
171
+ setCursorPosition((p) => p + input.length)
172
+ },
173
+ [value, cursorPosition, options.onSubmit],
174
+ )
175
+
176
+ const acceptSuggestion = useCallback(() => {
177
+ if (suggestion) {
178
+ setValue(suggestion)
179
+ setCursorPosition(suggestion.length)
180
+ }
181
+ }, [suggestion])
182
+
183
+ const clear = useCallback(() => {
184
+ setValue("")
185
+ setCursorPosition(0)
186
+ }, [])
187
+
188
+ return {
189
+ value,
190
+ setValue,
191
+ displayValue,
192
+ suggestion,
193
+ cursorPosition,
194
+ setCursorPosition,
195
+ handleInput,
196
+ acceptSuggestion,
197
+ clear,
198
+ }
199
+ }
200
+
201
+ /** Key object type (matches Ink's Key interface) */
202
+ interface InputKey {
203
+ return?: boolean
204
+ backspace?: boolean
205
+ delete?: boolean
206
+ leftArrow?: boolean
207
+ rightArrow?: boolean
208
+ upArrow?: boolean
209
+ downArrow?: boolean
210
+ tab?: boolean
211
+ escape?: boolean
212
+ ctrl?: boolean
213
+ meta?: boolean
214
+ shift?: boolean
215
+ }
216
+
217
+ /**
218
+ * Find a matching autocomplete suggestion for the current input
219
+ */
220
+ function getAutocompleteSuggestion(value: string, autocomplete?: string[]): string | undefined {
221
+ if (!value || !autocomplete?.length) {
222
+ return undefined
223
+ }
224
+
225
+ const lowerValue = value.toLowerCase()
226
+ return autocomplete.find((item) => item.toLowerCase().startsWith(lowerValue) && item.length > value.length)
227
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Input components for TUI apps
3
+ *
4
+ * @example
5
+ * ```tsx
6
+ * import { TextInput, Select } from "@silvery/ui/input";
7
+ *
8
+ * // Text input
9
+ * <TextInput value={name} onChange={setName} placeholder="Enter name" />
10
+ *
11
+ * // Selection list
12
+ * <Select
13
+ * options={[
14
+ * { label: "Option A", value: "a" },
15
+ * { label: "Option B", value: "b" },
16
+ * ]}
17
+ * value={selected}
18
+ * onChange={setSelected}
19
+ * />
20
+ * ```
21
+ */
22
+
23
+ export { TextInput, useTextInput } from "./TextInput"
24
+ export { Select, useSelect } from "./Select"
25
+ export type { TextInputProps, TextInputOptions, SelectProps, SelectOption } from "../types.js"
@@ -0,0 +1,160 @@
1
+ /**
2
+ * AsyncLocalStorage context for step progress reporting
3
+ *
4
+ * Provides a `step()` function that work functions can call to report progress.
5
+ * Returns a no-op context when called outside of a steps() execution context,
6
+ * so functions work in tests without the progress UI.
7
+ */
8
+
9
+ import { AsyncLocalStorage } from "node:async_hooks"
10
+ import type { TaskHandle } from "../cli/multi-progress"
11
+
12
+ /**
13
+ * Context available to work functions during step execution
14
+ */
15
+ export interface StepContext {
16
+ /** Update progress on current step */
17
+ progress(current: number, total: number): void
18
+
19
+ /** Create a sub-step (auto-completes previous sub-step) */
20
+ sub(label: string): void
21
+
22
+ /** Get current step label (for debugging) */
23
+ readonly label: string
24
+ }
25
+
26
+ /**
27
+ * Internal context with additional fields for the runner
28
+ */
29
+ export interface InternalStepContext extends StepContext {
30
+ /** TaskHandle for this step */
31
+ readonly handle: TaskHandle
32
+
33
+ /** Add a sub-step handle (called by runner) */
34
+ _addSubHandle(label: string, handle: TaskHandle): void
35
+
36
+ /** Get a pre-declared sub-step handle by label */
37
+ _getSubHandle(label: string): TaskHandle | undefined
38
+
39
+ /** Set the current sub-step (when starting a pre-declared step) */
40
+ _setCurrentSubHandle(label: string, handle: TaskHandle): void
41
+
42
+ /** Complete current sub-step (called by runner) */
43
+ _completeSubStep(): void
44
+ }
45
+
46
+ // AsyncLocalStorage instance
47
+ const stepContext = new AsyncLocalStorage<InternalStepContext>()
48
+
49
+ /**
50
+ * Get the current step context
51
+ *
52
+ * Safe to call anywhere - returns a no-op context when called outside
53
+ * of a steps() execution context.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * async function processFiles(files: string[]) {
58
+ * for (let i = 0; i < files.length; i++) {
59
+ * step().progress(i + 1, files.length);
60
+ * await process(files[i]);
61
+ * }
62
+ * }
63
+ *
64
+ * // In tests (no steps context)
65
+ * await processFiles(["a.md", "b.md"]); // step() returns no-op, no errors
66
+ *
67
+ * // In production (with steps context)
68
+ * await steps({ process: processFiles }).run(); // Shows progress
69
+ * ```
70
+ */
71
+ export function step(): StepContext {
72
+ return stepContext.getStore() ?? NO_OP_CONTEXT
73
+ }
74
+
75
+ /**
76
+ * Run a function with step context (internal use by runner)
77
+ */
78
+ export function runWithStepContext<T>(ctx: InternalStepContext, fn: () => T): T {
79
+ return stepContext.run(ctx, fn)
80
+ }
81
+
82
+ /**
83
+ * Create an internal step context for the runner
84
+ */
85
+ export function createStepContext(
86
+ label: string,
87
+ handle: TaskHandle,
88
+ onSubStep?: (label: string) => TaskHandle,
89
+ ): InternalStepContext {
90
+ let currentSubLabel: string | undefined
91
+ let currentSubHandle: TaskHandle | null = null
92
+ let subStepStartTime = 0
93
+ const declaredHandles = new Map<string, TaskHandle>()
94
+
95
+ return {
96
+ get label() {
97
+ return label
98
+ },
99
+
100
+ get handle() {
101
+ return handle
102
+ },
103
+
104
+ progress(current: number, total: number) {
105
+ if (currentSubHandle) {
106
+ currentSubHandle.setTitle(`${currentSubLabel} (${current}/${total})`)
107
+ } else {
108
+ handle.setTitle(`${label} (${current}/${total})`)
109
+ }
110
+ },
111
+
112
+ sub(subLabel: string) {
113
+ // Complete previous sub-step if any
114
+ this._completeSubStep()
115
+
116
+ currentSubLabel = subLabel
117
+ subStepStartTime = Date.now()
118
+
119
+ if (onSubStep) {
120
+ currentSubHandle = onSubStep(subLabel)
121
+ currentSubHandle.start()
122
+ }
123
+ },
124
+
125
+ _addSubHandle(subLabel: string, subHandle: TaskHandle) {
126
+ declaredHandles.set(subLabel, subHandle)
127
+ },
128
+
129
+ _getSubHandle(subLabel: string) {
130
+ return declaredHandles.get(subLabel)
131
+ },
132
+
133
+ _setCurrentSubHandle(subLabel: string, subHandle: TaskHandle) {
134
+ currentSubLabel = subLabel
135
+ currentSubHandle = subHandle
136
+ subStepStartTime = Date.now()
137
+ },
138
+
139
+ _completeSubStep() {
140
+ if (currentSubHandle && currentSubLabel) {
141
+ const elapsed = Date.now() - subStepStartTime
142
+ // Use numeric timing - preserves current title (which may have progress info)
143
+ currentSubHandle.complete(elapsed)
144
+ currentSubHandle = null
145
+ currentSubLabel = undefined
146
+ }
147
+ },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * No-op context for when step() is called outside execution context
153
+ */
154
+ const NO_OP_CONTEXT: StepContext = {
155
+ progress: () => {},
156
+ sub: () => {},
157
+ get label() {
158
+ return ""
159
+ },
160
+ }