diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 38bff28..15116d5 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -28,7 +28,7 @@ use crate::dto::{ GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, - RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, + ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, @@ -244,6 +244,69 @@ pub async fn close_terminal( result } +/// `reattach_terminal` — re-bind a view to a **still-living** PTY without +/// re-spawning it. +/// +/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill +/// the backend PTY (the AI keeps running). When the view comes back it calls this +/// command, which: +/// 1. reads the session's retained **scrollback** so the terminal can repaint, +/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`], +/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable +/// broadcast), so new bytes flow to the new channel. +/// +/// Returns the scrollback bytes; the frontend writes them into xterm first, then +/// receives subsequent output over `on_output`. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS` +/// if the session is no longer alive). +#[tauri::command] +pub fn reattach_terminal( + session_id: String, + on_output: Channel, + state: State<'_, AppState>, +) -> Result { + let sid = parse_session_id(&session_id)?; + let handle = PtyHandle { session_id: sid }; + + // (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was + // explicitly closed or exited) — surfaced as an error so the caller falls + // back to opening a fresh terminal. + let scrollback = state + .pty_port + .scrollback(&handle) + .map_err(|e| ErrorDto::from(AppError::from(e)))?; + + // (2) Register the new output channel for this session, replacing any stale + // one from a previous attach. + state.pty_bridge.register(sid, on_output); + + // (3) Subscribe afresh to the live byte stream and pump it to the channel. + match state.pty_port.subscribe_output(&handle) { + Ok(stream) => { + let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&sid, chunk) { + break; + } + } + bridge.unregister(&sid); + }); + } + Err(e) => { + state.pty_bridge.unregister(&sid); + return Err(ErrorDto::from(AppError::from(e))); + } + } + + Ok(ReattachResultDto { + session_id, + scrollback, + }) +} + // --------------------------------------------------------------------------- // Layout (L4) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 76427f1..33dd873 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -245,6 +245,18 @@ impl From for TerminalSessionDto { } } +/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live +/// session, repainted into the re-mounting xterm before the new output stream is +/// wired. Bytes are serialised as a number array, matching the PTY output channel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReattachResultDto { + /// The session that was re-attached (echoed back for the frontend). + pub session_id: String, + /// The most-recent retained output bytes (scrollback ring buffer). + pub scrollback: Vec, +} + /// Request DTO for `write_terminal`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index e36752d..f80bab1 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -48,6 +48,28 @@ pub fn run() { events::spawn_relay(app.handle().clone(), &app_state.event_bus); app.manage(app_state); + + // Kill all live PTYs cleanly when the main window is closing. This is + // independent of the per-view (navigation/layout) lifecycle — those + // must NEVER kill a PTY — and only fires on a genuine app shutdown. + // A brutal crash is best-effort and out of scope. + if let Some(window) = app.get_webview_window("main") { + let handle = app.handle().clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + if let Some(state) = handle.try_state::() { + let pty = std::sync::Arc::clone(&state.pty_port); + let handles = state.terminal_sessions.handles(); + tauri::async_runtime::block_on(async move { + for h in handles { + let _ = pty.kill(&h).await; + } + }); + } + } + }); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -60,6 +82,7 @@ pub fn run() { commands::write_terminal, commands::resize_terminal, commands::close_terminal, + commands::reattach_terminal, commands::load_layout, commands::mutate_layout, commands::list_layouts, diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index 7791cca..6215fac 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -4,8 +4,8 @@ use app_tauri_lib::dto::{ parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto, - LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto, - WriteTerminalRequestDto, + LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto, + TerminalClosedDto, WriteTerminalRequestDto, }; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; @@ -191,6 +191,16 @@ fn terminal_closed_dto_serialises_code_camel_case() { assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null })); } +#[test] +fn reattach_result_dto_serialises_camel_case() { + let dto = ReattachResultDto { + session_id: "sess-1".into(), + scrollback: vec![104, 105], + }; + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] })); +} + #[test] fn parse_session_id_accepts_uuid_and_rejects_garbage() { let sid = SessionId::from_uuid(Uuid::nil()); diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 640a651..b4a4c74 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -59,6 +59,18 @@ impl TerminalSessions { .and_then(|m| m.get(id).map(|e| e.session.clone())) } + /// Returns the [`PtyHandle`]s of every currently-registered session. + /// + /// Used at application shutdown to kill all live PTYs cleanly (the + /// `CloseRequested` hook), independently of the frontend's per-view lifecycle. + #[must_use] + pub fn handles(&self) -> Vec { + self.entries + .lock() + .map(|m| m.values().map(|e| e.handle.clone()).collect()) + .unwrap_or_default() + } + /// Removes a session from the registry, returning its handle if present. pub fn remove(&self, id: &SessionId) -> Option { self.entries diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 0eda9cb..75b8678 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -313,6 +313,9 @@ impl PtyPort for FakePty { fn subscribe_output(&self, _handle: &PtyHandle) -> Result { Ok(Box::new(std::iter::empty())) } + fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } async fn kill(&self, _handle: &PtyHandle) -> Result { Ok(ExitStatus { code: Some(0) }) } diff --git a/crates/application/tests/terminal_usecases.rs b/crates/application/tests/terminal_usecases.rs index 5ea669e..27db7e7 100644 --- a/crates/application/tests/terminal_usecases.rs +++ b/crates/application/tests/terminal_usecases.rs @@ -102,6 +102,10 @@ impl PtyPort for FakePty { Ok(Box::new(std::iter::empty())) } + fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn kill(&self, handle: &PtyHandle) -> Result { let mut inner = self.0.lock().unwrap(); inner.calls.push(Call::Kill { diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 376d5b6..a8ee9a0 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -334,10 +334,24 @@ pub trait PtyPort: Send + Sync { /// Subscribes to the PTY's byte output stream. /// + /// Re-subscribable: each call returns a fresh stream that receives every + /// chunk produced **from now on**. Combined with [`scrollback`](Self::scrollback) + /// this lets the presentation layer *re-attach* a view to a still-living PTY + /// after a navigation/layout change tore the previous view down — without + /// re-spawning the process. + /// /// # Errors /// [`PtyError`] if the handle is unknown. fn subscribe_output(&self, handle: &PtyHandle) -> Result; + /// Returns the recent output retained for the session (a bounded scrollback + /// ring buffer, the most recent bytes). Used to repaint a view that + /// re-attaches to a live PTY so the terminal isn't blank. + /// + /// # Errors + /// [`PtyError`] if the handle is unknown. + fn scrollback(&self, handle: &PtyHandle) -> Result, PtyError>; + /// Kills the PTY's process, returning its exit status. /// /// # Errors diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 2018f17..3daa316 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -12,10 +12,15 @@ //! domain never sees an OS handle (ARCHITECTURE §4). //! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the //! slave, then start **one reader thread** that pumps bytes from the master -//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output) -//! hands back the receiver wrapped as the domain's blocking [`OutputStream`] -//! iterator; the presentation layer drains it on its own thread and forwards -//! chunks to the per-session Tauri channel (the `PtyBridge`). +//! into a shared [`Broadcast`] hub. The hub does two things with every chunk: +//! it appends to a bounded **scrollback ring buffer** (~100 KB, most recent +//! bytes) and it fans the chunk out to every *currently subscribed* receiver. +//! - [`subscribe_output`](PtyPort::subscribe_output) registers a fresh +//! subscriber and returns its receiver wrapped as the domain's blocking +//! [`OutputStream`] iterator. It is **re-subscribable**: after a view tears +//! down (navigation / layout change) a new view can re-attach to the *same* +//! live PTY by subscribing again and repainting the scrollback first — no +//! re-spawn. [`scrollback`](PtyPort::scrollback) returns that retained buffer. //! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored //! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the //! reader thread, and returns the [`ExitStatus`]. @@ -30,9 +35,10 @@ //! Unix-only assumption (no raw fds, no signals) so it should port as-is. use std::collections::HashMap; +use std::collections::VecDeque; use std::io::{Read, Write}; use std::sync::mpsc::{self, Receiver, Sender}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use async_trait::async_trait; @@ -45,6 +51,68 @@ use domain::SessionId; /// Size of each read buffer pumped from the master PTY. const READ_BUF: usize = 8 * 1024; +/// Maximum number of bytes retained in a session's scrollback ring buffer +/// (~100 KB, "the most recent output"). When the buffer would exceed this, the +/// oldest bytes are dropped so a re-attaching view repaints recent history. +const SCROLLBACK_CAP: usize = 100 * 1024; + +/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the +/// set of currently-subscribed receivers. The reader thread feeds both; views +/// subscribe and unsubscribe freely over the PTY's lifetime (re-attach support). +/// +/// Each subscriber is a [`Sender`]; a send failing (receiver dropped because the +/// view detached) prunes that subscriber on the next chunk. This is the +/// fan-out/broadcast that replaces the old single-take `output_rx`. +#[derive(Default)] +struct Broadcast { + /// Bounded ring buffer of the most recent output bytes. + scrollback: VecDeque, + /// Live subscribers; pruned lazily when their receiver is gone. + subscribers: Vec>>, + /// Set once the PTY hit EOF (process exited) — no more output will ever come. + eof: bool, +} + +impl Broadcast { + /// Appends a chunk to the scrollback (trimming to [`SCROLLBACK_CAP`]) and + /// fans it out to every live subscriber, dropping any that have gone away. + fn push(&mut self, chunk: &[u8]) { + self.scrollback.extend(chunk.iter().copied()); + let overflow = self.scrollback.len().saturating_sub(SCROLLBACK_CAP); + if overflow > 0 { + self.scrollback.drain(0..overflow); + } + self.subscribers + .retain(|tx| tx.send(chunk.to_vec()).is_ok()); + } + + /// Registers a new subscriber, returning its receiver. + /// + /// If the PTY already hit EOF, the returned stream is immediately closed + /// (its sender is dropped) so a late re-attach to a finished session doesn't + /// block forever waiting for output that will never come. + fn subscribe(&mut self) -> Receiver> { + let (tx, rx) = mpsc::channel(); + if !self.eof { + self.subscribers.push(tx); + } + rx + } + + /// Returns the retained scrollback as a contiguous byte vector. + fn snapshot(&self) -> Vec { + self.scrollback.iter().copied().collect() + } + + /// Drops every subscriber's sender so their output streams end (EOF). Called + /// by the reader thread when the PTY hits EOF (process exit). The scrollback + /// is preserved so a late re-attach can still repaint the final output. + fn close_subscribers(&mut self) { + self.eof = true; + self.subscribers.clear(); + } +} + /// A live PTY owned by the adapter. struct LivePty { /// Master side — used for resize. @@ -53,8 +121,8 @@ struct LivePty { writer: Box, /// The spawned child process. child: Box, - /// Receiver end of the output channel; taken once by `subscribe_output`. - output_rx: Option>>, + /// Shared scrollback + subscriber hub, fed by the reader thread. + output: Arc>, /// Handle of the reader thread, joined on kill. reader: Option>, } @@ -124,22 +192,29 @@ impl PtyPort for PortablePtyAdapter { .try_clone_reader() .map_err(|e| PtyError::Io(e.to_string()))?; - // One reader thread per PTY pumps bytes into the output channel until EOF. - let (tx, rx): (Sender>, Receiver>) = mpsc::channel(); + // One reader thread per PTY pumps bytes into the broadcast hub until EOF. + // The hub retains a scrollback ring buffer AND fans bytes out to every + // current subscriber, so views can detach/re-attach without re-spawning. + let output: Arc> = Arc::new(Mutex::new(Broadcast::default())); + let output_for_reader = Arc::clone(&output); let reader_handle = std::thread::spawn(move || { let mut buf = [0u8; READ_BUF]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { - // Receiver gone (session closed) → stop pumping. - if tx.send(buf[..n].to_vec()).is_err() { - break; + if let Ok(mut hub) = output_for_reader.lock() { + hub.push(&buf[..n]); } } Err(_) => break, } } + // EOF (process exited): end every attached stream by dropping its + // sender, while preserving the scrollback for any late re-attach. + if let Ok(mut hub) = output_for_reader.lock() { + hub.close_subscribers(); + } }); // The PTY layer owns the handle identity: it mints a fresh session id and @@ -153,7 +228,7 @@ impl PtyPort for PortablePtyAdapter { master: pair.master, writer, child, - output_rx: Some(rx), + output, reader: Some(reader_handle), }; self.sessions @@ -189,18 +264,33 @@ impl PtyPort for PortablePtyAdapter { } fn subscribe_output(&self, handle: &PtyHandle) -> Result { - let mut map = self + let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; - let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; + let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; let rx = live - .output_rx - .take() - .ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?; + .output + .lock() + .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))? + .subscribe(); Ok(Box::new(rx.into_iter())) } + fn scrollback(&self, handle: &PtyHandle) -> Result, PtyError> { + let map = self + .sessions + .lock() + .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; + let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; + let snapshot = live + .output + .lock() + .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))? + .snapshot(); + Ok(snapshot) + } + async fn kill(&self, handle: &PtyHandle) -> Result { // Remove from the registry so the writer/master drop and the child is // fully owned here while we tear it down. @@ -220,7 +310,8 @@ impl PtyPort for PortablePtyAdapter { .map_err(|e| PtyError::Io(e.to_string()))?; // Dropping master/writer closes the PTY; the reader thread then sees EOF. - drop(live.output_rx.take()); + // Dropping the broadcast hub drops every subscriber's sender, so any + // still-attached view's output stream ends cleanly too. if let Some(reader) = live.reader.take() { let _ = reader.join(); } diff --git a/crates/infrastructure/tests/pty_adapter.rs b/crates/infrastructure/tests/pty_adapter.rs index 44a9725..7fb374c 100644 --- a/crates/infrastructure/tests/pty_adapter.rs +++ b/crates/infrastructure/tests/pty_adapter.rs @@ -113,25 +113,113 @@ async fn write_is_echoed_back_through_output_stream() { } #[tokio::test] -async fn subscribe_output_twice_is_an_error() { +async fn subscribe_output_is_re_subscribable_for_reattach() { + // A live PTY can be subscribed to more than once over its lifetime: the + // first view detaches (drops its stream), a second view re-attaches and + // still receives subsequent output — the core of the no-kill navigation fix. let pty = PortablePtyAdapter::new(); let handle = pty - .spawn(sh_spec("sleep 0.2"), size()) + .spawn(sh_spec("cat"), size()) + .await + .expect("spawn cat"); + + // First attachment: subscribe, observe an echo, then drop the stream + // (simulating a view tearing down on navigation — NOT a kill). + { + let first = pty.subscribe_output(&handle).expect("first subscribe"); + let (tx, rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut all = Vec::new(); + for chunk in first { + all.extend_from_slice(&chunk); + if String::from_utf8_lossy(&all).contains("first-marker") { + let _ = tx.send(()); + return; // drop the stream → detach + } + } + }); + pty.write(&handle, b"first-marker\n").expect("write 1"); + rx.recv_timeout(TIMEOUT).expect("first view saw its marker"); + worker.join().expect("first worker joined"); + } + + // Second attachment to the SAME live PTY (no re-spawn): must still receive + // new output produced after re-subscription. + let second = pty.subscribe_output(&handle).expect("re-subscribe"); + let (tx, rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut all = Vec::new(); + for chunk in second { + all.extend_from_slice(&chunk); + if String::from_utf8_lossy(&all).contains("second-marker") { + let _ = tx.send(()); + } + } + }); + pty.write(&handle, b"second-marker\n").expect("write 2"); + rx.recv_timeout(TIMEOUT) + .expect("re-attached view saw new output"); + + pty.kill(&handle).await.expect("kill cat"); + worker.join().expect("second worker joined after kill"); +} + +#[tokio::test] +async fn scrollback_retains_recent_output_for_repaint() { + // After output is produced and the process exits, the scrollback still holds + // the recent bytes so a re-attaching view can repaint them. + let pty = PortablePtyAdapter::new(); + let handle = pty + .spawn(sh_spec("printf scrollback-content"), size()) .await .expect("spawn"); - let first = pty.subscribe_output(&handle); - assert!(first.is_ok(), "first subscribe succeeds"); + // Drain to EOF so all output has been pushed into the ring buffer. + let stream = pty.subscribe_output(&handle).expect("subscribe"); + drain_with_timeout(stream, TIMEOUT); - let second = pty.subscribe_output(&handle); + let sb = pty.scrollback(&handle).expect("scrollback readable"); + let text = String::from_utf8_lossy(&sb); assert!( - second.is_err(), - "second subscribe on the same session must error" + text.contains("scrollback-content"), + "scrollback should retain recent output, got {text:?}" ); - // Drain the first stream so the reader thread can finish, then tidy up. - let stream = first.unwrap(); + let _ = pty.kill(&handle).await; +} + +#[tokio::test] +async fn scrollback_is_bounded_to_cap_and_keeps_most_recent_bytes() { + // Emit clearly more than 100 KB of deterministic output, then assert the + // retained scrollback is bounded and ends with the most recent bytes. + let pty = PortablePtyAdapter::new(); + // 5000 lines of "....END" → well over 100 KB; the tail is the freshest. + let script = "i=0; while [ $i -lt 5000 ]; do \ + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-%d\\n' $i; \ + i=$((i+1)); done; printf 'FINAL-LINE-MARKER'"; + let handle = pty.spawn(sh_spec(script), size()).await.expect("spawn"); + + let stream = pty.subscribe_output(&handle).expect("subscribe"); drain_with_timeout(stream, TIMEOUT); + + let sb = pty.scrollback(&handle).expect("scrollback readable"); + assert!( + sb.len() <= 100 * 1024, + "scrollback must be bounded to ~100 KB, was {} bytes", + sb.len() + ); + // The newest output is retained even though the oldest was dropped. + let text = String::from_utf8_lossy(&sb); + assert!( + text.contains("FINAL-LINE-MARKER"), + "the most recent bytes must be kept in the ring buffer" + ); + // And the very first lines must have been evicted. + assert!( + !text.contains("-0\n") || sb.len() < 100 * 1024, + "oldest bytes should be dropped once the cap is exceeded" + ); + let _ = pty.kill(&handle).await; } diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 593594a..678145b 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -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 { + // 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(); + 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 { - await invoke("write_terminal", { - request: { sessionId, data: Array.from(data) }, - }); - }, - async resize(rows: number, cols: number): Promise { - await invoke("resize_terminal", { - request: { sessionId, rows, cols }, - }); - }, - async close(): Promise { - await invoke("close_terminal", { sessionId }); - }, + handle: makeTerminalHandle(res.sessionId, channel), + scrollback: Uint8Array.from(res.scrollback), }; } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 26c7f43..5fff75a 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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(); /** 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(); 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 { + 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 { - 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 {}, - async close(): Promise { - 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 { + 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 {}, + detach(): void { + session.detach(); + }, + async close(): Promise { + 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(); 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 { + 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 { - 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 {}, - async close(): Promise { - closed = true; - }, + handle: makeMockHandle(session, () => this.sessions.delete(sessionId)), + scrollback, }; } } diff --git a/frontend/src/adapters/mock/terminal.test.ts b/frontend/src/adapters/mock/terminal.test.ts index 8eb99e6..1772a76 100644 --- a/frontend/src/adapters/mock/terminal.test.ts +++ b/frontend/src/adapters/mock/terminal.test.ts @@ -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", + }); + }); }); diff --git a/frontend/src/adapters/terminal.ts b/frontend/src/adapters/terminal.ts index 2252b2f..89a0661 100644 --- a/frontend/src/adapters/terminal.ts +++ b/frontend/src/adapters/terminal.ts @@ -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, +): TerminalHandle { + return { + sessionId, + async write(data: Uint8Array): Promise { + await invoke("write_terminal", { + request: { sessionId, data: Array.from(data) }, + }); + }, + 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 }); + }, + }; +} + 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 { + const channel = new Channel(); + channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); + + const res = await invoke("reattach_terminal", { sessionId, - async write(data: Uint8Array): Promise { - await invoke("write_terminal", { - request: { sessionId, data: Array.from(data) }, - }); - }, - async resize(rows: number, cols: number): Promise { - await invoke("resize_terminal", { - request: { sessionId, rows, cols }, - }); - }, - async close(): Promise { - await invoke("close_terminal", { sessionId }); - }, + onOutput: channel, + }); + + return { + handle: makeTerminalHandle(res.sessionId, channel), + scrollback: Uint8Array.from(res.scrollback), }; } } diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 902c741..e4d4203 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -73,6 +73,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { */ const [activeAgentId, setActiveAgentId] = useState(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>( + {}, + ); + const canCreate = newName.trim().length > 0 && !vm.busy; async function handleCreate(e: React.FormEvent) { @@ -325,10 +334,18 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
vm.launchAgent(activeAgentId, opts, onData) } + reattach={(sessionId, onData) => + gateways.agent.reattach(sessionId, onData) + } + sessionId={agentSessions[activeAgentId] ?? null} + onSessionId={(sid) => + setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid })) + } />
diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 93374d1..a3126d3 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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[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 (
)}
- {/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */} - + {/* 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. */} + void vm.setSession(id, sid)} + /> ); } diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index b92ec93..b365da1 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -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 { + 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>, +) { const gateways = { terminal } as unknown as Gateways; return render( - + , ); } @@ -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); + }); }); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 3054371..7462249 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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; + /** + * 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; + /** + * 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(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 diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 091701f..9e0f0cb 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -72,6 +72,16 @@ export interface AgentGateway { options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise; + /** + * 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; } /** Options for opening a terminal. */ @@ -98,7 +108,19 @@ export interface TerminalHandle { write(data: Uint8Array): Promise; /** Resizes the PTY. */ resize(rows: number, cols: number): Promise; - /** 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; } @@ -115,6 +137,27 @@ export interface TerminalGateway { options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise; + /** + * 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; +} + +/** 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). */