Skip to content
Open
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
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