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(); + }); });