Files
claude-code/src/hooks/usePasteHandler.ts
18243133 b67e9f9d38 Fix/plan paste fixes (#1238)
* fix: 降低 paste 检测阈值,修复非 bracketed-paste 终端粘贴文本损坏

非 bracketed-paste 终端下,短粘贴(<800 chars)的 stdin chunk 作为独立
keystroke 走 useTextInput.onInput 路径,闭包中 cursor 未刷新导致多次插入
竞态。现将 ≥3 字符的非特殊键输入纳入 paste 累积模式,绕过逐 chunk 处理。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* @
fix: Plan模式三处缺陷修复 — ExploreAgent可用性 + 弹窗一致性 + 方案文件保护

1. areExplorePlanAgentsEnabled()移除GrowthBook A/B实验依赖(tengu_amber_stoat),
   始终返回true,确保Explore/Plan agent在BUILTIN_EXPLORE_PLAN_AGENTS开启时始终可用

2. ExitPlanMode clear-context路径补setNeedsPlanModeExitAttachment(true),
   确保清除上下文退出Plan模式后生成plan_mode_exit附件

3. Plan mode full/sparse指令强化Plan文件读取要求:
   "can read" -> "MUST use FileRead to read first before any changes",
   新增"do NOT overwrite"禁止覆盖,Phase 1指令强化并行Explore Agent引导

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@

---------

Co-authored-by: psj88520 <qq18243133@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:57:15 +08:00

294 lines
10 KiB
TypeScript

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 '@anthropic/ink'
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 ||
(input.length >= 3 &&
!key.return &&
!key.tab &&
!key.escape &&
!key.upArrow &&
!key.downArrow &&
!key.leftArrow &&
!key.rightArrow))
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,
}
}