diff --git a/apps/cli/src/tui/clipboard.test.ts b/apps/cli/src/tui/clipboard.test.ts new file mode 100644 index 00000000..6209be1b --- /dev/null +++ b/apps/cli/src/tui/clipboard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; + +import { getOsc52Sequence, tryOsc52Copy } from './clipboard.ts'; + +describe('clipboard OSC52 fallback', () => { + test('builds a valid OSC52 sequence', () => { + expect(getOsc52Sequence('hello')).toBe('\u001b]52;c;aGVsbG8=\u0007'); + }); + + test('does not copy when stdout is not TTY', () => { + const writes: string[] = []; + const ok = tryOsc52Copy('hello', { + isTTY: false, + term: 'xterm-256color', + write: (value) => writes.push(value) + }); + expect(ok).toBe(false); + expect(writes).toHaveLength(0); + }); + + test('does not copy on dumb terminal', () => { + const writes: string[] = []; + const ok = tryOsc52Copy('hello', { + isTTY: true, + term: 'dumb', + write: (value) => writes.push(value) + }); + expect(ok).toBe(false); + expect(writes).toHaveLength(0); + }); + + test('writes OSC52 sequence on compatible terminal', () => { + const writes: string[] = []; + const ok = tryOsc52Copy('hello', { + isTTY: true, + term: 'xterm-256color', + write: (value) => writes.push(value) + }); + expect(ok).toBe(true); + expect(writes).toEqual(['\u001b]52;c;aGVsbG8=\u0007']); + }); +}); diff --git a/apps/cli/src/tui/clipboard.ts b/apps/cli/src/tui/clipboard.ts index 7772f100..7cc7270e 100644 --- a/apps/cli/src/tui/clipboard.ts +++ b/apps/cli/src/tui/clipboard.ts @@ -18,6 +18,35 @@ const isWayland = () => { return !!process.env.WAYLAND_DISPLAY; }; +export const getOsc52Sequence = (text: string) => { + const encoded = Buffer.from(text, 'utf8').toString('base64'); + return `\u001b]52;c;${encoded}\u0007`; +}; + +export const tryOsc52Copy = ( + text: string, + options: { + isTTY?: boolean; + term?: string; + write?: (value: string) => unknown; + } = {} +) => { + const isTTY = options.isTTY ?? process.stdout.isTTY; + const term = options.term ?? process.env.TERM; + const write = options.write ?? ((value: string) => process.stdout.write(value)); + + if (!isTTY) return false; + if (term === 'dumb') return false; + + try { + // OSC52 allows terminals to copy text to the system clipboard without external binaries. + write(getOsc52Sequence(text)); + return true; + } catch { + return false; + } +}; + export async function copyToClipboard(text: string) { const platform = process.platform; @@ -58,11 +87,16 @@ export async function copyToClipboard(text: string) { // Try xclip first, fall back to xsel const xclipResult = await runClipboard(['xclip', '-selection', 'clipboard']); - if (!xclipResult) { - const xselResult = await runClipboard(['xsel', '--clipboard', '--input']); - if (!xselResult) { - throw new Error('Failed to copy to clipboard: no compatible clipboard command succeeded.'); - } + if (xclipResult) return; + + const xselResult = await runClipboard(['xsel', '--clipboard', '--input']); + if (xselResult) return; + + const osc52Result = tryOsc52Copy(text); + if (!osc52Result) { + throw new Error( + 'Failed to copy to clipboard: no compatible clipboard command succeeded and terminal OSC52 fallback is unavailable.' + ); } } } diff --git a/apps/cli/src/tui/context/messages-context.tsx b/apps/cli/src/tui/context/messages-context.tsx index bd80ab4d..c599e589 100644 --- a/apps/cli/src/tui/context/messages-context.tsx +++ b/apps/cli/src/tui/context/messages-context.tsx @@ -472,7 +472,12 @@ export const MessagesProvider = (props: { children: ReactNode }) => { getAssistantMessageText(assistantMessage) ].join('\n'); try { - await runCliEffect(Effect.tryPromise(() => copyToClipboard(payload))); + await runCliEffect( + Effect.tryPromise({ + try: () => copyToClipboard(payload), + catch: (error) => (error instanceof Error ? error : new Error(String(error))) + }) + ); } catch (error) { addMessage({ role: 'system', content: `Error: ${formatError(error)}` }); return; @@ -493,7 +498,12 @@ export const MessagesProvider = (props: { children: ReactNode }) => { } try { - await runCliEffect(Effect.tryPromise(() => copyToClipboard(parts.join('\n\n')))); + await runCliEffect( + Effect.tryPromise({ + try: () => copyToClipboard(parts.join('\n\n')), + catch: (error) => (error instanceof Error ? error : new Error(String(error))) + }) + ); } catch (error) { addMessage({ role: 'system', content: `Error: ${formatError(error)}` }); return; diff --git a/apps/cli/src/tui/services.ts b/apps/cli/src/tui/services.ts index e3b6fca6..8eaf5123 100644 --- a/apps/cli/src/tui/services.ts +++ b/apps/cli/src/tui/services.ts @@ -115,21 +115,23 @@ export const services = { const chunkOrder: string[] = []; let doneEvent: Extract | undefined; try { - yield* Effect.tryPromise(() => - (async () => { - for await (const event of parseSSEStream(response)) { - if (signal.aborted) break; - if (event.type === 'error') { - throw new Error(formatTuiStreamError(event)); + yield* Effect.tryPromise({ + try: () => + (async () => { + for await (const event of parseSSEStream(response)) { + if (signal.aborted) break; + if (event.type === 'error') { + throw new Error(formatTuiStreamError(event)); + } + if (event.type === 'done') { + doneEvent = event; + continue; + } + processStreamEvent(event, chunksById, chunkOrder, onChunkUpdate); } - if (event.type === 'done') { - doneEvent = event; - continue; - } - processStreamEvent(event, chunksById, chunkOrder, onChunkUpdate); - } - })() - ); + })(), + catch: (e) => (e instanceof Error ? e : new Error(String(e))) + }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { return yield* Effect.fail(error); @@ -152,12 +154,14 @@ export const services = { if (!currentAbortController) return; currentAbortController.abort(); currentAbortController = null; - yield* Effect.tryPromise(() => - trackTelemetryEvent({ - event: 'cli_stream_cancelled', - properties: { command: 'btca', mode: 'tui' } - }) - ); + yield* Effect.tryPromise({ + try: () => + trackTelemetryEvent({ + event: 'cli_stream_cancelled', + properties: { command: 'btca', mode: 'tui' } + }), + catch: (e) => (e instanceof Error ? e : new Error(String(e))) + }); }) ); }, diff --git a/apps/docs/btca.spec.md b/apps/docs/btca.spec.md index 9f3471ed..487794b2 100644 --- a/apps/docs/btca.spec.md +++ b/apps/docs/btca.spec.md @@ -185,6 +185,13 @@ REPL supports `@resource` mentions. - `/copy` — copy the latest user question and assistant response - `/copy-all` — copy the full thread (all user and assistant messages) +Clipboard behavior on Linux: + +- On WSL, btca first tries Windows clipboard via `clip.exe` (including Windows path fallback). +- It then tries `wl-copy` (Wayland), then `xclip`, then `xsel`. +- If these binaries are unavailable, btca falls back to terminal OSC52 clipboard copy. +- OSC52 support depends on the terminal emulator and session configuration. + **TUI keyboard shortcuts**: - `Enter` — send message diff --git a/apps/docs/guides/cli-reference.mdx b/apps/docs/guides/cli-reference.mdx index bc9d33cc..b5e52071 100644 --- a/apps/docs/guides/cli-reference.mdx +++ b/apps/docs/guides/cli-reference.mdx @@ -41,6 +41,13 @@ TUI command palette (`/`): - `/copy` copies the latest user question and assistant response. - `/copy-all` copies the full thread (all user and assistant messages). +Clipboard behavior on Linux: + +- On WSL, btca first tries Windows clipboard via `clip.exe` (including Windows path fallback). +- It then tries `wl-copy` (Wayland), then `xclip`, then `xsel`. +- If those tools are not installed, btca falls back to terminal OSC52 clipboard copy. +- OSC52 support depends on your terminal emulator and SSH/session settings. + TUI keyboard shortcuts: - `Enter` sends message.