/** * public/terminal-session.ts — one independent terminal session. * * Encapsulates a single xterm Terminal + its own WebSocket + reconnect state. * One per tab (1 WS ↔ 1 server session; the backend manager already supports * many concurrent sessions, so tabs need no protocol/backend change). * * No console.log — status is written as ANSI to the terminal. */ import { Terminal } from '@xterm/xterm' import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' import { folderFromTitle } from './title-util.js' const RESET = '\x1b[0m' const BOLD = '\x1b[1m' const DIM = '\x1b[2m' const YELLOW = '\x1b[33m' const RED = '\x1b[31m' const GREEN = '\x1b[32m' const CYAN = '\x1b[36m' function statusLine(msg: string): string { return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n` } function buildMessage(msg: ClientMessage): string { return JSON.stringify(msg) } /** M6: scheme follows page protocol so HTTPS/Tailscale deploys use wss. */ function buildWsUrl(): string { const scheme = location.protocol === 'https:' ? 'wss' : 'ws' return `${scheme}://${location.host}/term` } /** Connection state, surfaced as a colored status dot on the tab. */ export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited' export interface TerminalSessionOpts { /** Existing session to re-attach to, or null to spawn a fresh one. */ sessionId: string | null /** Called whenever the server assigns/confirms this session's id (persist it). */ onSessionId: (id: string) => void /** Optional: fired on output to an inactive tab (for an unread indicator). */ onActivity?: () => void /** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */ onTitle?: (title: string) => void /** Optional: fired when connection status changes (for the tab status dot). */ onStatus?: (status: SessionStatus) => void /** Optional: fired when Claude Code activity changes (from a hook, H2). */ onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void } export class TerminalSession { readonly el: HTMLDivElement private readonly term: Terminal private readonly fitAddon: FitAddon private readonly searchAddon: SearchAddon private readonly resizeObserver: ResizeObserver private readonly onSessionId: (id: string) => void private readonly onActivity: (() => void) | undefined private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' private pendingApprovalValue = false private pendingToolValue: string | undefined = undefined private ws: WebSocket | null = null private sessionId: string | null private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 private reconnectTimer: ReturnType | null = null private resizeTimer: ReturnType | null = null private exitListener: { dispose(): void } | null = null private isConnecting = false private disposed = false private lastCols = 0 private lastRows = 0 constructor(opts: TerminalSessionOpts) { this.sessionId = opts.sessionId this.onSessionId = opts.onSessionId this.onActivity = opts.onActivity this.onTitle = opts.onTitle this.onStatus = opts.onStatus this.onClaudeStatus = opts.onClaudeStatus this.el = document.createElement('div') this.el.className = 'term-pane' this.el.style.display = 'none' this.term = new Terminal({ scrollback: 5000, fontFamily: 'Menlo, Consolas, monospace', theme: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' }, }) this.fitAddon = new FitAddon() this.term.loadAddon(this.fitAddon) this.searchAddon = new SearchAddon() this.term.loadAddon(this.searchAddon) this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it this.term.open(this.el) this.term.onData((data) => this.send(data)) // Tab title = current folder, derived from the shell's OSC title. this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title)) this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) this.resizeObserver.observe(this.el) } /** Current session id (null until the server assigns one). */ get id(): string | null { return this.sessionId } /** Current connection status (for the tab status dot). */ get status(): SessionStatus { return this.statusValue } /** Current Claude Code activity (from hooks, H2). */ get claudeStatus(): ClaudeStatus { return this.claudeStatusValue } /** Whether a tool approval is held server-side and can be resolved (H3). */ get pendingApproval(): boolean { return this.pendingApprovalValue } get pendingTool(): string | undefined { return this.pendingToolValue } private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) } /** fit() only when the pane has real dimensions (display:none → NaN, §9). */ private safefit(): { cols: number; rows: number } | null { try { this.fitAddon.fit() const { cols, rows } = this.term if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) { return null } return { cols, rows } } catch { return null } } private sendResize(cols: number, rows: number): void { if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return if (cols === this.lastCols && rows === this.lastRows) return this.lastCols = cols this.lastRows = rows this.ws.send(buildMessage({ type: 'resize', cols, rows })) } private scheduleResize(): void { if (this.el.style.display === 'none') return // hidden tab: skip (fit→NaN) if (this.resizeTimer !== null) clearTimeout(this.resizeTimer) this.resizeTimer = setTimeout(() => { this.resizeTimer = null const dims = this.safefit() if (dims !== null) this.sendResize(dims.cols, dims.rows) }, 100) } connect(): void { if (this.disposed || this.isConnecting) return this.isConnecting = true this.setStatus('connecting') this.term.write(statusLine(`${YELLOW}Connecting…${RESET}`)) const socket = new WebSocket(buildWsUrl()) this.ws = socket socket.addEventListener('open', () => { this.reconnectDelay = 1000 this.setStatus('connected') this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) }) socket.addEventListener('message', (event: MessageEvent) => { let msg: ServerMessage try { msg = JSON.parse(event.data) as ServerMessage } catch { return } this.handle(msg, socket) }) socket.addEventListener('close', () => { this.isConnecting = false this.ws = null if (!this.disposed) this.scheduleReconnect() }) socket.addEventListener('error', () => { this.isConnecting = false }) } private handle(msg: ServerMessage, socket: WebSocket): void { switch (msg.type) { case 'attached': { this.sessionId = msg.sessionId this.onSessionId(msg.sessionId) const dims = this.safefit() if (dims !== null) this.sendResize(dims.cols, dims.rows) break } case 'output': { this.term.write(msg.data) if (this.el.style.display === 'none') this.onActivity?.() break } case 'status': { this.claudeStatusValue = msg.status this.pendingApprovalValue = msg.pending === true this.pendingToolValue = msg.pending === true ? msg.detail : undefined this.onClaudeStatus?.(msg.status, msg.detail) break } case 'exit': { this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' this.term.write( statusLine( `${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` + `\r\n${DIM}Press Enter to reconnect…${RESET}`, ), ) if (this.exitListener) this.exitListener.dispose() this.exitListener = this.term.onData((data) => { if (data === '\r') { this.exitListener?.dispose() this.exitListener = null this.sessionId = null // spawn a fresh session on reconnect socket.close() this.connect() } }) break } } } private scheduleReconnect(): void { if (this.reconnectTimer !== null) return this.setStatus('reconnecting') const delay = this.reconnectDelay this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000) this.term.write( statusLine(`${YELLOW}Disconnected — reconnecting in ${(delay / 1000).toFixed(0)}s…${RESET}`), ) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect() }, delay) } private sendMsg(msg: ClientMessage): void { if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return this.ws.send(buildMessage(msg)) } /** Send raw bytes as input (used by term.onData and the key bar). */ send(data: string): void { this.sendMsg({ type: 'input', data }) } /** Resolve a held PermissionRequest (H3). */ approve(): void { this.pendingApprovalValue = false this.sendMsg({ type: 'approve' }) } reject(): void { this.pendingApprovalValue = false this.sendMsg({ type: 'reject' }) } /** Scrollback search (M1). */ findNext(query: string): void { this.searchAddon.findNext(query) } findPrevious(query: string): void { this.searchAddon.findPrevious(query) } clearSearch(): void { this.searchAddon.clearDecorations() } /** Apply a theme + font size (M3) and re-fit if visible. */ applyTheme(theme: ITheme, fontSize: number): void { this.term.options.theme = theme this.term.options.fontSize = fontSize if (this.el.style.display !== 'none') { const dims = this.safefit() if (dims !== null) this.sendResize(dims.cols, dims.rows) } } /** Make this pane visible, fit it, and focus it. */ show(): void { this.el.style.display = 'block' requestAnimationFrame(() => { const dims = this.safefit() if (dims !== null) this.sendResize(dims.cols, dims.rows) this.term.focus() }) } hide(): void { this.el.style.display = 'none' } /** Tear down: closing the WS detaches — the server PTY keeps running. */ dispose(): void { this.disposed = true if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } if (this.resizeTimer !== null) { clearTimeout(this.resizeTimer) this.resizeTimer = null } this.resizeObserver.disconnect() if (this.ws !== null) { try { this.ws.close() } catch { // ignore } this.ws = null } this.term.dispose() this.el.remove() } }