source dump of claude code
at main 529 lines 17 kB view raw
1import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js' 2import { useNotifications } from 'src/context/notifications.js' 3import stripAnsi from 'strip-ansi' 4import { markBackslashReturnUsed } from '../commands/terminalSetup/terminalSetup.js' 5import { addToHistory } from '../history.js' 6import type { Key } from '../ink.js' 7import type { 8 InlineGhostText, 9 TextInputState, 10} from '../types/textInputTypes.js' 11import { 12 Cursor, 13 getLastKill, 14 pushToKillRing, 15 recordYank, 16 resetKillAccumulation, 17 resetYankState, 18 updateYankLength, 19 yankPop, 20} from '../utils/Cursor.js' 21import { env } from '../utils/env.js' 22import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' 23import type { ImageDimensions } from '../utils/imageResizer.js' 24import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js' 25import { useDoublePress } from './useDoublePress.js' 26 27type MaybeCursor = void | Cursor 28type InputHandler = (input: string) => MaybeCursor 29type InputMapper = (input: string) => MaybeCursor 30const NOOP_HANDLER: InputHandler = () => {} 31function mapInput(input_map: Array<[string, InputHandler]>): InputMapper { 32 const map = new Map(input_map) 33 return function (input: string): MaybeCursor { 34 return (map.get(input) ?? NOOP_HANDLER)(input) 35 } 36} 37 38export type UseTextInputProps = { 39 value: string 40 onChange: (value: string) => void 41 onSubmit?: (value: string) => void 42 onExit?: () => void 43 onExitMessage?: (show: boolean, key?: string) => void 44 onHistoryUp?: () => void 45 onHistoryDown?: () => void 46 onHistoryReset?: () => void 47 onClearInput?: () => void 48 focus?: boolean 49 mask?: string 50 multiline?: boolean 51 cursorChar: string 52 highlightPastedText?: boolean 53 invert: (text: string) => string 54 themeText: (text: string) => string 55 columns: number 56 onImagePaste?: ( 57 base64Image: string, 58 mediaType?: string, 59 filename?: string, 60 dimensions?: ImageDimensions, 61 sourcePath?: string, 62 ) => void 63 disableCursorMovementForUpDownKeys?: boolean 64 disableEscapeDoublePress?: boolean 65 maxVisibleLines?: number 66 externalOffset: number 67 onOffsetChange: (offset: number) => void 68 inputFilter?: (input: string, key: Key) => string 69 inlineGhostText?: InlineGhostText 70 dim?: (text: string) => string 71} 72 73export function useTextInput({ 74 value: originalValue, 75 onChange, 76 onSubmit, 77 onExit, 78 onExitMessage, 79 onHistoryUp, 80 onHistoryDown, 81 onHistoryReset, 82 onClearInput, 83 mask = '', 84 multiline = false, 85 cursorChar, 86 invert, 87 columns, 88 onImagePaste: _onImagePaste, 89 disableCursorMovementForUpDownKeys = false, 90 disableEscapeDoublePress = false, 91 maxVisibleLines, 92 externalOffset, 93 onOffsetChange, 94 inputFilter, 95 inlineGhostText, 96 dim, 97}: UseTextInputProps): TextInputState { 98 // Pre-warm the modifiers module for Apple Terminal (has internal guard, safe to call multiple times) 99 if (env.terminal === 'Apple_Terminal') { 100 prewarmModifiers() 101 } 102 103 const offset = externalOffset 104 const setOffset = onOffsetChange 105 const cursor = Cursor.fromText(originalValue, columns, offset) 106 const { addNotification, removeNotification } = useNotifications() 107 108 const handleCtrlC = useDoublePress( 109 show => { 110 onExitMessage?.(show, 'Ctrl-C') 111 }, 112 () => onExit?.(), 113 () => { 114 if (originalValue) { 115 onChange('') 116 setOffset(0) 117 onHistoryReset?.() 118 } 119 }, 120 ) 121 122 // NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system. 123 // It's a text-level double-press escape for clearing input, not an action-level keybinding. 124 // Double-press Esc clears the input and saves to history - this is text editing behavior, 125 // not dialog dismissal, and needs the double-press safety mechanism. 126 const handleEscape = useDoublePress( 127 (show: boolean) => { 128 if (!originalValue || !show) { 129 return 130 } 131 addNotification({ 132 key: 'escape-again-to-clear', 133 text: 'Esc again to clear', 134 priority: 'immediate', 135 timeoutMs: 1000, 136 }) 137 }, 138 () => { 139 // Remove the "Esc again to clear" notification immediately 140 removeNotification('escape-again-to-clear') 141 onClearInput?.() 142 if (originalValue) { 143 // Track double-escape usage for feature discovery 144 // Save to history before clearing 145 if (originalValue.trim() !== '') { 146 addToHistory(originalValue) 147 } 148 onChange('') 149 setOffset(0) 150 onHistoryReset?.() 151 } 152 }, 153 ) 154 155 const handleEmptyCtrlD = useDoublePress( 156 show => { 157 if (originalValue !== '') { 158 return 159 } 160 onExitMessage?.(show, 'Ctrl-D') 161 }, 162 () => { 163 if (originalValue !== '') { 164 return 165 } 166 onExit?.() 167 }, 168 ) 169 170 function handleCtrlD(): MaybeCursor { 171 if (cursor.text === '') { 172 // When input is empty, handle double-press 173 handleEmptyCtrlD() 174 return cursor 175 } 176 // When input is not empty, delete forward like iPython 177 return cursor.del() 178 } 179 180 function killToLineEnd(): Cursor { 181 const { cursor: newCursor, killed } = cursor.deleteToLineEnd() 182 pushToKillRing(killed, 'append') 183 return newCursor 184 } 185 186 function killToLineStart(): Cursor { 187 const { cursor: newCursor, killed } = cursor.deleteToLineStart() 188 pushToKillRing(killed, 'prepend') 189 return newCursor 190 } 191 192 function killWordBefore(): Cursor { 193 const { cursor: newCursor, killed } = cursor.deleteWordBefore() 194 pushToKillRing(killed, 'prepend') 195 return newCursor 196 } 197 198 function yank(): Cursor { 199 const text = getLastKill() 200 if (text.length > 0) { 201 const startOffset = cursor.offset 202 const newCursor = cursor.insert(text) 203 recordYank(startOffset, text.length) 204 return newCursor 205 } 206 return cursor 207 } 208 209 function handleYankPop(): Cursor { 210 const popResult = yankPop() 211 if (!popResult) { 212 return cursor 213 } 214 const { text, start, length } = popResult 215 // Replace the previously yanked text with the new one 216 const before = cursor.text.slice(0, start) 217 const after = cursor.text.slice(start + length) 218 const newText = before + text + after 219 const newOffset = start + text.length 220 updateYankLength(text.length) 221 return Cursor.fromText(newText, columns, newOffset) 222 } 223 224 const handleCtrl = mapInput([ 225 ['a', () => cursor.startOfLine()], 226 ['b', () => cursor.left()], 227 ['c', handleCtrlC], 228 ['d', handleCtrlD], 229 ['e', () => cursor.endOfLine()], 230 ['f', () => cursor.right()], 231 ['h', () => cursor.deleteTokenBefore() ?? cursor.backspace()], 232 ['k', killToLineEnd], 233 ['n', () => downOrHistoryDown()], 234 ['p', () => upOrHistoryUp()], 235 ['u', killToLineStart], 236 ['w', killWordBefore], 237 ['y', yank], 238 ]) 239 240 const handleMeta = mapInput([ 241 ['b', () => cursor.prevWord()], 242 ['f', () => cursor.nextWord()], 243 ['d', () => cursor.deleteWordAfter()], 244 ['y', handleYankPop], 245 ]) 246 247 function handleEnter(key: Key) { 248 if ( 249 multiline && 250 cursor.offset > 0 && 251 cursor.text[cursor.offset - 1] === '\\' 252 ) { 253 // Track that the user has used backslash+return 254 markBackslashReturnUsed() 255 return cursor.backspace().insert('\n') 256 } 257 // Meta+Enter or Shift+Enter inserts a newline 258 if (key.meta || key.shift) { 259 return cursor.insert('\n') 260 } 261 // Apple Terminal doesn't support custom Shift+Enter keybindings, 262 // so we use native macOS modifier detection to check if Shift is held 263 if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) { 264 return cursor.insert('\n') 265 } 266 onSubmit?.(originalValue) 267 } 268 269 function upOrHistoryUp() { 270 if (disableCursorMovementForUpDownKeys) { 271 onHistoryUp?.() 272 return cursor 273 } 274 // Try to move by wrapped lines first 275 const cursorUp = cursor.up() 276 if (!cursorUp.equals(cursor)) { 277 return cursorUp 278 } 279 280 // If we can't move by wrapped lines and this is multiline input, 281 // try to move by logical lines (to handle paragraph boundaries) 282 if (multiline) { 283 const cursorUpLogical = cursor.upLogicalLine() 284 if (!cursorUpLogical.equals(cursor)) { 285 return cursorUpLogical 286 } 287 } 288 289 // Can't move up at all - trigger history navigation 290 onHistoryUp?.() 291 return cursor 292 } 293 function downOrHistoryDown() { 294 if (disableCursorMovementForUpDownKeys) { 295 onHistoryDown?.() 296 return cursor 297 } 298 // Try to move by wrapped lines first 299 const cursorDown = cursor.down() 300 if (!cursorDown.equals(cursor)) { 301 return cursorDown 302 } 303 304 // If we can't move by wrapped lines and this is multiline input, 305 // try to move by logical lines (to handle paragraph boundaries) 306 if (multiline) { 307 const cursorDownLogical = cursor.downLogicalLine() 308 if (!cursorDownLogical.equals(cursor)) { 309 return cursorDownLogical 310 } 311 } 312 313 // Can't move down at all - trigger history navigation 314 onHistoryDown?.() 315 return cursor 316 } 317 318 function mapKey(key: Key): InputMapper { 319 switch (true) { 320 case key.escape: 321 return () => { 322 // Skip when a keybinding context (e.g. Autocomplete) owns escape. 323 // useKeybindings can't shield us via stopImmediatePropagation — 324 // BaseTextInput's useInput registers first (child effects fire 325 // before parent effects), so this handler has already run by the 326 // time the keybinding's handler stops propagation. 327 if (disableEscapeDoublePress) return cursor 328 handleEscape() 329 // Return the current cursor unchanged - handleEscape manages state internally 330 return cursor 331 } 332 case key.leftArrow && (key.ctrl || key.meta || key.fn): 333 return () => cursor.prevWord() 334 case key.rightArrow && (key.ctrl || key.meta || key.fn): 335 return () => cursor.nextWord() 336 case key.backspace: 337 return key.meta || key.ctrl 338 ? killWordBefore 339 : () => cursor.deleteTokenBefore() ?? cursor.backspace() 340 case key.delete: 341 return key.meta ? killToLineEnd : () => cursor.del() 342 case key.ctrl: 343 return handleCtrl 344 case key.home: 345 return () => cursor.startOfLine() 346 case key.end: 347 return () => cursor.endOfLine() 348 case key.pageDown: 349 // In fullscreen mode, PgUp/PgDn scroll the message viewport instead 350 // of moving the cursor — no-op here, ScrollKeybindingHandler handles it. 351 if (isFullscreenEnvEnabled()) { 352 return NOOP_HANDLER 353 } 354 return () => cursor.endOfLine() 355 case key.pageUp: 356 if (isFullscreenEnvEnabled()) { 357 return NOOP_HANDLER 358 } 359 return () => cursor.startOfLine() 360 case key.wheelUp: 361 case key.wheelDown: 362 // Mouse wheel events only exist when fullscreen mouse tracking is on. 363 // ScrollKeybindingHandler handles them; no-op here to avoid inserting 364 // the raw SGR sequence as text. 365 return NOOP_HANDLER 366 case key.return: 367 // Must come before key.meta so Option+Return inserts newline 368 return () => handleEnter(key) 369 case key.meta: 370 return handleMeta 371 case key.tab: 372 return () => cursor 373 case key.upArrow && !key.shift: 374 return upOrHistoryUp 375 case key.downArrow && !key.shift: 376 return downOrHistoryDown 377 case key.leftArrow: 378 return () => cursor.left() 379 case key.rightArrow: 380 return () => cursor.right() 381 default: { 382 return function (input: string) { 383 switch (true) { 384 // Home key 385 case input === '\x1b[H' || input === '\x1b[1~': 386 return cursor.startOfLine() 387 // End key 388 case input === '\x1b[F' || input === '\x1b[4~': 389 return cursor.endOfLine() 390 default: { 391 // Trailing \r after text is SSH-coalesced Enter ("o\r") — 392 // strip it so the Enter isn't inserted as content. Lone \r 393 // here is Alt+Enter leaking through (META_KEY_CODE_RE doesn't 394 // match \x1b\r) — leave it for the \r→\n below. Embedded \r 395 // is multi-line paste from a terminal without bracketed 396 // paste — convert to \n. Backslash+\r is a stale VS Code 397 // Shift+Enter binding (pre-#8991 /terminal-setup wrote 398 // args.text "\\\r\n" to keybindings.json); keep the \r so 399 // it becomes \n below (anthropics/claude-code#31316). 400 const text = stripAnsi(input) 401 // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, str) on 1-2 char keystrokes: no-match returns same string (Object.is), regex never runs 402 .replace(/(?<=[^\\\r\n])\r$/, '') 403 .replace(/\r/g, '\n') 404 if (cursor.isAtStart() && isInputModeCharacter(input)) { 405 return cursor.insert(text).left() 406 } 407 return cursor.insert(text) 408 } 409 } 410 } 411 } 412 } 413 } 414 415 // Check if this is a kill command (Ctrl+K, Ctrl+U, Ctrl+W, or Meta+Backspace/Delete) 416 function isKillKey(key: Key, input: string): boolean { 417 if (key.ctrl && (input === 'k' || input === 'u' || input === 'w')) { 418 return true 419 } 420 if (key.meta && (key.backspace || key.delete)) { 421 return true 422 } 423 return false 424 } 425 426 // Check if this is a yank command (Ctrl+Y or Alt+Y) 427 function isYankKey(key: Key, input: string): boolean { 428 return (key.ctrl || key.meta) && input === 'y' 429 } 430 431 function onInput(input: string, key: Key): void { 432 // Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput 433 434 // Apply filter if provided 435 const filteredInput = inputFilter ? inputFilter(input, key) : input 436 437 // If the input was filtered out, do nothing 438 if (filteredInput === '' && input !== '') { 439 return 440 } 441 442 // Fix Issue #1853: Filter DEL characters that interfere with backspace in SSH/tmux 443 // In SSH/tmux environments, backspace generates both key events and raw DEL chars 444 if (!key.backspace && !key.delete && input.includes('\x7f')) { 445 const delCount = (input.match(/\x7f/g) || []).length 446 447 // Apply all DEL characters as backspace operations synchronously 448 // Try to delete tokens first, fall back to character backspace 449 let currentCursor = cursor 450 for (let i = 0; i < delCount; i++) { 451 currentCursor = 452 currentCursor.deleteTokenBefore() ?? currentCursor.backspace() 453 } 454 455 // Update state once with the final result 456 if (!cursor.equals(currentCursor)) { 457 if (cursor.text !== currentCursor.text) { 458 onChange(currentCursor.text) 459 } 460 setOffset(currentCursor.offset) 461 } 462 resetKillAccumulation() 463 resetYankState() 464 return 465 } 466 467 // Reset kill accumulation for non-kill keys 468 if (!isKillKey(key, filteredInput)) { 469 resetKillAccumulation() 470 } 471 472 // Reset yank state for non-yank keys (breaks yank-pop chain) 473 if (!isYankKey(key, filteredInput)) { 474 resetYankState() 475 } 476 477 const nextCursor = mapKey(key)(filteredInput) 478 if (nextCursor) { 479 if (!cursor.equals(nextCursor)) { 480 if (cursor.text !== nextCursor.text) { 481 onChange(nextCursor.text) 482 } 483 setOffset(nextCursor.offset) 484 } 485 // SSH-coalesced Enter: on slow links, "o" + Enter can arrive as one 486 // chunk "o\r". parseKeypress only matches s === '\r', so it hit the 487 // default handler above (which stripped the trailing \r). Text with 488 // exactly one trailing \r is coalesced Enter; lone \r is Alt+Enter 489 // (newline); embedded \r is multi-line paste. 490 if ( 491 filteredInput.length > 1 && 492 filteredInput.endsWith('\r') && 493 !filteredInput.slice(0, -1).includes('\r') && 494 // Backslash+CR is a stale VS Code Shift+Enter binding, not 495 // coalesced Enter. See default handler above. 496 filteredInput[filteredInput.length - 2] !== '\\' 497 ) { 498 onSubmit?.(nextCursor.text) 499 } 500 } 501 } 502 503 // Prepare ghost text for rendering - validate insertPosition matches current 504 // cursor offset to prevent stale ghost text from a previous keystroke causing 505 // a one-frame jitter (ghost text state is updated via useEffect after render) 506 const ghostTextForRender = 507 inlineGhostText && dim && inlineGhostText.insertPosition === offset 508 ? { text: inlineGhostText.text, dim } 509 : undefined 510 511 const cursorPos = cursor.getPosition() 512 513 return { 514 onInput, 515 renderedValue: cursor.render( 516 cursorChar, 517 mask, 518 invert, 519 ghostTextForRender, 520 maxVisibleLines, 521 ), 522 offset, 523 setOffset, 524 cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines), 525 cursorColumn: cursorPos.column, 526 viewportCharOffset: cursor.getViewportCharOffset(maxVisibleLines), 527 viewportCharEnd: cursor.getViewportCharEnd(maxVisibleLines), 528 } 529}