Files
letta-code/src/cli/components/PasteAwareTextInput.tsx
Charles Packer 36dcb891b0 fix: patch release event leaks (#463)
Co-authored-by: Letta <noreply@letta.com>
2026-01-04 19:19:34 -08:00

656 lines
23 KiB
TypeScript

// 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<number>(0);
const caretOffsetRef = useRef<number>((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 (
<TextInputAny
value={displayValue}
externalCursorOffset={nudgeCursorOffset}
onCursorOffsetChange={(n: number) => {
caretOffsetRef.current = n;
onCursorMove?.(n);
}}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={focus}
/>
);
}