Repository Public
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