// Paste-aware text input wrapper that: // 1. Detects large pastes (>5 lines or >500 chars) and replaces with placeholders // 2. Supports image pasting (iTerm2 inline, data URLs, file paths, macOS clipboard) // 3. Maintains separate display value (with placeholders) vs actual value (full content) // 4. Resolves placeholders on submit // Import useInput from vendored Ink for bracketed paste support import { useInput, useStdin } from "ink"; import RawTextInput from "ink-text-input"; import { useEffect, useRef, useState } from "react"; import { translatePasteForImages, tryImportClipboardImageMac, } from "../helpers/clipboard"; import { allocatePaste, resolvePlaceholders } from "../helpers/pasteRegistry"; // Global timestamp for forward delete coordination // Use globalThis to ensure singleton across bundle declare global { // eslint-disable-next-line no-var var __lettaForwardDeleteTimestamp: number | undefined; } interface PasteAwareTextInputProps { value: string; onChange: (value: string) => void; onSubmit?: (value: string) => void; placeholder?: string; focus?: boolean; cursorPosition?: number; onCursorMove?: (position: number) => void; /** * Called when the user presses `!` while the input is empty. * Return true to consume the keystroke (it will NOT appear in the input). */ onBangAtEmpty?: () => boolean; /** * Called when the user presses Backspace while the input is empty. * Return true to consume the keystroke. */ onBackspaceAtEmpty?: () => boolean; } function countLines(text: string): number { return (text.match(/\r\n|\r|\n/g) || []).length + 1; } /** Replace newlines with visual indicator for display */ function sanitizeForDisplay(text: string): string { return text.replace(/\r\n|\r|\n/g, "↵"); } /** Find the boundary of the previous word for option+left navigation */ function findPreviousWordBoundary(text: string, cursorPos: number): number { if (cursorPos === 0) return 0; // Move back one position if we're at the end of a word let pos = cursorPos - 1; // Skip whitespace backwards while (pos > 0 && /\s/.test(text.charAt(pos))) { pos--; } // Skip word characters backwards while (pos > 0 && /\S/.test(text.charAt(pos))) { pos--; } // If we stopped at whitespace, move forward one if (pos > 0 && /\s/.test(text.charAt(pos))) { pos++; } return Math.max(0, pos); } /** Find the boundary of the next word for option+right navigation */ function findNextWordBoundary(text: string, cursorPos: number): number { if (cursorPos >= text.length) return text.length; let pos = cursorPos; // Skip current word forward while (pos < text.length && /\S/.test(text.charAt(pos))) { pos++; } // Skip whitespace forward while (pos < text.length && /\s/.test(text.charAt(pos))) { pos++; } return pos; } type WordDirection = "left" | "right"; // biome-ignore lint/suspicious/noControlCharactersInRegex: Terminal escape sequences require ESC control character const OPTION_LEFT_PATTERN = /^\u001b\[(?:1;)?(?:3|4|7|8|9)D$/; // biome-ignore lint/suspicious/noControlCharactersInRegex: Terminal escape sequences require ESC control character const OPTION_RIGHT_PATTERN = /^\u001b\[(?:1;)?(?:3|4|7|8|9)C$/; function detectOptionWordDirection(sequence: string): WordDirection | null { if (!sequence.startsWith("\u001b")) return null; if (sequence === "\u001bb" || sequence === "\u001bB") return "left"; if (sequence === "\u001bf" || sequence === "\u001bF") return "right"; if (OPTION_LEFT_PATTERN.test(sequence)) return "left"; if (OPTION_RIGHT_PATTERN.test(sequence)) return "right"; return null; } export function PasteAwareTextInput({ value, onChange, onSubmit, placeholder, focus = true, cursorPosition, onCursorMove, onBangAtEmpty, onBackspaceAtEmpty, }: PasteAwareTextInputProps) { const { internal_eventEmitter } = useStdin(); const [displayValue, setDisplayValue] = useState(value); const [actualValue, setActualValue] = useState(value); const lastPasteDetectedAtRef = useRef(0); const caretOffsetRef = useRef((value || "").length); const [nudgeCursorOffset, setNudgeCursorOffset] = useState< number | undefined >(undefined); const displayValueRef = useRef(displayValue); const focusRef = useRef(focus); useEffect(() => { displayValueRef.current = displayValue; }, [displayValue]); useEffect(() => { focusRef.current = focus; }, [focus]); // Apply cursor position from parent useEffect(() => { if (typeof cursorPosition === "number") { setNudgeCursorOffset(cursorPosition); caretOffsetRef.current = cursorPosition; } }, [cursorPosition]); const TextInputAny = RawTextInput as unknown as React.ComponentType<{ value: string; onChange: (value: string) => void; onSubmit?: (value: string) => void; placeholder?: string; focus?: boolean; externalCursorOffset?: number; onCursorOffsetChange?: (n: number) => void; }>; // Sync external value changes (treat incoming value as DISPLAY value) useEffect(() => { setDisplayValue(value); // Recompute ACTUAL by substituting placeholders via shared registry const resolved = resolvePlaceholders(value); setActualValue(resolved); // Keep caret in bounds when parent updates value (e.g. clearing input). // This also ensures mode-switch hotkeys that depend on caret position behave correctly. const nextCaret = Math.max( 0, Math.min(caretOffsetRef.current, value.length), ); if (nextCaret !== caretOffsetRef.current) { setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } }, [value]); // Intercept paste events and macOS fallback for image clipboard imports useInput( (input, key) => { // Handle Shift/Option/Ctrl + Enter to insert newline if (key.return && (key.shift || key.meta || key.ctrl)) { const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValueRef.current.length), ); // Insert actual \n for visual newline (cursor moves to new line) const newValue = displayValueRef.current.slice(0, at) + "\n" + displayValueRef.current.slice(at); setDisplayValue(newValue); setActualValue(newValue); // Display and actual are same (both have \n) onChangeRef.current(newValue); const nextCaret = at + 1; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; return; } // Handle Ctrl+V to check clipboard for images (works in all terminals) // Native terminals don't send image data via bracketed paste, so we need // to explicitly check the clipboard when Ctrl+V is pressed. if (key.ctrl && input === "v") { const clip = tryImportClipboardImageMac(); if (clip) { const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValueRef.current.length), ); const newDisplay = displayValueRef.current.slice(0, at) + clip + displayValueRef.current.slice(at); displayValueRef.current = newDisplay; setDisplayValue(newDisplay); setActualValue(newDisplay); onChangeRef.current(newDisplay); const nextCaret = at + clip.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } // Don't return - let it fall through to normal paste handling // in case there's also text in the clipboard return; } // Handle bracketed paste events emitted by vendored Ink const isPasted = (key as unknown as { isPasted?: boolean })?.isPasted; if (isPasted) { lastPasteDetectedAtRef.current = Date.now(); const payload = typeof input === "string" ? input : ""; // Translate any image payloads in the paste (OSC 1337, data URLs, file paths) let translated = translatePasteForImages(payload); // If paste event carried no text (common for image-only clipboard), try macOS import if ((!translated || translated.length === 0) && payload.length === 0) { const clip = tryImportClipboardImageMac(); if (clip) translated = clip; } if (translated && translated.length > 0) { // Insert at current caret position const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValue.length), ); const isLarge = countLines(translated) > 5 || translated.length > 500; if (isLarge) { const pasteId = allocatePaste(translated); const placeholder = `[Pasted text #${pasteId} +${countLines(translated)} lines]`; const newDisplay = displayValue.slice(0, at) + placeholder + displayValue.slice(at); const newActual = actualValue.slice(0, at) + translated + actualValue.slice(at); setDisplayValue(newDisplay); setActualValue(newActual); onChange(newDisplay); const nextCaret = at + placeholder.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } else { const displayText = sanitizeForDisplay(translated); const newDisplay = displayValue.slice(0, at) + displayText + displayValue.slice(at); const newActual = actualValue.slice(0, at) + translated + actualValue.slice(at); setDisplayValue(newDisplay); setActualValue(newActual); onChange(newDisplay); const nextCaret = at + displayText.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } return; } // If nothing to insert, fall through } if ( (key.meta && (input === "v" || input === "V")) || (key.ctrl && key.shift && (input === "v" || input === "V")) ) { const placeholder = tryImportClipboardImageMac(); if (placeholder) { const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValue.length), ); const newDisplay = displayValue.slice(0, at) + placeholder + displayValue.slice(at); const newActual = actualValue.slice(0, at) + placeholder + actualValue.slice(at); setDisplayValue(newDisplay); setActualValue(newActual); onChange(newDisplay); const nextCaret = at + placeholder.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } } // Backspace on empty input - handle here since handleChange won't fire // (value doesn't change when backspacing on empty) // Use ref to avoid stale closure issues // Note: On macOS, backspace sends \x7f which Ink parses as "delete", not "backspace" if ((key.backspace || key.delete) && displayValueRef.current === "") { onBackspaceAtEmptyRef.current?.(); return; } }, { isActive: focus }, ); // Store callbacks in refs to avoid stale closures in event handlers const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); const onBackspaceAtEmptyRef = useRef(onBackspaceAtEmpty); useEffect(() => { onBackspaceAtEmptyRef.current = onBackspaceAtEmpty; }, [onBackspaceAtEmpty]); // Consolidated raw stdin handler for Option+Arrow navigation and Option+Delete // Uses internal_eventEmitter (Ink's private API) for escape sequences that useInput doesn't parse correctly. // Falls back gracefully if internal_eventEmitter is unavailable (useInput handler above still works for some cases). useEffect(() => { if (!internal_eventEmitter) return undefined; const moveCursorToPreviousWord = () => { const newPos = findPreviousWordBoundary( displayValueRef.current, caretOffsetRef.current, ); setNudgeCursorOffset(newPos); caretOffsetRef.current = newPos; }; const moveCursorToNextWord = () => { const newPos = findNextWordBoundary( displayValueRef.current, caretOffsetRef.current, ); setNudgeCursorOffset(newPos); caretOffsetRef.current = newPos; }; const deletePreviousWord = () => { const curPos = caretOffsetRef.current; const wordStart = findPreviousWordBoundary( displayValueRef.current, curPos, ); if (wordStart === curPos) return; const newDisplay = displayValueRef.current.slice(0, wordStart) + displayValueRef.current.slice(curPos); const resolvedActual = resolvePlaceholders(newDisplay); setDisplayValue(newDisplay); setActualValue(resolvedActual); onChangeRef.current(newDisplay); setNudgeCursorOffset(wordStart); caretOffsetRef.current = wordStart; }; // Forward delete: delete character AFTER cursor const forwardDeleteAtCursor = (cursorPos: number) => { if (cursorPos >= displayValueRef.current.length) return; const newDisplay = displayValueRef.current.slice(0, cursorPos) + displayValueRef.current.slice(cursorPos + 1); const resolvedActual = resolvePlaceholders(newDisplay); // Update refs synchronously for consecutive operations displayValueRef.current = newDisplay; caretOffsetRef.current = cursorPos; setDisplayValue(newDisplay); setActualValue(resolvedActual); onChangeRef.current(newDisplay); // Cursor stays in place, sync it setNudgeCursorOffset(cursorPos); }; const insertNewlineAtCursor = () => { const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValueRef.current.length), ); const newValue = displayValueRef.current.slice(0, at) + "\n" + displayValueRef.current.slice(at); setDisplayValue(newValue); setActualValue(newValue); onChangeRef.current(newValue); const nextCaret = at + 1; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; }; const handleRawInput = (payload: unknown) => { if (!focusRef.current) return; // Extract sequence from payload (may be string or object with sequence property) let sequence: string | null = null; if (typeof payload === "string") { sequence = payload; } else if ( payload && typeof payload === "object" && typeof (payload as { sequence?: unknown }).sequence === "string" ) { sequence = (payload as { sequence?: string }).sequence ?? null; } if (!sequence) return; // Optional debug logging for raw input bytes if (process.env.LETTA_DEBUG_INPUT === "1") { const debugHex = [...sequence] .map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`) .join(" "); // eslint-disable-next-line no-console console.error( `[debug:raw-input] len=${sequence.length} hex: ${debugHex}`, ); } // Option+Enter (Alt+Enter): ESC + carriage return // On macOS with "Option as Meta" enabled, this sends \x1b\r // Also check for \x1b\n (ESC + newline) for compatibility if (sequence === "\x1b\r" || sequence === "\x1b\n") { insertNewlineAtCursor(); return; } // VS Code/Cursor terminal keybinding style: // Often configured to send a literal "\\r" sequence for Shift+Enter. // Treat it as newline. if (sequence === "\\r") { insertNewlineAtCursor(); return; } // CSI u modifier+Enter (ESC[13;Nu) is now handled by the CSI u fallback // in use-input.js, which parses it as return + shift/ctrl/meta flags. // The useInput handler at line 186 then handles the newline insertion. // Note: Arrow keys with modifiers are now handled natively by parseKeypress // since we use kitty protocol flag 1 only (no event types). // With flag 1, arrows come as ESC[1;modifierD which parseKeypress recognizes. // Previously we handled ESC[1;modifier:eventD format (with flag 7) here. // fn+Delete (forward delete): ESC[3~ - standard ANSI escape sequence // With kitty flag 1, modifiers come as ESC[3;modifier~ (no event type). // Use caretOffsetRef which is updated synchronously via onCursorOffsetChange // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence matching if (sequence === "\x1b[3~" || /^\x1b\[3;\d+~$/.test(sequence)) { // Set timestamp so ink-text-input skips its delete handling globalThis.__lettaForwardDeleteTimestamp = Date.now(); forwardDeleteAtCursor(caretOffsetRef.current); return; } // Option+Delete sequences (check first as they're exact matches) // - iTerm2/some terminals: ESC + DEL (\x1b\x7f) // - Some terminals: ESC + Backspace (\x1b\x08) // - Warp: Ctrl+W (\x17) // Note: macOS Terminal sends plain \x7f (same as regular delete) - no modifier info if ( sequence === "\x1b\x7f" || sequence === "\x1b\x08" || sequence === "\x1b\b" || sequence === "\x17" ) { deletePreviousWord(); return; } // Option+Arrow navigation (only process escape sequences) if (sequence.length <= 32 && sequence.includes("\u001b")) { const parts = sequence.split("\u001b"); for (let i = 1; i < parts.length; i++) { const dir = detectOptionWordDirection(`\u001b${parts[i]}`); if (dir === "left") { moveCursorToPreviousWord(); return; } if (dir === "right") { moveCursorToNextWord(); return; } } } }; internal_eventEmitter.prependListener("input", handleRawInput); return () => { internal_eventEmitter.removeListener("input", handleRawInput); }; }, [internal_eventEmitter]); const handleChange = (newValue: string) => { // Bash mode entry: intercept "!" typed on empty input BEFORE updating state // This prevents any flicker since we never commit the "!" to displayValue if (displayValue === "" && newValue === "!") { if (onBangAtEmpty?.()) { // Parent handled it (entered bash mode) - don't update our state return; } } // Drop lone escape characters that Ink's text input would otherwise insert; // they are used as control keys for double-escape handling and should not // mutate the input value. const sanitizedValue = newValue.replaceAll("\u001b", ""); if (sanitizedValue !== newValue) { // Keep caret in bounds after stripping control chars const nextCaret = Math.min(caretOffsetRef.current, sanitizedValue.length); setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; newValue = sanitizedValue; // If nothing actually changed after stripping, bail out early if (sanitizedValue === displayValue) { return; } } // Heuristic: detect large additions that look like pastes const addedLen = newValue.length - displayValue.length; const lineDelta = countLines(newValue) - countLines(displayValue); const sincePasteMs = Date.now() - lastPasteDetectedAtRef.current; // If we see a large addition (and it's not too soon after the last paste), treat it as a paste if ( sincePasteMs > 1000 && addedLen > 0 && (addedLen > 500 || lineDelta > 5) ) { lastPasteDetectedAtRef.current = Date.now(); // Compute inserted segment via longest common prefix/suffix const a = displayValue; const b = newValue; let lcp = 0; while (lcp < a.length && lcp < b.length && a[lcp] === b[lcp]) lcp++; let lcs = 0; while ( lcs < a.length - lcp && lcs < b.length - lcp && a[a.length - 1 - lcs] === b[b.length - 1 - lcs] ) lcs++; const inserted = b.slice(lcp, b.length - lcs); // Translate any image payloads in the inserted text (run always for reliability) const translated = translatePasteForImages(inserted); const translatedLines = countLines(translated); const translatedChars = translated.length; // If translated text is still large, create a placeholder if (translatedLines > 5 || translatedChars > 500) { const pasteId = allocatePaste(translated); const placeholder = `[Pasted text #${pasteId} +${translatedLines} lines]`; const newDisplayValue = a.slice(0, lcp) + placeholder + a.slice(a.length - lcs); const newActualValue = actualValue.slice(0, lcp) + translated + actualValue.slice(actualValue.length - lcs); setDisplayValue(newDisplayValue); setActualValue(newActualValue); onChange(newDisplayValue); const nextCaret = lcp + placeholder.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; return; } // Otherwise, insert the translated text inline (sanitize newlines for display) const displayText = sanitizeForDisplay(translated); const newDisplayValue = a.slice(0, lcp) + displayText + a.slice(a.length - lcs); const newActualValue = actualValue.slice(0, lcp) + translated + actualValue.slice(actualValue.length - lcs); setDisplayValue(newDisplayValue); setActualValue(newActualValue); onChange(newDisplayValue); const nextCaret = lcp + displayText.length; setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; return; } // Normal typing/edits - update display and compute actual by substituting placeholders // Update displayValueRef synchronously for raw input handlers displayValueRef.current = newValue; setDisplayValue(newValue); const resolved = resolvePlaceholders(newValue); setActualValue(resolved); onChange(newValue); // Note: caretOffsetRef is updated by onCursorOffsetChange callback (called before onChange) }; const handleSubmit = () => { if (onSubmit) { // Pass the display value (with placeholders) to onSubmit // The parent will handle conversion to content parts and cleanup onSubmit(displayValue); } }; // Clear one-shot cursor nudge after it applies useEffect(() => { if (typeof nudgeCursorOffset === "number") { const t = setTimeout(() => setNudgeCursorOffset(undefined), 0); return () => clearTimeout(t); } }, [nudgeCursorOffset]); return ( { caretOffsetRef.current = n; onCursorMove?.(n); }} onChange={handleChange} onSubmit={handleSubmit} placeholder={placeholder} focus={focus} /> ); }