forked from aws/agentcore-cli
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTextInput.tsx
More file actions
194 lines (172 loc) · 6.56 KB
/
Copy pathTextInput.tsx
File metadata and controls
194 lines (172 loc) · 6.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useStdout } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';
/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
type CustomValidation = (value: string) => true | string;
interface TextInputProps {
prompt: string;
onSubmit: (value: string) => void;
onCancel: () => void;
placeholder?: string;
initialValue?: string;
/** Zod string schema for validation - error message is extracted from schema */
schema?: ZodString;
/** Custom validation beyond schema - both validate function and error message are required together */
customValidation?: CustomValidation;
allowEmpty?: boolean;
/** Mask character to hide input (e.g., '*' for passwords/API keys) */
mask?: string;
/** Hide the built-in "> " prompt arrow (default false) */
hideArrow?: boolean;
/** Allow text to wrap across multiple lines instead of truncating (default false) */
expandable?: boolean;
/** Called when the input value changes, with a setValue callback to transform input */
onChange?: (value: string, setValue: (v: string) => void) => void;
/** Called when backspace is pressed on an empty input */
onBackspaceEmpty?: () => void;
/** Called when up arrow is pressed */
onUpArrow?: () => void;
/** Called when down arrow is pressed */
onDownArrow?: () => void;
}
function validateValue(value: string, schema?: ZodString, customValidation?: CustomValidation): string | undefined {
if (!value) return undefined;
if (customValidation) {
const result = customValidation(value);
if (result !== true) {
return result;
}
}
if (schema) {
const parseResult = schema.safeParse(value);
if (!parseResult.success) {
return parseResult.error.issues[0]?.message;
}
}
return undefined;
}
export function TextInput({
prompt,
onSubmit,
onCancel,
placeholder,
initialValue = '',
schema,
customValidation,
allowEmpty = false,
mask,
hideArrow = false,
expandable = false,
onChange: onChangeProp,
onBackspaceEmpty,
onUpArrow,
onDownArrow,
}: TextInputProps) {
const [showError, setShowError] = useState(false);
const { stdout } = useStdout();
const { value, cursor, setValue } = useTextInput({
initialValue,
onChange: onChangeProp ? (v: string) => onChangeProp(v, setValue) : undefined,
onBackspaceEmpty,
onUpArrow,
onDownArrow,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
});
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;
// Get display value (masked or plain)
const displayValue = mask ? mask.repeat(value.length) : value;
// Simple split for cursor positioning (used by both modes)
const beforeCursorFull = displayValue.slice(0, cursor);
const charAtCursorFull = displayValue[cursor] ?? ' ';
const afterCursorFull = displayValue.slice(cursor + 1);
if (expandable) {
return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Text wrap="wrap">
{!hideArrow && <Text color="cyan">> </Text>}
<Text>{beforeCursorFull}</Text>
<Cursor char={charAtCursorFull} />
<Text>{afterCursorFull}</Text>
{!value && placeholder && <Text dimColor>{placeholder.slice(1)}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
{(showError || showInvalidMark) && validationErrorMsg && <Text color="red">{validationErrorMsg}</Text>}
</Box>
);
}
// Calculate available width for text display
// Account for: arrow (2 chars), cursor (1 char), checkmark/x (2 chars), padding (2 chars)
const terminalWidth = stdout?.columns ?? 80;
const reservedChars = (hideArrow ? 0 : 2) + 1 + 2 + 2;
const maxDisplayWidth = Math.max(20, terminalWidth - reservedChars);
// Calculate windowed view if text is too long
let beforeCursor: string;
let charAtCursor: string;
let afterCursor: string;
let showEllipsisBefore = false;
let showEllipsisAfter = false;
if (displayValue.length <= maxDisplayWidth) {
// Text fits - show everything
beforeCursor = beforeCursorFull;
charAtCursor = charAtCursorFull;
afterCursor = afterCursorFull;
} else {
// Text too long - create a window around cursor
const windowSize = maxDisplayWidth - 2; // Reserve space for ellipsis indicators
const halfWindow = Math.floor(windowSize / 2);
let windowStart = Math.max(0, cursor - halfWindow);
let windowEnd = Math.min(displayValue.length, windowStart + windowSize);
// Adjust window if we're near the end
if (windowEnd === displayValue.length) {
windowStart = Math.max(0, displayValue.length - windowSize);
}
// Adjust if we're near the start
if (windowStart === 0) {
windowEnd = Math.min(displayValue.length, windowSize);
}
showEllipsisBefore = windowStart > 0;
showEllipsisAfter = windowEnd < displayValue.length;
const cursorInWindow = cursor - windowStart;
const windowedText = displayValue.slice(windowStart, windowEnd);
beforeCursor = windowedText.slice(0, cursorInWindow);
charAtCursor = windowedText[cursorInWindow] ?? ' ';
afterCursor = windowedText.slice(cursorInWindow + 1);
}
return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Text wrap="truncate-end">
{!hideArrow && <Text color="cyan">> </Text>}
{showEllipsisBefore && <Text dimColor>…</Text>}
<Text>{beforeCursor}</Text>
<Cursor char={charAtCursor} />
<Text>{afterCursor}</Text>
{showEllipsisAfter && <Text dimColor>…</Text>}
{!value && placeholder && <Text dimColor>{placeholder.slice(1)}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
{(showError || showInvalidMark) && validationErrorMsg && <Text color="red">{validationErrorMsg}</Text>}
</Box>
);
}