diff options
Diffstat (limited to 'src/pane.rs')
| -rw-r--r-- | src/pane.rs | 370 |
1 files changed, 370 insertions, 0 deletions
diff --git a/src/pane.rs b/src/pane.rs new file mode 100644 index 00000000..821cdec9 --- /dev/null +++ b/src/pane.rs @@ -0,0 +1,370 @@ +use std::io::{BufWriter, Read, Write}; +use std::cell::Cell; +use std::sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + Arc, RwLock, +}; + +use bytes::Bytes; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::detect::{Agent, AgentState}; +use crate::events::AppEvent; +use crate::layout::PaneId; +use crate::pty_callbacks::PtyResponses; + +// --------------------------------------------------------------------------- +// PaneState — pure data, constructable without PTYs, testable +// --------------------------------------------------------------------------- + +/// Observable state for a single pane. +/// This is the only part of a pane that workspace logic and tests need. +pub struct PaneState { + pub detected_agent: Option<Agent>, + pub state: AgentState, + /// Whether the user has seen this pane since its last state change to Idle. + /// False = "Done" (agent finished while user was in another workspace). + pub seen: bool, +} + +impl PaneState { + pub fn new() -> Self { + Self { + detected_agent: None, + state: AgentState::Unknown, + seen: true, + } + } +} + +// --------------------------------------------------------------------------- +// PaneRuntime — PTY, parser, channels, background tasks +// --------------------------------------------------------------------------- + +/// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks. +/// Dropping this shuts down all background tasks and closes the PTY. +pub struct PaneRuntime { + pub parser: Arc<RwLock<vt100::Parser<PtyResponses>>>, + pub sender: mpsc::Sender<Bytes>, + resize_tx: mpsc::Sender<(u16, u16)>, + current_size: Cell<(u16, u16)>, + child_pid: Arc<AtomicU32>, + pub kitty_keyboard: Arc<AtomicBool>, + /// Live screen content snapshot — updated by reader, read by detector. + /// Decouples detection from parser viewport state (scrollback). + /// Kept alive here so the Arc isn't dropped; tasks hold their own clones. + #[allow(dead_code)] + screen_content: Arc<RwLock<String>>, + // Task handles for deterministic shutdown + detect_handle: tokio::task::AbortHandle, +} + +impl Drop for PaneRuntime { + fn drop(&mut self) { + // Abort detection task immediately. + // Reader/writer/resize tasks shut down naturally via channel close + // and PTY EOF when the rest of PaneRuntime is dropped. + self.detect_handle.abort(); + } +} + +impl PaneRuntime { + pub fn spawn( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + events: mpsc::Sender<AppEvent>, + ) -> std::io::Result<Self> { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let responses = PtyResponses::new(); + let kitty_keyboard = responses.kitty_keyboard.clone(); + let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks( + rows, cols, 10000, responses.clone(), + ))); + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd); + + let reader = pair + .master + .try_clone_reader() + .map_err(|e| std::io::Error::other(e.to_string()))?; + let writer = pair + .master + .take_writer() + .map_err(|e| std::io::Error::other(e.to_string()))?; + + // --- Child watcher task --- + let child_pid = Arc::new(AtomicU32::new(0)); + { + let child_pid = child_pid.clone(); + let slave = pair.slave; + let events = events.clone(); + let rt = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + match slave.spawn_command(cmd) { + Ok(mut child) => { + if let Some(pid) = child.process_id() { + child_pid.store(pid, Ordering::Release); + info!(pane = pane_id.raw(), pid, "child spawned"); + } + match child.wait() { + Ok(status) => info!(pane = pane_id.raw(), ?status, "child exited"), + Err(e) => error!(pane = pane_id.raw(), err = %e, "child wait failed"), + } + } + Err(e) => error!(pane = pane_id.raw(), err = %e, "failed to spawn shell"), + } + // Use blocking send — PaneDied is critical, must not be dropped + if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) { + error!(pane = pane_id.raw(), err = %e, "failed to send PaneDied event"); + } + }); + } + + // --- Writer channel --- + let (input_tx, mut input_rx) = mpsc::channel::<Bytes>(32); + + // Live screen snapshot for detection (decoupled from parser scrollback) + let screen_content = Arc::new(RwLock::new(String::new())); + + // --- Reader task: PTY → parser + screen snapshot + terminal query responses --- + { + let mut reader = reader; + let parser = parser.clone(); + let screen_content = screen_content.clone(); + let response_writer = input_tx.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Err(e) => { + debug!(pane = pane_id.raw(), err = %e, "pty reader closed"); + break; + } + Ok(n) => { + if let Ok(mut p) = parser.write() { + p.process(&buf[..n]); + // Snapshot live screen content for detection. + // Always reads at scrollback 0 (current view), + // without touching the user's scroll position. + let scrollback = p.screen().scrollback(); + if scrollback > 0 { + p.screen_mut().set_scrollback(0); + } + let content = p.screen().contents(); + if scrollback > 0 { + p.screen_mut().set_scrollback(scrollback); + } + if let Ok(mut sc) = screen_content.write() { + *sc = content; + } + } else { + error!(pane = pane_id.raw(), "parser lock poisoned in reader"); + break; + } + let resp = responses.take(); + if !resp.is_empty() { + if let Err(e) = response_writer.try_send(Bytes::from(resp)) { + warn!(pane = pane_id.raw(), err = %e, "dropped terminal query response"); + } + } + } + } + } + debug!(pane = pane_id.raw(), "reader task exiting"); + }); + } + + // --- Detection task --- + let detect_handle = { + use crate::detect; + use std::time::{Duration, Instant}; + + const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500); + const TICK_IDENTIFIED: Duration = Duration::from_millis(300); + const PROCESS_RECHECK: Duration = Duration::from_secs(5); + + let child_pid = child_pid.clone(); + let screen_content = screen_content.clone(); + let state_events = events.clone(); + + let handle = tokio::spawn(async move { + let mut agent: Option<Agent> = None; + let mut state = AgentState::Unknown; + let mut last_process_check = Instant::now(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + loop { + let tick = if agent.is_none() { + TICK_UNIDENTIFIED + } else { + TICK_IDENTIFIED + }; + tokio::time::sleep(tick).await; + + let now = Instant::now(); + let should_check_process = agent.is_none() + || now.duration_since(last_process_check) >= PROCESS_RECHECK; + + let mut agent_changed = false; + if should_check_process { + last_process_check = now; + let pid = child_pid.load(Ordering::Acquire); + if pid > 0 { + if let Some(name) = detect::foreground_process_name(pid) { + let new_agent = detect::identify_agent(&name); + if new_agent != agent { + info!( + pane = pane_id.raw(), + ?new_agent, + process = %name, + "agent changed" + ); + agent = new_agent; + agent_changed = true; + } + } + } + } + + let new_state = if let Ok(content) = screen_content.read() { + detect::detect_state(agent, &content) + } else { + continue; + }; + + if new_state != state || agent_changed { + debug!( + pane = pane_id.raw(), + ?state, + ?new_state, + ?agent, + "state changed" + ); + state = new_state; + if let Err(e) = state_events.try_send(AppEvent::StateChanged { + pane_id, + agent, + state: new_state, + }) { + warn!( + pane = pane_id.raw(), + err = %e, + "dropped StateChanged event" + ); + } + } + } + }); + handle.abort_handle() + }; + + // --- Writer task: channel → PTY --- + { + let mut writer = BufWriter::new(writer); + tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Handle::current(); + while let Some(bytes) = rt.block_on(input_rx.recv()) { + if let Err(e) = writer.write_all(&bytes) { + warn!(pane = pane_id.raw(), err = %e, "pty write failed"); + break; + } + if let Err(e) = writer.flush() { + warn!(pane = pane_id.raw(), err = %e, "pty flush failed"); + break; + } + } + debug!(pane = pane_id.raw(), "writer task exiting"); + }); + } + + // --- Resize task --- + let (resize_tx, mut resize_rx) = mpsc::channel::<(u16, u16)>(4); + { + let master = pair.master; + tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Handle::current(); + while let Some((rows, cols)) = rt.block_on(resize_rx.recv()) { + if let Err(e) = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) { + warn!(pane = pane_id.raw(), err = %e, rows, cols, "pty resize failed"); + } + } + }); + } + + Ok(Self { + parser, + sender: input_tx, + resize_tx, + current_size: Cell::new((rows, cols)), + child_pid, + kitty_keyboard, + screen_content, + detect_handle, + }) + } + + /// Resize if the dimensions actually changed. + pub fn resize(&self, rows: u16, cols: u16) { + let rows = rows.max(2); + let cols = cols.max(4); + if self.current_size.get() == (rows, cols) { + return; + } + self.current_size.set((rows, cols)); + if let Ok(mut p) = self.parser.write() { + p.screen_mut().set_size(rows, cols); + } + let _ = self.resize_tx.try_send((rows, cols)); + } + + /// Scroll up by N lines (into scrollback history). + pub fn scroll_up(&self, lines: usize) { + if let Ok(mut p) = self.parser.write() { + let current = p.screen().scrollback(); + p.screen_mut().set_scrollback(current + lines); + } + } + + /// Scroll down by N lines (toward live output). + pub fn scroll_down(&self, lines: usize) { + if let Ok(mut p) = self.parser.write() { + let current = p.screen().scrollback(); + p.screen_mut().set_scrollback(current.saturating_sub(lines)); + } + } + + /// Reset scroll to live view (offset = 0). + pub fn scroll_reset(&self) { + if let Ok(mut p) = self.parser.write() { + p.screen_mut().set_scrollback(0); + } + } + + /// Get the current working directory of the child shell process. + pub fn cwd(&self) -> Option<std::path::PathBuf> { + let pid = self.child_pid.load(Ordering::Relaxed); + crate::platform::process_cwd(pid) + } +} |
