import type { EnvironmentId, TerminalAttachStreamEvent, TerminalMetadataStreamEvent, TerminalSessionSnapshot, TerminalSummary, ThreadId, } from "@t3tools/contracts"; export interface TerminalSessionState { readonly summary: TerminalSummary | null; readonly buffer: string; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; readonly version: number; } export interface TerminalBufferState { readonly buffer: string; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly updatedAt: string | null; readonly version: number; } export interface KnownTerminalSessionTarget { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly terminalId: string; } export interface KnownTerminalSession { readonly target: KnownTerminalSessionTarget; readonly state: TerminalSessionState; } export function selectRunningSubprocessTerminalIds( sessions: ReadonlyArray, ): ReadonlyArray { return sessions .filter((session) => session.state.hasRunningSubprocess) .map((session) => session.target.terminalId); } export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ buffer: "", status: "closed", error: null, updatedAt: null, version: 0, }); export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ summary: null, buffer: "", status: "closed", error: null, hasRunningSubprocess: false, updatedAt: null, version: 0, }); export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { if (maxBufferBytes <= 0) { return ""; } const encoded = textEncoder.encode(buffer); if (encoded.byteLength <= maxBufferBytes) { return buffer; } let start = encoded.byteLength - maxBufferBytes; while (start < encoded.length) { const byte = encoded[start]; if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { break; } start += 1; } return textDecoder.decode(encoded.subarray(start)); } export function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, ): TerminalBufferState { return { buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), status: snapshot.status, error: null, updatedAt: snapshot.updatedAt, version: 1, }; } function latestTimestamp(left: string | null, right: string | null): string | null { if (left === null) return right; if (right === null) return left; return Date.parse(left) >= Date.parse(right) ? left : right; } export function combineTerminalSessionState( summary: TerminalSummary | null, buffer: TerminalBufferState, ): TerminalSessionState { return { summary, buffer: buffer.buffer, status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), error: buffer.error, hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), version: buffer.version, }; } export function applyTerminalAttachStreamEvent( current: TerminalBufferState, event: TerminalAttachStreamEvent, maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, ): TerminalBufferState { switch (event.type) { case "snapshot": case "restarted": return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); case "output": return { ...current, buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), status: current.status === "closed" ? "running" : current.status, error: null, version: current.version + 1, }; case "cleared": return { ...current, buffer: "", error: null, version: current.version + 1, }; case "exited": return { ...current, status: "exited", error: null, version: current.version + 1, }; case "closed": return { ...current, status: "closed", error: null, version: current.version + 1, }; case "error": return { ...current, status: "error", error: event.message, version: current.version + 1, }; case "activity": return current; } } export function applyTerminalMetadataStreamEvent( current: ReadonlyArray, event: TerminalMetadataStreamEvent, ): ReadonlyArray { if (event.type === "snapshot") { return event.terminals; } if (event.type === "remove") { return current.filter( (terminal) => terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, ); } const next = current.filter( (terminal) => terminal.threadId !== event.terminal.threadId || terminal.terminalId !== event.terminal.terminalId, ); return [...next, event.terminal]; }