fix: improve image paste handling with resizing and error feedback (#601)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -139,6 +139,7 @@ export function Input({
|
||||
ralphPendingYolo = false,
|
||||
onRalphExit,
|
||||
conversationId,
|
||||
onPasteError,
|
||||
}: {
|
||||
visible?: boolean;
|
||||
streaming: boolean;
|
||||
@@ -163,6 +164,7 @@ export function Input({
|
||||
ralphPendingYolo?: boolean;
|
||||
onRalphExit?: () => void;
|
||||
conversationId?: string;
|
||||
onPasteError?: (message: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [escapePressed, setEscapePressed] = useState(false);
|
||||
@@ -815,6 +817,7 @@ export function Input({
|
||||
focus={!onEscapeCancel}
|
||||
onBangAtEmpty={handleBangAtEmpty}
|
||||
onBackspaceAtEmpty={handleBackspaceAtEmpty}
|
||||
onPasteError={onPasteError}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -41,6 +41,11 @@ interface PasteAwareTextInputProps {
|
||||
* Return true to consume the keystroke.
|
||||
*/
|
||||
onBackspaceAtEmpty?: () => boolean;
|
||||
|
||||
/**
|
||||
* Called when an image paste fails (e.g., image too large).
|
||||
*/
|
||||
onPasteError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
@@ -122,6 +127,7 @@ export function PasteAwareTextInput({
|
||||
onCursorMove,
|
||||
onBangAtEmpty,
|
||||
onBackspaceAtEmpty,
|
||||
onPasteError,
|
||||
}: PasteAwareTextInputProps) {
|
||||
const { internal_eventEmitter } = useStdin();
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
@@ -209,26 +215,34 @@ export function PasteAwareTextInput({
|
||||
// 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
|
||||
// Fire async handler (can't await in useInput callback)
|
||||
(async () => {
|
||||
const result = await tryImportClipboardImageMac();
|
||||
if (result) {
|
||||
if ("error" in result) {
|
||||
// Report the error via callback
|
||||
onPasteErrorRef.current?.(result.error);
|
||||
return;
|
||||
}
|
||||
// Success - insert the placeholder
|
||||
const clip = result.placeholder;
|
||||
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;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,27 +253,22 @@ export function PasteAwareTextInput({
|
||||
|
||||
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;
|
||||
}
|
||||
const translated = translatePasteForImages(payload);
|
||||
|
||||
if (translated && translated.length > 0) {
|
||||
// Insert at current caret position
|
||||
// Helper to insert translated content
|
||||
const insertTranslated = (text: string) => {
|
||||
const at = Math.max(
|
||||
0,
|
||||
Math.min(caretOffsetRef.current, displayValue.length),
|
||||
);
|
||||
const isLarge = countLines(translated) > 5 || translated.length > 500;
|
||||
const isLarge = countLines(text) > 5 || text.length > 500;
|
||||
if (isLarge) {
|
||||
const pasteId = allocatePaste(translated);
|
||||
const placeholder = `[Pasted text #${pasteId} +${countLines(translated)} lines]`;
|
||||
const pasteId = allocatePaste(text);
|
||||
const placeholder = `[Pasted text #${pasteId} +${countLines(text)} lines]`;
|
||||
const newDisplay =
|
||||
displayValue.slice(0, at) + placeholder + displayValue.slice(at);
|
||||
const newActual =
|
||||
actualValue.slice(0, at) + translated + actualValue.slice(at);
|
||||
actualValue.slice(0, at) + text + actualValue.slice(at);
|
||||
setDisplayValue(newDisplay);
|
||||
setActualValue(newActual);
|
||||
onChange(newDisplay);
|
||||
@@ -267,11 +276,11 @@ export function PasteAwareTextInput({
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
} else {
|
||||
const displayText = sanitizeForDisplay(translated);
|
||||
const displayText = sanitizeForDisplay(text);
|
||||
const newDisplay =
|
||||
displayValue.slice(0, at) + displayText + displayValue.slice(at);
|
||||
const newActual =
|
||||
actualValue.slice(0, at) + translated + actualValue.slice(at);
|
||||
actualValue.slice(0, at) + text + actualValue.slice(at);
|
||||
setDisplayValue(newDisplay);
|
||||
setActualValue(newActual);
|
||||
onChange(newDisplay);
|
||||
@@ -279,6 +288,26 @@ export function PasteAwareTextInput({
|
||||
setNudgeCursorOffset(nextCaret);
|
||||
caretOffsetRef.current = nextCaret;
|
||||
}
|
||||
};
|
||||
|
||||
// If paste event carried no text (common for image-only clipboard), try macOS import
|
||||
if ((!translated || translated.length === 0) && payload.length === 0) {
|
||||
// Fire async handler
|
||||
(async () => {
|
||||
const clipResult = await tryImportClipboardImageMac();
|
||||
if (clipResult) {
|
||||
if ("error" in clipResult) {
|
||||
onPasteErrorRef.current?.(clipResult.error);
|
||||
return;
|
||||
}
|
||||
insertTranslated(clipResult.placeholder);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (translated && translated.length > 0) {
|
||||
insertTranslated(translated);
|
||||
return;
|
||||
}
|
||||
// If nothing to insert, fall through
|
||||
@@ -288,23 +317,31 @@ export function PasteAwareTextInput({
|
||||
(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;
|
||||
}
|
||||
// Fire async handler
|
||||
(async () => {
|
||||
const result = await tryImportClipboardImageMac();
|
||||
if (result) {
|
||||
if ("error" in result) {
|
||||
onPasteErrorRef.current?.(result.error);
|
||||
return;
|
||||
}
|
||||
const placeholder = result.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
|
||||
@@ -330,6 +367,11 @@ export function PasteAwareTextInput({
|
||||
onBackspaceAtEmptyRef.current = onBackspaceAtEmpty;
|
||||
}, [onBackspaceAtEmpty]);
|
||||
|
||||
const onPasteErrorRef = useRef(onPasteError);
|
||||
useEffect(() => {
|
||||
onPasteErrorRef.current = onPasteError;
|
||||
}, [onPasteError]);
|
||||
|
||||
// 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).
|
||||
|
||||
Reference in New Issue
Block a user