//! The four terminal use cases (ARCHITECTURE §6, L3). use std::sync::Arc; use domain::ports::{EventBus, PtyPort, SpawnSpec}; use domain::{ DomainEvent, NodeId, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, TerminalSession, }; use crate::error::AppError; use super::registry::TerminalSessions; /// Input for [`OpenTerminal::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenTerminalInput { /// Working directory for the shell (absolute path; defaults applied by the /// caller — typically the project root). pub cwd: String, /// Initial terminal height in rows. pub rows: u16, /// Initial terminal width in columns. pub cols: u16, /// Command to run. `None` ⇒ the platform default login shell. pub command: Option, /// Arguments for the command. pub args: Vec, /// The layout leaf hosting this session. `None` ⇒ a fresh node id (L4 will /// thread the real layout node through here). pub node_id: Option, } /// Output of [`OpenTerminal::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenTerminalOutput { /// The created terminal session (its `id` is the [`SessionId`] minted by the /// PTY layer and reused everywhere — write/resize/close, the output channel). pub session: TerminalSession, } /// Opens a PTY in a cwd, creates a [`TerminalSession`], registers the handle. pub struct OpenTerminal { pty: Arc, sessions: Arc, events: Arc, } impl OpenTerminal { /// Builds the use case from its injected ports/services. #[must_use] pub fn new( pty: Arc, sessions: Arc, events: Arc, ) -> Self { Self { pty, sessions, events, } } /// Executes the open: validate cwd + size, spawn the PTY, snapshot the /// session, register the live handle, publish [`DomainEvent::LayoutChanged`]. /// /// # Errors /// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal, /// - [`AppError::Process`] if the PTY fails to spawn. pub async fn execute(&self, input: OpenTerminalInput) -> Result { let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?; let size = PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; let command = input.command.unwrap_or_else(default_shell); let spec = SpawnSpec { command, args: input.args, cwd: cwd.clone(), env: Vec::new(), context_plan: None, }; // The PTY layer owns the session identity; we adopt the returned handle's // id as the `TerminalSession.id` (single source of truth, ARCHITECTURE §4). let handle = self.pty.spawn(spec, size).await?; let session_id = handle.session_id; let node_id = input.node_id.unwrap_or_else(NodeId::new_random); let mut session = TerminalSession::starting(session_id, node_id, cwd, SessionKind::Plain, size); session.status = SessionStatus::Running; self.sessions.insert(handle, session.clone()); // Output streaming + per-session Channel wiring happens in the presentation // layer (it owns the transport). Announce so the UI can react. self.events.publish(DomainEvent::PtyOutput { session_id, bytes: Vec::new(), }); Ok(OpenTerminalOutput { session }) } } /// Input for [`WriteToTerminal::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WriteToTerminalInput { /// Target session. pub session_id: SessionId, /// Bytes to write (typically keystrokes from xterm.js). pub data: Vec, } /// Forwards bytes (keystrokes) to a live PTY. pub struct WriteToTerminal { pty: Arc, sessions: Arc, } impl WriteToTerminal { /// Builds the use case. #[must_use] pub fn new(pty: Arc, sessions: Arc) -> Self { Self { pty, sessions } } /// Writes to the session's PTY. /// /// # Errors /// - [`AppError::NotFound`] if the session is unknown, /// - [`AppError::Process`] on PTY I/O failure. pub fn execute(&self, input: WriteToTerminalInput) -> Result<(), AppError> { let handle = self .sessions .handle(&input.session_id) .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; self.pty.write(&handle, &input.data)?; Ok(()) } } /// Input for [`ResizeTerminal::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResizeTerminalInput { /// Target session. pub session_id: SessionId, /// New height in rows. pub rows: u16, /// New width in columns. pub cols: u16, } /// Resizes a live PTY. pub struct ResizeTerminal { pty: Arc, sessions: Arc, } impl ResizeTerminal { /// Builds the use case. #[must_use] pub fn new(pty: Arc, sessions: Arc) -> Self { Self { pty, sessions } } /// Resizes the session's PTY. /// /// # Errors /// - [`AppError::Invalid`] for a zero-sized terminal, /// - [`AppError::NotFound`] if the session is unknown, /// - [`AppError::Process`] on PTY failure. pub fn execute(&self, input: ResizeTerminalInput) -> Result<(), AppError> { let size = PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; let handle = self .sessions .handle(&input.session_id) .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; self.pty.resize(&handle, size)?; Ok(()) } } /// Input for [`CloseTerminal::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CloseTerminalInput { /// Target session. pub session_id: SessionId, } /// Output of [`CloseTerminal::execute`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CloseTerminalOutput { /// Exit code reported by the killed process (`None` if signalled). pub code: Option, } /// Kills a live PTY and forgets its handle. pub struct CloseTerminal { pty: Arc, sessions: Arc, } impl CloseTerminal { /// Builds the use case. #[must_use] pub fn new(pty: Arc, sessions: Arc) -> Self { Self { pty, sessions } } /// Kills the session's PTY and removes it from the registry. Idempotent on /// the registry side (removing an unknown session is a no-op error). /// /// # Errors /// - [`AppError::NotFound`] if the session is unknown, /// - [`AppError::Process`] if the kill fails. pub async fn execute( &self, input: CloseTerminalInput, ) -> Result { let handle = self .sessions .remove(&input.session_id) .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; let status = self.pty.kill(&handle).await?; Ok(CloseTerminalOutput { code: status.code }) } } /// The platform default interactive shell. /// /// Resolution policy lives in the application layer (a metier default), not the /// adapter, so it is uniform and testable. On Unix we honour `$SHELL`, falling /// back to `/bin/sh`; on Windows we use `cmd.exe` (a ConPTY spike point — PowerShell /// could become the default, ARCHITECTURE §13.1). fn default_shell() -> String { #[cfg(windows)] { std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_owned()) } #[cfg(not(windows))] { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned()) } }