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:
@ -28,7 +28,7 @@ use crate::dto::{
|
|||||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
||||||
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
||||||
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
@ -244,6 +244,69 @@ pub async fn close_terminal(
|
|||||||
result
|
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<PtyChunk>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ReattachResultDto, ErrorDto> {
|
||||||
|
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<PtyBridge> = 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)
|
// Layout (L4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -245,6 +245,18 @@ impl From<OpenTerminalOutput> 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `write_terminal`.
|
/// Request DTO for `write_terminal`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -48,6 +48,28 @@ pub fn run() {
|
|||||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||||
|
|
||||||
app.manage(app_state);
|
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::<AppState>() {
|
||||||
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@ -60,6 +82,7 @@ pub fn run() {
|
|||||||
commands::write_terminal,
|
commands::write_terminal,
|
||||||
commands::resize_terminal,
|
commands::resize_terminal,
|
||||||
commands::close_terminal,
|
commands::close_terminal,
|
||||||
|
commands::reattach_terminal,
|
||||||
commands::load_layout,
|
commands::load_layout,
|
||||||
commands::mutate_layout,
|
commands::mutate_layout,
|
||||||
commands::list_layouts,
|
commands::list_layouts,
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
parse_node_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, LayoutDto,
|
||||||
LayoutOperationDto, OpenTerminalRequestDto, ResizeTerminalRequestDto, TerminalClosedDto,
|
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
||||||
WriteTerminalRequestDto,
|
TerminalClosedDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
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 }));
|
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]
|
#[test]
|
||||||
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
fn parse_session_id_accepts_uuid_and_rejects_garbage() {
|
||||||
let sid = SessionId::from_uuid(Uuid::nil());
|
let sid = SessionId::from_uuid(Uuid::nil());
|
||||||
|
|||||||
@ -59,6 +59,18 @@ impl TerminalSessions {
|
|||||||
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
.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<PtyHandle> {
|
||||||
|
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.
|
/// Removes a session from the registry, returning its handle if present.
|
||||||
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||||
self.entries
|
self.entries
|
||||||
|
|||||||
@ -313,6 +313,9 @@ impl PtyPort for FakePty {
|
|||||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||||
Ok(Box::new(std::iter::empty()))
|
Ok(Box::new(std::iter::empty()))
|
||||||
}
|
}
|
||||||
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
Ok(ExitStatus { code: Some(0) })
|
Ok(ExitStatus { code: Some(0) })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,10 @@ impl PtyPort for FakePty {
|
|||||||
Ok(Box::new(std::iter::empty()))
|
Ok(Box::new(std::iter::empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
let mut inner = self.0.lock().unwrap();
|
let mut inner = self.0.lock().unwrap();
|
||||||
inner.calls.push(Call::Kill {
|
inner.calls.push(Call::Kill {
|
||||||
|
|||||||
@ -334,10 +334,24 @@ pub trait PtyPort: Send + Sync {
|
|||||||
|
|
||||||
/// Subscribes to the PTY's byte output stream.
|
/// 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
|
/// # Errors
|
||||||
/// [`PtyError`] if the handle is unknown.
|
/// [`PtyError`] if the handle is unknown.
|
||||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
|
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError>;
|
||||||
|
|
||||||
|
/// 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<Vec<u8>, PtyError>;
|
||||||
|
|
||||||
/// Kills the PTY's process, returning its exit status.
|
/// Kills the PTY's process, returning its exit status.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -12,10 +12,15 @@
|
|||||||
//! domain never sees an OS handle (ARCHITECTURE §4).
|
//! domain never sees an OS handle (ARCHITECTURE §4).
|
||||||
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
|
//! - 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
|
//! slave, then start **one reader thread** that pumps bytes from the master
|
||||||
//! into an [`std::sync::mpsc`] channel. [`subscribe_output`](PtyPort::subscribe_output)
|
//! into a shared [`Broadcast`] hub. The hub does two things with every chunk:
|
||||||
//! hands back the receiver wrapped as the domain's blocking [`OutputStream`]
|
//! it appends to a bounded **scrollback ring buffer** (~100 KB, most recent
|
||||||
//! iterator; the presentation layer drains it on its own thread and forwards
|
//! bytes) and it fans the chunk out to every *currently subscribed* receiver.
|
||||||
//! chunks to the per-session Tauri channel (the `PtyBridge`).
|
//! - [`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
|
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
|
||||||
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
|
//! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
|
||||||
//! reader thread, and returns the [`ExitStatus`].
|
//! reader thread, and returns the [`ExitStatus`].
|
||||||
@ -30,9 +35,10 @@
|
|||||||
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
|
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -45,6 +51,68 @@ use domain::SessionId;
|
|||||||
/// Size of each read buffer pumped from the master PTY.
|
/// Size of each read buffer pumped from the master PTY.
|
||||||
const READ_BUF: usize = 8 * 1024;
|
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<u8>,
|
||||||
|
/// Live subscribers; pruned lazily when their receiver is gone.
|
||||||
|
subscribers: Vec<Sender<Vec<u8>>>,
|
||||||
|
/// 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<Vec<u8>> {
|
||||||
|
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<u8> {
|
||||||
|
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.
|
/// A live PTY owned by the adapter.
|
||||||
struct LivePty {
|
struct LivePty {
|
||||||
/// Master side — used for resize.
|
/// Master side — used for resize.
|
||||||
@ -53,8 +121,8 @@ struct LivePty {
|
|||||||
writer: Box<dyn Write + Send>,
|
writer: Box<dyn Write + Send>,
|
||||||
/// The spawned child process.
|
/// The spawned child process.
|
||||||
child: Box<dyn Child + Send + Sync>,
|
child: Box<dyn Child + Send + Sync>,
|
||||||
/// Receiver end of the output channel; taken once by `subscribe_output`.
|
/// Shared scrollback + subscriber hub, fed by the reader thread.
|
||||||
output_rx: Option<Receiver<Vec<u8>>>,
|
output: Arc<Mutex<Broadcast>>,
|
||||||
/// Handle of the reader thread, joined on kill.
|
/// Handle of the reader thread, joined on kill.
|
||||||
reader: Option<JoinHandle<()>>,
|
reader: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@ -124,22 +192,29 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.try_clone_reader()
|
.try_clone_reader()
|
||||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||||
|
|
||||||
// One reader thread per PTY pumps bytes into the output channel until EOF.
|
// One reader thread per PTY pumps bytes into the broadcast hub until EOF.
|
||||||
let (tx, rx): (Sender<Vec<u8>>, Receiver<Vec<u8>>) = mpsc::channel();
|
// 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<Mutex<Broadcast>> = Arc::new(Mutex::new(Broadcast::default()));
|
||||||
|
let output_for_reader = Arc::clone(&output);
|
||||||
let reader_handle = std::thread::spawn(move || {
|
let reader_handle = std::thread::spawn(move || {
|
||||||
let mut buf = [0u8; READ_BUF];
|
let mut buf = [0u8; READ_BUF];
|
||||||
loop {
|
loop {
|
||||||
match reader.read(&mut buf) {
|
match reader.read(&mut buf) {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
// Receiver gone (session closed) → stop pumping.
|
if let Ok(mut hub) = output_for_reader.lock() {
|
||||||
if tx.send(buf[..n].to_vec()).is_err() {
|
hub.push(&buf[..n]);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
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
|
// 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,
|
master: pair.master,
|
||||||
writer,
|
writer,
|
||||||
child,
|
child,
|
||||||
output_rx: Some(rx),
|
output,
|
||||||
reader: Some(reader_handle),
|
reader: Some(reader_handle),
|
||||||
};
|
};
|
||||||
self.sessions
|
self.sessions
|
||||||
@ -189,18 +264,33 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||||
let mut map = self
|
let map = self
|
||||||
.sessions
|
.sessions
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
|
.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
|
let rx = live
|
||||||
.output_rx
|
.output
|
||||||
.take()
|
.lock()
|
||||||
.ok_or_else(|| PtyError::Io("output already subscribed".to_owned()))?;
|
.map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
|
||||||
|
.subscribe();
|
||||||
Ok(Box::new(rx.into_iter()))
|
Ok(Box::new(rx.into_iter()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, 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<ExitStatus, PtyError> {
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
// Remove from the registry so the writer/master drop and the child is
|
// Remove from the registry so the writer/master drop and the child is
|
||||||
// fully owned here while we tear it down.
|
// fully owned here while we tear it down.
|
||||||
@ -220,7 +310,8 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||||
|
|
||||||
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
|
// 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() {
|
if let Some(reader) = live.reader.take() {
|
||||||
let _ = reader.join();
|
let _ = reader.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,25 +113,113 @@ async fn write_is_echoed_back_through_output_stream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 pty = PortablePtyAdapter::new();
|
||||||
let handle = pty
|
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
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
|
|
||||||
let first = pty.subscribe_output(&handle);
|
// Drain to EOF so all output has been pushed into the ring buffer.
|
||||||
assert!(first.is_ok(), "first subscribe succeeds");
|
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!(
|
assert!(
|
||||||
second.is_err(),
|
text.contains("scrollback-content"),
|
||||||
"second subscribe on the same session must error"
|
"scrollback should retain recent output, got {text:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drain the first stream so the reader thread can finish, then tidy up.
|
let _ = pty.kill(&handle).await;
|
||||||
let stream = first.unwrap();
|
}
|
||||||
|
|
||||||
|
#[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<n>" → 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);
|
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;
|
let _ = pty.kill(&handle).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,10 @@ import type {
|
|||||||
AgentGateway,
|
AgentGateway,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
import { makeTerminalHandle } from "./terminal";
|
||||||
|
|
||||||
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
|
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
|
||||||
interface LaunchAgentResponse {
|
interface LaunchAgentResponse {
|
||||||
@ -87,22 +89,26 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
onOutput: channel,
|
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 {
|
return {
|
||||||
sessionId,
|
handle: makeTerminalHandle(res.sessionId, channel),
|
||||||
async write(data: Uint8Array): Promise<void> {
|
scrollback: Uint8Array.from(res.scrollback),
|
||||||
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 });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import type {
|
|||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
@ -100,6 +101,47 @@ function slugify(name: string): string {
|
|||||||
return out.replace(/^-+|-+$/g, "");
|
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`,
|
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
||||||
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
||||||
@ -115,6 +157,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
private contexts = new Map<string, string>();
|
private contexts = new Map<string, string>();
|
||||||
/** Monotonic session counter for deterministic session ids in tests. */
|
/** Monotonic session counter for deterministic session ids in tests. */
|
||||||
private sessionSeq = 0;
|
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[] {
|
private getAgents(projectId: string): Agent[] {
|
||||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||||
@ -256,30 +300,65 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
||||||
const cwd = options.cwd;
|
const cwd = options.cwd;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
const session = new MockPtySession(sessionId, onData);
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
||||||
queueMicrotask(() =>
|
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 {
|
return {
|
||||||
sessionId,
|
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> {
|
async write(data: Uint8Array): Promise<void> {
|
||||||
if (closed) return;
|
if (session.closed) return;
|
||||||
// Echo back, translating CR to CRLF like a cooked terminal.
|
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
for (const b of data) {
|
for (const b of data) {
|
||||||
if (b === 0x0d) out.push(0x0d, 0x0a);
|
if (b === 0x0d) out.push(0x0d, 0x0a);
|
||||||
else out.push(b);
|
else out.push(b);
|
||||||
}
|
}
|
||||||
onData(Uint8Array.from(out));
|
session.emit(Uint8Array.from(out));
|
||||||
},
|
},
|
||||||
async resize(): Promise<void> {},
|
async resize(): Promise<void> {},
|
||||||
|
detach(): void {
|
||||||
|
session.detach();
|
||||||
|
},
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
closed = true;
|
session.closed = true;
|
||||||
|
unregister();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
|
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
|
||||||
@ -288,6 +367,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
*/
|
*/
|
||||||
export class MockTerminalGateway implements TerminalGateway {
|
export class MockTerminalGateway implements TerminalGateway {
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
|
/** Live sessions kept across detach so reattach can find them. */
|
||||||
|
private sessions = new Map<string, MockPtySession>();
|
||||||
|
|
||||||
async openTerminal(
|
async openTerminal(
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
@ -296,27 +377,31 @@ export class MockTerminalGateway implements TerminalGateway {
|
|||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
const sessionId = `mock-session-${this.seq}`;
|
const sessionId = `mock-session-${this.seq}`;
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
const session = new MockPtySession(sessionId, onData);
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
// Greet so something is visible immediately.
|
// Greet so something is visible immediately.
|
||||||
queueMicrotask(() =>
|
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));
|
||||||
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 reattach(
|
||||||
async resize(): Promise<void> {},
|
sessionId: string,
|
||||||
async close(): Promise<void> {
|
onData: (bytes: Uint8Array) => void,
|
||||||
closed = true;
|
): 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 {
|
||||||
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||||
|
scrollback,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,4 +111,61 @@ describe("MockTerminalGateway", () => {
|
|||||||
// Exactly one delivery so far: the greeting.
|
// Exactly one delivery so far: the greeting.
|
||||||
expect(onData).toHaveBeenCalledTimes(1);
|
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 {
|
import type {
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
@ -30,6 +31,46 @@ interface OpenTerminalResponse {
|
|||||||
cols: 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 {
|
||||||
|
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 {
|
export class TauriTerminalGateway implements TerminalGateway {
|
||||||
async openTerminal(
|
async openTerminal(
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
@ -44,22 +85,24 @@ export class TauriTerminalGateway implements TerminalGateway {
|
|||||||
onOutput: channel,
|
onOutput: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionId = res.sessionId;
|
return makeTerminalHandle(res.sessionId, channel);
|
||||||
return {
|
}
|
||||||
|
|
||||||
|
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,
|
sessionId,
|
||||||
async write(data: Uint8Array): Promise<void> {
|
onOutput: channel,
|
||||||
await invoke("write_terminal", {
|
|
||||||
request: { sessionId, data: Array.from(data) },
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
async resize(rows: number, cols: number): Promise<void> {
|
return {
|
||||||
await invoke("resize_terminal", {
|
handle: makeTerminalHandle(res.sessionId, channel),
|
||||||
request: { sessionId, rows, cols },
|
scrollback: Uint8Array.from(res.scrollback),
|
||||||
});
|
|
||||||
},
|
|
||||||
async close(): Promise<void> {
|
|
||||||
await invoke("close_terminal", { sessionId });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,6 +73,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
*/
|
*/
|
||||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
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;
|
const canCreate = newName.trim().length > 0 && !vm.busy;
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
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="border-t border-border p-4">
|
||||||
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
||||||
<TerminalView
|
<TerminalView
|
||||||
|
key={activeAgentId}
|
||||||
cwd={projectRoot}
|
cwd={projectRoot}
|
||||||
open={(opts, onData) =>
|
open={(opts, onData) =>
|
||||||
vm.launchAgent(activeAgentId, 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -112,7 +112,7 @@ interface LeafViewProps {
|
|||||||
projectId: string;
|
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 canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
||||||
const { agent: agentGateway } = useGateways();
|
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) =>
|
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
||||||
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -215,8 +221,17 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 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.
|
||||||
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,20 +5,44 @@
|
|||||||
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
|
* 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
|
* 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 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 { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
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 { MockTerminalGateway } from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { TerminalView } from "./TerminalView";
|
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;
|
const gateways = { terminal } as unknown as Gateways;
|
||||||
return render(
|
return render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<TerminalView cwd={cwd} />
|
<TerminalView cwd={cwd} {...extra} />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -49,20 +73,13 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("consuming gateway output (onData) does not throw", async () => {
|
it("consuming gateway output (onData) does not throw", async () => {
|
||||||
// A gateway that immediately pushes bytes to the consumer, exercising the
|
const handle = makeHandle();
|
||||||
// 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 terminal: TerminalGateway = {
|
const terminal: TerminalGateway = {
|
||||||
openTerminal: vi.fn(async (_opts, onData) => {
|
openTerminal: vi.fn(async (_opts, onData) => {
|
||||||
onData(new TextEncoder().encode("hello\r\n"));
|
onData(new TextEncoder().encode("hello\r\n"));
|
||||||
return handle;
|
return handle;
|
||||||
}),
|
}),
|
||||||
|
reattach: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => renderView(terminal)).not.toThrow();
|
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 close = vi.fn().mockResolvedValue(undefined);
|
||||||
const handle: TerminalHandle = {
|
const handle = makeHandle({ detach, close });
|
||||||
sessionId: "s1",
|
|
||||||
write: vi.fn().mockResolvedValue(undefined),
|
|
||||||
resize: vi.fn().mockResolvedValue(undefined),
|
|
||||||
close,
|
|
||||||
};
|
|
||||||
const openTerminal = vi.fn(async () => handle);
|
const openTerminal = vi.fn(async () => handle);
|
||||||
const terminal: TerminalGateway = { openTerminal };
|
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||||
|
|
||||||
const { unmount } = renderView(terminal);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(openTerminal.mock.calls.length >= 0).toBe(true);
|
expect(openTerminal.mock.calls.length >= 0).toBe(true);
|
||||||
});
|
});
|
||||||
@ -95,11 +106,57 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
|
|
||||||
if (wasOpened) {
|
if (wasOpened) {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(close).toHaveBeenCalled();
|
expect(detach).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
// The cardinal invariant: navigating away must NOT kill the PTY.
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
} else {
|
} 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();
|
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,
|
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
||||||
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
* 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";
|
import { useEffect, useRef } from "react";
|
||||||
@ -21,7 +32,11 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
import type {
|
||||||
|
OpenTerminalOptions,
|
||||||
|
ReattachResult,
|
||||||
|
TerminalHandle,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
/** Working directory the shell opens in (typically the project root). */
|
/** Working directory the shell opens in (typically the project root). */
|
||||||
@ -36,9 +51,35 @@ interface TerminalViewProps {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
) => Promise<TerminalHandle>;
|
) => 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 { terminal } = useGateways();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
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.
|
// so the correct opener is always captured at mount.
|
||||||
const openRef = useRef(open);
|
const openRef = useRef(open);
|
||||||
openRef.current = 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);
|
const terminalRef = useRef(terminal);
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
|
|
||||||
@ -58,6 +105,7 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const tgw = terminalRef.current;
|
const tgw = terminalRef.current;
|
||||||
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
||||||
|
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||||
if (!container || !opener) return;
|
if (!container || !opener) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
@ -94,15 +142,16 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
else pending += data;
|
else pending += data;
|
||||||
});
|
});
|
||||||
|
|
||||||
opener(
|
const onData = (bytes: Uint8Array) => {
|
||||||
{ cwd, rows: term.rows, cols: term.cols },
|
|
||||||
(bytes) => {
|
|
||||||
if (!disposed) term.write(bytes);
|
if (!disposed) term.write(bytes);
|
||||||
},
|
};
|
||||||
)
|
|
||||||
.then((h) => {
|
// 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) {
|
if (disposed) {
|
||||||
void h.close();
|
h.detach();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handle = h;
|
handle = h;
|
||||||
@ -110,14 +159,48 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
void h.write(encoder.encode(pending));
|
void h.write(encoder.encode(pending));
|
||||||
pending = "";
|
pending = "";
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.catch((e: unknown) => {
|
|
||||||
|
const onOpenError = (e: unknown) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
term.write(
|
term.write(
|
||||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
`\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.
|
// Refit + propagate size to the PTY on container resize.
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
@ -134,7 +217,10 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
onKey.dispose();
|
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();
|
term.dispose();
|
||||||
};
|
};
|
||||||
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
// 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,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<TerminalHandle>;
|
): 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. */
|
/** Options for opening a terminal. */
|
||||||
@ -98,7 +108,19 @@ export interface TerminalHandle {
|
|||||||
write(data: Uint8Array): Promise<void>;
|
write(data: Uint8Array): Promise<void>;
|
||||||
/** Resizes the PTY. */
|
/** Resizes the PTY. */
|
||||||
resize(rows: number, cols: number): Promise<void>;
|
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>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +137,27 @@ export interface TerminalGateway {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<TerminalHandle>;
|
): 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). */
|
/** Projects: create/open/close/list (L2). */
|
||||||
|
|||||||
Reference in New Issue
Block a user