diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..2635057c5004 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1091,6 +1091,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi flexDirection="column" backgroundColor={theme.background} onMouseDown={(evt) => { + if (evt.button === MouseButton.MIDDLE && process.platform === "linux") { + evt.preventDefault() + evt.stopPropagation() + keymap.dispatchCommand("prompt.paste.primary") + return + } if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return @@ -1100,7 +1106,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }} onMouseUp={ !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT - ? () => Selection.copy(renderer, toast, clipboard) + ? (evt) => { + if (evt.button === MouseButton.MIDDLE) return + Selection.copy(renderer, toast, clipboard) + } : undefined } > diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 2ae29da88894..956dac81cf23 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -94,6 +94,38 @@ export function copyCommand( } } +export function readPrimaryCommand( + os: NodeJS.Platform, + wayland: boolean, + has: (name: string) => boolean, +): string[] | undefined { + if (os !== "linux") return + if (wayland && has("wl-paste")) return ["wl-paste", "--primary", "--no-newline"] + if (has("xclip")) return ["xclip", "-selection", "primary", "-o"] + if (has("xsel")) return ["xsel", "--primary", "--output"] +} + +let readPrimaryMethod: Promise<(() => Promise) | undefined> | undefined + +function getReadPrimaryMethod() { + return (readPrimaryMethod ??= (async () => { + const { which } = await import("@opencode-ai/core/util/which") + const native = readPrimaryCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name))) + if (!native) return + return async () => { + const output = await command(native[0], native.slice(1)).catch(() => Buffer.alloc(0)) + if (!output.length) return + return output.toString("utf8") + } + })()) +} + +// X11/Wayland primary selection: the buffer that middle click traditionally pastes. +export async function readPrimary() { + const method = await getReadPrimaryMethod() + return method?.() +} + let copyMethod: Promise<(text: string) => Promise> | undefined function getCopyMethod() { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..5363174fa116 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -389,6 +389,18 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "Paste primary selection", + name: "prompt.paste.primary", + category: "Prompt", + hidden: true, + enabled: process.platform === "linux", + run: async () => { + const text = await clipboard.readPrimary?.() + if (!text) return + await pasteInputText(text) + }, + }, { title: "Interrupt session", name: "session.interrupt", diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index 6e0ac5370e1a..bd9cf4d81cc8 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -1,12 +1,13 @@ import { createContext, type JSX, useContext } from "solid-js" -import { read, write } from "../clipboard" +import { read, readPrimary, write } from "../clipboard" export type ClipboardContent = Readonly<{ data: string; mime: string }> export type ClipboardService = Readonly<{ read?(): Promise write?(text: string): Promise + readPrimary?(): Promise }> -const clipboard = { read, write } +const clipboard = { read, write, readPrimary } const ClipboardContext = createContext(clipboard) export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) { diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index f2d4994c7e2a..51983f645db3 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { copyCommand } from "../src/clipboard" +import { copyCommand, readPrimaryCommand } from "../src/clipboard" test("prefers Wayland clipboard when available", () => { expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"]) @@ -17,3 +17,17 @@ test("falls back through X11 clipboard commands", () => { test("returns undefined when native clipboard is unavailable", () => { expect(copyCommand("linux", false, () => false)).toBeUndefined() }) + +test("reads primary selection with Wayland tooling when available", () => { + expect(readPrimaryCommand("linux", true, (name) => name === "wl-paste")).toEqual(["wl-paste", "--primary", "--no-newline"]) +}) + +test("falls back through X11 primary selection commands", () => { + expect(readPrimaryCommand("linux", false, (name) => name === "xclip")).toEqual(["xclip", "-selection", "primary", "-o"]) + expect(readPrimaryCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--primary", "--output"]) +}) + +test("returns undefined for primary selection on non-Linux platforms", () => { + expect(readPrimaryCommand("darwin", true, () => true)).toBeUndefined() + expect(readPrimaryCommand("win32", true, () => true)).toBeUndefined() +})