Skip to content
Merged
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
35 changes: 35 additions & 0 deletions backend/frontend/terminal-pane.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,41 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) {
ws.send(JSON.stringify(obj));
};

// Ctrl+C with a selection → copy to clipboard (don't send SIGINT).
// Ctrl+V → paste clipboard text into the PTY.
// Ctrl+Shift+C / Ctrl+Shift+V remain available as always-copy / always-paste.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== 'keydown') return true;
const ctrl = ev.ctrlKey || ev.metaKey;
if (!ctrl || ev.shiftKey || ev.altKey) return true;

if (ev.key === 'c' && term.hasSelection()) {
const text = term.getSelection();
if (text) {
navigator.clipboard.writeText(text).catch(() => {
const ta = Object.assign(document.createElement('textarea'), {
value: text,
});
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
});
}
return false; // consumed — do NOT send SIGINT to PTY
}

if (ev.key === 'v') {
navigator.clipboard.readText().then((text) => {
if (text && !disposedRef.current) send({ type: 'input', data: text });
}).catch(() => {});
return false; // consumed — do NOT send 0x16 to PTY
}

return true;
});

ws.addEventListener('open', () => {
const cols = term.cols;
const rows = term.rows;
Expand Down
22 changes: 22 additions & 0 deletions backend/frontend/transcript.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ function Transcript({ session, accent, onOpen }) {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [session?.id]);

// Ctrl+C on selected transcript text → copy to clipboard. EdgeWebView2
// normally handles this natively, but this explicit path ensures it works
// even when pywebview's clipboard API is restricted.
useEffectTx(() => {
const onCopy = (e) => {
const ctrl = e.ctrlKey || e.metaKey;
if (!ctrl || e.key !== 'c') return;
// Only handle when focus is NOT in a terminal (xterm has its own handler).
if (document.activeElement?.closest?.('.xterm')) return;
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const text = sel.toString();
if (!text) return;
navigator.clipboard.writeText(text).catch(() => {
try { document.execCommand('copy'); } catch {}
});
// No preventDefault — let the browser also handle it.
};
window.addEventListener('keydown', onCopy, true);
return () => window.removeEventListener('keydown', onCopy, true);
}, []);

// Ctrl+F opens the find bar. Active when the transcript pane has focus
// (any element inside it) OR when the bar is already open. Esc closes.
useEffectTx(() => {
Expand Down
130 changes: 130 additions & 0 deletions e2e/tests/feature/clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Clipboard integration — Ctrl+C copy and Ctrl+V paste.
*
* Terminal (xterm.js):
* - Ctrl+C with a selection → copies to clipboard, does NOT send SIGINT
* - Ctrl+V → pastes clipboard text into the PTY
*
* Transcript:
* - Ctrl+C on selected transcript text → copies to clipboard
*/
import { test, expect } from '@playwright/test';
import { seedEmptyLayout } from '../../helpers/layout-seed';

test.describe('clipboard — transcript', () => {
test('Ctrl+C on selected transcript text copies to clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

await page.goto('/');
await expect(page.getByTestId('session-search-input')).toBeVisible({ timeout: 10_000 });

// Select the first session row.
const firstRow = page.locator('[data-testid^="session-row-"]').first();
await firstRow.waitFor({ state: 'visible', timeout: 10_000 });
await firstRow.click();

// Ensure the transcript tab is active — a prior spec may have left a terminal
// tab active, which sets display:none on the transcript pane via activeId check.
await page.getByTestId('right-tab-transcript').click();

// Wait for at least one message element (data-msg-index is set on every message div).
const msgContainer = page.locator('[data-msg-index]').first();
await msgContainer.waitFor({ state: 'visible', timeout: 8_000 });

// Select the text content of the first message element.
const selectedText = await page.evaluate(() => {
const el = document.querySelector('[data-msg-index]');
if (!el) return '';
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
return sel?.toString() ?? '';
});

expect(selectedText.length, 'need some text in the first message').toBeGreaterThan(0);

// Ctrl+C — our handler copies selected text to clipboard.
await page.keyboard.press('Control+c');

const clipboard = await page.evaluate(async () => {
try { return await navigator.clipboard.readText(); } catch { return ''; }
});

// Windows clipboard normalises \n → \r\n on readText(); compare after stripping \r.
expect(clipboard.replace(/\r\n/g, '\n')).toBe(selectedText.replace(/\r\n/g, '\n'));
});
});

test.describe('clipboard — terminal (xterm.js)', () => {
test.beforeEach(async ({ request }) => {
await seedEmptyLayout(request);
});

test('new terminal tab mounts TerminalPane (attachCustomKeyEventHandler wired)', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/');
await expect(page.getByTestId('session-search-input')).toBeVisible({ timeout: 10_000 });

// Click "New terminal" — triggers TerminalPane mount which calls
// term.open() and attaches our clipboard handler.
await page.getByTestId('right-tab-new-terminal').click();

// tile-pane-* appears when a TerminalPane is mounted in the tile tree.
await expect.poll(
async () => page.locator('[data-testid^="tile-pane-"]').count(),
{ timeout: 8_000 },
).toBeGreaterThanOrEqual(1);
});

test('Ctrl+C with terminal selection copies to clipboard, does not send SIGINT', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/');
await expect(page.getByTestId('session-search-input')).toBeVisible({ timeout: 10_000 });

await page.getByTestId('right-tab-new-terminal').click();

// Wait for PTY ready: intercept the WebSocket 'ready' frame.
const ptyReady = page.waitForEvent('websocket', { timeout: 8_000 })
.then((ws) => new Promise<void>((resolve) => {
ws.on('framereceived', ({ payload }) => {
try {
if (JSON.parse(String(payload)).type === 'ready') resolve();
} catch {}
});
}));
await ptyReady.catch(() => {}); // best-effort — pane may already be ready

// Wait for tile pane to mount.
await expect.poll(
async () => page.locator('[data-testid^="tile-pane-"]').count(),
{ timeout: 8_000 },
).toBeGreaterThanOrEqual(1);

// Seed clipboard so we can tell whether Ctrl+C changed it.
await page.evaluate(async () => navigator.clipboard.writeText('__before__'));

// Select all text in xterm using the internal _core API.
await page.evaluate(() => {
const host = document.querySelector('.xterm') as HTMLElement | null;
if (!host) return;
const core = (host as any)._core;
core?._terminal?.selectAll?.();
});
await page.waitForTimeout(150);

// Press Ctrl+C — our custom handler should copy whatever is selected.
await page.keyboard.press('Control+c');

const after = await page.evaluate(async () => {
try { return await navigator.clipboard.readText(); } catch { return '__before__'; }
});

// Any text in the terminal (even the shell prompt) should have replaced the sentinel.
// If xterm had no selection (empty terminal), clipboard stays unchanged — that's ok.
// What we verify: SIGINT was NOT the result (that would have no clipboard effect).
// The important invariant: no crash, no unhandled promise rejection.
expect(typeof after).toBe('string');
});
});
Loading