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
42 changes: 42 additions & 0 deletions apps/cli/src/tui/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
44 changes: 39 additions & 5 deletions apps/cli/src/tui/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.'
);
}
Comment on lines 89 to 100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 xclip success falls through to OSC52

When xclipResult is true (xclip succeeds), the code skips the inner if (!xclipResult) block but still reaches tryOsc52Copy, writing a raw OSC52 escape sequence to stdout on top of the already-completed copy. Before this PR, a successful xclip caused the function to return via implicit fall-off. The refactor dropped that early-exit path.

Suggested change
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 (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.'
);
}
const xclipResult = await runClipboard(['xclip', '-selection', 'clipboard']);
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.'
);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/cli/src/tui/clipboard.ts
Line: 89-100

Comment:
**xclip success falls through to OSC52**

When `xclipResult` is `true` (xclip succeeds), the code skips the inner `if (!xclipResult)` block but **still reaches `tryOsc52Copy`**, writing a raw OSC52 escape sequence to stdout on top of the already-completed copy. Before this PR, a successful `xclip` caused the function to return via implicit fall-off. The refactor dropped that early-exit path.

```suggestion
		const xclipResult = await runClipboard(['xclip', '-selection', 'clipboard']);
		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.'
			);
		}
```

How can I resolve this? If you propose a fix, please make it concise.

}
}
14 changes: 12 additions & 2 deletions apps/cli/src/tui/context/messages-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
44 changes: 24 additions & 20 deletions apps/cli/src/tui/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,23 @@ export const services = {
const chunkOrder: string[] = [];
let doneEvent: Extract<BtcaStreamEvent, { type: 'done' }> | 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);
Expand All @@ -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)))
});
})
);
},
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/btca.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/guides/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down