TextInput.tsx
1 import React from 'react' 2 import { Text, useInput } from 'ink' 3 import chalk from 'chalk' 4 import { useTextInput } from '../hooks/useTextInput.js' 5 import { getTheme } from '../utils/theme.js' 6 import { type Key } from 'ink' 7 8 export type Props = { 9 /** 10 * Optional callback for handling history navigation on up arrow at start of input 11 */ 12 readonly onHistoryUp?: () => void 13 14 /** 15 * Optional callback for handling history navigation on down arrow at end of input 16 */ 17 readonly onHistoryDown?: () => void 18 19 /** 20 * Text to display when `value` is empty. 21 */ 22 readonly placeholder?: string 23 24 /** 25 * Allow multi-line input via line ending with backslash (default: `true`) 26 */ 27 readonly multiline?: boolean 28 29 /** 30 * Listen to user's input. Useful in case there are multiple input components 31 * at the same time and input must be "routed" to a specific component. 32 */ 33 readonly focus?: boolean 34 35 /** 36 * Replace all chars and mask the value. Useful for password inputs. 37 */ 38 readonly mask?: string 39 40 /** 41 * Whether to show cursor and allow navigation inside text input with arrow keys. 42 */ 43 readonly showCursor?: boolean 44 45 /** 46 * Highlight pasted text 47 */ 48 readonly highlightPastedText?: boolean 49 50 /** 51 * Value to display in a text input. 52 */ 53 readonly value: string 54 55 /** 56 * Function to call when value updates. 57 */ 58 readonly onChange: (value: string) => void 59 60 /** 61 * Function to call when `Enter` is pressed, where first argument is a value of the input. 62 */ 63 readonly onSubmit?: (value: string) => void 64 65 /** 66 * Function to call when Ctrl+C is pressed to exit. 67 */ 68 readonly onExit?: () => void 69 70 /** 71 * Optional callback to show exit message 72 */ 73 readonly onExitMessage?: (show: boolean, key?: string) => void 74 75 /** 76 * Optional callback to show custom message 77 */ 78 readonly onMessage?: (show: boolean, message?: string) => void 79 80 /** 81 * Optional callback to reset history position 82 */ 83 readonly onHistoryReset?: () => void 84 85 /** 86 * Number of columns to wrap text at 87 */ 88 readonly columns: number 89 90 /** 91 * Optional callback when an image is pasted 92 */ 93 readonly onImagePaste?: (base64Image: string) => void 94 95 /** 96 * Optional callback when a large text (over 800 chars) is pasted 97 */ 98 readonly onPaste?: (text: string) => void 99 100 /** 101 * Whether the input is dimmed and non-interactive 102 */ 103 readonly isDimmed?: boolean 104 105 /** 106 * Whether to disable cursor movement for up/down arrow keys 107 */ 108 readonly disableCursorMovementForUpDownKeys?: boolean 109 110 readonly cursorOffset: number 111 112 /** 113 * Callback to set the offset of the cursor 114 */ 115 onChangeCursorOffset: (offset: number) => void 116 } 117 118 export default function TextInput({ 119 value: originalValue, 120 placeholder = '', 121 focus = true, 122 mask, 123 multiline = false, 124 highlightPastedText = false, 125 showCursor = true, 126 onChange, 127 onSubmit, 128 onExit, 129 onHistoryUp, 130 onHistoryDown, 131 onExitMessage, 132 onMessage, 133 onHistoryReset, 134 columns, 135 onImagePaste, 136 onPaste, 137 isDimmed = false, 138 disableCursorMovementForUpDownKeys = false, 139 cursorOffset, 140 onChangeCursorOffset, 141 }: Props): JSX.Element { 142 const { onInput, renderedValue } = useTextInput({ 143 value: originalValue, 144 onChange, 145 onSubmit, 146 onExit, 147 onExitMessage, 148 onMessage, 149 onHistoryReset, 150 onHistoryUp, 151 onHistoryDown, 152 focus, 153 mask, 154 multiline, 155 cursorChar: showCursor ? ' ' : '', 156 highlightPastedText, 157 invert: chalk.inverse, 158 themeText: (text: string) => chalk.hex(getTheme().text)(text), 159 columns, 160 onImagePaste, 161 disableCursorMovementForUpDownKeys, 162 externalOffset: cursorOffset, 163 onOffsetChange: onChangeCursorOffset, 164 }) 165 166 // Paste detection state 167 const [pasteState, setPasteState] = React.useState<{ 168 chunks: string[] 169 timeoutId: ReturnType<typeof setTimeout> | null 170 }>({ chunks: [], timeoutId: null }) 171 172 const resetPasteTimeout = ( 173 currentTimeoutId: ReturnType<typeof setTimeout> | null, 174 ) => { 175 if (currentTimeoutId) { 176 clearTimeout(currentTimeoutId) 177 } 178 return setTimeout(() => { 179 setPasteState(({ chunks }) => { 180 const pastedText = chunks.join('') 181 // Schedule callback after current render to avoid state updates during render 182 Promise.resolve().then(() => onPaste!(pastedText)) 183 return { chunks: [], timeoutId: null } 184 }) 185 }, 100) 186 } 187 188 const wrappedOnInput = (input: string, key: Key): void => { 189 // Handle pastes (>800 chars) 190 // Usually we get one or two input characters at a time. If we 191 // get a bunch, the user has probably pasted. 192 // Unfortunately node batches long pastes, so it's possible 193 // that we would see e.g. 1024 characters and then just a few 194 // more in the next frame that belong with the original paste. 195 // This batching number is not consistent. 196 if (onPaste && (input.length > 800 || pasteState.timeoutId)) { 197 setPasteState(({ chunks, timeoutId }) => { 198 return { 199 chunks: [...chunks, input], 200 timeoutId: resetPasteTimeout(timeoutId), 201 } 202 }) 203 return 204 } 205 206 onInput(input, key) 207 } 208 209 useInput(wrappedOnInput, { isActive: focus }) 210 211 let renderedPlaceholder = placeholder 212 ? chalk.hex(getTheme().secondaryText)(placeholder) 213 : undefined 214 215 // Fake mouse cursor, because we like punishment 216 if (showCursor && focus) { 217 renderedPlaceholder = 218 placeholder.length > 0 219 ? chalk.inverse(placeholder[0]) + 220 chalk.hex(getTheme().secondaryText)(placeholder.slice(1)) 221 : chalk.inverse(' ') 222 } 223 224 const showPlaceholder = originalValue.length == 0 && placeholder 225 return ( 226 <Text wrap="truncate-end" dimColor={isDimmed}> 227 {showPlaceholder ? renderedPlaceholder : renderedValue} 228 </Text> 229 ) 230 }