/** * Tauri adapter for {@link TerminalGateway} (L3). The single place that uses a * {@link Channel} for the high-frequency PTY byte stream and `invoke()` for the * control commands. Components reach it exclusively through the port. * * Flow (ARCHITECTURE §2 "Tauri Channels"): * - `openTerminal` creates a `Channel`, passes it to the * `open_terminal` command, and forwards every chunk to `onData` as a * `Uint8Array`. The backend pumps PTY output into that channel via the * `PtyBridge`. * - keystrokes go out through `write_terminal`, resize through * `resize_terminal`, teardown through `close_terminal`. * * Commands and payload keys are camelCase, matching the backend DTO convention. */ import { Channel, invoke } from "@tauri-apps/api/core"; import type { OpenTerminalOptions, ReattachResult, TerminalGateway, TerminalHandle, } from "@/ports"; /** Wire shape returned by the `open_terminal` command. */ interface OpenTerminalResponse { sessionId: string; cwd: string; rows: number; cols: number; } /** Wire shape returned by the `reattach_terminal` command. */ interface ReattachResponse { sessionId: string; scrollback: number[]; } /** * Builds a {@link TerminalHandle} over a session and its local output * {@link Channel}. `detach` stops the channel from delivering further bytes (the * view is gone) without touching the backend PTY; `close` kills the PTY. * * Shared by `openTerminal` and `reattach` so both produce identical handles. */ export function makeTerminalHandle( sessionId: string, channel: Channel, ): TerminalHandle { // Serialise writes per handle. Each `write` chains its `invoke` after the // previous one resolves, so the order in which `write` is *called* is the // order the bytes reach the backend stdin — regardless of how Tauri's IPC // schedules concurrent `invoke`s. Without this, typing/pasting fast puts // several `invoke`s in flight at once and they can land out of order, garbling // the CLI input (e.g. "le même" → "le mO é J é IDEIDE…"). // // The chain only sequences *ordering*; a failed write is swallowed for the // purpose of the chain (logged, then the chain continues) so one rejected // promise never blocks every subsequent keystroke. The error is still // surfaced to the caller of that specific `write` via its own promise. let chain: Promise = Promise.resolve(); return { sessionId, write(data: Uint8Array): Promise { const run = chain.then(async () => { logTerminalWrite("start", sessionId, data); await invoke("write_terminal", { request: { sessionId, data: Array.from(data) }, }); logTerminalWrite("ok", sessionId, data); }); // Keep the chain alive even if this write rejects: the next write must // still run. Swallow the error on the *chain* copy only — `run` keeps the // rejection so the caller can observe it. chain = run.catch(() => {}); return run; }, async resize(rows: number, cols: number): Promise { await invoke("resize_terminal", { request: { sessionId, rows, cols }, }); }, detach(): void { // Drop the local subscription: the backend PTY keeps running, but this // view stops receiving output. A later `reattach` re-wires a fresh channel. channel.onmessage = () => {}; }, async close(): Promise { await invoke("close_terminal", { sessionId }); }, }; } function logTerminalWrite(phase: "start" | "ok", sessionId: string, data: Uint8Array): void { if (data.length === 1 && data[0] >= 0x20 && data[0] !== 0x7f) return; console.info("[terminal-write]", phase, { sessionId, bytes: data.length, control: describeBytes(data), }); } function describeBytes(data: Uint8Array): string { if (data.length === 0) return ""; if (data.length > 16) return ""; return Array.from(data) .map((byte) => { switch (byte) { case 0x0d: return "\\r"; case 0x0a: return "\\n"; case 0x7f: return "\\x7f"; default: if (byte < 0x20) return `\\x${byte.toString(16).padStart(2, "0")}`; return String.fromCharCode(byte); } }) .join(""); } export class TauriTerminalGateway implements TerminalGateway { async openTerminal( options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { // Per-session output channel. The backend serialises chunks as byte arrays. const channel = new Channel(); channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); const res = await invoke("open_terminal", { request: { cwd: options.cwd, rows: options.rows, cols: options.cols }, onOutput: channel, }); return makeTerminalHandle(res.sessionId, channel); } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { const channel = new Channel(); channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); const res = await invoke("reattach_terminal", { sessionId, onOutput: channel, }); return { handle: makeTerminalHandle(res.sessionId, channel), scrollback: Uint8Array.from(res.scrollback), }; } async closeTerminal(sessionId: string): Promise { // Kills the PTY by id (the backend `close_terminal` command). Used when a // cell's agent changes and the old PTY must be torn down even though its // owning view only ever detaches. await invoke("close_terminal", { sessionId }); } }