import { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import { awsSsoRefresh, openPageInContainerBrowser, uploadHostFileToTerminal, } from "../../lib/tauri-commands"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { UrlDetector } from "../../lib/urlDetector"; import { RelayRateLimiter, URL_RELAY_OSC, parseUrlRelayOsc, sanitizeRelayUrl, } from "../../lib/urlRelay"; import UrlToast from "./UrlToast"; import { trimSelection } from "./trimSelection"; import TerminalContextMenu from "./TerminalContextMenu"; interface Props { sessionId: string; active: boolean; } export default function TerminalView({ sessionId, active }: Props) { const containerRef = useRef(null); const terminalContainerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const webglRef = useRef(null); const detectorRef = useRef(null); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection); const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom); const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom); const ssoBufferRef = useRef(""); const ssoTriggeredRef = useRef(false); const projectId = useAppState( (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId ); // One toast slot, two producers: the heuristic long-URL detector and the // container's explicit "open this in the host browser" relay (OSC 7777). // Sharing the slot keeps them from stacking on top of each other. // // Both producers read the container's PTY output, so both are untrusted, and // both must go through `sanitizeRelayUrl` before anything is stored here — // see `promptUrl` below, which is the only writer. // // `seq` exists because the slot is shared and long-lived: a second prompt // replacing a first would otherwise mutate the toast in place, swapping the // text under a user who is mid-read and mid-click. Keying the toast on it // remounts the component, so a new URL is unmistakably a new prompt. const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string; seq: number; } | null>(null); const promptSeqRef = useRef(0); const relayLimiterRef = useRef(new RelayRateLimiter()); /** * The only writer of the prompt slot. Re-validates whatever the caller * found: the OSC relay branch has already been through `parseUrlRelayOsc`, * but the heuristic detector branch has been through nothing at all, and a * raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse. */ const promptUrl = useCallback((raw: string, label: string) => { const url = sanitizeRelayUrl(raw); if (!url) { console.warn("Refusing to prompt for a URL that failed validation"); return; } promptSeqRef.current += 1; setUrlPrompt({ url, label, seq: promptSeqRef.current }); }, []); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const isAtBottomRef = useRef(true); // Tracks user intent to follow output — only set to false by explicit user // actions (mouse wheel up), not by xterm scroll events during writes. const autoFollowRef = useRef(true); const lastUserScrollTimeRef = useRef(0); // Keep latest `active` readable inside long-lived listeners (drag-drop below, // and the unmount-cleanup effect further down). const activeRef = useRef(active); activeRef.current = active; // File drag-and-drop: dropped files are copied into the container and their // in-container paths typed into the prompt so Claude Code can read them. // Tauri intercepts OS file drops at the webview level, so we use // onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths). // The listener is window-wide, so we route purely by a hit-test against this // terminal's bounds: the pane the drop lands on handles it. Inactive panes are // `display:none` (zero-size rect) so they never match — this works for the // current tabbed layout and would also do the right thing with split panes. useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; const insideThisTerminal = (pos: { x: number; y: number }): boolean => { const rect = containerRef.current?.getBoundingClientRect(); // A hidden (display:none) pane has a zero-size rect — never a drop target. if (!rect || rect.width === 0 || rect.height === 0) return false; const dpr = window.devicePixelRatio || 1; const x = pos.x / dpr; const y = pos.y / dpr; return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; }; // Always single-quote: a dropped filename can contain shell metacharacters // ($(), &&, ', spaces) even with no whitespace, and this path is typed into // a live shell. Single-quoting with '\'' escaping neutralizes all of them. const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`; (async () => { const un = await getCurrentWebview().onDragDropEvent(async (event) => { if (event.payload.type !== "drop") return; if (!insideThisTerminal(event.payload.position)) return; const paths = event.payload.paths ?? []; if (paths.length === 0) return; setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`); const containerPaths: string[] = []; for (const p of paths) { try { containerPaths.push(await uploadHostFileToTerminal(sessionId, p)); } catch (err) { console.error("File drop upload failed for", p, err); } } if (containerPaths.length === 0) { setImagePasteMsg("File drop failed"); return; } sendInput(sessionId, containerPaths.map(quote).join(" ") + " "); setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`); }); if (cancelled) un(); else unlisten = un; })(); return () => { cancelled = true; unlisten?.(); }; }, [sessionId, sendInput]); useEffect(() => { if (!containerRef.current) return; const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", theme: { background: "#0d1117", foreground: "#e6edf3", cursor: "#58a6ff", selectionBackground: "#264f78", black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39d353", white: "#b1bac4", brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d364", brightWhite: "#f0f6fc", }, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); // Web links addon — opens URLs in host browser via Tauri, with a permissive regex // that matches URLs even if they lack trailing path segments (the default regex // misses OAuth URLs that end mid-line). // eslint-disable-next-line no-control-regex const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/; const webLinksAddon = new WebLinksAddon((_event, uri) => { // Same sink, same rule: what xterm matched came off the container's // output, so it is validated before it reaches the OS opener. A click // here is a deliberate act on visible text, but "visible" is exactly // what a userinfo-spoofed URL subverts. const safe = sanitizeRelayUrl(uri); if (!safe) { console.warn("Refusing to open a link that failed validation"); return; } openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); }, { urlRegex }); term.loadAddon(webLinksAddon); term.open(containerRef.current); // Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding // stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw. // Both prevent the keystroke from reaching the container (where Ctrl+C // would send SIGINT and cancel running work). term.attachCustomKeyEventHandler((event) => { if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") { const sel = term.getSelection(); if (sel) { const out = event.altKey ? sel : trimSelection(sel); navigator.clipboard.writeText(out).catch((e) => console.error("Ctrl+Shift+C clipboard write failed:", e), ); } return false; // prevent xterm from processing this key } // Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status // bar, bound to the active session; trigger it via the store). if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") { useAppState.getState().sttToggle(); return false; } return true; }); // WebGL addon is loaded/disposed dynamically in the active effect // to avoid exhausting the browser's limited WebGL context pool. fitAddon.fit(); termRef.current = term; fitRef.current = fitAddon; // Send initial size resize(sessionId, term.cols, term.rows); // Handle OSC 52 clipboard write sequences from programs inside the container. // When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the // container's shim emits an OSC 52 escape sequence which xterm.js routes here. const osc52Disposable = term.parser.registerOscHandler(52, (data) => { const idx = data.indexOf(";"); if (idx === -1) return false; const payload = data.substring(idx + 1); if (payload === "?") return false; // clipboard read request, not supported try { const decoded = atob(payload); navigator.clipboard.writeText(decoded).catch((e) => console.error("OSC 52 clipboard write failed:", e), ); } catch (e) { console.error("OSC 52 decode failed:", e); } return true; }); // URL relay (OSC 7777) — a CLI inside the container asked for a URL to be // opened in a browser. The container has none; `triple-c-open` (installed // as xdg-open / $BROWSER / sensible-browser / ...) forwards the request // here instead. // // The container is untrusted, so this never opens anything by itself: // parseUrlRelayOsc enforces the http/https allowlist and the payload is // rate-limited, then the user gets the same confirmation toast the // long-URL detector uses. One click is a small price for not handing a // sandboxed agent a "make the host's logged-in browser fetch this" // primitive. const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => { const url = parseUrlRelayOsc(data); if (!url) { console.warn("URL relay: rejected request from container"); return true; // consumed either way — never let it reach the screen } if (!relayLimiterRef.current.allow(url)) { console.warn("URL relay: rate-limited", url); return true; } promptUrl(url, "Container asked to open a URL"); return true; }); // Handle user input -> backend const inputDisposable = term.onData((data) => { sendInput(sessionId, data); }); // Detect user-initiated scroll-up (mouse wheel) to pause auto-follow. // Captured during capture phase so it fires before xterm's own handler. const handleWheel = (e: WheelEvent) => { lastUserScrollTimeRef.current = Date.now(); if (e.deltaY < 0) { autoFollowRef.current = false; setIsAutoFollow(false); isAtBottomRef.current = false; setIsAtBottom(false); } }; containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true }); // Track scroll position to show "Jump to Current" button. // Debounce state updates via rAF to avoid excessive re-renders during rapid output. let scrollStateRafId: number | null = null; const scrollDisposable = term.onScroll(() => { const buf = term.buffer.active; const atBottom = buf.viewportY >= buf.baseY; isAtBottomRef.current = atBottom; // Re-enable auto-follow only when USER scrolls to bottom (not write-triggered) const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300; if (atBottom && isUserScroll && !autoFollowRef.current) { autoFollowRef.current = true; setIsAutoFollow(true); } if (scrollStateRafId === null) { scrollStateRafId = requestAnimationFrame(() => { scrollStateRafId = null; setIsAtBottom(isAtBottomRef.current); }); } }); // Track text selection to show copy hint in status bar const selectionDisposable = term.onSelectionChange(() => { setTerminalHasSelection(term.hasSelection()); }); // Handle image paste: intercept paste events with image data, // upload to the container, and inject the file path into terminal input. const handlePaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of Array.from(items)) { if (item.type.startsWith("image/")) { e.preventDefault(); e.stopPropagation(); const blob = item.getAsFile(); if (!blob) return; blob.arrayBuffer().then(async (buf) => { try { setImagePasteMsg("Uploading image..."); const data = new Uint8Array(buf); const filePath = await pasteImage(sessionId, data); // Inject the file path into terminal stdin sendInput(sessionId, filePath); setImagePasteMsg(`Image saved to ${filePath}`); } catch (err) { console.error("Image paste failed:", err); setImagePasteMsg("Image paste failed"); } }); return; // Only handle the first image } } }; containerRef.current.addEventListener("paste", handlePaste, { capture: true }); // Handle backend output -> terminal let aborted = false; // The width is read per scan, not captured: only a break the terminal // itself inserted may be deleted, and where that is moves with every // resize. const detector = new UrlDetector( (url) => promptUrl(url, "Long URL detected"), () => termRef.current?.cols ?? 0, ); detectorRef.current = detector; const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###"; const textDecoder = new TextDecoder(); const outputPromise = onOutput(sessionId, (data) => { if (aborted) return; term.write(data, () => { if (autoFollowRef.current) { term.scrollToBottom(); if (!isAtBottomRef.current) { isAtBottomRef.current = true; setIsAtBottom(true); } } }); detector.feed(data); // Scan for SSO refresh marker in terminal output if (!ssoTriggeredRef.current && projectId) { const text = textDecoder.decode(data, { stream: true }); // Combine with overlap from previous chunk to handle marker spanning chunks const combined = ssoBufferRef.current + text; if (combined.includes(SSO_MARKER)) { ssoTriggeredRef.current = true; ssoBufferRef.current = ""; awsSsoRefresh(projectId).catch((e) => console.error("AWS SSO refresh failed:", e) ); } else { // Keep last N chars as overlap for next chunk ssoBufferRef.current = combined.slice(-SSO_MARKER.length); } } }).then((unlisten) => { if (aborted) unlisten(); return unlisten; }); const exitPromise = onExit(sessionId, () => { if (aborted) return; term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n"); }).then((unlisten) => { if (aborted) unlisten(); return unlisten; }); // Handle resize (throttled via requestAnimationFrame to avoid excessive calls). // Skip resize work for hidden terminals — containerRef will have 0 dimensions. let resizeRafId: number | null = null; const resizeObserver = new ResizeObserver(() => { if (resizeRafId !== null) return; const el = containerRef.current; if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return; resizeRafId = requestAnimationFrame(() => { resizeRafId = null; if (!containerRef.current || containerRef.current.offsetWidth === 0) return; fitAddon.fit(); resize(sessionId, term.cols, term.rows); if (autoFollowRef.current) { term.scrollToBottom(); } }); }); resizeObserver.observe(containerRef.current); return () => { aborted = true; detector.dispose(); detectorRef.current = null; ssoTriggeredRef.current = false; ssoBufferRef.current = ""; osc52Disposable.dispose(); relayDisposable.dispose(); inputDisposable.dispose(); scrollDisposable.dispose(); selectionDisposable.dispose(); setTerminalHasSelection(false); containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); resizeObserver.disconnect(); try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } webglRef.current = null; term.dispose(); termRef.current = null; }; }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps // Manage WebGL lifecycle and re-fit when tab becomes active. // Only the active terminal holds a WebGL context to avoid exhausting // the browser's limited pool (~8-16 contexts). useEffect(() => { const term = termRef.current; if (!term) return; if (active) { // Attach WebGL renderer if (!webglRef.current) { try { const addon = new WebglAddon(); addon.onContextLoss(() => { try { addon.dispose(); } catch { /* ignore */ } webglRef.current = null; }); term.loadAddon(addon); webglRef.current = addon; } catch { // WebGL not available, canvas renderer is fine } } fitRef.current?.fit(); if (autoFollowRef.current) { term.scrollToBottom(); } term.focus(); } else { // Release WebGL context for inactive terminals if (webglRef.current) { try { webglRef.current.dispose(); } catch { /* ignore */ } webglRef.current = null; } } }, [active]); // Auto-dismiss toast after 30 seconds useEffect(() => { if (!urlPrompt) return; const timer = setTimeout(() => setUrlPrompt(null), 30_000); return () => clearTimeout(timer); }, [urlPrompt]); // Auto-dismiss image paste message after 3 seconds useEffect(() => { if (!imagePasteMsg) return; const timer = setTimeout(() => setImagePasteMsg(null), 3_000); return () => clearTimeout(timer); }, [imagePasteMsg]); const handleOpenUrl = useCallback(() => { if (!urlPrompt) return; // Validated again at the sink. `promptUrl` is the only writer and already // sanitizes, so this can only fail if that invariant is broken — which is // precisely when it matters that the last thing before `openUrl` checks. const safe = sanitizeRelayUrl(urlPrompt.url); setUrlPrompt(null); if (!safe) { console.warn("Refusing to open a URL that failed validation"); return; } openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); }, [urlPrompt]); /** * Open the prompted URL in the container's own browser instead of the host's. * * For a sign-in this is the shorter path: the callback listener the tool is * waiting on is inside the container, so a container-side browser closes the * loop with nothing crossing to the host. The page is published to the * project's Browser tab, which is where the user completes it by hand. */ const handleOpenUrlInContainer = useCallback(() => { if (!urlPrompt) return; const safe = sanitizeRelayUrl(urlPrompt.url); setUrlPrompt(null); if (!safe) { console.warn("Refusing to open a URL that failed validation"); return; } if (!projectId) return; // Land on the pane that will show it, before the work starts: opening takes // several seconds, and the progress line lives there. useAppState.getState().openProjectHomeTab(projectId, "browser"); // A sign-in page is the one case where the *window* size matters least and // the layout matters most, so it gets the ordinary desktop viewport. // `true`: from a terminal there is no Browser pane on screen, so the page // needs a window of its own or it opens somewhere the user isn't looking. openPageInContainerBrowser(projectId, safe, 1280, 720, true) .then((result) => { const push = useAppState.getState().pushToast; if (result.error) { push({ kind: "error", message: "The page didn’t open", detail: result.error }); } else { push({ kind: "success", message: "Opened in the container’s browser", }); } }) .catch((e) => useAppState.getState().pushToast({ kind: "error", message: "Could not open it in the container’s browser", detail: String(e), }), ); }, [urlPrompt, projectId]); const handleScrollToBottom = useCallback(() => { const term = termRef.current; if (term) { autoFollowRef.current = true; setIsAutoFollow(true); fitRef.current?.fit(); term.scrollToBottom(); isAtBottomRef.current = true; setIsAtBottom(true); } }, []); // Surface this terminal's scroll state to the status bar's "Jump to Current" // control, but only while it's the active (visible) terminal. useEffect(() => { if (!active) return; setTerminalAtBottom(isAtBottom); setScrollActiveToBottom(handleScrollToBottom); }, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]); // On unmount, if this was the active terminal, clear the status-bar scroll // state so it doesn't point at a disposed terminal. (Tab switches don't // unmount — the deactivating terminal stays mounted but hidden — so this // only fires when the active session is actually closed.) useEffect(() => { return () => { if (activeRef.current) { setTerminalAtBottom(true); setScrollActiveToBottom(() => {}); } }; }, [setTerminalAtBottom, setScrollActiveToBottom]); const writeSelection = useCallback((mode: "trimmed" | "raw") => { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) return; const out = mode === "raw" ? sel : trimSelection(sel); navigator.clipboard.writeText(out).catch((e) => console.error("Context menu clipboard write failed:", e), ); }, []); const handleContextMenu = useCallback((e: React.MouseEvent) => { if (!termRef.current?.hasSelection()) return; // let default menu happen e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }, []); const handleToggleAutoFollow = useCallback(() => { const next = !autoFollowRef.current; autoFollowRef.current = next; setIsAutoFollow(next); if (next) { const term = termRef.current; if (term) { fitRef.current?.fit(); term.scrollToBottom(); isAtBottomRef.current = true; setIsAtBottom(true); } } }, []); return (
{urlPrompt && ( setUrlPrompt(null)} /> )} {imagePasteMsg && (
setImagePasteMsg(null)} > {imagePasteMsg}
)} {/* Auto-follow toggle - top right */} {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's FitAddon measures the host element it's mounted into; padding there causes the grid to overhang and clip the rightmost column / bottom row. The host below fills this wrapper's content box with no padding. Kept to a tight, even gutter so the terminal claims as much area as possible while leaving a little breathing room beside the scrollbar. */}
{contextMenu && ( { writeSelection("trimmed"); setContextMenu(null); }} onCopyRaw={() => { writeSelection("raw"); setContextMenu(null); }} onDismiss={() => setContextMenu(null)} /> )}
); }