diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..b3dbab90deaa 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -278,7 +278,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), }} > - + diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 2ae29da88894..d4608d638a59 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -27,7 +27,12 @@ function writeOsc52(text: string) { process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence) } -export async function read() { +export type ClipboardSelection = "clipboard" | "primary" | "both" +export type ClipboardBuffer = Exclude + +export async function read(selection: ClipboardBuffer = "clipboard") { + const primary = selection === "primary" + if (platform() === "darwin") { const file = path.join(tmpdir(), "opencode-clipboard.png") try { @@ -61,14 +66,28 @@ export async function read() { } if (platform() === "linux") { - const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0)) - if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" } - const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() => - Buffer.alloc(0), + const waylandImage = await command("wl-paste", primary ? ["-p", "-t", "image/png"] : ["-t", "image/png"]).catch( + () => Buffer.alloc(0), ) - if (x11.length) return { data: x11.toString("base64"), mime: "image/png" } + if (waylandImage.length) return { data: waylandImage.toString("base64"), mime: "image/png" } + const x11Image = await command("xclip", [ + "-selection", + primary ? "primary" : "clipboard", + "-t", + "image/png", + "-o", + ]).catch(() => Buffer.alloc(0)) + if (x11Image.length) return { data: x11Image.toString("base64"), mime: "image/png" } + if (primary) { + const waylandText = await command("wl-paste", ["-p"]).catch(() => Buffer.alloc(0)) + if (waylandText.length) return { data: waylandText.toString("utf8"), mime: "text/plain" } + const x11Text = await command("xclip", ["-selection", "primary", "-o"]).catch(() => Buffer.alloc(0)) + if (x11Text.length) return { data: x11Text.toString("utf8"), mime: "text/plain" } + } } + // clipboardy only supports the clipboard; for "primary" this reads back the + // buffer the clipboardy fallback in write() targets const { default: clipboardy } = await import("clipboardy") const text = await clipboardy.read().catch(() => undefined) if (text) return { data: text, mime: "text/plain" } @@ -78,11 +97,16 @@ export function copyCommand( os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean, + selection: ClipboardBuffer = "clipboard", ): string[] | undefined { if (os === "darwin" && has("osascript")) return ["osascript"] - if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"] - if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"] - if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"] + if (os === "linux" && wayland && has("wl-copy")) + return selection === "primary" + ? ["wl-copy", "-p", "--type", "text/plain;charset=utf-8"] + : ["wl-copy", "--type", "text/plain;charset=utf-8"] + if (os === "linux" && has("xclip")) return ["xclip", "-selection", selection] + if (os === "linux" && has("xsel")) + return selection === "primary" ? ["xsel", "--primary", "--input"] : ["xsel", "--clipboard", "--input"] if (os === "win32" && has("powershell.exe")) { return [ "powershell.exe", @@ -94,32 +118,55 @@ export function copyCommand( } } -let copyMethod: Promise<(text: string) => Promise> | undefined +let copyMethod: Promise<(text: string, selection?: ClipboardSelection) => Promise> | undefined function getCopyMethod() { return (copyMethod ??= (async () => { const { which } = await import("@opencode-ai/core/util/which") - const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name))) + const os = platform() + const wayland = Boolean(process.env.WAYLAND_DISPLAY) + const has = (name: string) => Boolean(which(name)) + + const clipboardCmd = copyCommand(os, wayland, has, "clipboard") + const primaryCmd = copyCommand(os, wayland, has, "primary") + const native = clipboardCmd + // platforms without a primary buffer resolve both selections to the same + // command, so it must not be spawned twice for "both" + const distinctPrimary = + Boolean(primaryCmd && clipboardCmd) && JSON.stringify(primaryCmd) !== JSON.stringify(clipboardCmd) + if (native?.[0] === "osascript") { - return async (text: string) => { + return async (text: string, selection?: ClipboardSelection) => { const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined) } } if (native) { - return async (text: string) => { - await command(native[0], native.slice(1), text).catch(() => undefined) + return async (text: string, selection?: ClipboardSelection) => { + if (selection === "both" && distinctPrimary && primaryCmd) { + await Promise.allSettled([ + command(native[0], native.slice(1), text), + command(primaryCmd[0], primaryCmd.slice(1), text), + ]) + } else if (selection === "primary" && primaryCmd) { + await command(primaryCmd[0], primaryCmd.slice(1), text).catch(() => undefined) + } else { + await command(native[0], native.slice(1), text).catch(() => undefined) + } } } - return async (text: string) => { + return async (text: string, selection?: ClipboardSelection) => { const { default: clipboardy } = await import("clipboardy") + // clipboardy only supports the clipboard; "primary" falls back to it await clipboardy.write(text).catch(() => undefined) } })()) } -export async function write(text: string) { - writeOsc52(text) +export async function write(text: string, selection?: ClipboardSelection) { + if (selection !== "primary") { + writeOsc52(text) + } const method = await getCopyMethod() - await method(text) + await method(text, selection) } diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..f0c959a55f80 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -3,6 +3,7 @@ export * as TuiConfig from "." import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" import { createContext, type JSX, useContext } from "solid-js" +import { type ClipboardSelection } from "../clipboard" import { TuiKeybind } from "./keybind" export const AttentionSoundName = Schema.Literals([ @@ -39,6 +40,11 @@ export const Cursor = Schema.Struct({ }), }).annotate({ description: "Terminal cursor settings" }) +export const LinuxClipboardSelection = Schema.Literals(["clipboard", "primary", "both"]).annotate({ + description: + "Linux only. The buffer the TUI copy and paste use: 'clipboard' (Ctrl+V), 'primary' (middle-click), or 'both'. Ignored on other platforms.", +}) + export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String)) export type AttentionSoundPaths = Schema.Schema.Type export const Attention = Schema.Struct({ @@ -72,10 +78,17 @@ export const Info = Schema.Struct({ diff_style: Schema.optional(DiffStyle), cursor: Schema.optional(Cursor), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), + linux_clipboard_selection: Schema.optional(LinuxClipboardSelection).annotate({ + description: + "Linux only. The buffer the TUI copy and paste use: 'clipboard' (Ctrl+V), 'primary' (middle-click), or 'both'. Default: 'both'. Ignored on other platforms.", + }), }) export type Info = Schema.Schema.Type -export type Resolved = Omit & { +export type Resolved = Omit< + Info, + "attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor" | "linux_clipboard_selection" +> & { attention: { enabled: boolean notifications: boolean @@ -91,6 +104,7 @@ export type Resolved = Omit export type ClipboardService = Readonly<{ @@ -9,8 +9,20 @@ export type ClipboardService = Readonly<{ const clipboard = { read, write } const ClipboardContext = createContext(clipboard) -export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) { - return {props.children} +export function ClipboardProvider(props: { + value?: ClipboardService + children: JSX.Element + linuxClipboardSelection?: ClipboardSelection +}) { + const clipboardWithSelection = + props.value ?? + (props.linuxClipboardSelection + ? { + read: () => read(props.linuxClipboardSelection === "primary" ? "primary" : "clipboard"), + write: (text: string) => write(text, props.linuxClipboardSelection), + } + : clipboard) + return {props.children} } export function useClipboard() { diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index f2d4994c7e2a..0ea1755b075f 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -2,7 +2,11 @@ import { expect, test } from "bun:test" import { copyCommand } from "../src/clipboard" test("prefers Wayland clipboard when available", () => { - expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"]) + expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual([ + "wl-copy", + "--type", + "text/plain;charset=utf-8", + ]) }) test("uses osascript on macOS", () => { @@ -17,3 +21,32 @@ test("falls back through X11 clipboard commands", () => { test("returns undefined when native clipboard is unavailable", () => { expect(copyCommand("linux", false, () => false)).toBeUndefined() }) + +test("supports primary clipboard selection", () => { + expect(copyCommand("linux", true, (name) => name === "wl-copy", "primary")).toEqual([ + "wl-copy", + "-p", + "--type", + "text/plain;charset=utf-8", + ]) + expect(copyCommand("linux", false, (name) => name === "xclip", "primary")).toEqual(["xclip", "-selection", "primary"]) + expect(copyCommand("linux", false, (name) => name === "xsel", "primary")).toEqual(["xsel", "--primary", "--input"]) +}) + +test("supports clipboard selection (default)", () => { + expect(copyCommand("linux", true, (name) => name === "wl-copy", "clipboard")).toEqual([ + "wl-copy", + "--type", + "text/plain;charset=utf-8", + ]) + expect(copyCommand("linux", false, (name) => name === "xclip", "clipboard")).toEqual([ + "xclip", + "-selection", + "clipboard", + ]) + expect(copyCommand("linux", false, (name) => name === "xsel", "clipboard")).toEqual([ + "xsel", + "--clipboard", + "--input", + ]) +}) diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 37fc0033e561..6721407017cb 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -32,6 +32,7 @@ test("validates config constraints", () => { scroll_speed: 0.001, diff_style: "stacked", cursor: { blinking: false }, + linux_clipboard_selection: "primary", plugin: ["example-plugin"], }), ).toMatchObject({ @@ -39,12 +40,14 @@ test("validates config constraints", () => { attention: { volume: 1 }, diff_style: "stacked", cursor: { blinking: false }, + linux_clipboard_selection: "primary", }) expect(() => decodeInfo({ leader_timeout: 0 })).toThrow() expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow() expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow() expect(() => decodeInfo({ scroll_speed: 0 })).toThrow() expect(() => decodeInfo({ cursor: { style: "beam" } })).toThrow() + expect(() => decodeInfo({ linux_clipboard_selection: "middle" })).toThrow() expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } }) }) @@ -61,6 +64,7 @@ test("resolves host-neutral defaults", () => { }) expect(config.leader_timeout).toBe(LeaderTimeoutDefault) expect(config.mouse).toBe(true) + expect(config.linux_clipboard_selection).toBe("both") expect(config.keybinds.has("terminal.suspend")).toBe(true) expect(config.keybinds.has("session.list")).toBe(true) expect(config.cursor).toBeUndefined() @@ -71,6 +75,7 @@ test("resolves overrides without mutating input", () => { theme: "custom", mouse: false, leader_timeout: 750, + linux_clipboard_selection: "primary", attention: { enabled: true, notifications: false, @@ -88,6 +93,7 @@ test("resolves overrides without mutating input", () => { theme: "custom", mouse: false, leader_timeout: 750, + linux_clipboard_selection: "primary", attention: input.attention, cursor: { style: "block", blinking: false }, }) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 856ef392c748..c0167543652c 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -374,6 +374,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "blinking": true }, "mouse": true, + "linux_clipboard_selection": "both", "attention": { "enabled": true, "notifications": true, @@ -401,6 +402,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. +- `linux_clipboard_selection` - Linux only. Controls which selection buffer OpenCode's clipboard copy writes to: `"clipboard"` (Ctrl+V), `"primary"` (middle-click), or `"both"` (default). This also selects the buffer that OpenCode reads when pasting inside the TUI. On other platforms the regular clipboard is always used. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.