/** * T4 — mount xterm + FitAddon and drive it through an abstract `TerminalTransport`. * * Reproduces the base byte semantics against the SEAM (not `public/`): Enter → `\r` (0x0D, handled * by xterm's own onData), `resize` as its OWN protocol frame (triggering `TIOCSWINSZ`/SIGWINCH so * full-screen TUIs redraw), and `fit()` only after the container has real dimensions (calling it * while `display:none` yields NaN — base Gotcha). * * The terminal is injected (DI seam) so tests run in jsdom without a real canvas-backed xterm; the * default factory wires `@xterm/xterm` + `@xterm/addon-fit`. */ import { encodeClientMessage, decodeServerMessage } from './protocol' import type { TerminalTransport } from './ws-transport' /** Minimal xterm surface this view needs (keeps tests mock-able, avoids canvas in jsdom). */ export interface TerminalLike { readonly cols: number readonly rows: number open(container: HTMLElement): void write(data: string): void onData(cb: (data: string) => void): void fit(): void dispose(): void } export interface TerminalViewDeps { /** Factory for the terminal; default lazily loads xterm + FitAddon. */ readonly createTerminal?: () => TerminalLike } /** True only when the element has real, non-zero layout dimensions (guards NaN fit — Gotcha). */ function hasRealDimensions(el: HTMLElement): boolean { return el.clientWidth > 0 && el.clientHeight > 0 } async function defaultTerminal(): Promise { const [{ Terminal }, { FitAddon }] = await Promise.all([ import('@xterm/xterm'), import('@xterm/addon-fit'), ]) const term = new Terminal({ convertEol: false }) const fit = new FitAddon() term.loadAddon(fit) return { get cols() { return term.cols }, get rows() { return term.rows }, open: (container) => term.open(container), write: (data) => term.write(data), onData: (cb) => { term.onData(cb) }, fit: () => fit.fit(), dispose: () => term.dispose(), } } /** * Mount the terminal view. Returns `{ dispose }`. When `deps.createTerminal` is omitted the real * xterm is loaded asynchronously; tests always inject a mock for deterministic, synchronous setup. */ export function mountTerminalView( root: HTMLElement, transport: TerminalTransport, deps: TerminalViewDeps = {}, ): { dispose(): void } { let term: TerminalLike | null = null let disposed = false const send = (bytes: Uint8Array): void => { try { transport.send(bytes) } catch { // Socket not open / torn down — drop; onClose surfaces the reason to the UI. } } const sendResize = (): void => { if (!term || !hasRealDimensions(root)) return term.fit() send(encodeClientMessage({ type: 'resize', cols: term.cols, rows: term.rows })) } const wire = (t: TerminalLike): void => { if (disposed) { t.dispose() return } term = t t.open(root) // keypress → xterm onData → transport.send (Enter → '\r' comes from xterm itself). t.onData((data) => send(encodeClientMessage({ type: 'input', data }))) // incoming DATA → decode base ServerMessage → xterm.write (output only; attached/exit handled). transport.onMessage((bytes) => { const msg = decodeServerMessage(bytes) if (msg && msg.type === 'output') t.write(msg.data) }) // attach MUST be the first client message (base invariant); sessionId=null → fresh session. send(encodeClientMessage({ type: 'attach', sessionId: null })) if (hasRealDimensions(root)) sendResize() } const created = deps.createTerminal if (created) { wire(created()) } else { void defaultTerminal().then(wire) } return { dispose(): void { disposed = true if (term) term.dispose() transport.close() }, } }