530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
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),
|
|
}
|
|
}
|