feat(tui): use OpenTUI clipboard service

This commit is contained in:
Simon Klee 2026-08-03 08:15:58 +02:00
commit 679a587c9d
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
21 changed files with 403 additions and 236 deletions

View file

@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve()

View file

@ -1,19 +1,129 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import { createClipboardAdapter } from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
function coreClipboard(options: {
read?: ClipboardReadResult
write?: ClipboardWriteResult
onRead?: (input: ClipboardReadOptions) => void
onWrite?: (text: string, input: ClipboardWriteOptions) => void
}): CoreClipboardService {
return {
async read(input) {
options.onRead?.(input)
return options.read ?? { status: "empty" }
},
async writeText(text, input) {
options.onWrite?.(text, input)
return (
options.write ?? {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
}
)
},
async clear() {
return {
host: { status: "cleared" },
terminal: { status: "not-attempted", capability: "unknown" },
}
},
async dispose() {},
}
}
test("adapts OpenTUI image and text reads", async () => {
const requests: ClipboardReadOptions[] = []
const image = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
},
onRead: (input) => requests.push(input),
}),
)
const text = "line 1\r\n\t世界"
const plain = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
},
}),
)
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("uses all available routes but skips the process host remotely", async () => {
const writes = { host: 0, terminal: 0 }
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
writes.host++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboardAdapter(
createClipboard({
host,
terminal: {
remote: true,
writeText() {
writes.terminal++
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
}),
)
expect(await clipboard.write("hello")).toBeUndefined()
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
const fallback = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
}),
)
const clipboard = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "failed", error: failure },
terminal: { status: "local-failure", capability: "supported" },
},
onWrite: (text, input) => writes.push([text, input]),
}),
)
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
expect(await fallback.write("hello")).toBeUndefined()
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})

View file

@ -7,6 +7,14 @@ import {
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {},
}
export function TestTuiContexts(
props: ParentProps<{
@ -14,6 +22,7 @@ export function TestTuiContexts(
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@ -28,7 +37,9 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>

View file

@ -0,0 +1,97 @@
import { afterAll, expect, mock, test } from "bun:test"
import { TextareaRenderable, type HostClipboardService } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { createComponent } from "solid-js"
import { Prompt } from "../../src/component/prompt"
import { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let reads = 0
await mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => {
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer
},
createHostClipboard: () =>
({
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return { status: "empty" }
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}) satisfies HostClipboardService,
}))
await mock.module("../../src/routes/home", () => ({
Home: () => createComponent(Prompt, { showPlaceholder: false }),
}))
const { run } = await import("../../src/app")
afterAll(() => mock.restore())
test("only zero-byte terminal pastes read the host clipboard", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
activeSetup = setup
reads = 0
const mounted = Promise.withResolvers<void>()
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") mounted.resolve()
setTitle(title)
}
const events = createEventStream()
const calls = createFetch(undefined, events)
const preloaded = Promise.withResolvers<void>()
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const response = await calls.fetch(request)
if (new URL(request.url).pathname === "/api/session/active") preloaded.resolve()
return response
},
})
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await mounted.promise
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
await preloaded.promise
await Bun.sleep(0)
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
await setup.mockInput.pasteBracketedText(" \t\n")
await setup.waitFor(() => input.plainText === " \t\n")
expect(reads).toBe(0)
setup.renderer.keyInput.processPaste(new Uint8Array())
await setup.waitFor(() => reads === 1)
expect(input.plainText).toBe(" \t\n")
} finally {
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
}
})