useTextInput.ts
1 import { useState } from 'react' 2 import { type Key } from 'ink' 3 import { useDoublePress } from './useDoublePress.js' 4 import { Cursor } from '../utils/Cursor.js' 5 import { 6 getImageFromClipboard, 7 CLIPBOARD_ERROR_MESSAGE, 8 } from '../utils/imagePaste.js' 9 10 const IMAGE_PLACEHOLDER = '[Image pasted]' 11 12 type MaybeCursor = void | Cursor 13 type InputHandler = (input: string) => MaybeCursor 14 type InputMapper = (input: string) => MaybeCursor 15 function mapInput(input_map: Array<[string, InputHandler]>): InputMapper { 16 return function (input: string): MaybeCursor { 17 const handler = new Map(input_map).get(input) ?? (() => {}) 18 return handler(input) 19 } 20 } 21 22 type UseTextInputProps = { 23 value: string 24 onChange: (value: string) => void 25 onSubmit?: (value: string) => void 26 onExit?: () => void 27 onExitMessage?: (show: boolean, key?: string) => void 28 onMessage?: (show: boolean, message?: string) => void 29 onHistoryUp?: () => void 30 onHistoryDown?: () => void 31 onHistoryReset?: () => void 32 focus?: boolean 33 mask?: string 34 multiline?: boolean 35 cursorChar: string 36 highlightPastedText?: boolean 37 invert: (text: string) => string 38 themeText: (text: string) => string 39 columns: number 40 onImagePaste?: (base64Image: string) => void 41 disableCursorMovementForUpDownKeys?: boolean 42 externalOffset: number 43 onOffsetChange: (offset: number) => void 44 } 45 46 type UseTextInputResult = { 47 renderedValue: string 48 onInput: (input: string, key: Key) => void 49 offset: number 50 setOffset: (offset: number) => void 51 } 52 53 export function useTextInput({ 54 value: originalValue, 55 onChange, 56 onSubmit, 57 onExit, 58 onExitMessage, 59 onMessage, 60 onHistoryUp, 61 onHistoryDown, 62 onHistoryReset, 63 mask = '', 64 multiline = false, 65 cursorChar, 66 invert, 67 columns, 68 onImagePaste, 69 disableCursorMovementForUpDownKeys = false, 70 externalOffset, 71 onOffsetChange, 72 }: UseTextInputProps): UseTextInputResult { 73 const offset = externalOffset 74 const setOffset = onOffsetChange 75 const cursor = Cursor.fromText(originalValue, columns, offset) 76 const [imagePasteErrorTimeout, setImagePasteErrorTimeout] = 77 useState<NodeJS.Timeout | null>(null) 78 79 function maybeClearImagePasteErrorTimeout() { 80 if (!imagePasteErrorTimeout) { 81 return 82 } 83 clearTimeout(imagePasteErrorTimeout) 84 setImagePasteErrorTimeout(null) 85 onMessage?.(false) 86 } 87 88 const handleCtrlC = useDoublePress( 89 show => { 90 maybeClearImagePasteErrorTimeout() 91 onExitMessage?.(show, 'Ctrl-C') 92 }, 93 () => onExit?.(), 94 () => { 95 if (originalValue) { 96 onChange('') 97 onHistoryReset?.() 98 } 99 }, 100 ) 101 102 // Keep Escape for clearing input 103 const handleEscape = useDoublePress( 104 show => { 105 maybeClearImagePasteErrorTimeout() 106 onMessage?.(!!originalValue && show, `Press Escape again to clear`) 107 }, 108 () => { 109 if (originalValue) { 110 onChange('') 111 } 112 }, 113 ) 114 function clear() { 115 return Cursor.fromText('', columns, 0) 116 } 117 118 const handleEmptyCtrlD = useDoublePress( 119 show => onExitMessage?.(show, 'Ctrl-D'), 120 () => onExit?.(), 121 ) 122 123 function handleCtrlD(): MaybeCursor { 124 maybeClearImagePasteErrorTimeout() 125 if (cursor.text === '') { 126 // When input is empty, handle double-press 127 handleEmptyCtrlD() 128 return cursor 129 } 130 // When input is not empty, delete forward like iPython 131 return cursor.del() 132 } 133 134 function tryImagePaste() { 135 const base64Image = getImageFromClipboard() 136 if (base64Image === null) { 137 if (process.platform !== 'darwin') { 138 return cursor 139 } 140 onMessage?.(true, CLIPBOARD_ERROR_MESSAGE) 141 maybeClearImagePasteErrorTimeout() 142 setImagePasteErrorTimeout( 143 // @ts-expect-error: Bun is overloading types here, but we're using the NodeJS runtime 144 setTimeout(() => { 145 onMessage?.(false) 146 }, 4000), 147 ) 148 return cursor 149 } 150 151 onImagePaste?.(base64Image) 152 return cursor.insert(IMAGE_PLACEHOLDER) 153 } 154 155 const handleCtrl = mapInput([ 156 ['a', () => cursor.startOfLine()], 157 ['b', () => cursor.left()], 158 ['c', handleCtrlC], 159 ['d', handleCtrlD], 160 ['e', () => cursor.endOfLine()], 161 ['f', () => cursor.right()], 162 ['h', () => cursor.backspace()], 163 ['k', () => cursor.deleteToLineEnd()], 164 ['l', () => clear()], 165 ['n', () => downOrHistoryDown()], 166 ['p', () => upOrHistoryUp()], 167 ['u', () => cursor.deleteToLineStart()], 168 ['v', tryImagePaste], 169 ['w', () => cursor.deleteWordBefore()], 170 ]) 171 172 const handleMeta = mapInput([ 173 ['b', () => cursor.prevWord()], 174 ['f', () => cursor.nextWord()], 175 ['d', () => cursor.deleteWordAfter()], 176 ]) 177 178 function handleEnter(key: Key) { 179 if ( 180 multiline && 181 cursor.offset > 0 && 182 cursor.text[cursor.offset - 1] === '\\' 183 ) { 184 return cursor.backspace().insert('\n') 185 } 186 if (key.meta) { 187 return cursor.insert('\n') 188 } 189 onSubmit?.(originalValue) 190 } 191 192 function upOrHistoryUp() { 193 if (disableCursorMovementForUpDownKeys) { 194 onHistoryUp?.() 195 return cursor 196 } 197 const cursorUp = cursor.up() 198 if (cursorUp.equals(cursor)) { 199 // already at beginning 200 onHistoryUp?.() 201 } 202 return cursorUp 203 } 204 function downOrHistoryDown() { 205 if (disableCursorMovementForUpDownKeys) { 206 onHistoryDown?.() 207 return cursor 208 } 209 const cursorDown = cursor.down() 210 if (cursorDown.equals(cursor)) { 211 onHistoryDown?.() 212 } 213 return cursorDown 214 } 215 216 function mapKey(key: Key): InputMapper { 217 switch (true) { 218 case key.escape: 219 return handleEscape 220 case key.leftArrow && (key.ctrl || key.meta || key.fn): 221 return () => cursor.prevWord() 222 case key.rightArrow && (key.ctrl || key.meta || key.fn): 223 return () => cursor.nextWord() 224 case key.backspace: 225 return key.meta 226 ? () => cursor.deleteWordBefore() 227 : () => cursor.backspace() 228 case key.delete: 229 return key.meta ? () => cursor.deleteToLineEnd() : () => cursor.del() 230 case key.ctrl: 231 return handleCtrl 232 case key.home: 233 return () => cursor.startOfLine() 234 case key.end: 235 return () => cursor.endOfLine() 236 case key.pageDown: 237 return () => cursor.endOfLine() 238 case key.pageUp: 239 return () => cursor.startOfLine() 240 case key.meta: 241 return handleMeta 242 case key.return: 243 return () => handleEnter(key) 244 case key.tab: 245 return () => {} 246 case key.upArrow: 247 return upOrHistoryUp 248 case key.downArrow: 249 return downOrHistoryDown 250 case key.leftArrow: 251 return () => cursor.left() 252 case key.rightArrow: 253 return () => cursor.right() 254 } 255 return function (input: string) { 256 switch (true) { 257 // Home key 258 case input == '\x1b[H' || input == '\x1b[1~': 259 return cursor.startOfLine() 260 // End key 261 case input == '\x1b[F' || input == '\x1b[4~': 262 return cursor.endOfLine() 263 default: 264 return cursor.insert(input.replace(/\r/g, '\n')) 265 } 266 } 267 } 268 269 function onInput(input: string, key: Key): void { 270 const nextCursor = mapKey(key)(input) 271 if (nextCursor) { 272 if (!cursor.equals(nextCursor)) { 273 setOffset(nextCursor.offset) 274 if (cursor.text != nextCursor.text) { 275 onChange(nextCursor.text) 276 } 277 } 278 } 279 } 280 281 return { 282 onInput, 283 renderedValue: cursor.render(cursorChar, mask, invert), 284 offset, 285 setOffset, 286 } 287 }