fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called handle.close(), killing the backend PTY and cutting off running AIs. Now the view's cleanup only detaches; only an explicit user action kills a PTY. Backend: - PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most recent) + re-subscribable fan-out broadcast replacing the single-take output_rx. Reader thread feeds both the ring buffer and current subscribers; on EOF it closes subscribers (streams end) while keeping scrollback for late re-attach. - PtyPort: new scrollback() method; subscribe_output is now re-subscribable (all impls + test fakes updated). - reattach_terminal IPC command: returns scrollback and re-wires a fresh output channel on the live session without re-spawning. - CloseRequested hook kills all live PTYs cleanly on app shutdown. - TerminalSessions::handles() to enumerate live sessions at shutdown. Frontend: - TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks. - TerminalView cleanup detaches (never close); on mount it re-attaches to a persisted session (repainting scrollback) instead of opening a new PTY. - LayoutGrid persists the cell's session id via setSession; AgentsPanel tracks per-agent session ids — both drive reattach-vs-open. Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained; re-subscription delivers post-reattach output; TerminalView detaches (not closes) on unmount and reattaches with a known session; mock detach/reattach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,8 +19,10 @@ import type {
|
||||
AgentGateway,
|
||||
CreateAgentInput,
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import { makeTerminalHandle } from "./terminal";
|
||||
|
||||
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
|
||||
interface LaunchAgentResponse {
|
||||
@ -87,22 +89,26 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
const sessionId = res.sessionId;
|
||||
return makeTerminalHandle(res.sessionId, channel);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
// Agent sessions reattach through the same session-based `reattach_terminal`
|
||||
// command as plain terminals (the PTY is identified by its session id).
|
||||
const channel = new Channel<number[]>();
|
||||
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||
|
||||
const res = await invoke<{ sessionId: string; scrollback: number[] }>(
|
||||
"reattach_terminal",
|
||||
{ sessionId, onOutput: channel },
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await invoke("resize_terminal", {
|
||||
request: { sessionId, rows, cols },
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await invoke("close_terminal", { sessionId });
|
||||
},
|
||||
handle: makeTerminalHandle(res.sessionId, channel),
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
SystemGateway,
|
||||
TemplateGateway,
|
||||
@ -100,6 +101,47 @@ function slugify(name: string): string {
|
||||
return out.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A live in-memory mock PTY session: it retains a scrollback (everything written
|
||||
* to `onData`) and tracks the *current* output sink so a view can detach (sink
|
||||
* cleared, session stays alive) and later re-attach (new sink, scrollback
|
||||
* replayed). Only `close` ends the session — mirroring the backend's decoupling
|
||||
* of PTY lifecycle from view lifecycle.
|
||||
*/
|
||||
class MockPtySession {
|
||||
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
|
||||
private scrollback: number[] = [];
|
||||
/** Current view sink; `null` while detached. */
|
||||
private sink: ((bytes: Uint8Array) => void) | null = null;
|
||||
/** Whether the session was explicitly closed (PTY killed). */
|
||||
closed = false;
|
||||
|
||||
constructor(
|
||||
readonly sessionId: string,
|
||||
sink: (bytes: Uint8Array) => void,
|
||||
) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
/** Records output into the scrollback and forwards it to the current sink. */
|
||||
emit(bytes: Uint8Array): void {
|
||||
if (this.closed) return;
|
||||
for (const b of bytes) this.scrollback.push(b);
|
||||
this.sink?.(bytes);
|
||||
}
|
||||
|
||||
/** Detaches the current view: stop delivering, keep the session alive. */
|
||||
detach(): void {
|
||||
this.sink = null;
|
||||
}
|
||||
|
||||
/** Re-attaches a new view, returning the retained scrollback to repaint. */
|
||||
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
|
||||
this.sink = sink;
|
||||
return Uint8Array.from(this.scrollback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
||||
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
||||
@ -115,6 +157,8 @@ export class MockAgentGateway implements AgentGateway {
|
||||
private contexts = new Map<string, string>();
|
||||
/** Monotonic session counter for deterministic session ids in tests. */
|
||||
private sessionSeq = 0;
|
||||
/** Live agent PTY sessions, kept across detach so reattach can find them. */
|
||||
private sessions = new Map<string, MockPtySession>();
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -256,31 +300,66 @@ export class MockAgentGateway implements AgentGateway {
|
||||
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
||||
const cwd = options.cwd;
|
||||
const enc = new TextEncoder();
|
||||
const session = new MockPtySession(sessionId, onData);
|
||||
this.sessions.set(sessionId, session);
|
||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
||||
queueMicrotask(() =>
|
||||
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||
);
|
||||
let closed = false;
|
||||
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent session ${sessionId} is not alive`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const scrollback = session.reattach(onData);
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
if (closed) return;
|
||||
// Echo back, translating CR to CRLF like a cooked terminal.
|
||||
const out: number[] = [];
|
||||
for (const b of data) {
|
||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
||||
else out.push(b);
|
||||
}
|
||||
onData(Uint8Array.from(out));
|
||||
},
|
||||
async resize(): Promise<void> {},
|
||||
async close(): Promise<void> {
|
||||
closed = true;
|
||||
},
|
||||
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
|
||||
* (cooked-terminal CRLF translation) through the session so the scrollback
|
||||
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
|
||||
*/
|
||||
function makeMockHandle(
|
||||
session: MockPtySession,
|
||||
unregister: () => void,
|
||||
): TerminalHandle {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
if (session.closed) return;
|
||||
const out: number[] = [];
|
||||
for (const b of data) {
|
||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
||||
else out.push(b);
|
||||
}
|
||||
session.emit(Uint8Array.from(out));
|
||||
},
|
||||
async resize(): Promise<void> {},
|
||||
detach(): void {
|
||||
session.detach();
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
session.closed = true;
|
||||
unregister();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
|
||||
* back to `onData` (so the xterm wrapper renders typed input) and greets on
|
||||
@ -288,6 +367,8 @@ export class MockAgentGateway implements AgentGateway {
|
||||
*/
|
||||
export class MockTerminalGateway implements TerminalGateway {
|
||||
private seq = 0;
|
||||
/** Live sessions kept across detach so reattach can find them. */
|
||||
private sessions = new Map<string, MockPtySession>();
|
||||
|
||||
async openTerminal(
|
||||
options: OpenTerminalOptions,
|
||||
@ -296,27 +377,31 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
this.seq += 1;
|
||||
const sessionId = `mock-session-${this.seq}`;
|
||||
const enc = new TextEncoder();
|
||||
const session = new MockPtySession(sessionId, onData);
|
||||
this.sessions.set(sessionId, session);
|
||||
// Greet so something is visible immediately.
|
||||
queueMicrotask(() =>
|
||||
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
||||
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
||||
);
|
||||
let closed = false;
|
||||
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `terminal session ${sessionId} is not alive`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const scrollback = session.reattach(onData);
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
if (closed) return;
|
||||
// Echo back, translating CR to CRLF like a cooked terminal.
|
||||
const out: number[] = [];
|
||||
for (const b of data) {
|
||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
||||
else out.push(b);
|
||||
}
|
||||
onData(Uint8Array.from(out));
|
||||
},
|
||||
async resize(): Promise<void> {},
|
||||
async close(): Promise<void> {
|
||||
closed = true;
|
||||
},
|
||||
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,4 +111,61 @@ describe("MockTerminalGateway", () => {
|
||||
// Exactly one delivery so far: the greeting.
|
||||
expect(onData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("detach stops delivery to the old view but keeps the session alive", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const first: Uint8Array[] = [];
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/c", rows: 24, cols: 80 },
|
||||
(b) => first.push(b),
|
||||
);
|
||||
await flushMicrotasks();
|
||||
first.length = 0;
|
||||
|
||||
handle.detach();
|
||||
// Output produced after detach must NOT reach the detached view.
|
||||
await handle.write(new TextEncoder().encode("after-detach"));
|
||||
expect(first).toHaveLength(0);
|
||||
|
||||
// But the session is still alive: reattach succeeds.
|
||||
await expect(
|
||||
gw.reattach(handle.sessionId, () => {}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("reattach replays scrollback and resumes live output", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/work", rows: 24, cols: 80 },
|
||||
() => {},
|
||||
);
|
||||
await flushMicrotasks();
|
||||
await handle.write(new TextEncoder().encode("typed"));
|
||||
handle.detach();
|
||||
|
||||
const fresh: Uint8Array[] = [];
|
||||
const { handle: h2, scrollback } = await gw.reattach(
|
||||
handle.sessionId,
|
||||
(b) => fresh.push(b),
|
||||
);
|
||||
// Scrollback carries the prior greeting + echoed input.
|
||||
const sb = decode([scrollback]);
|
||||
expect(sb).toContain("/work");
|
||||
expect(sb).toContain("typed");
|
||||
// New output now flows to the re-attached view.
|
||||
await h2.write(new TextEncoder().encode("more"));
|
||||
expect(decode(fresh)).toBe("more");
|
||||
});
|
||||
|
||||
it("reattach to a closed session rejects (PTY is gone)", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/c", rows: 24, cols: 80 },
|
||||
() => {},
|
||||
);
|
||||
await handle.close();
|
||||
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalGateway,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
@ -30,6 +31,46 @@ interface OpenTerminalResponse {
|
||||
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 {
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
},
|
||||
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,
|
||||
@ -44,22 +85,24 @@ export class TauriTerminalGateway implements TerminalGateway {
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
const sessionId = res.sessionId;
|
||||
return {
|
||||
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,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await invoke("resize_terminal", {
|
||||
request: { sessionId, rows, cols },
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await invoke("close_terminal", { sessionId });
|
||||
},
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
return {
|
||||
handle: makeTerminalHandle(res.sessionId, channel),
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
*/
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Live PTY session id of the running agent terminal, keyed by agent id. Lets
|
||||
* the terminal re-attach (rather than re-launch) when the panel re-mounts the
|
||||
* view, so navigating away never kills the agent.
|
||||
*/
|
||||
const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const canCreate = newName.trim().length > 0 && !vm.busy;
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
@ -325,10 +334,18 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<TerminalView
|
||||
key={activeAgentId}
|
||||
cwd={projectRoot}
|
||||
open={(opts, onData) =>
|
||||
vm.launchAgent(activeAgentId, opts, onData)
|
||||
}
|
||||
reattach={(sessionId, onData) =>
|
||||
gateways.agent.reattach(sessionId, onData)
|
||||
}
|
||||
sessionId={agentSessions[activeAgentId] ?? null}
|
||||
onSessionId={(sid) =>
|
||||
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -112,7 +112,7 @@ interface LeafViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
||||
const { agent: agentGateway } = useGateways();
|
||||
|
||||
@ -133,6 +133,12 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
|
||||
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
||||
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
||||
: undefined;
|
||||
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
||||
// the terminal gateway's reattach (handled by TerminalView's default).
|
||||
const reattachOpener = agentGateway && agentId
|
||||
? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
|
||||
agentGateway.reattach(sessionId, onData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -215,8 +221,17 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
|
||||
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
|
||||
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
|
||||
The cell's persisted session id drives reattach-vs-open so navigating
|
||||
(layout/tab switch) never kills the PTY. */}
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}`}
|
||||
cwd={cwd}
|
||||
open={terminalOpener}
|
||||
reattach={reattachOpener}
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,20 +5,44 @@
|
||||
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
|
||||
* so these tests assert the *wiring contract* (mounts without throwing, talks to
|
||||
* the gateway port, tears down on unmount) rather than xterm's visual rendering.
|
||||
*
|
||||
* The core lifecycle invariant tested here: unmounting the view (navigation /
|
||||
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
|
||||
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways, TerminalGateway, TerminalHandle } from "@/ports";
|
||||
import type {
|
||||
Gateways,
|
||||
ReattachResult,
|
||||
TerminalGateway,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import { MockTerminalGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
|
||||
function renderView(terminal: TerminalGateway, cwd = "/home/me/proj") {
|
||||
function makeHandle(overrides: Partial<TerminalHandle> = {}): TerminalHandle {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(
|
||||
terminal: TerminalGateway,
|
||||
cwd = "/home/me/proj",
|
||||
extra?: Partial<React.ComponentProps<typeof TerminalView>>,
|
||||
) {
|
||||
const gateways = { terminal } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd={cwd} />
|
||||
<TerminalView cwd={cwd} {...extra} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
@ -49,20 +73,13 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
});
|
||||
|
||||
it("consuming gateway output (onData) does not throw", async () => {
|
||||
// A gateway that immediately pushes bytes to the consumer, exercising the
|
||||
// gateway→term.write path. The component must swallow this safely even when
|
||||
// xterm bailed under jsdom.
|
||||
const handle: TerminalHandle = {
|
||||
sessionId: "s1",
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const handle = makeHandle();
|
||||
const terminal: TerminalGateway = {
|
||||
openTerminal: vi.fn(async (_opts, onData) => {
|
||||
onData(new TextEncoder().encode("hello\r\n"));
|
||||
return handle;
|
||||
}),
|
||||
reattach: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => renderView(terminal)).not.toThrow();
|
||||
@ -71,21 +88,15 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the opened handle on unmount (cleanup)", async () => {
|
||||
it("DETACHES (does not close) the handle on unmount — the PTY must survive", async () => {
|
||||
const detach = vi.fn();
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const handle: TerminalHandle = {
|
||||
sessionId: "s1",
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
close,
|
||||
};
|
||||
const handle = makeHandle({ detach, close });
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal };
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||
|
||||
const { unmount } = renderView(terminal);
|
||||
|
||||
// Only assert close-on-unmount if the gateway was actually opened (i.e.
|
||||
// xterm.open did not bail in this jsdom run).
|
||||
await waitFor(() => {
|
||||
expect(openTerminal.mock.calls.length >= 0).toBe(true);
|
||||
});
|
||||
@ -95,11 +106,57 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
|
||||
if (wasOpened) {
|
||||
await waitFor(() => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(detach).toHaveBeenCalled();
|
||||
});
|
||||
// The cardinal invariant: navigating away must NOT kill the PTY.
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
} else {
|
||||
// Bailed render: unmount must still be clean (no throw, no close needed).
|
||||
// Bailed render: unmount must still be clean (no close, no detach needed).
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("REATTACHES to an existing session instead of opening a new PTY", async () => {
|
||||
const handle = makeHandle({ sessionId: "live-1" });
|
||||
const reattach = vi.fn(
|
||||
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
|
||||
onData(new TextEncoder().encode("scroll"));
|
||||
const result: ReattachResult = {
|
||||
handle,
|
||||
scrollback: new TextEncoder().encode("history"),
|
||||
};
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal, reattach };
|
||||
|
||||
renderView(terminal, "/cwd", { sessionId: "live-1" });
|
||||
|
||||
await waitFor(() => {
|
||||
// When xterm wired up, reattach must be used (with the known id) and a
|
||||
// fresh open must NOT happen.
|
||||
if (reattach.mock.calls.length > 0) {
|
||||
expect(reattach.mock.calls[0][0]).toBe("live-1");
|
||||
expect(openTerminal).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("persists a newly opened session id via onSessionId", async () => {
|
||||
const handle = makeHandle({ sessionId: "new-99" });
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||
const onSessionId = vi.fn();
|
||||
|
||||
renderView(terminal, "/cwd", { onSessionId });
|
||||
|
||||
await waitFor(() => {
|
||||
if (openTerminal.mock.calls.length > 0) {
|
||||
expect(onSessionId).toHaveBeenCalledWith("new-99");
|
||||
}
|
||||
});
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,17 @@
|
||||
*
|
||||
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
||||
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
||||
*
|
||||
* **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
|
||||
* layout or project tab) tears this view down but must NEVER kill the backend
|
||||
* PTY — otherwise running AIs would be cut off. So:
|
||||
* - On unmount the cleanup only `detach`es (drops the local output subscription)
|
||||
* and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
|
||||
* explicit user action handled elsewhere (the terminal's close button).
|
||||
* - On mount, if a `sessionId` already exists for this cell (persisted by the
|
||||
* caller via `onSessionId`), the view **re-attaches** to the still-running PTY
|
||||
* — repainting its scrollback and resuming its output — instead of opening a
|
||||
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
@ -21,7 +32,11 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
|
||||
interface TerminalViewProps {
|
||||
/** Working directory the shell opens in (typically the project root). */
|
||||
@ -36,9 +51,35 @@ interface TerminalViewProps {
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<TerminalHandle>;
|
||||
/**
|
||||
* Optional re-attach opener. When provided together with a {@link sessionId},
|
||||
* the view re-binds to the existing live PTY instead of opening a new one.
|
||||
* When absent, falls back to the terminal gateway's `reattach`.
|
||||
*/
|
||||
reattach?: (
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<ReattachResult>;
|
||||
/**
|
||||
* Persisted session id for this cell, if a PTY is already running for it.
|
||||
* Drives the reattach-vs-open decision at mount.
|
||||
*/
|
||||
sessionId?: string | null;
|
||||
/**
|
||||
* Called once a session is established (opened) so the caller can persist its
|
||||
* id for this cell and re-attach to it on the next mount. Not called on
|
||||
* reattach (the id is already known).
|
||||
*/
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
export function TerminalView({
|
||||
cwd,
|
||||
open,
|
||||
reattach,
|
||||
sessionId,
|
||||
onSessionId,
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@ -51,6 +92,12 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
// so the correct opener is always captured at mount.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const reattachRef = useRef(reattach);
|
||||
reattachRef.current = reattach;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
const onSessionIdRef = useRef(onSessionId);
|
||||
onSessionIdRef.current = onSessionId;
|
||||
const terminalRef = useRef(terminal);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
@ -58,6 +105,7 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
const container = containerRef.current;
|
||||
const tgw = terminalRef.current;
|
||||
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
||||
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||
if (!container || !opener) return;
|
||||
|
||||
const term = new Terminal({
|
||||
@ -94,30 +142,65 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
else pending += data;
|
||||
});
|
||||
|
||||
opener(
|
||||
{ cwd, rows: term.rows, cols: term.cols },
|
||||
(bytes) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
},
|
||||
)
|
||||
.then((h) => {
|
||||
if (disposed) {
|
||||
void h.close();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!disposed) {
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
const onData = (bytes: Uint8Array) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
};
|
||||
|
||||
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
|
||||
// was disposed before the promise resolved, just detach (NEVER close — the
|
||||
// PTY must survive a transient mount/unmount).
|
||||
const adopt = (h: TerminalHandle) => {
|
||||
if (disposed) {
|
||||
h.detach();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenError = (e: unknown) => {
|
||||
if (!disposed) {
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-attach to an existing live PTY when this cell already has a session;
|
||||
// otherwise open a fresh one and persist its id for the next mount.
|
||||
const existingSession = sessionIdRef.current;
|
||||
if (existingSession && reattacher) {
|
||||
reattacher(existingSession, onData)
|
||||
.then(({ handle: h, scrollback }) => {
|
||||
if (disposed) {
|
||||
h.detach();
|
||||
return;
|
||||
}
|
||||
if (scrollback.length > 0) term.write(scrollback);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(() => {
|
||||
// The session is gone (explicitly closed / exited): fall back to a
|
||||
// fresh terminal so the cell still works.
|
||||
if (disposed) return;
|
||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(onOpenError);
|
||||
});
|
||||
} else {
|
||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(onOpenError);
|
||||
}
|
||||
|
||||
// Refit + propagate size to the PTY on container resize.
|
||||
const ro = new ResizeObserver(() => {
|
||||
@ -134,7 +217,10 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
disposed = true;
|
||||
ro.disconnect();
|
||||
onKey.dispose();
|
||||
if (handle) void handle.close();
|
||||
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
||||
// PTY is an explicit user action handled elsewhere.
|
||||
if (handle) handle.detach();
|
||||
term.dispose();
|
||||
};
|
||||
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
||||
|
||||
@ -72,6 +72,16 @@ export interface AgentGateway {
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle>;
|
||||
/**
|
||||
* Re-attaches to an agent's already-running PTY (same backend mechanism as
|
||||
* {@link TerminalGateway.reattach}; agent sessions share the session-based
|
||||
* terminal commands). Used when an agent cell's view re-mounts after a
|
||||
* navigation/layout change, so the agent is never killed.
|
||||
*/
|
||||
reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult>;
|
||||
}
|
||||
|
||||
/** Options for opening a terminal. */
|
||||
@ -98,7 +108,19 @@ export interface TerminalHandle {
|
||||
write(data: Uint8Array): Promise<void>;
|
||||
/** Resizes the PTY. */
|
||||
resize(rows: number, cols: number): Promise<void>;
|
||||
/** Kills the PTY and stops the output stream. */
|
||||
/**
|
||||
* Detaches the **view** from the PTY without killing it: stops the local
|
||||
* output subscription so a torn-down view (navigation / layout change) stops
|
||||
* receiving bytes, while the backend PTY keeps running. The session can later
|
||||
* be re-attached via {@link TerminalGateway.reattach}.
|
||||
*
|
||||
* This is the lifecycle the view's cleanup must use — never {@link close}.
|
||||
*/
|
||||
detach(): void;
|
||||
/**
|
||||
* Kills the PTY and stops the output stream. Reserved for an **explicit** user
|
||||
* action (closing the terminal); navigation must never call this.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@ -115,6 +137,27 @@ export interface TerminalGateway {
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle>;
|
||||
/**
|
||||
* Re-attaches to an **already-running** PTY identified by `sessionId` (after a
|
||||
* view was torn down by navigation/layout change). Returns the live handle and
|
||||
* the retained scrollback, which the caller repaints into xterm before the new
|
||||
* output stream (`onData`) starts delivering subsequent bytes. Does NOT
|
||||
* re-spawn the process.
|
||||
*
|
||||
* Rejects if the session is no longer alive (the caller then opens fresh).
|
||||
*/
|
||||
reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult>;
|
||||
}
|
||||
|
||||
/** The outcome of {@link TerminalGateway.reattach}. */
|
||||
export interface ReattachResult {
|
||||
/** The live terminal handle for the re-attached session. */
|
||||
handle: TerminalHandle;
|
||||
/** The retained scrollback bytes to repaint before the live stream resumes. */
|
||||
scrollback: Uint8Array;
|
||||
}
|
||||
|
||||
/** Projects: create/open/close/list (L2). */
|
||||
|
||||
Reference in New Issue
Block a user