Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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

Expand All @@ -1100,7 +1106,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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
}
>
Expand Down
32 changes: 32 additions & 0 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>) | 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<void>> | undefined

function getCopyMethod() {
Expand Down
12 changes: 12 additions & 0 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions packages/tui/src/context/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -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<ClipboardContent | undefined>
write?(text: string): Promise<void>
readPrimary?(): Promise<string | undefined>
}>
const clipboard = { read, write }
const clipboard = { read, write, readPrimary }
const ClipboardContext = createContext<ClipboardService>(clipboard)

export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
Expand Down
16 changes: 15 additions & 1 deletion packages/tui/test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
Expand All @@ -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()
})
Loading