source dump of claude code
at main 364 lines 10 kB view raw
1import { useCallback, useState } from 'react' 2import { KeyboardEvent } from '../ink/events/keyboard-event.js' 3// eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown> 4import { useInput } from '../ink.js' 5import { 6 Cursor, 7 getLastKill, 8 pushToKillRing, 9 recordYank, 10 resetKillAccumulation, 11 resetYankState, 12 updateYankLength, 13 yankPop, 14} from '../utils/Cursor.js' 15import { useTerminalSize } from './useTerminalSize.js' 16 17type UseSearchInputOptions = { 18 isActive: boolean 19 onExit: () => void 20 /** Esc + Ctrl+C abandon (distinct from onExit = Enter commit). When 21 * provided: single-Esc calls this directly (no clear-first-then-exit 22 * two-press). When absent: current behavior — Esc clears non-empty 23 * query, exits on empty; Ctrl+C silently swallowed (no switch case). */ 24 onCancel?: () => void 25 onExitUp?: () => void 26 columns?: number 27 passthroughCtrlKeys?: string[] 28 initialQuery?: string 29 /** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the 30 * less/vim "delete past the /" convention. Dialogs that want Esc-only 31 * cancel set this false so a held backspace doesn't eject the user. */ 32 backspaceExitsOnEmpty?: boolean 33} 34 35type UseSearchInputReturn = { 36 query: string 37 setQuery: (q: string) => void 38 cursorOffset: number 39 handleKeyDown: (e: KeyboardEvent) => void 40} 41 42function isKillKey(e: KeyboardEvent): boolean { 43 if (e.ctrl && (e.key === 'k' || e.key === 'u' || e.key === 'w')) { 44 return true 45 } 46 if (e.meta && e.key === 'backspace') { 47 return true 48 } 49 return false 50} 51 52function isYankKey(e: KeyboardEvent): boolean { 53 return (e.ctrl || e.meta) && e.key === 'y' 54} 55 56// Special key names that fall through the explicit handlers above the 57// text-input branch (return/escape/arrows/home/end/tab/backspace/delete 58// all early-return). Reject these so e.g. PageUp doesn't leak 'pageup' 59// as literal text. The length>=1 check below is intentionally loose — 60// batched input like stdin.write('abc') arrives as one multi-char e.key, 61// matching the old useInput(input) behavior where cursor.insert(input) 62// inserted the full chunk. 63const UNHANDLED_SPECIAL_KEYS = new Set([ 64 'pageup', 65 'pagedown', 66 'insert', 67 'wheelup', 68 'wheeldown', 69 'mouse', 70 'f1', 71 'f2', 72 'f3', 73 'f4', 74 'f5', 75 'f6', 76 'f7', 77 'f8', 78 'f9', 79 'f10', 80 'f11', 81 'f12', 82]) 83 84export function useSearchInput({ 85 isActive, 86 onExit, 87 onCancel, 88 onExitUp, 89 columns, 90 passthroughCtrlKeys = [], 91 initialQuery = '', 92 backspaceExitsOnEmpty = true, 93}: UseSearchInputOptions): UseSearchInputReturn { 94 const { columns: terminalColumns } = useTerminalSize() 95 const effectiveColumns = columns ?? terminalColumns 96 const [query, setQueryState] = useState(initialQuery) 97 const [cursorOffset, setCursorOffset] = useState(initialQuery.length) 98 99 const setQuery = useCallback((q: string) => { 100 setQueryState(q) 101 setCursorOffset(q.length) 102 }, []) 103 104 const handleKeyDown = (e: KeyboardEvent): void => { 105 if (!isActive) return 106 107 const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset) 108 109 // Check passthrough ctrl keys 110 if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) { 111 return 112 } 113 114 // Reset kill accumulation for non-kill keys 115 if (!isKillKey(e)) { 116 resetKillAccumulation() 117 } 118 119 // Reset yank state for non-yank keys 120 if (!isYankKey(e)) { 121 resetYankState() 122 } 123 124 // Exit conditions 125 if (e.key === 'return' || e.key === 'down') { 126 e.preventDefault() 127 onExit() 128 return 129 } 130 if (e.key === 'up') { 131 e.preventDefault() 132 if (onExitUp) { 133 onExitUp() 134 } 135 return 136 } 137 if (e.key === 'escape') { 138 e.preventDefault() 139 if (onCancel) { 140 onCancel() 141 } else if (query.length > 0) { 142 setQueryState('') 143 setCursorOffset(0) 144 } else { 145 onExit() 146 } 147 return 148 } 149 150 // Backspace/Delete 151 if (e.key === 'backspace') { 152 e.preventDefault() 153 if (e.meta) { 154 // Meta+Backspace: kill word before 155 const { cursor: newCursor, killed } = cursor.deleteWordBefore() 156 pushToKillRing(killed, 'prepend') 157 setQueryState(newCursor.text) 158 setCursorOffset(newCursor.offset) 159 return 160 } 161 if (query.length === 0) { 162 // Backspace past the / — cancel (clear + snap back), not commit. 163 // less: same. vim: deletes the / and exits command mode. 164 if (backspaceExitsOnEmpty) (onCancel ?? onExit)() 165 return 166 } 167 const newCursor = cursor.backspace() 168 setQueryState(newCursor.text) 169 setCursorOffset(newCursor.offset) 170 return 171 } 172 173 if (e.key === 'delete') { 174 e.preventDefault() 175 const newCursor = cursor.del() 176 setQueryState(newCursor.text) 177 setCursorOffset(newCursor.offset) 178 return 179 } 180 181 // Arrow keys with modifiers (word jump) 182 if (e.key === 'left' && (e.ctrl || e.meta || e.fn)) { 183 e.preventDefault() 184 const newCursor = cursor.prevWord() 185 setCursorOffset(newCursor.offset) 186 return 187 } 188 if (e.key === 'right' && (e.ctrl || e.meta || e.fn)) { 189 e.preventDefault() 190 const newCursor = cursor.nextWord() 191 setCursorOffset(newCursor.offset) 192 return 193 } 194 195 // Plain arrow keys 196 if (e.key === 'left') { 197 e.preventDefault() 198 const newCursor = cursor.left() 199 setCursorOffset(newCursor.offset) 200 return 201 } 202 if (e.key === 'right') { 203 e.preventDefault() 204 const newCursor = cursor.right() 205 setCursorOffset(newCursor.offset) 206 return 207 } 208 209 // Home/End 210 if (e.key === 'home') { 211 e.preventDefault() 212 setCursorOffset(0) 213 return 214 } 215 if (e.key === 'end') { 216 e.preventDefault() 217 setCursorOffset(query.length) 218 return 219 } 220 221 // Ctrl key bindings 222 if (e.ctrl) { 223 e.preventDefault() 224 switch (e.key.toLowerCase()) { 225 case 'a': 226 setCursorOffset(0) 227 return 228 case 'e': 229 setCursorOffset(query.length) 230 return 231 case 'b': 232 setCursorOffset(cursor.left().offset) 233 return 234 case 'f': 235 setCursorOffset(cursor.right().offset) 236 return 237 case 'd': { 238 if (query.length === 0) { 239 ;(onCancel ?? onExit)() 240 return 241 } 242 const newCursor = cursor.del() 243 setQueryState(newCursor.text) 244 setCursorOffset(newCursor.offset) 245 return 246 } 247 case 'h': { 248 if (query.length === 0) { 249 if (backspaceExitsOnEmpty) (onCancel ?? onExit)() 250 return 251 } 252 const newCursor = cursor.backspace() 253 setQueryState(newCursor.text) 254 setCursorOffset(newCursor.offset) 255 return 256 } 257 case 'k': { 258 const { cursor: newCursor, killed } = cursor.deleteToLineEnd() 259 pushToKillRing(killed, 'append') 260 setQueryState(newCursor.text) 261 setCursorOffset(newCursor.offset) 262 return 263 } 264 case 'u': { 265 const { cursor: newCursor, killed } = cursor.deleteToLineStart() 266 pushToKillRing(killed, 'prepend') 267 setQueryState(newCursor.text) 268 setCursorOffset(newCursor.offset) 269 return 270 } 271 case 'w': { 272 const { cursor: newCursor, killed } = cursor.deleteWordBefore() 273 pushToKillRing(killed, 'prepend') 274 setQueryState(newCursor.text) 275 setCursorOffset(newCursor.offset) 276 return 277 } 278 case 'y': { 279 const text = getLastKill() 280 if (text.length > 0) { 281 const startOffset = cursor.offset 282 const newCursor = cursor.insert(text) 283 recordYank(startOffset, text.length) 284 setQueryState(newCursor.text) 285 setCursorOffset(newCursor.offset) 286 } 287 return 288 } 289 case 'g': 290 case 'c': 291 // Cancel (abandon search). ctrl+g is less's cancel key. Only 292 // fires if onCancel provided — otherwise falls through and 293 // returns silently (11 call sites, most expect ctrl+c to no-op). 294 if (onCancel) { 295 onCancel() 296 return 297 } 298 } 299 return 300 } 301 302 // Meta key bindings 303 if (e.meta) { 304 e.preventDefault() 305 switch (e.key.toLowerCase()) { 306 case 'b': 307 setCursorOffset(cursor.prevWord().offset) 308 return 309 case 'f': 310 setCursorOffset(cursor.nextWord().offset) 311 return 312 case 'd': { 313 const newCursor = cursor.deleteWordAfter() 314 setQueryState(newCursor.text) 315 setCursorOffset(newCursor.offset) 316 return 317 } 318 case 'y': { 319 const popResult = yankPop() 320 if (popResult) { 321 const { text, start, length } = popResult 322 const before = query.slice(0, start) 323 const after = query.slice(start + length) 324 const newText = before + text + after 325 const newOffset = start + text.length 326 updateYankLength(text.length) 327 setQueryState(newText) 328 setCursorOffset(newOffset) 329 } 330 return 331 } 332 } 333 return 334 } 335 336 // Tab: ignore 337 if (e.key === 'tab') { 338 return 339 } 340 341 // Regular character input. Accepts multi-char e.key so batched writes 342 // (stdin.write('abc') in tests, or paste outside bracketed-paste mode) 343 // insert the full chunk — matching the old useInput behavior. 344 if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) { 345 e.preventDefault() 346 const newCursor = cursor.insert(e.key) 347 setQueryState(newCursor.text) 348 setCursorOffset(newCursor.offset) 349 } 350 } 351 352 // Backward-compat bridge: existing consumers don't yet wire handleKeyDown 353 // to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent → 354 // KeyboardEvent until all 11 call sites are migrated (separate PRs). 355 // TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown. 356 useInput( 357 (_input, _key, event) => { 358 handleKeyDown(new KeyboardEvent(event.keypress)) 359 }, 360 { isActive }, 361 ) 362 363 return { query, setQuery, cursorOffset, handleKeyDown } 364}