/ hooks / useTextInput.ts
useTextInput.ts
  1  import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
  2  import { useNotifications } from 'src/context/notifications.js'
  3  import stripAnsi from 'strip-ansi'
  4  import { markBackslashReturnUsed } from '../commands/terminalSetup/terminalSetup.js'
  5  import { addToHistory } from '../history.js'
  6  import type { Key } from '../ink.js'
  7  import type {
  8    InlineGhostText,
  9    TextInputState,
 10  } from '../types/textInputTypes.js'
 11  import {
 12    Cursor,
 13    getLastKill,
 14    pushToKillRing,
 15    recordYank,
 16    resetKillAccumulation,
 17    resetYankState,
 18    updateYankLength,
 19    yankPop,
 20  } from '../utils/Cursor.js'
 21  import { env } from '../utils/env.js'
 22  import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
 23  import type { ImageDimensions } from '../utils/imageResizer.js'
 24  import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js'
 25  import { useDoublePress } from './useDoublePress.js'
 26  
 27  type MaybeCursor = void | Cursor
 28  type InputHandler = (input: string) => MaybeCursor
 29  type InputMapper = (input: string) => MaybeCursor
 30  const NOOP_HANDLER: InputHandler = () => {}
 31  function 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  
 38  export 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  
 73  export 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  }