● Zion Boggan
repos/Darwin/face/src/hooks/useTermSocket.ts
zionboggan.com ↗
310 lines · typescript
History for this file →
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
}