useTextInput.ts
hooks/useTextInput.ts
No strong subsystem tag
530
Lines
17027
Bytes
2
Exports
13
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 530 lines, 13 detected imports, and 2 detected exports.
Important relationships
Detected exports
UseTextInputPropsuseTextInput
Keywords
cursortextinputcaseentervoidbackspaceoffsetmetaoriginalvalue
Detected imports
src/components/PromptInput/inputModes.jssrc/context/notifications.jsstrip-ansi../commands/terminalSetup/terminalSetup.js../history.js../ink.js../types/textInputTypes.js../utils/Cursor.js../utils/env.js../utils/fullscreen.js../utils/imageResizer.js../utils/modifiers.js./useDoublePress.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import { isInputModeCharacter } from 'src/components/PromptInput/inputModes.js'
import { useNotifications } from 'src/context/notifications.js'
import stripAnsi from 'strip-ansi'
import { markBackslashReturnUsed } from '../commands/terminalSetup/terminalSetup.js'
import { addToHistory } from '../history.js'
import type { Key } from '../ink.js'
import type {
InlineGhostText,
TextInputState,
} from '../types/textInputTypes.js'
import {
Cursor,
getLastKill,
pushToKillRing,
recordYank,
resetKillAccumulation,
resetYankState,
updateYankLength,
yankPop,
} from '../utils/Cursor.js'
import { env } from '../utils/env.js'
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
import type { ImageDimensions } from '../utils/imageResizer.js'
import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js'
import { useDoublePress } from './useDoublePress.js'
type MaybeCursor = void | Cursor
type InputHandler = (input: string) => MaybeCursor
type InputMapper = (input: string) => MaybeCursor
const NOOP_HANDLER: InputHandler = () => {}
function mapInput(input_map: Array<[string, InputHandler]>): InputMapper {
const map = new Map(input_map)
return function (input: string): MaybeCursor {
return (map.get(input) ?? NOOP_HANDLER)(input)
}
}
export type UseTextInputProps = {
value: string
onChange: (value: string) => void
onSubmit?: (value: string) => void
onExit?: () => void
onExitMessage?: (show: boolean, key?: string) => void
onHistoryUp?: () => void
onHistoryDown?: () => void
onHistoryReset?: () => void
onClearInput?: () => void
focus?: boolean
mask?: string
multiline?: boolean
cursorChar: string
highlightPastedText?: boolean
invert: (text: string) => string
themeText: (text: string) => string
columns: number
onImagePaste?: (
base64Image: string,
mediaType?: string,
filename?: string,
dimensions?: ImageDimensions,
sourcePath?: string,
) => void
disableCursorMovementForUpDownKeys?: boolean
disableEscapeDoublePress?: boolean
maxVisibleLines?: number
externalOffset: number
onOffsetChange: (offset: number) => void
inputFilter?: (input: string, key: Key) => string
inlineGhostText?: InlineGhostText
dim?: (text: string) => string
}
export function useTextInput({
value: originalValue,
onChange,
onSubmit,
onExit,
onExitMessage,
onHistoryUp,
onHistoryDown,
onHistoryReset,
onClearInput,
mask = '',
multiline = false,
cursorChar,
invert,
columns,
onImagePaste: _onImagePaste,
disableCursorMovementForUpDownKeys = false,
disableEscapeDoublePress = false,
maxVisibleLines,
externalOffset,
onOffsetChange,
inputFilter,
inlineGhostText,
dim,
}: UseTextInputProps): TextInputState {
// Pre-warm the modifiers module for Apple Terminal (has internal guard, safe to call multiple times)
if (env.terminal === 'Apple_Terminal') {
prewarmModifiers()
}
const offset = externalOffset
const setOffset = onOffsetChange
const cursor = Cursor.fromText(originalValue, columns, offset)
const { addNotification, removeNotification } = useNotifications()
const handleCtrlC = useDoublePress(
show => {
onExitMessage?.(show, 'Ctrl-C')
},
() => onExit?.(),
() => {
if (originalValue) {
onChange('')
setOffset(0)
onHistoryReset?.()
}
},
)
// NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
// It's a text-level double-press escape for clearing input, not an action-level keybinding.
// Double-press Esc clears the input and saves to history - this is text editing behavior,
// not dialog dismissal, and needs the double-press safety mechanism.
const handleEscape = useDoublePress(
(show: boolean) => {
if (!originalValue || !show) {
return
}
addNotification({
key: 'escape-again-to-clear',
text: 'Esc again to clear',
priority: 'immediate',
timeoutMs: 1000,
})
},
() => {
// Remove the "Esc again to clear" notification immediately
removeNotification('escape-again-to-clear')
onClearInput?.()
if (originalValue) {
// Track double-escape usage for feature discovery
// Save to history before clearing
if (originalValue.trim() !== '') {
addToHistory(originalValue)
}
onChange('')
setOffset(0)
onHistoryReset?.()
}
},
)
const handleEmptyCtrlD = useDoublePress(
show => {
if (originalValue !== '') {
return
}
onExitMessage?.(show, 'Ctrl-D')
},
() => {
if (originalValue !== '') {
return
}
onExit?.()
},
)
function handleCtrlD(): MaybeCursor {
if (cursor.text === '') {
// When input is empty, handle double-press
handleEmptyCtrlD()
return cursor
}
// When input is not empty, delete forward like iPython
return cursor.del()
}
function killToLineEnd(): Cursor {
const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
pushToKillRing(killed, 'append')
return newCursor
}
function killToLineStart(): Cursor {
const { cursor: newCursor, killed } = cursor.deleteToLineStart()
pushToKillRing(killed, 'prepend')
return newCursor
}
function killWordBefore(): Cursor {
const { cursor: newCursor, killed } = cursor.deleteWordBefore()
pushToKillRing(killed, 'prepend')
return newCursor
}
function yank(): Cursor {
const text = getLastKill()
if (text.length > 0) {
const startOffset = cursor.offset
const newCursor = cursor.insert(text)
recordYank(startOffset, text.length)
return newCursor
}
return cursor
}
function handleYankPop(): Cursor {
const popResult = yankPop()
if (!popResult) {
return cursor
}
const { text, start, length } = popResult
// Replace the previously yanked text with the new one
const before = cursor.text.slice(0, start)
const after = cursor.text.slice(start + length)
const newText = before + text + after
const newOffset = start + text.length
updateYankLength(text.length)
return Cursor.fromText(newText, columns, newOffset)
}
const handleCtrl = mapInput([
['a', () => cursor.startOfLine()],
['b', () => cursor.left()],
['c', handleCtrlC],
['d', handleCtrlD],
['e', () => cursor.endOfLine()],
['f', () => cursor.right()],
['h', () => cursor.deleteTokenBefore() ?? cursor.backspace()],
['k', killToLineEnd],
['n', () => downOrHistoryDown()],
['p', () => upOrHistoryUp()],
['u', killToLineStart],
['w', killWordBefore],
['y', yank],
])
const handleMeta = mapInput([
['b', () => cursor.prevWord()],
['f', () => cursor.nextWord()],
['d', () => cursor.deleteWordAfter()],
['y', handleYankPop],
])
function handleEnter(key: Key) {
if (
multiline &&
cursor.offset > 0 &&
cursor.text[cursor.offset - 1] === '\\'
) {
// Track that the user has used backslash+return
markBackslashReturnUsed()
return cursor.backspace().insert('\n')
}
// Meta+Enter or Shift+Enter inserts a newline
if (key.meta || key.shift) {
return cursor.insert('\n')
}
// Apple Terminal doesn't support custom Shift+Enter keybindings,
// so we use native macOS modifier detection to check if Shift is held
if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
return cursor.insert('\n')
}
onSubmit?.(originalValue)
}
function upOrHistoryUp() {
if (disableCursorMovementForUpDownKeys) {
onHistoryUp?.()
return cursor
}
// Try to move by wrapped lines first
const cursorUp = cursor.up()
if (!cursorUp.equals(cursor)) {
return cursorUp
}
// If we can't move by wrapped lines and this is multiline input,
// try to move by logical lines (to handle paragraph boundaries)
if (multiline) {
const cursorUpLogical = cursor.upLogicalLine()
if (!cursorUpLogical.equals(cursor)) {
return cursorUpLogical
}
}
// Can't move up at all - trigger history navigation
onHistoryUp?.()
return cursor
}
function downOrHistoryDown() {
if (disableCursorMovementForUpDownKeys) {
onHistoryDown?.()
return cursor
}
// Try to move by wrapped lines first
const cursorDown = cursor.down()
if (!cursorDown.equals(cursor)) {
return cursorDown
}
// If we can't move by wrapped lines and this is multiline input,
// try to move by logical lines (to handle paragraph boundaries)
if (multiline) {
const cursorDownLogical = cursor.downLogicalLine()
if (!cursorDownLogical.equals(cursor)) {
return cursorDownLogical
}
}
// Can't move down at all - trigger history navigation
onHistoryDown?.()
return cursor
}
function mapKey(key: Key): InputMapper {
switch (true) {
case key.escape:
return () => {
// Skip when a keybinding context (e.g. Autocomplete) owns escape.
// useKeybindings can't shield us via stopImmediatePropagation —
// BaseTextInput's useInput registers first (child effects fire
// before parent effects), so this handler has already run by the
// time the keybinding's handler stops propagation.
if (disableEscapeDoublePress) return cursor
handleEscape()
// Return the current cursor unchanged - handleEscape manages state internally
return cursor
}
case key.leftArrow && (key.ctrl || key.meta || key.fn):
return () => cursor.prevWord()
case key.rightArrow && (key.ctrl || key.meta || key.fn):
return () => cursor.nextWord()
case key.backspace:
return key.meta || key.ctrl
? killWordBefore
: () => cursor.deleteTokenBefore() ?? cursor.backspace()
case key.delete:
return key.meta ? killToLineEnd : () => cursor.del()
case key.ctrl:
return handleCtrl
case key.home:
return () => cursor.startOfLine()
case key.end:
return () => cursor.endOfLine()
case key.pageDown:
// In fullscreen mode, PgUp/PgDn scroll the message viewport instead
// of moving the cursor — no-op here, ScrollKeybindingHandler handles it.
if (isFullscreenEnvEnabled()) {
return NOOP_HANDLER
}
return () => cursor.endOfLine()
case key.pageUp:
if (isFullscreenEnvEnabled()) {
return NOOP_HANDLER
}
return () => cursor.startOfLine()
case key.wheelUp:
case key.wheelDown:
// Mouse wheel events only exist when fullscreen mouse tracking is on.
// ScrollKeybindingHandler handles them; no-op here to avoid inserting
// the raw SGR sequence as text.
return NOOP_HANDLER
case key.return:
// Must come before key.meta so Option+Return inserts newline
return () => handleEnter(key)
case key.meta:
return handleMeta
case key.tab:
return () => cursor
case key.upArrow && !key.shift:
return upOrHistoryUp
case key.downArrow && !key.shift:
return downOrHistoryDown
case key.leftArrow:
return () => cursor.left()
case key.rightArrow:
return () => cursor.right()
default: {
return function (input: string) {
switch (true) {
// Home key
case input === '\x1b[H' || input === '\x1b[1~':
return cursor.startOfLine()
// End key
case input === '\x1b[F' || input === '\x1b[4~':
return cursor.endOfLine()
default: {
// Trailing \r after text is SSH-coalesced Enter ("o\r") —
// strip it so the Enter isn't inserted as content. Lone \r
// here is Alt+Enter leaking through (META_KEY_CODE_RE doesn't
// match \x1b\r) — leave it for the \r→\n below. Embedded \r
// is multi-line paste from a terminal without bracketed
// paste — convert to \n. Backslash+\r is a stale VS Code
// Shift+Enter binding (pre-#8991 /terminal-setup wrote
// args.text "\\\r\n" to keybindings.json); keep the \r so
// it becomes \n below (anthropics/claude-code#31316).
const text = stripAnsi(input)
// 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
.replace(/(?<=[^\\\r\n])\r$/, '')
.replace(/\r/g, '\n')
if (cursor.isAtStart() && isInputModeCharacter(input)) {
return cursor.insert(text).left()
}
return cursor.insert(text)
}
}
}
}
}
}
// Check if this is a kill command (Ctrl+K, Ctrl+U, Ctrl+W, or Meta+Backspace/Delete)
function isKillKey(key: Key, input: string): boolean {
if (key.ctrl && (input === 'k' || input === 'u' || input === 'w')) {
return true
}
if (key.meta && (key.backspace || key.delete)) {
return true
}
return false
}
// Check if this is a yank command (Ctrl+Y or Alt+Y)
function isYankKey(key: Key, input: string): boolean {
return (key.ctrl || key.meta) && input === 'y'
}
function onInput(input: string, key: Key): void {
// Note: Image paste shortcut (chat:imagePaste) is handled via useKeybindings in PromptInput
// Apply filter if provided
const filteredInput = inputFilter ? inputFilter(input, key) : input
// If the input was filtered out, do nothing
if (filteredInput === '' && input !== '') {
return
}
// Fix Issue #1853: Filter DEL characters that interfere with backspace in SSH/tmux
// In SSH/tmux environments, backspace generates both key events and raw DEL chars
if (!key.backspace && !key.delete && input.includes('\x7f')) {
const delCount = (input.match(/\x7f/g) || []).length
// Apply all DEL characters as backspace operations synchronously
// Try to delete tokens first, fall back to character backspace
let currentCursor = cursor
for (let i = 0; i < delCount; i++) {
currentCursor =
currentCursor.deleteTokenBefore() ?? currentCursor.backspace()
}
// Update state once with the final result
if (!cursor.equals(currentCursor)) {
if (cursor.text !== currentCursor.text) {
onChange(currentCursor.text)
}
setOffset(currentCursor.offset)
}
resetKillAccumulation()
resetYankState()
return
}
// Reset kill accumulation for non-kill keys
if (!isKillKey(key, filteredInput)) {
resetKillAccumulation()
}
// Reset yank state for non-yank keys (breaks yank-pop chain)
if (!isYankKey(key, filteredInput)) {
resetYankState()
}
const nextCursor = mapKey(key)(filteredInput)
if (nextCursor) {
if (!cursor.equals(nextCursor)) {
if (cursor.text !== nextCursor.text) {
onChange(nextCursor.text)
}
setOffset(nextCursor.offset)
}
// SSH-coalesced Enter: on slow links, "o" + Enter can arrive as one
// chunk "o\r". parseKeypress only matches s === '\r', so it hit the
// default handler above (which stripped the trailing \r). Text with
// exactly one trailing \r is coalesced Enter; lone \r is Alt+Enter
// (newline); embedded \r is multi-line paste.
if (
filteredInput.length > 1 &&
filteredInput.endsWith('\r') &&
!filteredInput.slice(0, -1).includes('\r') &&
// Backslash+CR is a stale VS Code Shift+Enter binding, not
// coalesced Enter. See default handler above.
filteredInput[filteredInput.length - 2] !== '\\'
) {
onSubmit?.(nextCursor.text)
}
}
}
// Prepare ghost text for rendering - validate insertPosition matches current
// cursor offset to prevent stale ghost text from a previous keystroke causing
// a one-frame jitter (ghost text state is updated via useEffect after render)
const ghostTextForRender =
inlineGhostText && dim && inlineGhostText.insertPosition === offset
? { text: inlineGhostText.text, dim }
: undefined
const cursorPos = cursor.getPosition()
return {
onInput,
renderedValue: cursor.render(
cursorChar,
mask,
invert,
ghostTextForRender,
maxVisibleLines,
),
offset,
setOffset,
cursorLine: cursorPos.line - cursor.getViewportStartLine(maxVisibleLines),
cursorColumn: cursorPos.column,
viewportCharOffset: cursor.getViewportCharOffset(maxVisibleLines),
viewportCharEnd: cursor.getViewportCharEnd(maxVisibleLines),
}
}