Compare commits

...
Author SHA1 Message Date
John Choi 9de13181cf refactor: add paste timeout cleanup and extract appendToActivePaste helper
- Add useEffect cleanup for pasteCompletionTimeoutRef and pasteUpdateTimeoutRef
  on unmount to prevent stale callbacks firing on unmounted component
- Extract appendToActivePaste() helper to deduplicate paste chunk append logic
  between the small-chunk (active session) and large-chunk (continuation) paths
2026-03-31 21:05:07 -07:00
John Choi b36a230483 fix: capture trailing paste chunks in CLI TUI input
When pasting large text into cline --tui, terminals send data in
multiple chunks. The final chunk is often smaller than the 100-char
PASTE_COLLAPSE_THRESHOLD, causing it to bypass paste detection and
get inserted as regular text after the placeholder.

e.g., '[Pasted text #1 +66 lines]ng scripts' instead of
'[Pasted text #1 +66 lines]'

Fix: Add a paste session completion timeout (200ms). While a paste
session is active, ALL input is captured regardless of size. The
session auto-ends after 200ms of silence, so normal typing is
completely unaffected.
2026-03-31 15:20:02 -07:00
2 changed files with 141 additions and 27 deletions
+65
View File
@@ -291,6 +291,71 @@ describe("ChatView Exit and Cleanup", () => {
})
})
describe("ChatView Paste Handling", () => {
beforeEach(() => {
vi.clearAllMocks()
shutdownMockState.reset()
})
it("should collapse large paste (>100 chars) into a placeholder", async () => {
const { lastFrame, stdin } = render(<ChatView />)
await delay()
// Simulate pasting a large block of text (>100 chars)
const largePaste = "a".repeat(50) + "\n" + "b".repeat(60)
stdin.write(largePaste)
await delay()
const frame = lastFrame()
// Should show placeholder, not the raw pasted text
expect(frame).toContain("[Pasted text #1")
expect(frame).not.toContain("aaaaaa")
})
it("should capture small trailing chunk during active paste session", async () => {
const { lastFrame, stdin } = render(<ChatView />)
await delay()
// First chunk - large paste that triggers placeholder
const firstChunk = "x".repeat(120)
stdin.write(firstChunk)
await delay(30) // Short delay - within paste session window
// Second chunk - small trailing text that was previously leaking
const trailingChunk = "ng scripts"
stdin.write(trailingChunk)
await delay()
const frame = lastFrame()
// The trailing text should NOT appear as separate text after the placeholder
expect(frame).not.toContain("ng scripts")
// Should still show the placeholder
expect(frame).toContain("[Pasted text #1")
})
it("should allow normal typing after paste session ends", async () => {
const { lastFrame, stdin } = render(<ChatView />)
await delay()
// Paste a large block
stdin.write("y".repeat(120))
await delay()
// Wait for paste session to expire (>200ms)
await delay(250)
// Type normal text - should appear as regular input, not captured by paste
stdin.write("h")
stdin.write("i")
await delay()
const frame = lastFrame()
// Should have both the placeholder and the typed text
expect(frame).toContain("[Pasted text #1")
expect(frame).toContain("hi")
})
})
describe("ChatView UI State During Exit", () => {
beforeEach(() => {
vi.clearAllMocks()
+76 -27
View File
@@ -405,9 +405,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
const activePasteStartPosRef = useRef<number>(0) // Where the placeholder starts in the text
const activePasteLinesRef = useRef<number>(0) // Total line count for current paste
const pasteUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Debounce placeholder updates
const pasteCompletionTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Marks paste session as complete after cooldown
const PASTE_CHUNK_WINDOW_MS = 150 // Chunks within this window are combined into one paste
const PASTE_COMPLETION_MS = 200 // After this much silence, paste session ends and normal input resumes
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
// Cleanup paste timeouts on unmount
useEffect(() => {
return () => {
if (pasteCompletionTimeoutRef.current) clearTimeout(pasteCompletionTimeoutRef.current)
if (pasteUpdateTimeoutRef.current) clearTimeout(pasteUpdateTimeoutRef.current)
}
}, [])
// Slash command state
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>(() => createCliOnlySlashCommands())
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
@@ -1353,6 +1363,68 @@ export const ChatView: React.FC<ChatViewProps> = ({
// 11. Detect paste by checking if input length exceeds threshold
// Large pastes mess up the terminal UI, so we collapse them into a placeholder
// Terminal sends large pastes in multiple chunks, so we combine chunks that arrive rapidly
//
// BUG FIX: The final chunk of a paste is often small (<PASTE_COLLAPSE_THRESHOLD chars).
// Without the active paste session check below, these small trailing chunks would fall
// through to normal input handling and appear as leaked text after the placeholder.
// e.g., "[Pasted text #1 +66 lines]ng scripts" instead of "[Pasted text #1 +66 lines]"
// We use pasteCompletionTimeoutRef to keep the paste session active for PASTE_COMPLETION_MS
// after the last chunk, capturing ALL input during that window regardless of size.
// Helper to schedule paste session completion (resets active paste state after cooldown)
const schedulePasteCompletion = () => {
if (pasteCompletionTimeoutRef.current) {
clearTimeout(pasteCompletionTimeoutRef.current)
}
pasteCompletionTimeoutRef.current = setTimeout(() => {
activePasteNumRef.current = 0
lastPasteTimeRef.current = 0
pasteCompletionTimeoutRef.current = null
}, PASTE_COMPLETION_MS)
}
// Helper to append a chunk to the active paste (stores content + debounces placeholder update)
const appendToActivePaste = (chunk: string) => {
const pasteNum = activePasteNumRef.current
const chunkLines = chunk.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current += chunkLines
setPastedTexts((prev) => {
const next = new Map(prev)
const existing = next.get(pasteNum) || ""
next.set(pasteNum, existing + chunk)
return next
})
// Debounce the visual update to avoid flicker while chunks are arriving
if (pasteUpdateTimeoutRef.current) {
clearTimeout(pasteUpdateTimeoutRef.current)
}
pasteUpdateTimeoutRef.current = setTimeout(() => {
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
const newText = textInputRef.current.replace(pattern, newPlaceholder)
textInputRef.current = newText
setTextInput(newText)
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
Logger.info(`Paste #${pasteNum} updated: ${activePasteLinesRef.current} lines`)
}, PASTE_UPDATE_DEBOUNCE_MS)
schedulePasteCompletion()
}
// Active paste session: capture ALL input (even small chunks) as part of the ongoing paste.
// This prevents the last small chunk of a multi-chunk paste from leaking as normal text.
if (input && activePasteNumRef.current > 0 && input.length <= PASTE_COLLAPSE_THRESHOLD) {
const now = Date.now()
const timeSinceLastPaste = now - lastPasteTimeRef.current
if (timeSinceLastPaste < PASTE_COMPLETION_MS) {
lastPasteTimeRef.current = now
appendToActivePaste(input)
return
}
}
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
const now = Date.now()
const timeSinceLastPaste = now - lastPasteTimeRef.current
@@ -1360,33 +1432,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Check if this is a continuation of a recent paste (within time window)
if (timeSinceLastPaste < PASTE_CHUNK_WINDOW_MS && activePasteNumRef.current > 0) {
// Append to existing paste content (store immediately, don't lose data)
const pasteNum = activePasteNumRef.current
const chunkLines = input.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current += chunkLines
setPastedTexts((prev) => {
const next = new Map(prev)
const existing = next.get(pasteNum) || ""
next.set(pasteNum, existing + input)
return next
})
// Debounce the visual update to avoid flicker while chunks are arriving
if (pasteUpdateTimeoutRef.current) {
clearTimeout(pasteUpdateTimeoutRef.current)
}
pasteUpdateTimeoutRef.current = setTimeout(() => {
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
const newText = textInputRef.current.replace(pattern, newPlaceholder)
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
setTextInput(newText)
// Update cursor to be right after the placeholder
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
}, PASTE_UPDATE_DEBOUNCE_MS)
appendToActivePaste(input)
return // Don't add another placeholder
}
@@ -1412,6 +1458,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
textInputRef.current = newText // Update ref immediately so setCursorPos bounds check works
setTextInput(newText)
setCursorPos(currentCursorPos + placeholder.length)
// Start paste completion timer - after PASTE_COMPLETION_MS of silence, paste session ends
schedulePasteCompletion()
return // Exit early - don't also add the raw input via normal handling below
}