🎯 Use case
This file lives under “hooks/”, which covers reusable UI or integration hooks. On the API surface it exposes usePasteHandler — mainly functions, hooks, or classes. Dependencies touch Node path helpers, React UI, src, and usehooks-ts. It composes internal code from ink and utils (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
import { basename } from 'path' import React from 'react' import { logError } from 'src/utils/log.js' import { useDebounceCallback } from 'usehooks-ts' import type { InputEvent, Key } from '../ink.js'
📤 Exports (heuristic)
usePasteHandler
📚 External import roots
Package roots from from "…" (relative paths omitted).
pathreactsrcusehooks-ts
🖥️ Source preview
import { basename } from 'path'
import React from 'react'
import { logError } from 'src/utils/log.js'
import { useDebounceCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../ink.js'
import {
getImageFromClipboard,
isImageFilePath,
PASTE_THRESHOLD,
tryReadImageFromPath,
} from '../utils/imagePaste.js'
import type { ImageDimensions } from '../utils/imageResizer.js'
import { getPlatform } from '../utils/platform.js'
const CLIPBOARD_CHECK_DEBOUNCE_MS = 50
const PASTE_COMPLETION_TIMEOUT_MS = 100
type PasteHandlerProps = {
onPaste?: (text: string) => void
onInput: (input: string, key: Key) => void
onImagePaste?: (
base64Image: string,
mediaType?: string,
filename?: string,
dimensions?: ImageDimensions,
sourcePath?: string,
) => void
}
export function usePasteHandler({
onPaste,
onInput,
onImagePaste,
}: PasteHandlerProps): {
wrappedOnInput: (input: string, key: Key, event: InputEvent) => void
pasteState: {
chunks: string[]
timeoutId: ReturnType<typeof setTimeout> | null
}
isPasting: boolean
} {
const [pasteState, setPasteState] = React.useState<{
chunks: string[]
timeoutId: ReturnType<typeof setTimeout> | null
}>({ chunks: [], timeoutId: null })
const [isPasting, setIsPasting] = React.useState(false)
const isMountedRef = React.useRef(true)
// Mirrors pasteState.timeoutId but updated synchronously. When paste + a
// keystroke arrive in the same stdin chunk, both wrappedOnInput calls run
// in the same discreteUpdates batch before React commits — the second call
// reads stale pasteState.timeoutId (null) and takes the onInput path. If
// that key is Enter, it submits the old input and the paste is lost.
const pastePendingRef = React.useRef(false)
const isMacOS = React.useMemo(() => getPlatform() === 'macos', [])
React.useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const checkClipboardForImageImpl = React.useCallback(() => {
if (!onImagePaste || !isMountedRef.current) return
void getImageFromClipboard()
.then(imageData => {
if (imageData && isMountedRef.current) {
onImagePaste(
imageData.base64,
imageData.mediaType,
undefined, // no filename for clipboard images
imageData.dimensions,
)
}
})
.catch(error => {
if (isMountedRef.current) {
logError(error as Error)
}
})
.finally(() => {
if (isMountedRef.current) {
setIsPasting(false)
}
})
}, [onImagePaste])
const checkClipboardForImage = useDebounceCallback(
checkClipboardForImageImpl,
CLIPBOARD_CHECK_DEBOUNCE_MS,
)
const resetPasteTimeout = React.useCallback(
(currentTimeoutId: ReturnType<typeof setTimeout> | null) => {
if (currentTimeoutId) {
clearTimeout(currentTimeoutId)
}
return setTimeout(
(
setPasteState,
onImagePaste,
onPaste,
setIsPasting,
checkClipboardForImage,
isMacOS,
pastePendingRef,
) => {
pastePendingRef.current = false
setPasteState(({ chunks }) => {
// Join chunks and filter out orphaned focus sequences
// These can appear when focus events split during paste
const pastedText = chunks
.join('')
.replace(/\[I$/, '')
.replace(/\[O$/, '')
// Check if the pasted text contains image file paths
// When dragging multiple images, they may come as:
// 1. Newline-separated paths (common in some terminals)
// 2. Space-separated paths (common when dragging from Finder)
// For space-separated paths, we split on spaces that precede absolute paths:
// - Unix: space followed by `/` (e.g., `/Users/...`)
// - Windows: space followed by drive letter and `:\` (e.g., `C:\Users\...`)
// This works because spaces within paths are escaped (e.g., `file\ name.png`)
const lines = pastedText
.split(/ (?=\/|[A-Za-z]:\\)/)
.flatMap(part => part.split('\n'))
.filter(line => line.trim())
const imagePaths = lines.filter(line => isImageFilePath(line))
if (onImagePaste && imagePaths.length > 0) {
const isTempScreenshot =
/\/TemporaryItems\/.*screencaptureui.*\/Screenshot/i.test(
pastedText,
)
// Process all image paths
void Promise.all(
imagePaths.map(imagePath => tryReadImageFromPath(imagePath)),
).then(results => {
const validImages = results.filter(
(r): r is NonNullable<typeof r> => r !== null,
)
if (validImages.length > 0) {
// Successfully read at least one image
for (const imageData of validImages) {
const filename = basename(imageData.path)
onImagePaste(
imageData.base64,
imageData.mediaType,
filename,
imageData.dimensions,
imageData.path,
)
}
// If some paths weren't images, paste them as text
const nonImageLines = lines.filter(
line => !isImageFilePath(line),
)
if (nonImageLines.length > 0 && onPaste) {
onPaste(nonImageLines.join('\n'))
}
setIsPasting(false)
} else if (isTempScreenshot && isMacOS) {
// For temporary screenshot files that no longer exist, try clipboard
checkClipboardForImage()
} else {
if (onPaste) {
onPaste(pastedText)
}
setIsPasting(false)
}
})
return { chunks: [], timeoutId: null }
}
// If paste is empty (common when trying to paste images with Cmd+V),
// check if clipboard has an image (macOS only)
if (isMacOS && onImagePaste && pastedText.length === 0) {
checkClipboardForImage()
return { chunks: [], timeoutId: null }
}
// Handle regular paste
if (onPaste) {
onPaste(pastedText)
}
// Reset isPasting state after paste is complete
setIsPasting(false)
return { chunks: [], timeoutId: null }
})
},
PASTE_COMPLETION_TIMEOUT_MS,
setPasteState,
onImagePaste,
onPaste,
setIsPasting,
checkClipboardForImage,
isMacOS,
pastePendingRef,
)
},
[checkClipboardForImage, isMacOS, onImagePaste, onPaste],
)
// Paste detection is now done via the InputEvent's keypress.isPasted flag,
// which is set by the keypress parser when it detects bracketed paste mode.
// This avoids the race condition caused by having multiple listeners on stdin.
// Previously, we had a stdin.on('data') listener here which competed with
// the 'readable' listener in App.tsx, causing dropped characters.
const wrappedOnInput = (input: string, key: Key, event: InputEvent): void => {
// Detect paste from the parsed keypress event.
// The keypress parser sets isPasted=true for content within bracketed paste.
const isFromPaste = event.keypress.isPasted
// If this is pasted content, set isPasting state for UI feedback
if (isFromPaste) {
setIsPasting(true)
}
// Handle large pastes (>PASTE_THRESHOLD chars)
// Usually we get one or two input characters at a time. If we
// get more than the threshold, the user has probably pasted.
// Unfortunately node batches long pastes, so it's possible
// that we would see e.g. 1024 characters and then just a few
// more in the next frame that belong with the original paste.
// This batching number is not consistent.
// Handle potential image filenames (even if they're shorter than paste threshold)
// When dragging multiple images, they may come as newline-separated or
// space-separated paths. Split on spaces preceding absolute paths:
// - Unix: ` /` - Windows: ` C:\` etc.
const hasImageFilePath = input
.split(/ (?=\/|[A-Za-z]:\\)/)
.flatMap(part => part.split('\n'))
.some(line => isImageFilePath(line.trim()))
// Handle empty paste (clipboard image on macOS)
// When the user pastes an image with Cmd+V, the terminal sends an empty
// bracketed paste sequence. The keypress parser emits this as isPasted=true
// with empty input.
if (isFromPaste && input.length === 0 && isMacOS && onImagePaste) {
checkClipboardForImage()
// Reset isPasting since there's no text content to process
setIsPasting(false)
return
}
// Check if we should handle as paste (from bracketed paste, large input, or continuation)
const shouldHandleAsPaste =
onPaste &&
(input.length > PASTE_THRESHOLD ||
pastePendingRef.current ||
hasImageFilePath ||
isFromPaste)
if (shouldHandleAsPaste) {
pastePendingRef.current = true
setPasteState(({ chunks, timeoutId }) => {
return {
chunks: [...chunks, input],
timeoutId: resetPasteTimeout(timeoutId),
}
})
return
}
onInput(input, key)
if (input.length > 10) {
// Ensure that setIsPasting is turned off on any other multicharacter
// input, because the stdin buffer may chunk at arbitrary points and split
// the closing escape sequence if the input length is too long for the
// stdin buffer.
setIsPasting(false)
}
}
return {
wrappedOnInput,
pasteState,
isPasting,
}
}