diff --git a/apps/next/src/components/agent-workspace/xterm-session.tsx b/apps/next/src/components/agent-workspace/xterm-session.tsx
index 2730587..13b26a6 100644
--- a/apps/next/src/components/agent-workspace/xterm-session.tsx
+++ b/apps/next/src/components/agent-workspace/xterm-session.tsx
@@ -13,6 +13,21 @@ import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
+// Persisted user preference for copy-on-select. A stored value takes precedence
+// over the `copyOnSelect` prop default so the toggle sticks across sessions.
+const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect';
+
+const readStoredCopyOnSelect = (): boolean | undefined => {
+ try {
+ const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY);
+ if (stored === 'true') return true;
+ if (stored === 'false') return false;
+ } catch {
+ // localStorage may be unavailable (private mode, SSR); fall back to prop.
+ }
+ return undefined;
+};
+
export type XtermStatus =
| 'idle'
| 'connecting'
@@ -95,6 +110,29 @@ export const XtermSession = ({
const [status, setStatus] = useState('idle');
const [errorText, setErrorText] = useState();
const [reconnectKey, setReconnectKey] = useState(0);
+ // localStorage overrides the prop default; the prop is only the initial value
+ // when nothing has been persisted yet.
+ const [copyEnabled, setCopyEnabled] = useState(
+ () => readStoredCopyOnSelect() ?? copyOnSelect ?? false,
+ );
+ // The connect effect registers onSelectionChange once; it reads the current
+ // toggle through this ref so live flips take effect without a reconnect.
+ const copyEnabledRef = useRef(copyEnabled);
+ useEffect(() => {
+ copyEnabledRef.current = copyEnabled;
+ }, [copyEnabled]);
+
+ const toggleCopyOnSelect = () => {
+ setCopyEnabled((prev) => {
+ const next = !prev;
+ try {
+ window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next));
+ } catch {
+ // Persistence is best-effort; ignore storage failures.
+ }
+ return next;
+ });
+ };
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
@@ -164,13 +202,22 @@ export const XtermSession = ({
termRef.current = term;
fitRef.current = fit;
- // Task 11 will wire clipboard copy-on-select behavior here. Accept the
- // prop now so callers (and the effect deps) are stable across that change.
- if (copyOnSelect) {
- term.onSelectionChange(() => {
- // no-op placeholder until Task 11
- });
- }
+ // Copy-on-select: when enabled, mirror the selection to the clipboard.
+ // Registered once; the ref lets live toggle flips apply without a
+ // reconnect. Clipboard access is guarded — it may be unavailable or
+ // denied, and empty selections are skipped.
+ term.onSelectionChange(() => {
+ if (!copyEnabledRef.current) return;
+ const selection = term.getSelection();
+ if (!selection) return;
+ try {
+ void navigator.clipboard.writeText(selection).catch(() => {
+ // clipboard write denied/unavailable; ignore
+ });
+ } catch {
+ // navigator.clipboard missing entirely; ignore
+ }
+ });
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
@@ -285,16 +332,27 @@ export const XtermSession = ({
? 'Terminal not configured'
: 'Connection error'}
- {status === 'closed' || status === 'error' ? (
-
- ) : null}
+
+
+ {status === 'closed' || status === 'error' ? (
+
+ ) : null}
+
{status === 'unconfigured' ? (
diff --git a/apps/next/tests/component/xterm-session.test.tsx b/apps/next/tests/component/xterm-session.test.tsx
index 095389c..963e0f7 100644
--- a/apps/next/tests/component/xterm-session.test.tsx
+++ b/apps/next/tests/component/xterm-session.test.tsx
@@ -1,9 +1,17 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
-const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
+const {
+ openSpy,
+ wsInstances,
+ fetchSpy,
+ fitSpy,
+ selectionHandlers,
+ getSelectionSpy,
+ writeTextSpy,
+} = vi.hoisted(() => ({
openSpy: vi.fn(),
wsInstances: [] as Array<{
url: string;
@@ -12,6 +20,11 @@ const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
}>,
fetchSpy: vi.fn(),
fitSpy: vi.fn(),
+ selectionHandlers: [] as (() => void)[],
+ getSelectionSpy: vi.fn<() => string>(() => ''),
+ writeTextSpy: vi.fn<(text: string) => Promise>(() =>
+ Promise.resolve(),
+ ),
}));
class FakeTerminal {
@@ -22,7 +35,10 @@ class FakeTerminal {
loadAddon = vi.fn();
onData = vi.fn();
onResize = vi.fn();
- onSelectionChange = vi.fn();
+ onSelectionChange = vi.fn((cb: () => void) => {
+ selectionHandlers.push(cb);
+ });
+ getSelection = getSelectionSpy;
dispose = vi.fn();
refresh = vi.fn();
write = vi.fn();
@@ -64,8 +80,32 @@ class FakeWebSocket {
beforeEach(() => {
wsInstances.length = 0;
+ selectionHandlers.length = 0;
openSpy.mockClear();
fitSpy.mockClear();
+ getSelectionSpy.mockReset();
+ getSelectionSpy.mockReturnValue('');
+ writeTextSpy.mockReset();
+ writeTextSpy.mockResolvedValue(undefined);
+ const store = new Map();
+ const localStorageMock = {
+ getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
+ setItem: (key: string, value: string) => store.set(key, String(value)),
+ removeItem: (key: string) => store.delete(key),
+ clear: () => store.clear(),
+ key: (index: number) => Array.from(store.keys())[index] ?? null,
+ get length() {
+ return store.size;
+ },
+ };
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ value: localStorageMock,
+ });
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: writeTextSpy },
+ });
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
@@ -93,6 +133,7 @@ beforeEach(() => {
});
afterEach(() => {
+ cleanup();
vi.unstubAllGlobals();
});
@@ -193,4 +234,133 @@ describe('XtermSession', () => {
expect(ws.close).not.toHaveBeenCalled();
expect(wsInstances).toHaveLength(1);
});
+
+ it('copies the selection to the clipboard when copy-on-select is enabled', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
+ getSelectionSpy.mockReturnValue('hello world');
+ selectionHandlers.forEach((cb) => cb());
+
+ await waitFor(() =>
+ expect(writeTextSpy).toHaveBeenCalledWith('hello world'),
+ );
+ });
+
+ it('does not copy when copy-on-select is disabled', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
+ getSelectionSpy.mockReturnValue('should not copy');
+ selectionHandlers.forEach((cb) => cb());
+ await Promise.resolve();
+
+ expect(writeTextSpy).not.toHaveBeenCalled();
+ });
+
+ it('skips empty selections even when enabled', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
+ getSelectionSpy.mockReturnValue('');
+ selectionHandlers.forEach((cb) => cb());
+ await Promise.resolve();
+
+ expect(writeTextSpy).not.toHaveBeenCalled();
+ });
+
+ it('swallows clipboard errors without crashing', async () => {
+ writeTextSpy.mockRejectedValue(new Error('denied'));
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
+ getSelectionSpy.mockReturnValue('boom');
+ selectionHandlers.forEach((cb) => cb());
+
+ await waitFor(() => expect(writeTextSpy).toHaveBeenCalledWith('boom'));
+ // Rejection is caught; the component stays mounted.
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
+ });
+
+ it('renders the header toggle and persists flips to localStorage', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).not.toBeChecked();
+
+ // Off: a selection is not copied.
+ getSelectionSpy.mockReturnValue('nope');
+ selectionHandlers.forEach((cb) => cb());
+ await Promise.resolve();
+ expect(writeTextSpy).not.toHaveBeenCalled();
+
+ // Flip on: persists and now copies.
+ fireEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+ expect(
+ window.localStorage.getItem('spoon.terminal.copyOnSelect'),
+ ).toBe('true');
+
+ getSelectionSpy.mockReturnValue('now copied');
+ selectionHandlers.forEach((cb) => cb());
+ await waitFor(() =>
+ expect(writeTextSpy).toHaveBeenCalledWith('now copied'),
+ );
+
+ // Flip off again: persists false.
+ fireEvent.click(checkbox);
+ expect(checkbox).not.toBeChecked();
+ expect(
+ window.localStorage.getItem('spoon.terminal.copyOnSelect'),
+ ).toBe('false');
+ });
+
+ it('lets the persisted localStorage value override the prop default', () => {
+ window.localStorage.setItem('spoon.terminal.copyOnSelect', 'true');
+ render(
+ ,
+ );
+
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).toBeChecked();
+
+ window.localStorage.setItem('spoon.terminal.copyOnSelect', 'false');
+ render(
+ ,
+ );
+ // The second instance's checkbox reads the persisted false over prop=true.
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes[checkboxes.length - 1]).not.toBeChecked();
+ });
});