/ hooks / usePasteHandler.ts
usePasteHandler.ts
  1  import { basename } from 'path'
  2  import React from 'react'
  3  import { logError } from 'src/utils/log.js'
  4  import { useDebounceCallback } from 'usehooks-ts'
  5  import type { InputEvent, Key } from '../ink.js'
  6  import {
  7    getImageFromClipboard,
  8    isImageFilePath,
  9    PASTE_THRESHOLD,
 10    tryReadImageFromPath,
 11  } from '../utils/imagePaste.js'
 12  import type { ImageDimensions } from '../utils/imageResizer.js'
 13  import { getPlatform } from '../utils/platform.js'
 14  
 15  const CLIPBOARD_CHECK_DEBOUNCE_MS = 50
 16  const PASTE_COMPLETION_TIMEOUT_MS = 100
 17  
 18  type PasteHandlerProps = {
 19    onPaste?: (text: string) => void
 20    onInput: (input: string, key: Key) => void
 21    onImagePaste?: (
 22      base64Image: string,
 23      mediaType?: string,
 24      filename?: string,
 25      dimensions?: ImageDimensions,
 26      sourcePath?: string,
 27    ) => void
 28  }
 29  
 30  export function usePasteHandler({
 31    onPaste,
 32    onInput,
 33    onImagePaste,
 34  }: PasteHandlerProps): {
 35    wrappedOnInput: (input: string, key: Key, event: InputEvent) => void
 36    pasteState: {
 37      chunks: string[]
 38      timeoutId: ReturnType<typeof setTimeout> | null
 39    }
 40    isPasting: boolean
 41  } {
 42    const [pasteState, setPasteState] = React.useState<{
 43      chunks: string[]
 44      timeoutId: ReturnType<typeof setTimeout> | null
 45    }>({ chunks: [], timeoutId: null })
 46    const [isPasting, setIsPasting] = React.useState(false)
 47    const isMountedRef = React.useRef(true)
 48    // Mirrors pasteState.timeoutId but updated synchronously. When paste + a
 49    // keystroke arrive in the same stdin chunk, both wrappedOnInput calls run
 50    // in the same discreteUpdates batch before React commits — the second call
 51    // reads stale pasteState.timeoutId (null) and takes the onInput path. If
 52    // that key is Enter, it submits the old input and the paste is lost.
 53    const pastePendingRef = React.useRef(false)
 54  
 55    const isMacOS = React.useMemo(() => getPlatform() === 'macos', [])
 56  
 57    React.useEffect(() => {
 58      return () => {
 59        isMountedRef.current = false
 60      }
 61    }, [])
 62  
 63    const checkClipboardForImageImpl = React.useCallback(() => {
 64      if (!onImagePaste || !isMountedRef.current) return
 65  
 66      void getImageFromClipboard()
 67        .then(imageData => {
 68          if (imageData && isMountedRef.current) {
 69            onImagePaste(
 70              imageData.base64,
 71              imageData.mediaType,
 72              undefined, // no filename for clipboard images
 73              imageData.dimensions,
 74            )
 75          }
 76        })
 77        .catch(error => {
 78          if (isMountedRef.current) {
 79            logError(error as Error)
 80          }
 81        })
 82        .finally(() => {
 83          if (isMountedRef.current) {
 84            setIsPasting(false)
 85          }
 86        })
 87    }, [onImagePaste])
 88  
 89    const checkClipboardForImage = useDebounceCallback(
 90      checkClipboardForImageImpl,
 91      CLIPBOARD_CHECK_DEBOUNCE_MS,
 92    )
 93  
 94    const resetPasteTimeout = React.useCallback(
 95      (currentTimeoutId: ReturnType<typeof setTimeout> | null) => {
 96        if (currentTimeoutId) {
 97          clearTimeout(currentTimeoutId)
 98        }
 99        return setTimeout(
100          (
101            setPasteState,
102            onImagePaste,
103            onPaste,
104            setIsPasting,
105            checkClipboardForImage,
106            isMacOS,
107            pastePendingRef,
108          ) => {
109            pastePendingRef.current = false
110            setPasteState(({ chunks }) => {
111              // Join chunks and filter out orphaned focus sequences
112              // These can appear when focus events split during paste
113              const pastedText = chunks
114                .join('')
115                .replace(/\[I$/, '')
116                .replace(/\[O$/, '')
117  
118              // Check if the pasted text contains image file paths
119              // When dragging multiple images, they may come as:
120              // 1. Newline-separated paths (common in some terminals)
121              // 2. Space-separated paths (common when dragging from Finder)
122              // For space-separated paths, we split on spaces that precede absolute paths:
123              // - Unix: space followed by `/` (e.g., `/Users/...`)
124              // - Windows: space followed by drive letter and `:\` (e.g., `C:\Users\...`)
125              // This works because spaces within paths are escaped (e.g., `file\ name.png`)
126              const lines = pastedText
127                .split(/ (?=\/|[A-Za-z]:\\)/)
128                .flatMap(part => part.split('\n'))
129                .filter(line => line.trim())
130              const imagePaths = lines.filter(line => isImageFilePath(line))
131  
132              if (onImagePaste && imagePaths.length > 0) {
133                const isTempScreenshot =
134                  /\/TemporaryItems\/.*screencaptureui.*\/Screenshot/i.test(
135                    pastedText,
136                  )
137  
138                // Process all image paths
139                void Promise.all(
140                  imagePaths.map(imagePath => tryReadImageFromPath(imagePath)),
141                ).then(results => {
142                  const validImages = results.filter(
143                    (r): r is NonNullable<typeof r> => r !== null,
144                  )
145  
146                  if (validImages.length > 0) {
147                    // Successfully read at least one image
148                    for (const imageData of validImages) {
149                      const filename = basename(imageData.path)
150                      onImagePaste(
151                        imageData.base64,
152                        imageData.mediaType,
153                        filename,
154                        imageData.dimensions,
155                        imageData.path,
156                      )
157                    }
158                    // If some paths weren't images, paste them as text
159                    const nonImageLines = lines.filter(
160                      line => !isImageFilePath(line),
161                    )
162                    if (nonImageLines.length > 0 && onPaste) {
163                      onPaste(nonImageLines.join('\n'))
164                    }
165                    setIsPasting(false)
166                  } else if (isTempScreenshot && isMacOS) {
167                    // For temporary screenshot files that no longer exist, try clipboard
168                    checkClipboardForImage()
169                  } else {
170                    if (onPaste) {
171                      onPaste(pastedText)
172                    }
173                    setIsPasting(false)
174                  }
175                })
176                return { chunks: [], timeoutId: null }
177              }
178  
179              // If paste is empty (common when trying to paste images with Cmd+V),
180              // check if clipboard has an image (macOS only)
181              if (isMacOS && onImagePaste && pastedText.length === 0) {
182                checkClipboardForImage()
183                return { chunks: [], timeoutId: null }
184              }
185  
186              // Handle regular paste
187              if (onPaste) {
188                onPaste(pastedText)
189              }
190              // Reset isPasting state after paste is complete
191              setIsPasting(false)
192              return { chunks: [], timeoutId: null }
193            })
194          },
195          PASTE_COMPLETION_TIMEOUT_MS,
196          setPasteState,
197          onImagePaste,
198          onPaste,
199          setIsPasting,
200          checkClipboardForImage,
201          isMacOS,
202          pastePendingRef,
203        )
204      },
205      [checkClipboardForImage, isMacOS, onImagePaste, onPaste],
206    )
207  
208    // Paste detection is now done via the InputEvent's keypress.isPasted flag,
209    // which is set by the keypress parser when it detects bracketed paste mode.
210    // This avoids the race condition caused by having multiple listeners on stdin.
211    // Previously, we had a stdin.on('data') listener here which competed with
212    // the 'readable' listener in App.tsx, causing dropped characters.
213  
214    const wrappedOnInput = (input: string, key: Key, event: InputEvent): void => {
215      // Detect paste from the parsed keypress event.
216      // The keypress parser sets isPasted=true for content within bracketed paste.
217      const isFromPaste = event.keypress.isPasted
218  
219      // If this is pasted content, set isPasting state for UI feedback
220      if (isFromPaste) {
221        setIsPasting(true)
222      }
223  
224      // Handle large pastes (>PASTE_THRESHOLD chars)
225      // Usually we get one or two input characters at a time. If we
226      // get more than the threshold, the user has probably pasted.
227      // Unfortunately node batches long pastes, so it's possible
228      // that we would see e.g. 1024 characters and then just a few
229      // more in the next frame that belong with the original paste.
230      // This batching number is not consistent.
231  
232      // Handle potential image filenames (even if they're shorter than paste threshold)
233      // When dragging multiple images, they may come as newline-separated or
234      // space-separated paths. Split on spaces preceding absolute paths:
235      // - Unix: ` /` - Windows: ` C:\` etc.
236      const hasImageFilePath = input
237        .split(/ (?=\/|[A-Za-z]:\\)/)
238        .flatMap(part => part.split('\n'))
239        .some(line => isImageFilePath(line.trim()))
240  
241      // Handle empty paste (clipboard image on macOS)
242      // When the user pastes an image with Cmd+V, the terminal sends an empty
243      // bracketed paste sequence. The keypress parser emits this as isPasted=true
244      // with empty input.
245      if (isFromPaste && input.length === 0 && isMacOS && onImagePaste) {
246        checkClipboardForImage()
247        // Reset isPasting since there's no text content to process
248        setIsPasting(false)
249        return
250      }
251  
252      // Check if we should handle as paste (from bracketed paste, large input, or continuation)
253      const shouldHandleAsPaste =
254        onPaste &&
255        (input.length > PASTE_THRESHOLD ||
256          pastePendingRef.current ||
257          hasImageFilePath ||
258          isFromPaste)
259  
260      if (shouldHandleAsPaste) {
261        pastePendingRef.current = true
262        setPasteState(({ chunks, timeoutId }) => {
263          return {
264            chunks: [...chunks, input],
265            timeoutId: resetPasteTimeout(timeoutId),
266          }
267        })
268        return
269      }
270      onInput(input, key)
271      if (input.length > 10) {
272        // Ensure that setIsPasting is turned off on any other multicharacter
273        // input, because the stdin buffer may chunk at arbitrary points and split
274        // the closing escape sequence if the input length is too long for the
275        // stdin buffer.
276        setIsPasting(false)
277      }
278    }
279  
280    return {
281      wrappedOnInput,
282      pasteState,
283      isPasting,
284    }
285  }