Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181// Interactive pseudo-terminal client connecting to the daemon's PTY service.
export interface TerminalReady { id: string; cwd: string; shell: string; cols: number; rows: number; state: 'running' | 'exited'; exit_code: number | null;}
export interface TerminalOptions { cwd?: string; sessionId?: string; cols?: number; rows?: number; restart?: boolean;}
export class PtyClient { readonly id: string; connected = $state(false); ready = $state<TerminalReady | null>(null); error = $state<string | null>(null); exitCode = $state<number | null>(null);
#ws: WebSocket | null = null; #options: TerminalOptions; #onOutput: ((data: Uint8Array | string) => void) | null = null; #onExit: ((code: number) => void) | null = null; #disposed = false; #reconnectTimer: ReturnType<typeof setTimeout> | undefined;
constructor(id: string, options: TerminalOptions = {}) { this.id = id; this.#options = options; }
onOutput(callback: (data: Uint8Array | string) => void) { this.#onOutput = callback; }
onExit(callback: (code: number) => void) { this.#onExit = callback; }
connect() { if (this.#disposed || this.#ws) return; clearTimeout(this.#reconnectTimer);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const params = new URLSearchParams(); if (this.#options.cwd) params.set('cwd', this.#options.cwd); if (this.#options.sessionId) params.set('session_id', this.#options.sessionId); if (this.#options.cols) params.set('cols', String(this.#options.cols)); if (this.#options.rows) params.set('rows', String(this.#options.rows)); if (this.#options.restart) params.set('restart', 'true');
const query = params.toString() ? `?${params.toString()}` : ''; const url = `${protocol}//${window.location.host}/api/terminals/${encodeURIComponent(this.id)}${query}`;
try { const ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; this.#ws = ws;
ws.onopen = () => { if (this.#ws !== ws) return; this.connected = true; this.error = null; };
ws.onmessage = (event: MessageEvent) => { if (this.#ws !== ws) return; if (event.data instanceof ArrayBuffer) { const bytes = new Uint8Array(event.data); this.#onOutput?.(bytes); } else if (typeof event.data === 'string') { try { const msg = JSON.parse(event.data); if (msg.type === 'ready') { this.ready = { id: msg.id, cwd: msg.cwd, shell: msg.shell, cols: msg.cols, rows: msg.rows, state: msg.state, exit_code: msg.exit_code ?? null, }; if (msg.state === 'exited' && typeof msg.exit_code === 'number') { this.exitCode = msg.exit_code; this.#onExit?.(msg.exit_code); } } else if (msg.type === 'exit') { const code = typeof msg.exit_code === 'number' ? msg.exit_code : 0; this.exitCode = code; if (this.ready) this.ready.state = 'exited'; this.#onExit?.(code); } else if (msg.type === 'error') { this.error = msg.detail || 'terminal connection error'; } } catch { // raw text output this.#onOutput?.(event.data); } } };
ws.onerror = () => { if (this.#ws !== ws) return; this.error = 'terminal socket error'; };
ws.onclose = () => { if (this.#ws !== ws) return; this.#ws = null; this.connected = false; if (!this.#disposed && this.ready?.state === 'running') { this.#reconnectTimer = setTimeout(() => this.connect(), 2000); } }; } catch (err) { this.error = String((err as Error).message ?? err); } }
sendInput(data: string | Uint8Array) { if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; if (typeof data === 'string') { this.#ws.send(new TextEncoder().encode(data)); } else { const bytes = data as Uint8Array; this.#ws.send(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer); } }
resize(cols: number, rows: number) { this.#options.cols = cols; this.#options.rows = rows; if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; this.#ws.send(JSON.stringify({ type: 'resize', cols, rows })); }
restart(cols?: number, rows?: number) { if (this.#ws && this.#ws.readyState === WebSocket.OPEN) { this.exitCode = null; if (this.ready) this.ready.state = 'running'; this.#ws.send(JSON.stringify({ type: 'restart', cols: cols ?? this.#options.cols ?? 80, rows: rows ?? this.#options.rows ?? 24 })); } else { this.#options.restart = true; this.connect(); } }
signal(sigName: 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGHUP' | 'SIGQUIT') { if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; this.#ws.send(JSON.stringify({ type: 'signal', signal: sigName })); }
disconnect() { clearTimeout(this.#reconnectTimer); if (this.#ws) { const ws = this.#ws; this.#ws = null; ws.close(); } this.connected = false; }
dispose() { this.#disposed = true; this.disconnect(); }}
export async function deleteTerminal(id: string): Promise<void> { await fetch(`/api/terminals/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {});}