Files
IdeA/frontend/src/adapters/terminal.ts
Blomios 018eb1a25e fix(input): fiabilise la livraison de délégation et journalise le submit
Durcit le portail d'écriture de délégation et son acheminement bout-en-bout
(commande Tauri → orchestrateur → file d'entrée infrastructure → portail
frontend), avec une journalisation du submit pour diagnostiquer les cas où la
délégation n'était pas remise. Couvre le portail d'écriture côté front
(useWritePortal) avec ses tests.

QA : checks application/front/infrastructure/app-tauri verts. Les tests loopback
socket Unix réels ne sont pas exécutables en sandbox (UnixListener::bind →
PermissionDenied) ; alternatives avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:57:20 +02:00

167 lines
5.6 KiB
TypeScript

/**
* 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<number[]>`, 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<number[]>,
): 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<void> = Promise.resolve();
return {
sessionId,
write(data: Uint8Array): Promise<void> {
const run = chain.then(async () => {
logTerminalWrite("start", sessionId, data);
await invoke<void>("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<void> {
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<void> {
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 "<empty>";
if (data.length > 16) return "<bulk>";
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<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<OpenTerminalResponse>("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<ReattachResult> {
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<ReattachResponse>("reattach_terminal", {
sessionId,
onOutput: channel,
});
return {
handle: makeTerminalHandle(res.sessionId, channel),
scrollback: Uint8Array.from(res.scrollback),
};
}
async closeTerminal(sessionId: string): Promise<void> {
// 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 });
}
}