init
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user