From 51bd622d150084d003c02ea648c26c39ce623804 Mon Sep 17 00:00:00 2001 From: Sergei Ivashchenko Date: Thu, 25 Dec 2025 20:43:46 +0000 Subject: [PATCH] Enable middle-click paste across TUI --- packages/opencode/src/cli/cmd/tui/app.tsx | 6 +- .../cli/cmd/tui/component/prompt/index.tsx | 144 ++++++++++-------- 2 files changed, 89 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 13c95d9b9ea..f902c783b0f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,6 +1,6 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" -import { TextAttributes } from "@opentui/core" +import { MouseButton, TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" @@ -580,6 +580,10 @@ function App() { width={dimensions().width} height={dimensions().height} backgroundColor={theme.background} + onMouseDown={async (event) => { + if (event.button !== MouseButton.MIDDLE) return + await promptRef.current?.pasteFromClipboard?.() + }} onMouseUp={async () => { if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { renderer.clearSelection() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9494b81cb10..c5f1163c5d0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -47,6 +47,7 @@ export type PromptRef = { blur(): void focus(): void submit(): void + pasteFromClipboard(): Promise } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] @@ -514,6 +515,7 @@ export function Prompt(props: PromptProps) { submit() { submit() }, + pasteFromClipboard, }) async function submit() { @@ -710,6 +712,84 @@ export function Prompt(props: PromptProps) { return } + async function pasteFromClipboard() { + if (props.disabled) return + input.focus() + + const content = await Clipboard.read() + if (!content) return + if (content.mime.startsWith("image/")) { + await pasteImage({ + filename: "clipboard", + mime: content.mime, + content: content.data, + }) + return + } + if (content.mime.startsWith("text/")) { + await handleTextPaste(content.data, { insertText: true }) + } + } + + async function handleTextPaste(rawText: string, options: { insertText: boolean; preventDefault?: () => void }) { + const normalizedText = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const trimmedText = normalizedText.trim() + if (!trimmedText) { + command.trigger("prompt.paste") + return + } + + const filepath = trimmedText.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const file = Bun.file(filepath) + // Handle SVG as raw text content, not as base64 image + if (file.type === "image/svg+xml") { + options.preventDefault?.() + const content = await file.text().catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${file.name ?? "image"}]`) + return + } + } + if (file.type.startsWith("image/")) { + options.preventDefault?.() + const content = await file + .arrayBuffer() + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteImage({ + filename: file.name, + mime: file.type, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (trimmedText.match(/\n/g)?.length ?? 0) + 1 + if ((lineCount >= 3 || trimmedText.length > 150) && !sync.data.config.experimental?.disable_paste_summary) { + options.preventDefault?.() + pasteText(trimmedText, `[Pasted ~${lineCount} lines]`) + return + } + + if (options.insertText) { + input.insertText(normalizedText) + } + + // Force layout update and render for the pasted content + setTimeout(() => { + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -874,66 +954,10 @@ export function Prompt(props: PromptProps) { return } - // Normalize line endings at the boundary - // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste - // Replace CRLF first, then any remaining CR - const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const pastedContent = normalizedText.trim() - if (!pastedContent) { - command.trigger("prompt.paste") - return - } - - // trim ' from the beginning and end of the pasted content. just - // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const file = Bun.file(filepath) - // Handle SVG as raw text content, not as base64 image - if (file.type === "image/svg+xml") { - event.preventDefault() - const content = await file.text().catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${file.name ?? "image"}]`) - return - } - } - if (file.type.startsWith("image/")) { - event.preventDefault() - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - !sync.data.config.experimental?.disable_paste_summary - ) { - event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - // Force layout update and render for the pasted content - setTimeout(() => { - input.getLayoutNode().markDirty() - input.gotoBufferEnd() - renderer.requestRender() - }, 0) + await handleTextPaste(event.text, { + insertText: false, + preventDefault: () => event.preventDefault(), + }) }} ref={(r: TextareaRenderable) => { input = r