fix: handle text clipboard paste on Ctrl+V for Windows terminals#13871
fix: handle text clipboard paste on Ctrl+V for Windows terminals#13871yxshee wants to merge 1 commit intoanomalyco:devfrom
Conversation
Windows terminals (Windows Terminal, cmd, PowerShell) do not reliably emit bracketed paste sequences when the user presses Ctrl+V. The existing handler only checked for image content and fell through to rely on bracketed paste for text, which silently failed on Windows. - Add Clipboard.readText() with CRLF normalization - Handle text/plain from Clipboard.read() in the Ctrl+V onKeyDown handler - Route pasted text through the same pipeline as onPaste (file detection, image file paths, large paste summarization, direct insertion) - Preserve image paste and bracketed paste behavior - Add 18 unit tests for clipboard text reading and paste pipeline
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: Potential Duplicate FoundPR #13798: fix: Ctrl+V paste not working on Windows 11 TUI This PR appears to be addressing the exact same issue as PR #13871 — fixing Ctrl+V paste not working on Windows 11 in the TUI. Both PRs target the same problem: Windows terminals not reliably emitting bracketed paste sequences for Ctrl+V. Recommendation: Check if PR #13798 is still open and whether it's been superseded by the current PR, or if they need to be consolidated. Related PRs (Historical Context)These older PRs addressed similar clipboard/paste issues but on different aspects:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a Windows-specific issue where pressing Ctrl+V in the TUI prompt fails to paste text. The root cause is that Windows terminals (Windows Terminal, cmd, PowerShell) don't reliably emit bracketed paste sequences for Ctrl+V key presses. The fix adds direct clipboard text reading when Ctrl+V is pressed, routing it through the same paste pipeline as bracketed paste.
Changes:
- Added
Clipboard.readText()function that reads and normalizes clipboard text (CRLF → LF) - Modified Ctrl+V handler to directly handle
text/plainclipboard content when bracketed paste isn't available - Added 18 unit tests covering clipboard text reading, CRLF normalization, and paste pipeline logic
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| packages/opencode/src/cli/cmd/tui/util/clipboard.ts | Added readText() function for reading and normalizing clipboard text (unused in implementation) |
| packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | Extended Ctrl+V handler to directly process text clipboard content, duplicating logic from onPaste handler; includes unrelated formatting changes |
| packages/opencode/test/cli/tui/clipboard.test.ts | Added comprehensive test coverage for clipboard operations and paste pipeline logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Reads text from the clipboard, normalizing CRLF to LF. | ||
| * Returns undefined if the clipboard is empty, contains non-text, or on error. | ||
| * This is used by the Ctrl+V handler to directly paste text on platforms | ||
| * where bracketed paste is not reliably emitted (e.g. Windows terminals). | ||
| */ | ||
| export async function readText(): Promise<string | undefined> { | ||
| try { | ||
| const text = await clipboardy.read() | ||
| if (!text) return undefined | ||
| // Normalize Windows CRLF and stray CR to LF | ||
| return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } |
There was a problem hiding this comment.
The readText() function is defined but never used in the codebase. The Ctrl+V handler in prompt/index.tsx uses Clipboard.read() instead and checks for content?.mime === "text/plain". Consider either using readText() in the handler or removing this unused function to reduce code duplication and maintenance burden.
| /** | |
| * Reads text from the clipboard, normalizing CRLF to LF. | |
| * Returns undefined if the clipboard is empty, contains non-text, or on error. | |
| * This is used by the Ctrl+V handler to directly paste text on platforms | |
| * where bracketed paste is not reliably emitted (e.g. Windows terminals). | |
| */ | |
| export async function readText(): Promise<string | undefined> { | |
| try { | |
| const text = await clipboardy.read() | |
| if (!text) return undefined | |
| // Normalize Windows CRLF and stray CR to LF | |
| return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") | |
| } catch { | |
| return undefined | |
| } | |
| } |
| if (content?.mime === "text/plain" && content.data) { | ||
| e.preventDefault() | ||
| // Normalize CRLF → LF (Windows clipboard often has CRLF) | ||
| const normalizedText = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n") | ||
| const pastedContent = normalizedText.trim() | ||
| if (!pastedContent) return | ||
|
|
||
| // Check if pasted content is a file path (same logic as onPaste) | ||
| const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") | ||
| const isUrl = /^(https?):\/\//.test(filepath) | ||
| if (!isUrl) { | ||
| try { | ||
| const file = Bun.file(filepath) | ||
| if (file.type === "image/svg+xml") { | ||
| const svgContent = await file.text().catch(() => { }) | ||
| if (svgContent) { | ||
| pasteText(svgContent, `[SVG: ${file.name ?? "image"}]`) | ||
| return | ||
| } | ||
| } | ||
| if (file.type.startsWith("image/")) { | ||
| const imgContent = await file | ||
| .arrayBuffer() | ||
| .then((buffer) => Buffer.from(buffer).toString("base64")) | ||
| .catch(() => { }) | ||
| if (imgContent) { | ||
| await pasteImage({ | ||
| filename: file.name, | ||
| mime: file.type, | ||
| content: imgContent, | ||
| }) | ||
| return | ||
| } | ||
| } | ||
| } catch { } | ||
| } | ||
|
|
||
| // Summarize large pastes (same threshold as onPaste) | ||
| const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 | ||
| if ( | ||
| (lineCount >= 3 || pastedContent.length > 150) && | ||
| !sync.data.config.experimental?.disable_paste_summary | ||
| ) { | ||
| pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) | ||
| return | ||
| } | ||
|
|
||
| // Short text: insert directly | ||
| input.insertText(pastedContent) | ||
| setTimeout(() => { | ||
| if (!input || input.isDestroyed) return | ||
| input.getLayoutNode().markDirty() | ||
| renderer.requestRender() | ||
| }, 0) | ||
| return | ||
| } |
There was a problem hiding this comment.
There is significant code duplication between the Ctrl+V handler (lines 836-891) and the onPaste handler (lines 952-1019). Both implement the same logic for: 1) normalizing CRLF to LF, 2) trimming content, 3) detecting file paths, 4) handling SVG files, 5) handling image files, 6) summarizing large pastes, and 7) inserting short text. This duplication increases maintenance burden and the risk of bugs when one handler is updated but not the other. Consider extracting this shared logic into a common helper function that both handlers can call.
| ], | ||
| }) | ||
| .catch(() => {}) | ||
| .catch(() => { }) |
There was a problem hiding this comment.
The formatting changes in this PR are inconsistent with the established codebase convention. The codebase consistently uses .catch(() => {}) with no space inside the braces (see clipboard.ts:69, auth.ts:254, import.ts:119, run.ts:303, and many other files). This PR changes it to .catch(() => { }) with a space. While this is a minor style issue, it creates inconsistency. Consider either reverting these formatting changes or applying them consistently across the entire codebase in a separate formatting PR.
| .catch(() => { }) | |
| .catch(() => {}) |
| @@ -0,0 +1,138 @@ | |||
| import { describe, expect, test, mock, beforeEach } from "bun:test" | |||
There was a problem hiding this comment.
The imports mock and beforeEach are unused in this test file. Consider removing them to keep the imports clean and avoid confusion.
| import { describe, expect, test, mock, beforeEach } from "bun:test" | |
| import { describe, expect, test } from "bun:test" |
| if (content?.mime === "text/plain" && content.data) { | ||
| e.preventDefault() | ||
| // Normalize CRLF → LF (Windows clipboard often has CRLF) | ||
| const normalizedText = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n") | ||
| const pastedContent = normalizedText.trim() | ||
| if (!pastedContent) return |
There was a problem hiding this comment.
There's a logic issue with empty clipboard content handling. When content?.mime === "text/plain" and content.data is truthy (line 836), the code calls e.preventDefault() at line 837. However, if the content becomes empty after normalization and trim (checked at line 841), the code returns early without doing anything. This means the default paste behavior has already been prevented, so the user sees no paste operation occur at all. If the intention is to let the terminal handle whitespace-only clipboard content, the e.preventDefault() call should be moved after the empty check, or the check should happen before preventDefault().
Fixes this issue #13800
Summary
On Windows 11, pressing Ctrl+V in the TUI prompt does not paste text. This PR fixes the issue by directly reading clipboard text when Ctrl+V is pressed, instead of relying solely on bracketed paste sequences that Windows terminals don't reliably emit.
Root Cause
The
onKeyDownhandler forinput_paste(Ctrl+V) in prompt/index.tsx callsClipboard.read(), which successfully reads both images and text from the clipboard. However, only theimage/*case was handled — for text content, the handler fell through expecting the terminal to emit a bracketed paste sequence. Windows Terminal, cmd, and PowerShell do not reliably send bracketed paste sequences for Ctrl+V key presses, causing text paste to silently fail.Fix
clipboard.ts: Added
Clipboard.readText()— a dedicated text reader that usesclipboardy.read()and normalizes CRLF/CR line endings to LF.prompt/index.tsx: Modified the Ctrl+V handler to also handle
text/plaincontent fromClipboard.read():onPaste: file path detection, image file handling, large paste summarization (≥3 lines or >150 chars), and direct insertion for short textTesting
onPaste) path is fully preservedNotes for Reviewers
[Pasted ~N lines]summary). Test with an image in the clipboard (should still paste as image).