From f5bfa85743582fb324b204a7c85b1b98fa2c325a Mon Sep 17 00:00:00 2001 From: bornmw Date: Tue, 23 Jun 2026 23:01:47 -0400 Subject: [PATCH 1/6] trigger title check From 433e9d9d652fb495e2145d75abcf463caf0bcaa2 Mon Sep 17 00:00:00 2001 From: bornmw Date: Tue, 18 Aug 2026 00:21:30 -0400 Subject: [PATCH 2/6] fix(tui): add linux_clipboard_selection config for primary buffer support --- packages/tui/src/app.tsx | 2 +- packages/tui/src/clipboard.ts | 41 +++++++++++++++++++------- packages/tui/src/config/index.tsx | 11 ++++++- packages/tui/src/context/clipboard.tsx | 9 ++++-- 4 files changed, 48 insertions(+), 15 deletions(-) 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..bcbed07bbb28 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -74,15 +74,18 @@ export async function read() { if (text) return { data: text, mime: "text/plain" } } +export type ClipboardSelection = "clipboard" | "primary" + export function copyCommand( os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean, + selection: ClipboardSelection = "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"] : ["wl-copy"] + 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 +97,48 @@ export function copyCommand( } } -let copyMethod: Promise<(text: string) => Promise> | undefined +let copyMethod: Promise<((text: string, selection?: "clipboard" | "primary" | "both") => 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 + if (native?.[0] === "osascript") { - return async (text: string) => { + return async (text: string, selection?: "clipboard" | "primary" | "both") => { 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?: "clipboard" | "primary" | "both") => { + if (selection === "both" && 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?: "clipboard" | "primary" | "both") => { const { default: clipboardy } = await import("clipboardy") await clipboardy.write(text).catch(() => undefined) } })()) } -export async function write(text: string) { +export async function write(text: string, selection?: "clipboard" | "primary" | "both") { 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..62db08293232 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -39,6 +39,10 @@ export const Cursor = Schema.Struct({ }), }).annotate({ description: "Terminal cursor settings" }) +export const LinuxClipboardSelection = Schema.Literals(["clipboard", "primary", "both"]).annotate({ + description: "Linux clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", +}) + export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String)) export type AttentionSoundPaths = Schema.Schema.Type export const Attention = Schema.Struct({ @@ -72,10 +76,13 @@ 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 clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", + }), }) export type Info = Schema.Schema.Type -export type Resolved = Omit & { +export type Resolved = Omit & { attention: { enabled: boolean notifications: boolean @@ -91,6 +98,7 @@ export type Resolved = Omit export type ClipboardService = Readonly<{ read?(): Promise @@ -9,8 +11,11 @@ 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?: ClipboardConfig }) { + const clipboardWithSelection = props.value ?? (props.linuxClipboardSelection + ? { read, write: (text: string) => write(text, props.linuxClipboardSelection!) } + : clipboard) + return {props.children} } export function useClipboard() { From e8b0c88857d818385837dbf3a712d0ae3314132c Mon Sep 17 00:00:00 2001 From: omikheev Date: Tue, 18 Aug 2026 20:43:39 -0400 Subject: [PATCH 3/6] fix(tui): offer text/plain from wl-copy so pastes work --- packages/tui/src/clipboard.ts | 10 ++++++++-- packages/tui/test/clipboard.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index bcbed07bbb28..90fc6052e71b 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -80,10 +80,13 @@ export function copyCommand( os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean, - selection: ClipboardSelection = "clipboard", + selection: "clipboard" | "primary" = "clipboard", ): string[] | undefined { if (os === "darwin" && has("osascript")) return ["osascript"] - if (os === "linux" && wayland && has("wl-copy")) return selection === "primary" ? ["wl-copy", "-p"] : ["wl-copy"] + 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")) { @@ -132,6 +135,9 @@ function getCopyMethod() { } return async (text: string, selection?: "clipboard" | "primary" | "both") => { const { default: clipboardy } = await import("clipboardy") + // clipboardy only supports the clipboard (not primary) selection + if (selection === "primary") return + // For "clipboard" or "both", write to clipboard (primary is not supported without native tools) await clipboardy.write(text).catch(() => undefined) } })()) diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index f2d4994c7e2a..2866a5bd934e 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,24 @@ 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"]) +}) From 115aa803e180952abcc902b55863d49c3ad6fde3 Mon Sep 17 00:00:00 2001 From: omikheev Date: Tue, 18 Aug 2026 21:16:48 -0400 Subject: [PATCH 4/6] fix(tui): use ClipboardSelection from clipboard module in context --- packages/tui/src/clipboard.ts | 20 ++++++++++---------- packages/tui/src/context/clipboard.tsx | 8 +++----- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 90fc6052e71b..d246af0a560a 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -74,13 +74,13 @@ export async function read() { if (text) return { data: text, mime: "text/plain" } } -export type ClipboardSelection = "clipboard" | "primary" +export type ClipboardSelection = "clipboard" | "primary" | "both" export function copyCommand( os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean, - selection: "clipboard" | "primary" = "clipboard", + selection: ClipboardSelection = "clipboard", ): string[] | undefined { if (os === "darwin" && has("osascript")) return ["osascript"] if (os === "linux" && wayland && has("wl-copy")) @@ -100,7 +100,7 @@ export function copyCommand( } } -let copyMethod: Promise<((text: string, selection?: "clipboard" | "primary" | "both") => Promise)> | undefined +let copyMethod: Promise<(text: string, selection?: ClipboardSelection) => Promise> | undefined function getCopyMethod() { return (copyMethod ??= (async () => { @@ -114,13 +114,13 @@ function getCopyMethod() { const native = clipboardCmd if (native?.[0] === "osascript") { - return async (text: string, selection?: "clipboard" | "primary" | "both") => { + 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, selection?: "clipboard" | "primary" | "both") => { + return async (text: string, selection?: ClipboardSelection) => { if (selection === "both" && primaryCmd) { await Promise.allSettled([ command(native[0], native.slice(1), text), @@ -133,18 +133,18 @@ function getCopyMethod() { } } } - return async (text: string, selection?: "clipboard" | "primary" | "both") => { + return async (text: string, selection?: ClipboardSelection) => { const { default: clipboardy } = await import("clipboardy") - // clipboardy only supports the clipboard (not primary) selection if (selection === "primary") return - // For "clipboard" or "both", write to clipboard (primary is not supported without native tools) await clipboardy.write(text).catch(() => undefined) } })()) } -export async function write(text: string, selection?: "clipboard" | "primary" | "both") { - writeOsc52(text) +export async function write(text: string, selection?: ClipboardSelection) { + if (selection !== "primary") { + writeOsc52(text) + } const method = await getCopyMethod() await method(text, selection) } diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index 4d7c168bd64b..f901d318ae85 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -1,7 +1,5 @@ import { createContext, type JSX, useContext } from "solid-js" -import { read, write } from "../clipboard" - -export type ClipboardConfig = "clipboard" | "primary" | "both" +import { read, write, type ClipboardSelection } from "../clipboard" export type ClipboardContent = Readonly<{ data: string; mime: string }> export type ClipboardService = Readonly<{ @@ -11,9 +9,9 @@ export type ClipboardService = Readonly<{ const clipboard = { read, write } const ClipboardContext = createContext(clipboard) -export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element; linuxClipboardSelection?: ClipboardConfig }) { +export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element; linuxClipboardSelection?: ClipboardSelection }) { const clipboardWithSelection = props.value ?? (props.linuxClipboardSelection - ? { read, write: (text: string) => write(text, props.linuxClipboardSelection!) } + ? { read, write: (text: string) => write(text, props.linuxClipboardSelection) } : clipboard) return {props.children} } From cb7bf057ca13c02ceb10ed94ba40a84f30a1be6e Mon Sep 17 00:00:00 2001 From: omikheev Date: Tue, 18 Aug 2026 21:22:50 -0400 Subject: [PATCH 5/6] fix(tui): run prettier on modified files --- packages/tui/src/clipboard.ts | 3 ++- packages/tui/src/config/index.tsx | 8 ++++++-- packages/tui/src/context/clipboard.tsx | 14 ++++++++++---- packages/tui/test/clipboard.test.ts | 12 ++++++++++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index d246af0a560a..997829ff9145 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -88,7 +88,8 @@ export function copyCommand( ? ["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 === "linux" && has("xsel")) + return selection === "primary" ? ["xsel", "--primary", "--input"] : ["xsel", "--clipboard", "--input"] if (os === "win32" && has("powershell.exe")) { return [ "powershell.exe", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 62db08293232..dd8b7cf45720 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -77,12 +77,16 @@ export const Info = Schema.Struct({ 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 clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", + description: + "Linux clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", }), }) 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 diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index f901d318ae85..9faf5bada5d6 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -9,10 +9,16 @@ export type ClipboardService = Readonly<{ const clipboard = { read, write } const ClipboardContext = createContext(clipboard) -export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element; linuxClipboardSelection?: ClipboardSelection }) { - const clipboardWithSelection = props.value ?? (props.linuxClipboardSelection - ? { read, write: (text: string) => write(text, props.linuxClipboardSelection) } - : clipboard) +export function ClipboardProvider(props: { + value?: ClipboardService + children: JSX.Element + linuxClipboardSelection?: ClipboardSelection +}) { + const clipboardWithSelection = + props.value ?? + (props.linuxClipboardSelection + ? { read, write: (text: string) => write(text, props.linuxClipboardSelection) } + : clipboard) return {props.children} } diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index 2866a5bd934e..0ea1755b075f 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -39,6 +39,14 @@ test("supports clipboard selection (default)", () => { "--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"]) + expect(copyCommand("linux", false, (name) => name === "xclip", "clipboard")).toEqual([ + "xclip", + "-selection", + "clipboard", + ]) + expect(copyCommand("linux", false, (name) => name === "xsel", "clipboard")).toEqual([ + "xsel", + "--clipboard", + "--input", + ]) }) From 23da22c449f6892c939bb40fe6ce6394f0367f8f Mon Sep 17 00:00:00 2001 From: omikheev Date: Fri, 21 Aug 2026 00:24:03 -0400 Subject: [PATCH 6/6] fix(tui): selection-aware clipboard read, config tests and docs --- packages/tui/src/clipboard.ts | 43 +++++++++++++++++++------- packages/tui/src/config/index.tsx | 8 +++-- packages/tui/src/context/clipboard.tsx | 5 ++- packages/tui/test/config.test.tsx | 6 ++++ packages/web/src/content/docs/tui.mdx | 2 ++ 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 997829ff9145..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,26 +66,38 @@ 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" } } -export type ClipboardSelection = "clipboard" | "primary" | "both" - export function copyCommand( os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean, - selection: ClipboardSelection = "clipboard", + selection: ClipboardBuffer = "clipboard", ): string[] | undefined { if (os === "darwin" && has("osascript")) return ["osascript"] if (os === "linux" && wayland && has("wl-copy")) @@ -113,6 +130,10 @@ function getCopyMethod() { 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, selection?: ClipboardSelection) => { @@ -122,7 +143,7 @@ function getCopyMethod() { } if (native) { return async (text: string, selection?: ClipboardSelection) => { - if (selection === "both" && primaryCmd) { + if (selection === "both" && distinctPrimary && primaryCmd) { await Promise.allSettled([ command(native[0], native.slice(1), text), command(primaryCmd[0], primaryCmd.slice(1), text), @@ -136,7 +157,7 @@ function getCopyMethod() { } return async (text: string, selection?: ClipboardSelection) => { const { default: clipboardy } = await import("clipboardy") - if (selection === "primary") return + // clipboardy only supports the clipboard; "primary" falls back to it await clipboardy.write(text).catch(() => undefined) } })()) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index dd8b7cf45720..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([ @@ -40,7 +41,8 @@ export const Cursor = Schema.Struct({ }).annotate({ description: "Terminal cursor settings" }) export const LinuxClipboardSelection = Schema.Literals(["clipboard", "primary", "both"]).annotate({ - description: "Linux clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", + 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)) @@ -78,7 +80,7 @@ export const Info = Schema.Struct({ mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), linux_clipboard_selection: Schema.optional(LinuxClipboardSelection).annotate({ description: - "Linux clipboard selection: 'clipboard' (Ctrl+C), 'primary' (middle-click), or 'both' (default: 'both')", + "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 @@ -102,7 +104,7 @@ export type Resolved = Omit< style: "block" | "underline" | "line" | "default" blinking: boolean } - linux_clipboard_selection: "clipboard" | "primary" | "both" + linux_clipboard_selection: ClipboardSelection } export const ResolveOptions = Schema.Struct({ diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index 9faf5bada5d6..c86a742917cb 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -17,7 +17,10 @@ export function ClipboardProvider(props: { const clipboardWithSelection = props.value ?? (props.linuxClipboardSelection - ? { read, write: (text: string) => write(text, props.linuxClipboardSelection) } + ? { + read: () => read(props.linuxClipboardSelection === "primary" ? "primary" : "clipboard"), + write: (text: string) => write(text, props.linuxClipboardSelection), + } : clipboard) return {props.children} } 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.