SPB Git

Repository Public

controller.ts 21.9 KB 538 lines typescript

1/**2 * One xterm.js instance + one WebSocket per terminal session (docs/CONTRACT.md, wire.ts).3 *  - binary frames = raw PTY bytes in both directions; text frames = JSON control messages4 *  - reconnects with exponential backoff + jitter (0.5 s → 10 s) and immediately on5 *    visibility/online/pageshow; every new attach repaints, so the terminal is reset first6 *  - never creates or restarts a session by itself7 */8import '@/lib/zod-config';9import { Terminal, type ITheme } from '@xterm/xterm';10import { FitAddon } from '@xterm/addon-fit';11import { Unicode11Addon } from '@xterm/addon-unicode11';12import { WebLinksAddon } from '@xterm/addon-web-links';13import { TermServerMsg, WS_CLOSE, type TermClientMsg, type TerminalSession } from '@cr/shared';14import { api, ApiRequestError, redirectToLogin, wsUrl } from '@/lib/api';15import { useTerminals, type TermConn, type TermStatus } from '@/stores/terminals';16import { applyModsToInput, hasMods, keySequence } from './keys';17import { registerController, getController } from './registry';18import type { AgentId } from '@cr/shared';19import { getBrand } from '@/stores/branding';2021/**22 * Terminal palettes. Every ANSI colour of the light palette is ≥ 4.5:1 on its background23 * (dev/check-contrast.mjs); `minimumContrastRatio` additionally lifts 256/true-colour text24 * (e.g. the agent CLI's orange and dim greys) to 4.5:1 in both themes.25 */26export type TermThemeName = 'light' | 'dark';27export const TERM_THEMES: Record<TermThemeName, ITheme> = {28  dark: {29    background: '#080B10', foreground: '#F4F7FB', cursor: '#8F8FFF', cursorAccent: '#080B10',30    selectionBackground: '#8F8FFF59', selectionInactiveBackground: '#8F8FFF33',31    scrollbarSliderBackground: '#ffffff1f', scrollbarSliderHoverBackground: '#ffffff38', scrollbarSliderActiveBackground: '#ffffff4d',32    black: '#161D27', red: '#ED7777', green: '#42C995', yellow: '#E7B86B', blue: '#729FFF', magenta: '#B692F6', cyan: '#5CC6D6', white: '#A1ADBD',33    brightBlack: '#7F8CA0', brightRed: '#F59C9C', brightGreen: '#72DDB4', brightYellow: '#F1CF95', brightBlue: '#9DBCFF', brightMagenta: '#CDB4FA', brightCyan: '#8DDCE8', brightWhite: '#F4F7FB',34  },35  light: {36    background: '#FCFCFA', foreground: '#1F2328', cursor: '#5151E8', cursorAccent: '#FCFCFA',37    selectionBackground: '#5151E838', selectionInactiveBackground: '#5151E820',38    scrollbarSliderBackground: '#17182024', scrollbarSliderHoverBackground: '#17182040', scrollbarSliderActiveBackground: '#17182059',39    black: '#24292F', red: '#C4232D', green: '#116329', yellow: '#7D4E00', blue: '#0860C7', magenta: '#7C3FD6', cyan: '#0F6E75', white: '#57606A',40    brightBlack: '#636C76', brightRed: '#A40E26', brightGreen: '#1A7F37', brightYellow: '#8F5F00', brightBlue: '#0550AE', brightMagenta: '#6639BA', brightCyan: '#0B5D63', brightWhite: '#3D444D',41  },42};43/**44 * Accent-tinted parts of the palettes (cursor, selection) for a Codex instance: the emerald/teal45 * accent of globals.css (`html[data-agent='codex']`) instead of indigo. The ANSI colours are shared.46 */47const TERM_ACCENTS: Record<AgentId, Record<TermThemeName, Pick<ITheme, 'cursor' | 'selectionBackground' | 'selectionInactiveBackground'>> | null> = {48  claude: null, // the palettes above are the Claude ones49  codex: {50    dark: { cursor: '#2DD4BF', selectionBackground: '#2DD4BF4D', selectionInactiveBackground: '#2DD4BF2B' },51    light: { cursor: '#0B7268', selectionBackground: '#0B726838', selectionInactiveBackground: '#0B726820' },52  },53};54/** Terminal palette for a theme, with the accent of the instance's agent. */55export function termTheme(name: TermThemeName, agent: AgentId = getBrand().agent): ITheme {56  const accent = TERM_ACCENTS[agent];57  return accent ? { ...TERM_THEMES[name], ...accent[name] } : TERM_THEMES[name];58}59/** @deprecated kept for callers that only need the dark palette */60export const TERM_THEME: ITheme = TERM_THEMES.dark;6162export interface TermOptions {63  fontFamily: string;64  fontSize: number;65  scrollback: number;66  theme: TermThemeName;67}68const fontStack = (name: string) => `"${name.replace(/"/g, '')}", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace`;69const isIOS = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);70const isMacLike = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);71const encoder = new TextEncoder();72const SYMBOL_SAMPLE = '─│╭╯█▌→⠋✻●';73const CHUNK = 64 * 1024; // inbound frames above 1 MiB are rejected by the server7475export class TerminalController {76  readonly el: HTMLDivElement;77  readonly term: Terminal;78  private readonly fitAddon = new FitAddon();79  private ws: WebSocket | null = null;80  private opened = false;81  private disposed = false;82  private attempt = 0;83  private retryTimer: ReturnType<typeof setTimeout> | null = null;84  private pingTimer: ReturnType<typeof setInterval> | null = null;85  private liveTimer: ReturnType<typeof setTimeout> | null = null;86  private resizeTimer: ReturnType<typeof setTimeout> | null = null;87  private pingSeq = 0;88  private pings = new Map<number, number>();89  private lastPongAt = 0;90  private ro: ResizeObserver | null = null;91  private ready: Promise<void> | null = null;92  status: TermStatus = 'idle';93  private everOpened = false;94  private scrolledUp = false;95  private unread = false;96  private themeName: TermThemeName;97  /** E2E only: bytes sent to the server, as text (never populated in production). */98  debugSent: string[] | null = process.env.NEXT_PUBLIC_E2E === '1' ? [] : null;99100  constructor(readonly id: string, opts: TermOptions) {101    this.el = document.createElement('div');102    this.el.className = 'h-full w-full';103    this.el.dataset.termId = id;104    this.themeName = opts.theme;105    this.term = new Terminal({106      allowProposedApi: true, // unicode11107      fontFamily: fontStack(opts.fontFamily),108      fontSize: opts.fontSize,109      lineHeight: 1.15,110      scrollback: opts.scrollback,111      theme: termTheme(opts.theme),112      cursorBlink: true,113      cursorStyle: 'bar',114      cursorInactiveStyle: 'outline',115      macOptionIsMeta: true,116      minimumContrastRatio: 4.5,117      drawBoldTextInBrightColors: false,118      rightClickSelectsWord: false,119      scrollOnUserInput: true,120      smoothScrollDuration: 0,121    });122    this.term.loadAddon(this.fitAddon);123    this.term.loadAddon(new Unicode11Addon());124    this.term.unicode.activeVersion = '11';125    this.term.loadAddon(new WebLinksAddon((ev, uri) => {126      if (/^https?:\/\//i.test(uri) && (ev.metaKey || ev.ctrlKey || ev.type === 'click')) window.open(uri, '_blank', 'noopener,noreferrer');127    }));128    this.term.onData((d) => this.input(d));129    this.term.onBinary((d) => this.sendBytes(Uint8Array.from(d, (c) => c.charCodeAt(0) & 0xff)));130    this.term.onResize(({ cols, rows }) => this.queueResize(cols, rows));131    this.term.onScroll(() => this.syncScroll());132    this.term.attachCustomKeyEventHandler((ev) => this.onKey(ev));133    registerController(this);134  }135136  // ------------------------------------------------------------------ mounting137  async mount(container: HTMLElement): Promise<void> {138    container.appendChild(this.el);139    this.ready ??= this.openTerm();140    await this.ready;141    if (this.disposed || this.el.parentElement !== container) return;142    this.ro?.disconnect();143    this.ro = new ResizeObserver(() => this.fit());144    this.ro.observe(container);145    this.fit();146    if (this.status === 'idle') this.connect();147  }148  unmount(container: HTMLElement): void {149    if (this.el.parentElement === container) {150      this.ro?.disconnect();151      this.ro = null;152      container.removeChild(this.el);153    }154  }155  private async openTerm(): Promise<void> {156    const size = this.term.options.fontSize ?? 13;157    const family = this.term.options.fontFamily ?? 'monospace';158    try {159      // Glyph metrics must be final before xterm measures cells.160      await Promise.race([161        // The symbol faces (box drawing, braille, dingbats) are separate unicode-range files: load them162        // too, otherwise xterm caches the fallback font's advance widths and frames drift by a cell.163        Promise.all([document.fonts.load(`${size}px ${family}`), document.fonts.load(`bold ${size}px ${family}`), document.fonts.load(`${size}px ${family}`, SYMBOL_SAMPLE), document.fonts.load(`bold ${size}px ${family}`, SYMBOL_SAMPLE)]),164        new Promise((r) => setTimeout(r, 1500)),165      ]);166    } catch {167      /* font loading API unavailable */168    }169    if (this.disposed) return;170    this.term.open(this.el);171    if (!isIOS()) await this.tryWebgl();172    const ta = this.term.textarea;173    if (ta) {174      ta.setAttribute('autocapitalize', 'off');175      ta.setAttribute('autocorrect', 'off');176      ta.setAttribute('autocomplete', 'off');177      ta.setAttribute('spellcheck', 'false');178      ta.setAttribute('enterkeyhint', 'send');179      ta.setAttribute('aria-label', 'Terminal input');180    }181  }182  /** WebGL renderer when available; DOM renderer on context loss, on failure and on iOS. */183  private async tryWebgl(): Promise<void> {184    try {185      const probe = document.createElement('canvas').getContext('webgl2');186      if (!probe) return;187      const { WebglAddon } = await import('@xterm/addon-webgl');188      if (this.disposed) return;189      const addon = new WebglAddon();190      addon.onContextLoss(() => addon.dispose());191      this.term.loadAddon(addon);192    } catch {193      /* DOM renderer stays active */194    }195  }196  fit(): void {197    if (!this.el.isConnected || this.el.clientWidth < 40 || this.el.clientHeight < 20 || !this.term.element) return;198    try {199      this.fitAddon.fit();200    } catch {201      /* not measurable yet */202    }203  }204  focus(): void {205    this.term.focus();206  }207  applyOptions(o: Partial<TermOptions>): void {208    if (o.fontSize !== undefined && o.fontSize !== this.term.options.fontSize) this.term.options.fontSize = o.fontSize;209    if (o.fontFamily !== undefined) {210      const f = fontStack(o.fontFamily);211      if (f !== this.term.options.fontFamily) this.term.options.fontFamily = f;212    }213    if (o.scrollback !== undefined && o.scrollback !== this.term.options.scrollback) this.term.options.scrollback = o.scrollback;214    if (o.theme !== undefined && this.themeName !== o.theme) {215      this.themeName = o.theme;216      this.term.options.theme = termTheme(o.theme);217    }218    this.fit();219  }220221  // ------------------------------------------------------------------ connection222  private setConn(patch: Partial<TermConn>): void {223    if (patch.status) this.status = patch.status;224    useTerminals.getState().setConn(this.id, patch);225  }226  connect(): void {227    if (this.disposed || this.ws) return;228    if (this.retryTimer) clearTimeout(this.retryTimer);229    this.retryTimer = null;230    this.setConn({ status: this.attempt === 0 && this.status === 'idle' ? 'connecting' : 'reconnecting' });231    const cols = clamp(this.term.cols, 20, 500);232    const rows = clamp(this.term.rows, 5, 200);233    const ws = new WebSocket(wsUrl(`/sessions/${this.id}/stream?cols=${cols}&rows=${rows}`));234    ws.binaryType = 'arraybuffer';235    this.ws = ws;236    this.opened = false;237    ws.onopen = () => {238      if (this.ws !== ws) return;239      this.opened = true;240      this.attempt = 0;241      this.lastPongAt = performance.now();242      // A new attach repaints the whole screen: start from a clean terminal.243      this.term.reset();244      this.setConn({ status: 'open', detail: null, exitCode: null, scrolledUp: false, unread: false, ...(this.everOpened ? { reconnects: (useTerminals.getState().conns[this.id]?.reconnects ?? 0) + 1 } : {}) });245      this.everOpened = true;246      this.control({ t: 'resize', cols: clamp(this.term.cols, 20, 500), rows: clamp(this.term.rows, 5, 200) });247      this.startPing();248    };249    ws.onmessage = (ev) => {250      if (this.ws !== ws) return;251      if (typeof ev.data === 'string') this.onControl(ev.data);252      else this.term.write(new Uint8Array(ev.data as ArrayBuffer), () => this.syncScroll(true));253    };254    ws.onclose = (ev) => {255      if (this.ws !== ws) return;256      this.ws = null;257      this.onClosed(ev.code);258    };259  }260  private onControl(raw: string): void {261    let msg: TermServerMsg;262    try {263      const parsed = TermServerMsg.safeParse(JSON.parse(raw));264      if (!parsed.success) return;265      msg = parsed.data;266    } catch {267      return;268    }269    if (msg.t === 'pong') {270      const sent = this.pings.get(msg.n);271      this.pings.delete(msg.n);272      this.lastPongAt = performance.now();273      if (sent !== undefined) this.setConn({ latencyMs: Math.max(0, Math.round(performance.now() - sent)) });274    } else if (msg.t === 'exit') {275      this.setConn({ exitCode: msg.code });276    } else if (msg.t === 'error') {277      this.setConn({ detail: msg.message });278    }279  }280  private onClosed(code: number): void {281    this.stopPing();282    const wasOpen = this.opened;283    this.opened = false;284    if (this.disposed) return;285    switch (code) {286      case WS_CLOSE.UNAUTHORIZED:287        this.setConn({ status: 'unauthorized', latencyMs: null });288        redirectToLogin();289        return;290      case WS_CLOSE.SESSION_ENDED:291        this.setConn({ status: 'ended', latencyMs: null });292        return;293      case WS_CLOSE.NOT_FOUND:294        this.setConn({ status: 'notfound', latencyMs: null });295        return;296      case WS_CLOSE.FORBIDDEN:297        this.setConn({ status: 'forbidden', latencyMs: null });298        return;299    }300    const detail = code === WS_CLOSE.RUNTIME_UNAVAILABLE ? 'Runtime unavailable' : code === WS_CLOSE.BACKPRESSURE ? 'Connection too slow for the output rate' : null;301    this.setConn({ status: navigator.onLine ? 'reconnecting' : 'offline', latencyMs: null, detail });302    // If the socket never opened (or the runtime is down), ask the API what happened to the303    // session before retrying: it may have ended, or the host may have restarted.304    if (!wasOpen || code === WS_CLOSE.RUNTIME_UNAVAILABLE) void this.probeThenRetry();305    else this.scheduleRetry();306  }307  private async probeThenRetry(): Promise<void> {308    try {309      const s = await api.get<TerminalSession>(`/sessions/${this.id}`);310      if (this.disposed || this.ws) return;311      if (s.state === 'STOPPED' || s.state === 'FAILED') return this.setConn({ status: 'ended', exitCode: s.exitCode });312      if (s.state === 'RECOVERABLE') return this.setConn({ status: 'recoverable' });313    } catch (e) {314      if (e instanceof ApiRequestError && e.status === 404) return this.setConn({ status: 'notfound' });315      if (e instanceof ApiRequestError && e.status === 401) return this.setConn({ status: 'unauthorized' });316    }317    this.scheduleRetry();318  }319  private scheduleRetry(): void {320    if (this.disposed || this.retryTimer || this.ws) return;321    const delay = Math.min(10_000, 500 * 2 ** this.attempt) * (0.75 + Math.random() * 0.5);322    this.attempt++;323    this.retryTimer = setTimeout(() => {324      this.retryTimer = null;325      this.connect();326    }, delay);327  }328  /** Page became visible / network came back: retry now, or verify that an open socket is alive. */329  wake(): void {330    if (this.disposed) return;331    if (this.status === 'reconnecting' || this.status === 'offline') {332      this.attempt = 0;333      if (!this.ws) this.connect();334      return;335    }336    if (this.status === 'open' && this.ws) {337      const before = this.lastPongAt;338      this.ping();339      if (this.liveTimer) clearTimeout(this.liveTimer);340      this.liveTimer = setTimeout(() => {341        if (this.lastPongAt === before && this.status === 'open') this.dropAndReconnect();342      }, 2500);343    }344  }345  wentOffline(): void {346    if (this.status === 'reconnecting') this.setConn({ status: 'offline' });347  }348  /** After the user restarted the session through the API. */349  reconnectNow(): void {350    this.attempt = 0;351    if (this.ws) this.dropAndReconnect();352    else {353      this.status = 'idle';354      this.connect();355    }356  }357  private dropAndReconnect(): void {358    const ws = this.ws;359    this.ws = null;360    this.stopPing();361    if (ws) {362      ws.onopen = ws.onmessage = ws.onclose = null;363      try {364        ws.close();365      } catch {366        /* already closed */367      }368    }369    this.opened = false;370    this.attempt = 0;371    this.setConn({ status: 'reconnecting', latencyMs: null });372    this.connect();373  }374  debugDropSocket(): void {375    this.ws?.close(4000, 'e2e drop');376  }377  private startPing(): void {378    this.stopPing();379    this.ping();380    this.pingTimer = setInterval(() => {381      // Three unanswered pings: the connection is dead even if the browser has not noticed.382      if (performance.now() - this.lastPongAt > 16_000) return this.dropAndReconnect();383      this.ping();384    }, 5000);385  }386  private stopPing(): void {387    if (this.pingTimer) clearInterval(this.pingTimer);388    if (this.liveTimer) clearTimeout(this.liveTimer);389    this.pingTimer = this.liveTimer = null;390    this.pings.clear();391  }392  private ping(): void {393    const n = ++this.pingSeq;394    this.pings.set(n, performance.now());395    this.control({ t: 'ping', n });396  }397  private control(msg: TermClientMsg): void {398    if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));399  }400  private queueResize(cols: number, rows: number): void {401    if (this.resizeTimer) clearTimeout(this.resizeTimer);402    this.resizeTimer = setTimeout(() => {403      this.resizeTimer = null;404      if (cols >= 20 && rows >= 5) this.control({ t: 'resize', cols: Math.min(cols, 500), rows: Math.min(rows, 200) });405    }, 80);406  }407408  // ------------------------------------------------------------------ input409  /** Keyboard / IME / paste data from xterm. Sticky toolbar modifiers apply to single characters. */410  private input(data: string): void {411    const st = useTerminals.getState();412    if (hasMods(st.mods)) {413      const out = applyModsToInput(data, st.mods);414      st.consumeMods();415      this.send(out);416    } else this.send(data);417  }418  /** Send text to the PTY as binary UTF-8. Dropped (never queued) while disconnected. */419  send(data: string): void {420    if (!data) return;421    this.debugSent?.push(data);422    this.sendBytes(encoder.encode(data));423  }424  private sendBytes(bytes: Uint8Array): void {425    if (this.ws?.readyState !== WebSocket.OPEN) return;426    for (let i = 0; i < bytes.length; i += CHUNK) this.ws.send(bytes.slice(i, i + CHUNK) as Uint8Array<ArrayBuffer>);427  }428  /** Mobile toolbar key: honours sticky modifiers and application cursor mode. */429  sendKey(key: string): void {430    const st = useTerminals.getState();431    this.send(keySequence(key, st.mods, this.term.modes.applicationCursorKeysMode));432    if (hasMods(st.mods)) st.consumeMods();433  }434  paste(text: string): void {435    if (text) this.term.paste(text); // xterm applies bracketed paste when the application enabled it436  }437  async pasteFromClipboard(): Promise<boolean> {438    try {439      this.paste(await navigator.clipboard.readText());440      return true;441    } catch {442      return false;443    }444  }445  async copySelection(): Promise<boolean> {446    const sel = this.term.getSelection();447    if (!sel) return false;448    try {449      await navigator.clipboard.writeText(sel);450      return true;451    } catch {452      return false;453    }454  }455  /**456   * Return false = xterm ignores the event and the browser/app handles it.457   * Browser shortcuts (Cmd+R/T/W/L…) are never swallowed.458   */459  private onKey(ev: KeyboardEvent): boolean {460    if (ev.type !== 'keydown') return true;461    const mac = isMacLike();462    const primary = mac ? ev.metaKey : ev.ctrlKey;463    const k = ev.key.toLowerCase();464    if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && (k === 'k' || k === 'j') && primary) return false; // palette / switcher465    if (ev.ctrlKey && ev.key === '`') return false; // quick switcher466    if (k === 'c' && (ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey) {467      if (this.term.hasSelection()) {468        void this.copySelection();469        if (ev.ctrlKey) ev.preventDefault();470        return false;471      }472      if (ev.metaKey) {473        this.send('\x03'); // no selection: Cmd+C interrupts, like Ctrl+C474        return false;475      }476      return true; // Ctrl+C → ^C by xterm477    }478    if (k === 'v' && primary && !ev.altKey) return false; // native paste event → xterm (bracketed paste aware)479    if (k === 'v' && ev.ctrlKey && ev.shiftKey) return false;480    if (mac && ev.metaKey) return false; // every other Cmd shortcut belongs to the browser481    return true;482  }483484  // ------------------------------------------------------------------ scrollback485  /** Tracks whether the viewport left the bottom, and whether output arrived meanwhile. */486  private syncScroll(wrote = false): void {487    const b = this.term.buffer.active;488    const up = b.type === 'normal' && b.viewportY < b.baseY;489    const unread = up && (this.unread || (wrote && this.scrolledUp));490    if (up !== this.scrolledUp || unread !== this.unread) {491      this.scrolledUp = up;492      this.unread = unread;493      this.setConn({ scrolledUp: up, unread });494    }495  }496  scrollToBottom(): void {497    this.term.scrollToBottom();498    this.syncScroll();499  }500  /** Text currently on screen (for "copy visible"). */501  visibleText(): string {502    const b = this.term.buffer.active;503    const lines: string[] = [];504    for (let i = b.viewportY; i < b.viewportY + this.term.rows; i++) lines.push(b.getLine(i)?.translateToString(true) ?? '');505    return lines.join('\n').replace(/\n+$/, '');506  }507508  // ------------------------------------------------------------------ misc509  bufferText(): string {510    const b = this.term.buffer.active;511    const lines: string[] = [];512    for (let i = 0; i < b.length; i++) lines.push(b.getLine(i)?.translateToString(true) ?? '');513    return lines.join('\n');514  }515  dispose(): void {516    this.disposed = true;517    if (this.retryTimer) clearTimeout(this.retryTimer);518    if (this.resizeTimer) clearTimeout(this.resizeTimer);519    this.stopPing();520    this.ro?.disconnect();521    const ws = this.ws;522    this.ws = null;523    if (ws) {524      ws.onopen = ws.onmessage = ws.onclose = null;525      ws.close(1000, 'client closed');526    }527    this.term.dispose();528    this.el.remove();529  }530}531532function clamp(n: number, lo: number, hi: number): number {533  return Math.max(lo, Math.min(hi, n));534}535export function ensureController(id: string, opts: TermOptions): TerminalController {536  return getController(id) ?? new TerminalController(id, opts);537}538
Files main
Menu

Theme