import { useEffect, useRef } 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"; /** Strip ANSI escape sequences from a string. */ function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, ""); } interface Props { sessionId: string; active: boolean; } export default function TerminalView({ sessionId, active }: Props) { const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const { sendInput, resize, onOutput, onExit } = useTerminal(); 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). const urlRegex = /https?:\/\/[^\s'"\x07]+/; const webLinksAddon = new WebLinksAddon((_event, uri) => { openUrl(uri).catch((e) => console.error("Failed to open URL:", e)); }, { urlRegex }); term.loadAddon(webLinksAddon); term.open(containerRef.current); // Try WebGL renderer, fall back silently try { const webglAddon = new WebglAddon(); term.loadAddon(webglAddon); } catch { // WebGL not available, canvas renderer is fine } fitAddon.fit(); termRef.current = term; fitRef.current = fitAddon; // Send initial size resize(sessionId, term.cols, term.rows); // Handle user input -> backend const inputDisposable = term.onData((data) => { sendInput(sessionId, data); }); // ── URL accumulator ────────────────────────────────────────────── // Claude Code login emits a long OAuth URL that gets split across // hard newlines (\n / \r\n). The WebLinksAddon only joins // soft-wrapped lines (the `isWrapped` flag), so the URL match is // truncated and the link fails when clicked. // // Fix: buffer recent output, strip ANSI codes, and after a short // debounce check for a URL that spans multiple lines. When found, // write a single clean clickable copy to the terminal. let outputBuffer = ""; let debounceTimer: ReturnType | null = null; const flushUrlBuffer = () => { const plain = stripAnsi(outputBuffer); // Reassemble: strip hard newlines and carriage returns to join // fragments that were split across terminal lines. const joined = plain.replace(/[\r\n]+/g, ""); // Look for a long OAuth/auth URL (Claude login URLs contain // "oauth" or "console.anthropic.com" or "/authorize"). const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/); if (match) { const url = match[0]; term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n"); term.write(`\x1b[4;34m${url}\x1b[0m\r\n`); } outputBuffer = ""; }; // Handle backend output -> terminal let unlistenOutput: (() => void) | null = null; let unlistenExit: (() => void) | null = null; onOutput(sessionId, (data) => { term.write(data); // Accumulate for URL detection outputBuffer += data; // Cap buffer size to avoid memory growth if (outputBuffer.length > 8192) { outputBuffer = outputBuffer.slice(-4096); } if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(flushUrlBuffer, 150); }).then((unlisten) => { unlistenOutput = unlisten; }); onExit(sessionId, () => { term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n"); }).then((unlisten) => { unlistenExit = unlisten; }); // Handle resize const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); resize(sessionId, term.cols, term.rows); }); resizeObserver.observe(containerRef.current); return () => { if (debounceTimer) clearTimeout(debounceTimer); inputDisposable.dispose(); unlistenOutput?.(); unlistenExit?.(); resizeObserver.disconnect(); term.dispose(); }; }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps // Re-fit when tab becomes active useEffect(() => { if (active && fitRef.current && termRef.current) { fitRef.current.fit(); termRef.current.focus(); } }, [active]); return (
); }