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