Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f5bfa85
trigger title check
bornmw Jun 24, 2026
433e9d9
fix(tui): add linux_clipboard_selection config for primary buffer sup…
bornmw Aug 18, 2026
c57201b
Merge branch 'anomalyco:dev' into linux-clipboard-selection
bornmw Aug 18, 2026
e8b0c88
fix(tui): offer text/plain from wl-copy so pastes work
bornmw Aug 19, 2026
54d9673
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 19, 2026
115aa80
fix(tui): use ClipboardSelection from clipboard module in context
bornmw Aug 19, 2026
cb7bf05
fix(tui): run prettier on modified files
bornmw Aug 19, 2026
bda6279
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 19, 2026
26d2042
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 19, 2026
2fdfe1c
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 20, 2026
59dae9d
Merge branch 'anomalyco:dev' into linux-clipboard-selection
bornmw Aug 21, 2026
319d66e
Merge branch 'anomalyco:dev' into linux-clipboard-selection
bornmw Aug 21, 2026
23da22c
fix(tui): selection-aware clipboard read, config tests and docs
bornmw Aug 21, 2026
2f4d730
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 21, 2026
0a89b78
Merge branch 'dev' into linux-clipboard-selection
bornmw Aug 24, 2026
d947409
Merge branch 'anomalyco:dev' into linux-clipboard-selection
bornmw Aug 29, 2026
53ef9ec
Merge branch 'dev' into linux-clipboard-selection
bornmw Sep 4, 2026
52caaed
Merge branch 'dev' into linux-clipboard-selection
bornmw Sep 10, 2026
c146f04
Merge branch 'dev' into linux-clipboard-selection
bornmw Sep 14, 2026
049fd8e
Merge branch 'dev' into linux-clipboard-selection
bornmw Sep 16, 2026
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
2 changes: 1 addition & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider linuxClipboardSelection={input.config.linux_clipboard_selection}>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
Expand Down
83 changes: 65 additions & 18 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClipboardSelection, "both">

export async function read(selection: ClipboardBuffer = "clipboard") {
const primary = selection === "primary"

if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
Expand Down Expand Up @@ -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" }
Expand All @@ -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",
Expand All @@ -94,32 +118,55 @@ export function copyCommand(
}
}

let copyMethod: Promise<(text: string) => Promise<void>> | undefined
let copyMethod: Promise<(text: string, selection?: ClipboardSelection) => Promise<void>> | 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)
}
17 changes: 16 additions & 1 deletion packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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<typeof AttentionSounds>
export const Attention = Schema.Struct({
Expand Down Expand Up @@ -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<typeof Info>

export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor"> & {
export type Resolved = Omit<
Info,
"attention" | "keybinds" | "leader_timeout" | "mouse" | "cursor" | "linux_clipboard_selection"
> & {
attention: {
enabled: boolean
notifications: boolean
Expand All @@ -91,6 +104,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" |
style: "block" | "underline" | "line" | "default"
blinking: boolean
}
linux_clipboard_selection: ClipboardSelection
}

export const ResolveOptions = Schema.Struct({
Expand Down Expand Up @@ -132,6 +146,7 @@ export function resolve(input: Info, options: ResolveOptions): Resolved {
blinking: input.cursor.blinking ?? true,
}
: undefined,
linux_clipboard_selection: input.linux_clipboard_selection ?? "both",
}
}

Expand Down
18 changes: 15 additions & 3 deletions packages/tui/src/context/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
import { read, write, type ClipboardSelection } from "../clipboard"

export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
Expand All @@ -9,8 +9,20 @@ export type ClipboardService = Readonly<{
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)

export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
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 <ClipboardContext.Provider value={clipboardWithSelection}>{props.children}</ClipboardContext.Provider>
}

export function useClipboard() {
Expand Down
35 changes: 34 additions & 1 deletion packages/tui/test/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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",
])
})
6 changes: 6 additions & 0 deletions packages/tui/test/config.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ test("validates config constraints", () => {
scroll_speed: 0.001,
diff_style: "stacked",
cursor: { blinking: false },
linux_clipboard_selection: "primary",
plugin: ["example-plugin"],
}),
).toMatchObject({
leader_timeout: 250,
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: {} } })
})

Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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 },
})
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/content/docs/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading