Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
11 kB · 280 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281import { useEffect, useRef } from "react";import { useStore } from "../stores/useStore";import { terminalWsUrl } from "./terminalWs";import { createTerminalBackend, type TerminalBackend } from "../terminal/backend";import { terminalTheme } from "../terminal/palette";import { fontStack, fontSizeFor } from "../settings";
interface TerminalProps { sessionId: string; kind: string; color: string;}
export function Terminal({ sessionId, kind, color }: TerminalProps) { const terminalRef = useRef<HTMLDivElement>(null); const backendRef = useRef<TerminalBackend | null>(null); const wsRef = useRef<WebSocket | null>(null); // `color` only tints the cursor. Held in a ref so the mount effect can read it at creation // without listing it as a dep — live changes re-theme via the setTheme effect below, not a // full rebuild (which would tear down the pane + WebSocket and replay history: a flash). const colorRef = useRef(color); colorRef.current = color; const serverPort = useStore((s) => s.serverPort); const resolvedTheme = useStore((s) => s.resolvedTheme); const lineSpacing = useStore((s) => s.lineSpacing); const backendId = useStore((s) => s.terminalBackend); const xtermFont = useStore((s) => s.xtermFont); const ghosttyFont = useStore((s) => s.ghosttyFont); const fontSizes = useStore((s) => s.fontSizes);
// Font is per-emulator; the active pane renders the current backend's font. const activeFont = backendId === "xterm" ? xtermFont : ghosttyFont; // Font size is per (emulator, font) pair; resolve the current pair's size. const activeFontSize = fontSizeFor(fontSizes, backendId, activeFont);
// Backends that can't mutate theme/line-height/font/size live (ghostty) fold those into a // remount token, so changing any of them rebuilds the pane at the right settings. xterm // applies them all live, so its token is constant and its rebuild triggers stay the old deps. const remountToken = backendId === "xterm" ? "" : `${resolvedTheme}:${lineSpacing}:${ghosttyFont}:${activeFontSize}`;
useEffect(() => { const container = terminalRef.current; if (!container || !sessionId) return;
// Each effect run owns its own `cancelled` flag; the async body checks it after every // await so a StrictMode double-mount (or a rapid dep change) disposes the just-built // backend instead of leaking a second terminal + WebSocket. let cancelled = false; let backend: TerminalBackend | null = null; let ws: WebSocket | null = null; let resizeObserver: ResizeObserver | null = null; let connectTimeout: ReturnType<typeof setTimeout> | undefined; let fitTimeout: ReturnType<typeof setTimeout> | undefined;
// Clear the container completely before (re)mounting a terminal. while (container.firstChild) container.removeChild(container.firstChild);
(async () => { const created = await createTerminalBackend(backendId, { cursorBlink: true, cursorStyle: "bar", // Read non-reactively; live size changes are handled by the setFontSize effect (xterm) or // the remount token (ghostty), not by tearing down here. fontSize: fontSizeFor( useStore.getState().fontSizes, backendId, backendId === "xterm" ? useStore.getState().xtermFont : useStore.getState().ghosttyFont, ), // Read non-reactively; live font changes are handled by the setFont effect (xterm) or // the remount token (ghostty), not by tearing down here. fontFamily: fontStack( backendId === "xterm" ? useStore.getState().xtermFont : useStore.getState().ghosttyFont, ), fontWeight: "400", letterSpacing: 0, scrollback: 10000, allowProposedApi: true, // Read non-reactively; live changes are handled by the effects below (xterm) or the // remount token (ghostty), not by tearing down here. The theme's cursor color (the // workstream color) likewise re-themes live via the setTheme effect below. lineHeight: useStore.getState().lineSpacing, theme: terminalTheme(useStore.getState().resolvedTheme, colorRef.current), }).catch((err) => { console.warn("[papyrus] terminal backend init failed", err); return null; });
// Torn down while awaiting the backend (StrictMode / rapid dep change) → bail. if (cancelled || !created) { created?.dispose(); return; } backend = created; backendRef.current = created;
backend.open(container); // Reset all terminal attributes and show the cursor before buffered content arrives. backend.write("\x1b[0m\x1b[?25h"); fitTimeout = setTimeout(() => { if (!cancelled) backend?.fit(); }, 50);
// Connect straight to the backend port (from /api/config) so dev bypasses Vite's WS // proxy, which does not relay frames; falls back to the page origin (prod = same port). const wsUrl = terminalWsUrl(window.location, serverPort, sessionId); let isFirstMessage = true;
const connectWs = () => { if (cancelled) return;
ws = new WebSocket(wsUrl); wsRef.current = ws;
ws.onopen = () => { const size = backend?.size; if (size) ws?.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); };
ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "output") { // The first message is buffered history: reset terminal state first. if (isFirstMessage) { isFirstMessage = false; backend?.write("\x1b[2J\x1b[H\x1b[0m"); } backend?.write(msg.data); } } catch { backend?.write(event.data); } }; };
// Small delay to let the server session be ready. connectTimeout = setTimeout(connectWs, 100);
backend.onData((data) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data })); } });
resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { if (cancelled) return; backend?.fit(); const size = backend?.size; if (ws?.readyState === WebSocket.OPEN && size) { ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); } }); }); resizeObserver.observe(container); })();
return () => { cancelled = true; clearTimeout(connectTimeout); clearTimeout(fitTimeout); resizeObserver?.disconnect(); ws?.close(); backend?.dispose(); backendRef.current = null; wsRef.current = null; }; }, [sessionId, serverPort, backendId, remountToken]);
// Re-theme the live terminal when the app theme (or node color) changes, without // recreating it — keeps scrollback and the WebSocket intact. Backends that can't // re-theme live return false and are handled by the remount token instead. useEffect(() => { backendRef.current?.setTheme(resolvedTheme, color); }, [resolvedTheme, color]);
// Apply line-spacing changes live. Unlike a re-theme, line height changes the row count, // so refit and tell the PTY the new dimensions. Backends without live support return // false (the remount token already rebuilt the pane) and this no-ops. useEffect(() => { const backend = backendRef.current; if (backend && backend.setLineHeight(lineSpacing)) { backend.fit(); const ws = wsRef.current; const size = backend.size; if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); } } }, [lineSpacing]);
// Apply font changes live for the active emulator. Like line spacing, a new font changes // cell metrics → row/col count, so refit and tell the PTY. Backends without live support // (ghostty) return false and are handled by the remount token instead. useEffect(() => { const backend = backendRef.current; if (backend && backend.setFont(fontStack(activeFont))) { backend.fit(); const ws = wsRef.current; const size = backend.size; if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); } } }, [activeFont]);
// Apply font-size changes live for the active (emulator, font) pair. Like font/line-spacing, a // new size changes cell metrics → row/col count, so refit and tell the PTY. Backends without // live support (ghostty) return false and are handled by the remount token instead. useEffect(() => { const backend = backendRef.current; if (backend && backend.setFontSize(activeFontSize)) { backend.fit(); const ws = wsRef.current; const size = backend.size; if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); } } }, [activeFontSize]);
// Image paste: papyrus is 100% local, so Claude Code reads the OS clipboard itself on Ctrl+V. // Neither emulator's paste handler inspects images (both read only text/plain), so intercept // the paste in the capture phase — before the emulator's own listener on its inner textarea — // and for an image forward Ctrl+V (\x16) to the PTY, letting Claude read the same clipboard. // Text pastes are untouched (we don't preventDefault), so the emulator handles them normally. useEffect(() => { const el = terminalRef.current; if (!el) return; const onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; const hasImage = !!items && [...items].some((it) => it.kind === "file" && it.type.startsWith("image/")); if (!hasImage) return; e.preventDefault(); e.stopImmediatePropagation(); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data: "\x16" })); } }; el.addEventListener("paste", onPaste, { capture: true }); return () => el.removeEventListener("paste", onPaste, { capture: true }); }, []);
useEffect(() => { if (kind !== "codex") return; const el = terminalRef.current; if (!el) return; const onKeyDown = (e: KeyboardEvent) => { if ( e.key !== "Enter" || !e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.isComposing ) return; // Send the modified key before either emulator turns Shift+Enter into plain Enter. e.preventDefault(); e.stopImmediatePropagation(); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data: "\x1b[13;2u" })); } }; el.addEventListener("keydown", onKeyDown, { capture: true }); return () => el.removeEventListener("keydown", onKeyDown, { capture: true }); }, [kind]);
return ( <div ref={terminalRef} className="w-full h-full" style={{ padding: "12px", backgroundColor: "rgb(var(--color-terminal-bg))", minHeight: "200px", }} /> );}