@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextArea Component
|
|
3
|
+
*
|
|
4
|
+
* Multi-line text input with word wrapping, scrolling, and cursor movement.
|
|
5
|
+
* Uses useContentRect for width-aware word wrapping and VirtualList-style
|
|
6
|
+
* scroll tracking to keep the cursor visible.
|
|
7
|
+
*
|
|
8
|
+
* Built on useTextArea hook — use the hook directly for custom rendering.
|
|
9
|
+
*
|
|
10
|
+
* Includes full readline-style editing: word movement, word kill, kill ring
|
|
11
|
+
* (yank/cycle), and character transpose -- shared with TextInput via readline-ops.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const [value, setValue] = useState('')
|
|
16
|
+
* <TextArea
|
|
17
|
+
* value={value}
|
|
18
|
+
* onChange={setValue}
|
|
19
|
+
* onSubmit={(val) => console.log('Submitted:', val)}
|
|
20
|
+
* height={10}
|
|
21
|
+
* placeholder="Type here..."
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Supported shortcuts:
|
|
26
|
+
* - Arrow keys: Move cursor (clears selection)
|
|
27
|
+
* - Shift+Arrow: Extend selection
|
|
28
|
+
* - Shift+Home/End: Select to line boundaries
|
|
29
|
+
* - Ctrl+Shift+Arrow: Word-wise selection
|
|
30
|
+
* - Ctrl+A: Select all text
|
|
31
|
+
* - Ctrl+E: End of line
|
|
32
|
+
* - Home/End: Beginning/end of line
|
|
33
|
+
* - Alt+B/F: Move by word (wraps across lines)
|
|
34
|
+
* - Ctrl+W, Alt+Backspace: Delete word backwards (kill ring)
|
|
35
|
+
* - Alt+D: Delete word forwards (kill ring)
|
|
36
|
+
* - Ctrl+K: Kill to end of line (kill ring)
|
|
37
|
+
* - Ctrl+U: Kill to beginning of line (kill ring)
|
|
38
|
+
* - Ctrl+Y: Yank (paste from kill ring)
|
|
39
|
+
* - Alt+Y: Cycle through kill ring (after Ctrl+Y)
|
|
40
|
+
* - Ctrl+T: Transpose characters
|
|
41
|
+
* - PageUp/PageDown: Scroll by viewport height
|
|
42
|
+
* - Backspace/Delete: Delete characters (or selected text)
|
|
43
|
+
* - Enter: Insert newline (replaces selection, or submit with submitKey)
|
|
44
|
+
* - Typing with selection: Replaces selected text
|
|
45
|
+
*/
|
|
46
|
+
import { forwardRef, useImperativeHandle } from "react"
|
|
47
|
+
import { useContentRect } from "@silvery/react/hooks/useLayout"
|
|
48
|
+
import { useFocusable } from "@silvery/react/hooks/useFocusable"
|
|
49
|
+
import { useCursor } from "@silvery/react/hooks/useCursor"
|
|
50
|
+
import { Box } from "@silvery/react/components/Box"
|
|
51
|
+
import { Text } from "@silvery/react/components/Text"
|
|
52
|
+
import { useTextArea } from "./useTextArea"
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Types
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
export interface TextAreaProps {
|
|
59
|
+
/** Current value (controlled) */
|
|
60
|
+
value?: string
|
|
61
|
+
/** Initial value (uncontrolled) */
|
|
62
|
+
defaultValue?: string
|
|
63
|
+
/** Called when value changes */
|
|
64
|
+
onChange?: (value: string) => void
|
|
65
|
+
/** Called on submit (Ctrl+Enter by default, or Enter if submitKey="enter") */
|
|
66
|
+
onSubmit?: (value: string) => void
|
|
67
|
+
/** Key to trigger submit: "ctrl+enter" (default), "enter", or "meta+enter" */
|
|
68
|
+
submitKey?: "ctrl+enter" | "enter" | "meta+enter"
|
|
69
|
+
/** Placeholder text when empty */
|
|
70
|
+
placeholder?: string
|
|
71
|
+
/** Whether input is focused/active (overrides focus system) */
|
|
72
|
+
isActive?: boolean
|
|
73
|
+
/** Visible height in rows (required) */
|
|
74
|
+
height: number
|
|
75
|
+
/** Cursor style: 'block' (inverse) or 'underline' */
|
|
76
|
+
cursorStyle?: "block" | "underline"
|
|
77
|
+
/** Number of context lines to keep visible above/below cursor when scrolling (default: 1) */
|
|
78
|
+
scrollMargin?: number
|
|
79
|
+
/** When true, ignore all input and dim the text */
|
|
80
|
+
disabled?: boolean
|
|
81
|
+
/** Maximum number of characters allowed */
|
|
82
|
+
maxLength?: number
|
|
83
|
+
/** Border style (e.g., "round", "single") — wraps input in bordered Box */
|
|
84
|
+
borderStyle?: string
|
|
85
|
+
/** Border color when unfocused (default: "$border") */
|
|
86
|
+
borderColor?: string
|
|
87
|
+
/** Border color when focused (default: "$focusborder") */
|
|
88
|
+
focusBorderColor?: string
|
|
89
|
+
/** Test ID for focus system identification */
|
|
90
|
+
testID?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Selection range as [start, end) character offsets */
|
|
94
|
+
export { type TextAreaSelection } from "./useTextArea"
|
|
95
|
+
|
|
96
|
+
export interface TextAreaHandle {
|
|
97
|
+
/** Clear the input */
|
|
98
|
+
clear: () => void
|
|
99
|
+
/** Get current value */
|
|
100
|
+
getValue: () => string
|
|
101
|
+
/** Set value programmatically */
|
|
102
|
+
setValue: (value: string) => void
|
|
103
|
+
/** Get the current selection range, or null if no selection */
|
|
104
|
+
getSelection: () => import("./useTextArea").TextAreaSelection | null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// Component
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
export const TextArea = forwardRef<TextAreaHandle, TextAreaProps>(function TextArea(
|
|
112
|
+
{
|
|
113
|
+
value: controlledValue,
|
|
114
|
+
defaultValue = "",
|
|
115
|
+
onChange,
|
|
116
|
+
onSubmit,
|
|
117
|
+
submitKey = "ctrl+enter",
|
|
118
|
+
placeholder = "",
|
|
119
|
+
isActive: isActiveProp,
|
|
120
|
+
height,
|
|
121
|
+
cursorStyle = "block",
|
|
122
|
+
scrollMargin = 1,
|
|
123
|
+
disabled,
|
|
124
|
+
maxLength,
|
|
125
|
+
borderStyle: borderStyleProp,
|
|
126
|
+
borderColor: borderColorProp = "$border",
|
|
127
|
+
focusBorderColor = "$focusborder",
|
|
128
|
+
testID,
|
|
129
|
+
},
|
|
130
|
+
ref,
|
|
131
|
+
) {
|
|
132
|
+
// Focus system integration: prop overrides hook.
|
|
133
|
+
// When testID is set, the component participates in the focus tree and
|
|
134
|
+
// isActive derives from focus state. Without testID, default to true
|
|
135
|
+
// for backward compatibility.
|
|
136
|
+
const { focused } = useFocusable()
|
|
137
|
+
const isActive = isActiveProp ?? (testID ? focused : true)
|
|
138
|
+
|
|
139
|
+
const { width } = useContentRect()
|
|
140
|
+
|
|
141
|
+
const ta = useTextArea({
|
|
142
|
+
value: controlledValue,
|
|
143
|
+
defaultValue,
|
|
144
|
+
onChange,
|
|
145
|
+
onSubmit,
|
|
146
|
+
submitKey,
|
|
147
|
+
isActive,
|
|
148
|
+
height,
|
|
149
|
+
wrapWidth: width,
|
|
150
|
+
scrollMargin,
|
|
151
|
+
disabled,
|
|
152
|
+
maxLength,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Imperative handle
|
|
156
|
+
useImperativeHandle(ref, () => ({
|
|
157
|
+
clear: ta.clear,
|
|
158
|
+
getValue: () => ta.value,
|
|
159
|
+
setValue: ta.setValue,
|
|
160
|
+
getSelection: ta.getSelection,
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
// =========================================================================
|
|
164
|
+
// Rendering
|
|
165
|
+
// =========================================================================
|
|
166
|
+
|
|
167
|
+
// Hide hardware cursor when selection is active (cursor shown as part of selection rendering)
|
|
168
|
+
useCursor({
|
|
169
|
+
col: ta.cursorCol,
|
|
170
|
+
row: ta.visibleCursorRow,
|
|
171
|
+
visible: isActive && !disabled && !ta.selection,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const showPlaceholder = !ta.value && placeholder
|
|
175
|
+
|
|
176
|
+
const borderProps = borderStyleProp
|
|
177
|
+
? {
|
|
178
|
+
borderStyle: borderStyleProp as any,
|
|
179
|
+
borderColor: isActive ? focusBorderColor : borderColorProp,
|
|
180
|
+
paddingX: 1 as const,
|
|
181
|
+
}
|
|
182
|
+
: {}
|
|
183
|
+
|
|
184
|
+
if (showPlaceholder) {
|
|
185
|
+
return (
|
|
186
|
+
<Box
|
|
187
|
+
focusable
|
|
188
|
+
testID={testID}
|
|
189
|
+
flexDirection="column"
|
|
190
|
+
height={height}
|
|
191
|
+
justifyContent="center"
|
|
192
|
+
alignItems="center"
|
|
193
|
+
{...borderProps}
|
|
194
|
+
>
|
|
195
|
+
<Text dimColor>{placeholder}</Text>
|
|
196
|
+
</Box>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<Box focusable testID={testID} key={ta.scrollOffset} flexDirection="column" height={height} {...borderProps}>
|
|
202
|
+
{ta.visibleLines.map((wl, i) => {
|
|
203
|
+
const absoluteRow = ta.scrollOffset + i
|
|
204
|
+
const isCursorRow = absoluteRow === ta.cursorRow
|
|
205
|
+
const lineStart = wl.startOffset
|
|
206
|
+
const lineEnd = lineStart + wl.line.length
|
|
207
|
+
|
|
208
|
+
// Check if this line has any selection overlap
|
|
209
|
+
const hasSelectionOnLine = ta.selection && lineStart < ta.selection.end && lineEnd > ta.selection.start
|
|
210
|
+
|
|
211
|
+
if (disabled) {
|
|
212
|
+
return (
|
|
213
|
+
<Text key={absoluteRow} dimColor>
|
|
214
|
+
{wl.line || " "}
|
|
215
|
+
</Text>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (hasSelectionOnLine) {
|
|
220
|
+
// Compute selection overlap on this line (in line-local coordinates)
|
|
221
|
+
const selStart = Math.max(0, ta.selection!.start - lineStart)
|
|
222
|
+
const selEnd = Math.min(wl.line.length, ta.selection!.end - lineStart)
|
|
223
|
+
|
|
224
|
+
const before = wl.line.slice(0, selStart)
|
|
225
|
+
const selected = wl.line.slice(selStart, selEnd)
|
|
226
|
+
const after = wl.line.slice(selEnd)
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Text key={absoluteRow}>
|
|
230
|
+
{before}
|
|
231
|
+
<Text inverse>{selected || (selEnd === wl.line.length && isCursorRow ? " " : "")}</Text>
|
|
232
|
+
{after}
|
|
233
|
+
</Text>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!isCursorRow) {
|
|
238
|
+
return <Text key={absoluteRow}>{wl.line || " "}</Text>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const beforeCursor = wl.line.slice(0, ta.cursorCol)
|
|
242
|
+
const atCursor = wl.line[ta.cursorCol] ?? " "
|
|
243
|
+
const afterCursor = wl.line.slice(ta.cursorCol + 1)
|
|
244
|
+
|
|
245
|
+
// Active: plain text (real cursor handles it). Inactive: fake cursor.
|
|
246
|
+
const cursorEl = isActive ? (
|
|
247
|
+
<Text>{atCursor}</Text>
|
|
248
|
+
) : cursorStyle === "block" ? (
|
|
249
|
+
<Text inverse>{atCursor}</Text>
|
|
250
|
+
) : (
|
|
251
|
+
<Text underline>{atCursor}</Text>
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<Text key={absoluteRow}>
|
|
256
|
+
{beforeCursor}
|
|
257
|
+
{cursorEl}
|
|
258
|
+
{afterCursor}
|
|
259
|
+
</Text>
|
|
260
|
+
)
|
|
261
|
+
})}
|
|
262
|
+
</Box>
|
|
263
|
+
)
|
|
264
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextInput Component
|
|
3
|
+
*
|
|
4
|
+
* Full readline-style single-line text input with kill ring, word movement, and
|
|
5
|
+
* all standard shortcuts. Built on useReadline hook.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [value, setValue] = useState('')
|
|
10
|
+
* <TextInput
|
|
11
|
+
* value={value}
|
|
12
|
+
* onChange={setValue}
|
|
13
|
+
* onSubmit={(val) => console.log('Submitted:', val)}
|
|
14
|
+
* placeholder="Type here..."
|
|
15
|
+
* />
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Supported shortcuts:
|
|
19
|
+
* - Ctrl+A/E: Beginning/end of line
|
|
20
|
+
* - Ctrl+B/F, Left/Right: Move cursor
|
|
21
|
+
* - Alt+B/F: Move by word
|
|
22
|
+
* - Ctrl+W, Alt+Backspace: Delete word backwards (kill)
|
|
23
|
+
* - Alt+D: Delete word forwards (kill)
|
|
24
|
+
* - Ctrl+U/K: Delete to beginning/end (kill)
|
|
25
|
+
* - Ctrl+Y: Yank (paste)
|
|
26
|
+
* - Alt+Y: Cycle kill ring
|
|
27
|
+
* - Ctrl+T: Transpose characters
|
|
28
|
+
*/
|
|
29
|
+
import { useCallback, useImperativeHandle, forwardRef, useState, useEffect, useRef } from "react"
|
|
30
|
+
import { Box } from "@silvery/react/components/Box"
|
|
31
|
+
import { Text } from "@silvery/react/components/Text"
|
|
32
|
+
import { useReadline } from "./useReadline"
|
|
33
|
+
import { useFocusable } from "@silvery/react/hooks/useFocusable"
|
|
34
|
+
import { useCursor } from "@silvery/react/hooks/useCursor"
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
export interface TextInputProps {
|
|
41
|
+
/** Current value (controlled) */
|
|
42
|
+
value?: string
|
|
43
|
+
/** Initial value (uncontrolled) */
|
|
44
|
+
defaultValue?: string
|
|
45
|
+
/** Called when value changes */
|
|
46
|
+
onChange?: (value: string) => void
|
|
47
|
+
/** Called when Enter is pressed */
|
|
48
|
+
onSubmit?: (value: string) => void
|
|
49
|
+
/** Called on Ctrl+D with empty input */
|
|
50
|
+
onEOF?: () => void
|
|
51
|
+
/** Placeholder text when empty */
|
|
52
|
+
placeholder?: string
|
|
53
|
+
/** Whether input is focused/active (overrides focus system) */
|
|
54
|
+
isActive?: boolean
|
|
55
|
+
/** Prompt prefix (e.g., "$ " or "> ") */
|
|
56
|
+
prompt?: string
|
|
57
|
+
/** Prompt color */
|
|
58
|
+
promptColor?: string
|
|
59
|
+
/** Text color */
|
|
60
|
+
color?: string
|
|
61
|
+
/** Cursor style: 'block' (inverse) or 'underline' */
|
|
62
|
+
cursorStyle?: "block" | "underline"
|
|
63
|
+
/** Show underline below input */
|
|
64
|
+
showUnderline?: boolean
|
|
65
|
+
/** Underline width (default: 40) */
|
|
66
|
+
underlineWidth?: number
|
|
67
|
+
/** Mask character for passwords */
|
|
68
|
+
mask?: string
|
|
69
|
+
/** Border style (e.g., "round", "single") — wraps input in bordered Box */
|
|
70
|
+
borderStyle?: string
|
|
71
|
+
/** Border color when unfocused (default: "$border") */
|
|
72
|
+
borderColor?: string
|
|
73
|
+
/** Border color when focused (default: "$focusborder") */
|
|
74
|
+
focusBorderColor?: string
|
|
75
|
+
/** Test ID for focus system identification */
|
|
76
|
+
testID?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface TextInputHandle {
|
|
80
|
+
/** Clear the input */
|
|
81
|
+
clear: () => void
|
|
82
|
+
/** Get current value */
|
|
83
|
+
getValue: () => string
|
|
84
|
+
/** Set value programmatically */
|
|
85
|
+
setValue: (value: string) => void
|
|
86
|
+
/** Get kill ring contents */
|
|
87
|
+
getKillRing: () => string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =============================================================================
|
|
91
|
+
// Component
|
|
92
|
+
// =============================================================================
|
|
93
|
+
|
|
94
|
+
export const TextInput = forwardRef<TextInputHandle, TextInputProps>(function TextInput(
|
|
95
|
+
{
|
|
96
|
+
value: controlledValue,
|
|
97
|
+
defaultValue = "",
|
|
98
|
+
onChange,
|
|
99
|
+
onSubmit,
|
|
100
|
+
onEOF,
|
|
101
|
+
placeholder = "",
|
|
102
|
+
isActive: isActiveProp,
|
|
103
|
+
prompt = "",
|
|
104
|
+
promptColor = "$control",
|
|
105
|
+
color,
|
|
106
|
+
cursorStyle = "block",
|
|
107
|
+
showUnderline = false,
|
|
108
|
+
underlineWidth = 40,
|
|
109
|
+
mask,
|
|
110
|
+
borderStyle: borderStyleProp,
|
|
111
|
+
borderColor: borderColorProp = "$border",
|
|
112
|
+
focusBorderColor = "$focusborder",
|
|
113
|
+
testID,
|
|
114
|
+
},
|
|
115
|
+
ref,
|
|
116
|
+
) {
|
|
117
|
+
// Focus system integration: prop overrides hook.
|
|
118
|
+
// When testID is set, the component participates in the focus tree and
|
|
119
|
+
// isActive derives from focus state. Without testID, default to true
|
|
120
|
+
// for backward compatibility.
|
|
121
|
+
const { focused } = useFocusable()
|
|
122
|
+
const isActive = isActiveProp ?? (testID ? focused : true)
|
|
123
|
+
|
|
124
|
+
// Track whether we're in controlled mode
|
|
125
|
+
const isControlled = controlledValue !== undefined
|
|
126
|
+
|
|
127
|
+
// Track value changes that originated from internal editing (keystroke → onChange).
|
|
128
|
+
// When the parent feeds back the same value via controlledValue, we skip
|
|
129
|
+
// readline.setValue() since readline already has the correct cursor position.
|
|
130
|
+
const internalChangeRef = useRef(false)
|
|
131
|
+
|
|
132
|
+
// Use readline hook
|
|
133
|
+
const readline = useReadline({
|
|
134
|
+
initialValue: isControlled ? (controlledValue ?? "") : defaultValue,
|
|
135
|
+
onChange: useCallback(
|
|
136
|
+
(newValue: string) => {
|
|
137
|
+
internalChangeRef.current = true
|
|
138
|
+
onChange?.(newValue)
|
|
139
|
+
},
|
|
140
|
+
[onChange],
|
|
141
|
+
),
|
|
142
|
+
isActive,
|
|
143
|
+
handleEnter: !!onSubmit,
|
|
144
|
+
onSubmit,
|
|
145
|
+
onEOF,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Sync controlled value to readline — only for external changes.
|
|
149
|
+
// Internal changes (from editing) already have correct cursor position.
|
|
150
|
+
const [lastControlledValue, setLastControlledValue] = useState(controlledValue)
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (isControlled && controlledValue !== lastControlledValue) {
|
|
153
|
+
if (internalChangeRef.current) {
|
|
154
|
+
// Change originated from our own editing — readline already has correct state
|
|
155
|
+
internalChangeRef.current = false
|
|
156
|
+
} else {
|
|
157
|
+
// External change — sync readline (cursor goes to end)
|
|
158
|
+
readline.setValue(controlledValue ?? "")
|
|
159
|
+
}
|
|
160
|
+
setLastControlledValue(controlledValue)
|
|
161
|
+
}
|
|
162
|
+
}, [isControlled, controlledValue, lastControlledValue, readline])
|
|
163
|
+
|
|
164
|
+
// Handle Enter separately for onSubmit
|
|
165
|
+
const { value, cursor, clear, setValue, killRing } = readline
|
|
166
|
+
|
|
167
|
+
// Imperative handle for parent control
|
|
168
|
+
useImperativeHandle(ref, () => ({
|
|
169
|
+
clear,
|
|
170
|
+
getValue: () => value,
|
|
171
|
+
setValue,
|
|
172
|
+
getKillRing: () => killRing,
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
// Compute display value (with optional mask)
|
|
176
|
+
const displayValue = mask ? mask.repeat(value.length) : value
|
|
177
|
+
const displayBeforeCursor = displayValue.slice(0, cursor)
|
|
178
|
+
const displayAtCursor = displayValue[cursor] ?? " "
|
|
179
|
+
const displayAfterCursor = displayValue.slice(cursor + 1)
|
|
180
|
+
const showPlaceholder = !value && placeholder
|
|
181
|
+
|
|
182
|
+
// Always show visual cursor (inverse/underline). When active, the hardware
|
|
183
|
+
// cursor is also positioned via useCursor() for terminal blink support.
|
|
184
|
+
const cursorEl =
|
|
185
|
+
cursorStyle === "underline" ? <Text underline>{displayAtCursor}</Text> : <Text inverse>{displayAtCursor}</Text>
|
|
186
|
+
useCursor({
|
|
187
|
+
col: prompt.length + displayBeforeCursor.length,
|
|
188
|
+
row: 0,
|
|
189
|
+
visible: isActive,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const inputContent = (
|
|
193
|
+
<Text color={color}>
|
|
194
|
+
{prompt && <Text color={promptColor}>{prompt}</Text>}
|
|
195
|
+
{showPlaceholder ? (
|
|
196
|
+
<>
|
|
197
|
+
{cursorStyle === "underline" ? (
|
|
198
|
+
<Text underline dimColor>
|
|
199
|
+
{placeholder[0]}
|
|
200
|
+
</Text>
|
|
201
|
+
) : (
|
|
202
|
+
<Text inverse dimColor>
|
|
203
|
+
{placeholder[0]}
|
|
204
|
+
</Text>
|
|
205
|
+
)}
|
|
206
|
+
<Text dimColor>{placeholder.slice(1)}</Text>
|
|
207
|
+
</>
|
|
208
|
+
) : (
|
|
209
|
+
<>
|
|
210
|
+
<Text>{displayBeforeCursor}</Text>
|
|
211
|
+
{cursorEl}
|
|
212
|
+
<Text>{displayAfterCursor}</Text>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
</Text>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if (borderStyleProp) {
|
|
219
|
+
return (
|
|
220
|
+
<Box
|
|
221
|
+
focusable
|
|
222
|
+
testID={testID}
|
|
223
|
+
flexDirection="column"
|
|
224
|
+
borderStyle={borderStyleProp as any}
|
|
225
|
+
borderColor={isActive ? focusBorderColor : borderColorProp}
|
|
226
|
+
paddingX={1}
|
|
227
|
+
>
|
|
228
|
+
{inputContent}
|
|
229
|
+
{showUnderline && <Text dimColor>{"─".repeat(underlineWidth)}</Text>}
|
|
230
|
+
</Box>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Box focusable testID={testID} flexDirection="column">
|
|
236
|
+
{inputContent}
|
|
237
|
+
{showUnderline && <Text dimColor>{"─".repeat(underlineWidth)}</Text>}
|
|
238
|
+
</Box>
|
|
239
|
+
)
|
|
240
|
+
})
|