Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.7 KiB
TypeScript
136 lines
4.7 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(() =>
|
|
invoke<void>("write_terminal", {
|
|
request: { sessionId, data: Array.from(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 });
|
|
},
|
|
};
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|