Files
spoon/apps/next/src/components/agent-workspace/xterm-session.tsx
T
Gabriel Brown 12b1c98cfc feat(web): terminal copy-on-select toggle and web-links polish
Wire copy-on-select in XtermSession (shared by the workspace terminal
and the /machine terminal): a header checkbox toggles the behavior and
persists to localStorage under spoon.terminal.copyOnSelect. On mount the
stored value takes precedence over the copyOnSelect prop default. When
enabled, onSelectionChange copies the current selection via
navigator.clipboard.writeText — guarded (empty selections skipped;
clipboard-unavailable/denied errors swallowed). The handler reads the
toggle through a ref so live flips apply without reconnecting.

WebLinksAddon remains loaded so printed URLs are clickable.

Manual verification: in /machine and a workspace terminal, enabling
copy-on-select and selecting text places it on the clipboard; a printed
URL is clickable and opens in a new tab.
2026-07-11 13:17:46 -04:00

380 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import { scheduleTerminalFits } from './terminal-fit';
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'
| 'connected'
| 'closed'
| 'error'
| 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const XtermSession = ({
active,
visible = true,
tokenUrl,
sessionKey,
waitingLabel,
copyOnSelect,
label = 'workspace shell',
}: {
active: boolean;
visible?: boolean;
tokenUrl: string;
sessionKey: string;
waitingLabel?: string;
copyOnSelect?: boolean;
label?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<{ fit: () => void } | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('idle');
const [errorText, setErrorText] = useState<string>();
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<boolean>(
() => 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(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(tokenUrl, { signal });
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
fitRef.current = fit;
// 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;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
scheduleTerminalFits({
fit: () => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
},
sendResize,
fontsReady: document.fonts.ready,
loadNerdFont: () =>
document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
}),
raf: (cb) => requestAnimationFrame(cb),
isAborted,
});
ws = new WebSocket(url);
wsRef.current = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]);
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
// tab is inactive. When the terminal becomes visible again, refit inside a
// frame (so layout has settled) and push the new dimensions to the PTY. The
// WebSocket stays open across visibility changes — this never reconnects.
useEffect(() => {
if (!visible || !active) return;
const frame = requestAnimationFrame(() => {
try {
fitRef.current?.fit();
} catch {
// ignore transient layout errors
}
const ws = wsRef.current;
const term = termRef.current;
if (ws?.readyState === WebSocket.OPEN && term) {
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
}
});
return () => cancelAnimationFrame(frame);
}, [visible, active]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? `Connected · ${label}`
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
<div className='flex items-center gap-3'>
<label className='text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none'>
<input
type='checkbox'
className='accent-primary size-3.5'
checked={copyEnabled}
onChange={toggleCopyOnSelect}
/>
Copy on select
</label>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : !active && waitingLabel ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
{waitingLabel}
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};