// Pont d'une session vers un terminal — module pur (aucun import vscode), testable. // Le ws-client remet des octets BRUTS (un chunk PTY peut couper un caractère UTF-8 en frontière de // frame, exactement comme côté web où xterm.write(Uint8Array) gérait le décodage). Ici la cible est // un Pseudoterminal qui veut des `string` → on décode en streaming avec un TextDecoder, et on recrée // le décodeur à chaque RESYNC pour ne pas traîner une séquence partielle au-delà d'un reset. import type { ArbWsClient, Attachment, AttachmentSink, DetachReason } from '../api/ws-client.js'; /** Décodeur UTF-8 incrémental : conserve l'octet partiel d'une frontière de frame jusqu'au suivant. */ export class StreamingUtf8Decoder { private decoder = new TextDecoder('utf-8'); decode(chunk: Uint8Array): string { return this.decoder.decode(chunk, { stream: true }); } /** Vide l'état partiel et repart à neuf (appelé sur RESYNC). */ reset(): void { this.decoder = new TextDecoder('utf-8'); } } export interface SessionBridgeCallbacks { /** texte décodé à écrire dans le terminal. */ onText(text: string): void; /** RESYNC reçu : le terminal doit se réinitialiser (le texte de replay suit via onText). */ onReset(): void; onDetached(reason: DetachReason): void; onControlChanged(controlling: boolean): void; } /** * Relie une session Arboretum à un terminal via le ws-client. Instancié par l'adaptateur * Pseudoterminal ; reste pur pour être testable avec un faux ws-client. */ export class SessionBridge { private attachment: Attachment | null = null; private readonly decoder = new StreamingUtf8Decoder(); private detached = false; constructor( private readonly ws: ArbWsClient, private readonly sessionId: string, private readonly mode: 'interactive' | 'observer', private readonly cb: SessionBridgeCallbacks, ) {} /** Ouvre l'attache ; résout quand le canal est établi. Rejette si la session est introuvable. */ async open(cols: number, rows: number): Promise { const sink: AttachmentSink = { data: (payload) => { if (this.detached) return; const text = this.decoder.decode(payload); if (text.length > 0) this.cb.onText(text); }, reset: () => { this.decoder.reset(); this.cb.onReset(); }, detached: (reason) => { this.detached = true; this.cb.onDetached(reason); }, controlChanged: (c) => this.cb.onControlChanged(c), }; this.attachment = await this.ws.attach({ sessionId: this.sessionId, mode: this.mode, cols, rows, sink }); } sendStdin(data: string): void { this.attachment?.sendStdin(data); } resize(cols: number, rows: number): void { this.attachment?.resize(cols, rows); } answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void { this.attachment?.answer(action, optionN); } close(): void { this.detached = true; this.attachment?.detach(); this.attachment = null; } }