Skip to content

Commit f39f01e

Browse files
committed
tui: enable primary clipboard copy for Wayland/X11 to fix Linux middle-click paste
Adds optional config `clipboard.linux.enablePrimaryCopy` to copy to primary clipboard in addition to regular clipboard, enabling middle-click paste on Linux and improving compatibility with environments like VMware Workstation that sync only the primary clipboard.
1 parent 9b6db08 commit f39f01e

4 files changed

Lines changed: 69 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/tui/util/clipboard.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import path from "path"
55
import fs from "fs/promises"
66
import * as Filesystem from "../../../../util/filesystem"
77
import * as Process from "../../../../util/process"
8+
import { Config } from "../../../../config/index.js"
9+
import { AppRuntime } from "../../../../effect/app-runtime.js"
810

911
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
1012
const getWhich = lazy(async () => {
@@ -126,16 +128,35 @@ const getCopyMethod = lazy(async () => {
126128
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
127129
console.log("clipboard: using wl-copy")
128130
return async (text: string) => {
131+
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())).catch(
132+
() => ({}) as Config.Info,
133+
)
134+
const enablePrimary = config.clipboard?.linux?.enablePrimaryCopy ?? false
129135
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
130136
if (!proc.stdin) return
131137
proc.stdin.write(text)
132138
proc.stdin.end()
133139
await proc.exited.catch(() => {})
140+
if (enablePrimary) {
141+
const procPrimary = Process.spawn(["wl-copy", "--primary"], {
142+
stdin: "pipe",
143+
stdout: "ignore",
144+
stderr: "ignore",
145+
})
146+
if (!procPrimary.stdin) return
147+
procPrimary.stdin.write(text)
148+
procPrimary.stdin.end()
149+
await procPrimary.exited.catch(() => {})
150+
}
134151
}
135152
}
136153
if (which("xclip")) {
137154
console.log("clipboard: using xclip")
138155
return async (text: string) => {
156+
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())).catch(
157+
() => ({}) as Config.Info,
158+
)
159+
const enablePrimary = config.clipboard?.linux?.enablePrimaryCopy ?? false
139160
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
140161
stdin: "pipe",
141162
stdout: "ignore",
@@ -145,11 +166,26 @@ const getCopyMethod = lazy(async () => {
145166
proc.stdin.write(text)
146167
proc.stdin.end()
147168
await proc.exited.catch(() => {})
169+
if (enablePrimary) {
170+
const procPrimary = Process.spawn(["xclip", "-selection", "primary"], {
171+
stdin: "pipe",
172+
stdout: "ignore",
173+
stderr: "ignore",
174+
})
175+
if (!procPrimary.stdin) return
176+
procPrimary.stdin.write(text)
177+
procPrimary.stdin.end()
178+
await procPrimary.exited.catch(() => {})
179+
}
148180
}
149181
}
150182
if (which("xsel")) {
151183
console.log("clipboard: using xsel")
152184
return async (text: string) => {
185+
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())).catch(
186+
() => ({}) as Config.Info,
187+
)
188+
const enablePrimary = config.clipboard?.linux?.enablePrimaryCopy ?? false
153189
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
154190
stdin: "pipe",
155191
stdout: "ignore",
@@ -159,6 +195,17 @@ const getCopyMethod = lazy(async () => {
159195
proc.stdin.write(text)
160196
proc.stdin.end()
161197
await proc.exited.catch(() => {})
198+
if (enablePrimary) {
199+
const procPrimary = Process.spawn(["xsel", "--primary", "--input"], {
200+
stdin: "pipe",
201+
stdout: "ignore",
202+
stderr: "ignore",
203+
})
204+
if (!procPrimary.stdin) return
205+
procPrimary.stdin.write(text)
206+
procPrimary.stdin.end()
207+
await procPrimary.exited.catch(() => {})
208+
}
162209
}
163210
}
164211
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Schema } from "effect"
2+
import { zod } from "@/util/effect-zod"
3+
import { withStatics } from "@/util/schema"
4+
5+
const Linux = Schema.Struct({
6+
enablePrimaryCopy: Schema.optional(Schema.Boolean).annotate({
7+
description: "Copy to primary clipboard in addition to regular clipboard on Linux (Wayland/X11)",
8+
}),
9+
}).pipe(withStatics((s) => ({ zod: zod(s) })))
10+
11+
export const Info = Schema.Struct({
12+
linux: Schema.optional(Linux).annotate({
13+
description: "Linux-specific clipboard settings",
14+
}),
15+
}).pipe(withStatics((s) => ({ zod: zod(s) })))
16+
17+
export type Info = Schema.Schema.Type<typeof Info>
18+
19+
export * as ConfigClipboard from "./clipboard"

packages/opencode/src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
2626
import { InstanceRef } from "@/effect/instance-ref"
2727
import { zod, ZodOverride } from "@/util/effect-zod"
2828
import { withStatics } from "@/util/schema"
29+
import { ConfigClipboard } from "./clipboard"
2930
import { ConfigAgent } from "./agent"
3031
import { ConfigCommand } from "./command"
3132
import { ConfigFormatter } from "./formatter"
@@ -111,6 +112,7 @@ export const Info = Schema.Struct({
111112
description: "Command configuration, see https://opencode.ai/docs/commands",
112113
}),
113114
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
115+
clipboard: Schema.optional(ConfigClipboard.Info).annotate({ description: "Clipboard configuration" }),
114116
watcher: Schema.optional(
115117
Schema.Struct({
116118
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),

packages/opencode/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * as Config from "./config"
22
export * as ConfigAgent from "./agent"
3+
export * as ConfigClipboard from "./clipboard"
34
export * as ConfigCommand from "./command"
45
export * as ConfigError from "./error"
56
export * as ConfigFormatter from "./formatter"

0 commit comments

Comments
 (0)