| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useState, useCallback } from "react"; |
| 4 | import type { Terminal } from "@xterm/xterm"; |
| 5 | import type { FitAddon } from "@xterm/addon-fit"; |
| 6 | import type { TermCreds, LaunchSpec } from "@/lib/term"; |
| 7 | import { buildWsUrl } from "@/lib/term"; |
| 8 | |
| 9 | export type TermStatus = "idle" | "connecting" | "live" | "closed" | "error"; |
| 10 | |
| 11 | interface UseTermSocketArgs { |
| 12 | creds: TermCreds | null; |
| 13 | spec: LaunchSpec | null; |
| 14 | visible: boolean; |
| 15 | } |
| 16 | |
| 17 | interface UseTermSocketReturn { |
| 18 | containerRef: (el: HTMLDivElement | null) => void; |
| 19 | status: TermStatus; |
| 20 | sessionName: string | null; |
| 21 | fit: () => void; |
| 22 | banner: string | null; |
| 23 | fontSize: number; |
| 24 | setFontSize: (n: number) => void; |
| 25 | } |
| 26 | |
| 27 | const TERM_THEME = { |
| 28 | background: "#050607", |
| 29 | foreground: "#e8eaed", |
| 30 | cursor: "#16e0c8", |
| 31 | cursorAccent: "#050607", |
| 32 | selectionBackground: "rgba(22,224,200,0.22)", |
| 33 | black: "#0a0c0e", |
| 34 | red: "#ff6b6b", |
| 35 | green: "#0d9e8e", |
| 36 | yellow: "#c9a85a", |
| 37 | blue: "#6ba8ff", |
| 38 | magenta: "#c792ea", |
| 39 | cyan: "#0d9e8e", |
| 40 | white: "#8b929e", |
| 41 | brightBlack: "#4a525e", |
| 42 | brightRed: "#ff8585", |
| 43 | brightGreen: "#16e0c8", |
| 44 | brightYellow: "#e8c468", |
| 45 | brightBlue: "#8fbcff", |
| 46 | brightMagenta: "#dcb4ff", |
| 47 | brightCyan: "#16e0c8", |
| 48 | brightWhite: "#e8eaed", |
| 49 | }; |
| 50 | |
| 51 | const TERM_FONT_KEY = "darwin-term-fontsize"; |
| 52 | const FONT_MIN = 9; |
| 53 | const FONT_MAX = 20; |
| 54 | |
| 55 | function clampFontSize(n: number): number { |
| 56 | return Math.max(FONT_MIN, Math.min(FONT_MAX, Math.round(n))); |
| 57 | } |
| 58 | |
| 59 | function readPersistedFontSize(): number { |
| 60 | if (typeof window === "undefined") return 13; |
| 61 | try { |
| 62 | const raw = window.localStorage.getItem(TERM_FONT_KEY); |
| 63 | if (raw != null) { |
| 64 | const n = parseInt(raw, 10); |
| 65 | if (Number.isFinite(n)) return clampFontSize(n); |
| 66 | } |
| 67 | } catch { |
| 68 | } |
| 69 | return window.innerWidth <= 820 ? 11 : 13; |
| 70 | } |
| 71 | |
| 72 | export function useTermSocket({ |
| 73 | creds, |
| 74 | spec, |
| 75 | visible, |
| 76 | }: UseTermSocketArgs): UseTermSocketReturn { |
| 77 | const [status, setStatus] = useState<TermStatus>("idle"); |
| 78 | const [sessionName, setSessionName] = useState<string | null>(null); |
| 79 | const [banner, setBanner] = useState<string | null>(null); |
| 80 | |
| 81 | const [fontSize, setFontSizeState] = useState<number>(() => readPersistedFontSize()); |
| 82 | const fontSizeRef = useRef(fontSize); |
| 83 | fontSizeRef.current = fontSize; |
| 84 | |
| 85 | const termRef = useRef<Terminal | null>(null); |
| 86 | const fitRef = useRef<FitAddon | null>(null); |
| 87 | const wsRef = useRef<WebSocket | null>(null); |
| 88 | const elRef = useRef<HTMLDivElement | null>(null); |
| 89 | const visibleRef = useRef(visible); |
| 90 | const lastSentSize = useRef<{ cols: number; rows: number }>({ cols: 0, rows: 0 }); |
| 91 | const decoder = useRef(new TextDecoder()); |
| 92 | |
| 93 | visibleRef.current = visible; |
| 94 | |
| 95 | const fit = useCallback(() => { |
| 96 | const term = termRef.current; |
| 97 | const fitAddon = fitRef.current; |
| 98 | const el = elRef.current; |
| 99 | const ws = wsRef.current; |
| 100 | if (!term || !fitAddon || !el) return; |
| 101 | if (!visibleRef.current) return; |
| 102 | const rect = el.getBoundingClientRect(); |
| 103 | if (rect.width < 8 || rect.height < 8) return; |
| 104 | |
| 105 | try { |
| 106 | fitAddon.fit(); |
| 107 | } catch { |
| 108 | return; |
| 109 | } |
| 110 | const cols = term.cols; |
| 111 | const rows = term.rows; |
| 112 | if (!cols || !rows) return; |
| 113 | if (cols === lastSentSize.current.cols && rows === lastSentSize.current.rows) return; |
| 114 | lastSentSize.current = { cols, rows }; |
| 115 | if (ws && ws.readyState === WebSocket.OPEN) { |
| 116 | ws.send(JSON.stringify({ t: "resize", cols, rows })); |
| 117 | } |
| 118 | }, []); |
| 119 | |
| 120 | const setFontSize = useCallback((n: number) => { |
| 121 | const clamped = clampFontSize(n); |
| 122 | try { |
| 123 | window.localStorage.setItem(TERM_FONT_KEY, String(clamped)); |
| 124 | } catch { |
| 125 | } |
| 126 | setFontSizeState(clamped); |
| 127 | const term = termRef.current; |
| 128 | if (term) { |
| 129 | term.options.fontSize = clamped; |
| 130 | requestAnimationFrame(() => fit()); |
| 131 | } |
| 132 | }, [fit]); |
| 133 | |
| 134 | const containerRef = useCallback((el: HTMLDivElement | null) => { |
| 135 | elRef.current = el; |
| 136 | }, []); |
| 137 | |
| 138 | useEffect(() => { |
| 139 | if (!creds || !spec) return; |
| 140 | let disposed = false; |
| 141 | let term: Terminal | null = null; |
| 142 | let fitAddon: FitAddon | null = null; |
| 143 | let ws: WebSocket | null = null; |
| 144 | let ro: ResizeObserver | null = null; |
| 145 | let onWinResize: (() => void) | null = null; |
| 146 | let onVvResize: (() => void) | null = null; |
| 147 | let onVvScroll: (() => void) | null = null; |
| 148 | let vp: HTMLElement | null = null; |
| 149 | let onTouchStart: ((e: TouchEvent) => void) | null = null; |
| 150 | let onTouchMove: ((e: TouchEvent) => void) | null = null; |
| 151 | |
| 152 | setStatus("connecting"); |
| 153 | setSessionName(null); |
| 154 | setBanner(null); |
| 155 | |
| 156 | (async () => { |
| 157 | const [{ Terminal }, { FitAddon }] = await Promise.all([ |
| 158 | import("@xterm/xterm"), |
| 159 | import("@xterm/addon-fit"), |
| 160 | ]); |
| 161 | if (disposed || !elRef.current) return; |
| 162 | |
| 163 | term = new Terminal({ |
| 164 | cursorBlink: true, |
| 165 | fontFamily: "'JetBrains Mono', 'Fira Code', monospace", |
| 166 | fontSize: fontSizeRef.current, |
| 167 | lineHeight: 1.25, |
| 168 | letterSpacing: 0, |
| 169 | scrollback: 20000, |
| 170 | theme: TERM_THEME, |
| 171 | allowProposedApi: true, |
| 172 | macOptionIsMeta: true, |
| 173 | convertEol: false, |
| 174 | }); |
| 175 | fitAddon = new FitAddon(); |
| 176 | term.loadAddon(fitAddon); |
| 177 | term.open(elRef.current); |
| 178 | |
| 179 | termRef.current = term; |
| 180 | fitRef.current = fitAddon; |
| 181 | (elRef.current as unknown as { __term?: Terminal }).__term = term; |
| 182 | |
| 183 | requestAnimationFrame(() => requestAnimationFrame(() => fit())); |
| 184 | |
| 185 | if (typeof document !== "undefined" && document.fonts && document.fonts.ready) { |
| 186 | document.fonts.ready.then(() => { |
| 187 | if (disposed) return; |
| 188 | fit(); |
| 189 | }); |
| 190 | } |
| 191 | |
| 192 | const url = buildWsUrl(creds, spec); |
| 193 | ws = new WebSocket(url); |
| 194 | ws.binaryType = "arraybuffer"; |
| 195 | wsRef.current = ws; |
| 196 | |
| 197 | ws.onopen = () => { |
| 198 | if (disposed) return; |
| 199 | setStatus("live"); |
| 200 | requestAnimationFrame(() => fit()); |
| 201 | }; |
| 202 | |
| 203 | ws.onmessage = (ev) => { |
| 204 | if (!termRef.current) return; |
| 205 | if (typeof ev.data === "string") { |
| 206 | try { |
| 207 | const obj = JSON.parse(ev.data); |
| 208 | if (obj && obj._cockpit === "session" && obj.name) { |
| 209 | setSessionName(String(obj.name)); |
| 210 | return; |
| 211 | } |
| 212 | } catch { |
| 213 | } |
| 214 | setBanner((b) => (b ? b : ev.data)); |
| 215 | termRef.current.write(ev.data); |
| 216 | } else { |
| 217 | const bytes = new Uint8Array(ev.data as ArrayBuffer); |
| 218 | termRef.current.write(bytes); |
| 219 | } |
| 220 | }; |
| 221 | |
| 222 | ws.onclose = () => { |
| 223 | if (disposed) return; |
| 224 | setStatus("closed"); |
| 225 | termRef.current?.write("\r\n\x1b[38;2;74;82;94m── session closed ──\x1b[0m\r\n"); |
| 226 | }; |
| 227 | ws.onerror = () => { |
| 228 | if (disposed) return; |
| 229 | setStatus("error"); |
| 230 | }; |
| 231 | |
| 232 | term.onData((data) => { |
| 233 | const sock = wsRef.current; |
| 234 | if (sock && sock.readyState === WebSocket.OPEN) { |
| 235 | sock.send(new TextEncoder().encode(data)); |
| 236 | } |
| 237 | }); |
| 238 | |
| 239 | vp = elRef.current.querySelector(".xterm-viewport") as HTMLElement | null; |
| 240 | if (vp) { |
| 241 | vp.style.overflowY = "auto"; |
| 242 | vp.style.setProperty("-webkit-overflow-scrolling", "touch"); |
| 243 | vp.style.touchAction = "pan-y"; |
| 244 | let startY = 0; |
| 245 | onTouchStart = (e: TouchEvent) => { |
| 246 | if (e.touches.length > 0) startY = e.touches[0].clientY; |
| 247 | }; |
| 248 | onTouchMove = (e: TouchEvent) => { |
| 249 | const t = termRef.current; |
| 250 | if (!t || e.touches.length === 0) return; |
| 251 | const currentY = e.touches[0].clientY; |
| 252 | const dy = startY - currentY; |
| 253 | const cellHeight = fontSizeRef.current * 1.25; |
| 254 | const lineDelta = Math.round(dy / cellHeight); |
| 255 | if (lineDelta !== 0) { |
| 256 | t.scrollLines(lineDelta); |
| 257 | startY = currentY; |
| 258 | } |
| 259 | }; |
| 260 | vp.addEventListener("touchstart", onTouchStart, { passive: true }); |
| 261 | vp.addEventListener("touchmove", onTouchMove, { passive: true }); |
| 262 | } |
| 263 | |
| 264 | const scheduleFit = () => requestAnimationFrame(() => fit()); |
| 265 | onWinResize = scheduleFit; |
| 266 | window.addEventListener("resize", onWinResize, { passive: true }); |
| 267 | const vv = typeof window !== "undefined" ? window.visualViewport : null; |
| 268 | if (vv) { |
| 269 | onVvResize = scheduleFit; |
| 270 | onVvScroll = scheduleFit; |
| 271 | vv.addEventListener("resize", onVvResize); |
| 272 | vv.addEventListener("scroll", onVvScroll); |
| 273 | } |
| 274 | |
| 275 | ro = new ResizeObserver(() => { |
| 276 | requestAnimationFrame(() => fit()); |
| 277 | }); |
| 278 | if (elRef.current) ro.observe(elRef.current); |
| 279 | })(); |
| 280 | |
| 281 | return () => { |
| 282 | disposed = true; |
| 283 | try { ro?.disconnect(); } catch {} |
| 284 | if (onWinResize) window.removeEventListener("resize", onWinResize); |
| 285 | const vv = typeof window !== "undefined" ? window.visualViewport : null; |
| 286 | if (vv) { |
| 287 | if (onVvResize) vv.removeEventListener("resize", onVvResize); |
| 288 | if (onVvScroll) vv.removeEventListener("scroll", onVvScroll); |
| 289 | } |
| 290 | if (vp) { |
| 291 | if (onTouchStart) vp.removeEventListener("touchstart", onTouchStart); |
| 292 | if (onTouchMove) vp.removeEventListener("touchmove", onTouchMove); |
| 293 | } |
| 294 | try { ws?.close(); } catch {} |
| 295 | try { term?.dispose(); } catch {} |
| 296 | termRef.current = null; |
| 297 | fitRef.current = null; |
| 298 | wsRef.current = null; |
| 299 | lastSentSize.current = { cols: 0, rows: 0 }; |
| 300 | }; |
| 301 | }, [creds, spec?.target, spec?.agent, spec?.mode, spec?.cwd, spec?.session]); |
| 302 | |
| 303 | useEffect(() => { |
| 304 | if (visible) { |
| 305 | requestAnimationFrame(() => requestAnimationFrame(() => fit())); |
| 306 | } |
| 307 | }, [visible, fit]); |
| 308 | |
| 309 | return { containerRef, status, sessionName, fit, banner, fontSize, setFontSize }; |
| 310 | } |