source dump of claude code
at main 285 lines 10 kB view raw
1import { basename } from 'path' 2import React from 'react' 3import { logError } from 'src/utils/log.js' 4import { useDebounceCallback } from 'usehooks-ts' 5import type { InputEvent, Key } from '../ink.js' 6import { 7 getImageFromClipboard, 8 isImageFilePath, 9 PASTE_THRESHOLD, 10 tryReadImageFromPath, 11} from '../utils/imagePaste.js' 12import type { ImageDimensions } from '../utils/imageResizer.js' 13import { getPlatform } from '../utils/platform.js' 14 15const CLIPBOARD_CHECK_DEBOUNCE_MS = 50 16const PASTE_COMPLETION_TIMEOUT_MS = 100 17 18type 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 30export 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}