diff --git a/src/api/mod.rs b/src/api/mod.rs index e2c29490..bf95bf29 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use regex::Regex; +use tokio::sync::mpsc; use crate::api::schema::{ ErrorBody, ErrorResponse, Method, PaneAgentStateChangedEvent, PaneOutputMatchedEvent, Request, @@ -23,6 +24,8 @@ pub struct ApiRequestMessage { pub respond_to: std::sync::mpsc::Sender, } +pub type ApiRequestSender = mpsc::UnboundedSender; + #[derive(Clone, Default)] pub struct EventHub { inner: std::sync::Arc>, @@ -99,7 +102,7 @@ impl Drop for ServerHandle { } pub fn start_server( - api_tx: std::sync::mpsc::Sender, + api_tx: ApiRequestSender, event_hub: EventHub, ) -> std::io::Result { let path = socket_path(); @@ -151,7 +154,7 @@ fn prepare_socket_path(path: &Path) -> std::io::Result<()> { fn handle_connection( mut stream: UnixStream, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, event_hub: &EventHub, ) -> std::io::Result<()> { let mut line = String::new(); @@ -205,7 +208,7 @@ fn handle_connection( } } -fn handle_request(request: Request, api_tx: &std::sync::mpsc::Sender) -> String { +fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { let request_id = request.id.clone(); match request.method { Method::Ping(_) => serde_json::to_string(&SuccessResponse { @@ -226,7 +229,7 @@ fn handle_request(request: Request, api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, ) -> String { let deadline = params .timeout_ms @@ -316,7 +319,7 @@ fn stream_subscriptions( mut stream: UnixStream, request_id: String, params: crate::api::schema::EventsSubscribeParams, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, event_hub: &EventHub, ) -> std::io::Result<()> { let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); @@ -411,7 +414,7 @@ impl ActiveSubscription { subscription: Subscription, request_id: &str, index: usize, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, _event_hub: &EventHub, ) -> Result { match subscription { @@ -514,7 +517,7 @@ impl ActiveSubscription { fn poll( &mut self, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, event_hub: &EventHub, ) -> Option { match self { @@ -542,10 +545,7 @@ impl ActiveEventSubscription { } impl ActiveOutputMatchedSubscription { - fn poll( - &mut self, - api_tx: &std::sync::mpsc::Sender, - ) -> Option { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { let read = pane_read( format!("{}:read", self.request_prefix), &self.pane_id, @@ -581,10 +581,7 @@ impl ActiveOutputMatchedSubscription { } impl ActiveAgentStateChangedSubscription { - fn poll( - &mut self, - api_tx: &std::sync::mpsc::Sender, - ) -> Option { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { let pane = pane_get( format!("{}:pane", self.request_prefix), &self.pane_id, @@ -621,7 +618,7 @@ fn pane_read( source: crate::api::schema::ReadSource, lines: Option, strip_ansi: bool, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, ) -> Result { let response = dispatch_to_app( Request { @@ -663,7 +660,7 @@ fn pane_read( fn pane_get( request_id: String, pane_id: &str, - api_tx: &std::sync::mpsc::Sender, + api_tx: &ApiRequestSender, ) -> Result { let response = dispatch_to_app( Request { @@ -699,10 +696,7 @@ fn pane_get( }) } -fn dispatch_to_app( - request: Request, - api_tx: &std::sync::mpsc::Sender, -) -> String { +fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { let (respond_to, response_rx) = std::sync::mpsc::channel(); if let Err(err) = api_tx.send(ApiRequestMessage { request, @@ -748,7 +742,7 @@ mod tests { #[test] fn ping_request_returns_pong() { - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = mpsc::unbounded_channel(); let response = handle_request( Request { id: "req_1".into(), @@ -764,7 +758,7 @@ mod tests { #[test] fn request_dispatches_to_app_channel() { - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, mut rx) = mpsc::unbounded_channel(); let request = Request { id: "req_2".into(), method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), @@ -773,7 +767,7 @@ mod tests { let request_for_thread = request.clone(); let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx)); - let msg = rx.recv().unwrap(); + let msg = rx.blocking_recv().unwrap(); assert_eq!(msg.request.id, "req_2"); msg.respond_to .send( diff --git a/src/app/input.rs b/src/app/input.rs index 56eda4f2..9827d4d2 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1418,7 +1418,7 @@ mod tests { } fn app_for_mouse_test() -> App { - let (_api_tx, api_rx) = std::sync::mpsc::channel(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), true, diff --git a/src/app/mod.rs b/src/app/mod.rs index 617db561..293a252b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,17 +8,22 @@ mod actions; mod input; pub mod state; +use std::future::pending; use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; -const RENDER_INTERVAL: Duration = Duration::from_millis(16); +const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); +const ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); use crossterm::terminal; use ratatui::layout::Rect; use ratatui::DefaultTerminal; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Notify}; use tracing::{error, info}; use crate::config::Config; @@ -32,7 +37,7 @@ pub struct App { pub state: AppState, pub event_tx: mpsc::Sender, event_rx: mpsc::Receiver, - api_rx: std::sync::mpsc::Receiver, + api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, last_focus: Option<(usize, crate::layout::PaneId)>, no_session: bool, @@ -42,14 +47,50 @@ pub struct App { toast_deadline: Option, last_git_remote_status_refresh: Instant, last_sidebar_divider_click: Option, + next_resize_poll: Instant, + next_animation_tick: Option, + last_render_at: Option, + render_notify: Arc, + render_dirty: Arc, } enum LoopEvent { - RenderTick, + Timer, Internal(AppEvent), + Api(crate::api::ApiRequestMessage), RawInput(crate::raw_input::RawInputEvent), InputClosed, - Idle, + RenderRequested, +} + +async fn recv_raw_input_or_pending( + input_rx: Option<&mut mpsc::Receiver>, +) -> Option { + match input_rx { + Some(rx) => rx.recv().await, + None => pending().await, + } +} + +async fn sleep_until_or_pending(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, + None => pending().await, + } +} + +fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool { + use crate::api::schema::Method; + + matches!( + &request.method, + Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorkspaceRename(_) + | Method::WorkspaceClose(_) + | Method::PaneSplit(_) + | Method::PaneClose(_) + ) } /// Resolve the palette from config: base theme + optional custom overrides. @@ -89,17 +130,26 @@ impl App { config: &Config, no_session: bool, config_diagnostic: Option, - api_rx: std::sync::mpsc::Receiver, + api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, ) -> Self { let (prefix_code, prefix_mods) = config.prefix_key(); let (event_tx, event_rx) = mpsc::channel::(64); + let render_notify = Arc::new(Notify::new()); + let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session let (workspaces, active, selected) = if no_session { (Vec::new(), None, 0) } else if let Some(snap) = crate::persist::load() { - let ws = crate::persist::restore(&snap, 24, 80, event_tx.clone()); + let ws = crate::persist::restore( + &snap, + 24, + 80, + event_tx.clone(), + render_notify.clone(), + render_dirty.clone(), + ); if ws.is_empty() { info!("session file found but no workspaces restored"); (Vec::new(), None, 0) @@ -199,12 +249,17 @@ impl App { event_rx, last_git_remote_status_refresh: Instant::now(), last_sidebar_divider_click: None, + next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, + next_animation_tick: None, + last_render_at: None, api_rx, event_hub, last_focus, no_session, input_rx: None, last_terminal_size: terminal::size().ok(), + render_notify, + render_dirty, } } @@ -213,28 +268,25 @@ impl App { self.input_rx = Some(crate::raw_input::spawn_input_reader()); } - let mut render_tick = tokio::time::interval(RENDER_INTERVAL); - render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut needs_render = true; while !self.state.should_quit { - // Drain internal events first so API reads observe fresh pane state. - let had_internal_events = self.drain_internal_events(); - - let mut had_api_requests = false; - while let Ok(msg) = self.api_rx.try_recv() { - had_api_requests = true; - let response = self.handle_api_request(msg.request); - let _ = msg.respond_to.send(response); + if self.render_dirty.load(Ordering::Acquire) { + needs_render = true; } - if had_internal_events || had_api_requests { + // Drain internal events first so API reads observe fresh pane state. + if self.drain_internal_events() { + needs_render = true; + } + if self.drain_api_requests() { needs_render = true; } self.sync_focus_events(); - self.handle_resize_poll(); - if self.handle_frame_timers() { + + let now = Instant::now(); + if self.handle_scheduled_tasks(now) { needs_render = true; } @@ -250,52 +302,65 @@ impl App { needs_render = true; } - if needs_render { + let now = Instant::now(); + self.sync_animation_timer(now); + + if needs_render && self.can_render_now(now) { + self.render_dirty.swap(false, Ordering::AcqRel); terminal.draw(|frame| { crate::ui::compute_view(&mut self.state, frame.area()); crate::ui::render(&self.state, frame); })?; + self.last_render_at = Some(now); needs_render = false; continue; } + let next_deadline = self.next_loop_deadline(now, needs_render); let event = { let input_rx = self.input_rx.as_mut(); tokio::select! { - _ = render_tick.tick() => LoopEvent::RenderTick, + maybe_api = self.api_rx.recv() => match maybe_api { + Some(msg) => LoopEvent::Api(msg), + None => LoopEvent::Timer, + }, maybe_ev = self.event_rx.recv() => match maybe_ev { Some(ev) => LoopEvent::Internal(ev), - None => LoopEvent::Idle, + None => LoopEvent::Timer, }, - maybe_input = async { - match input_rx { - Some(rx) => rx.recv().await, - None => None, - } - } => match maybe_input { + maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input { Some(input) => LoopEvent::RawInput(input), None => LoopEvent::InputClosed, }, + _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, + _ = self.render_notify.notified() => LoopEvent::RenderRequested, } }; match event { - LoopEvent::RenderTick => { - self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); - needs_render = true; - } + LoopEvent::Timer => {} LoopEvent::Internal(ev) => { self.handle_internal_event(ev); needs_render = true; } + LoopEvent::Api(msg) => { + if self.handle_api_request_message(msg) { + needs_render = true; + } + } LoopEvent::RawInput(input) => { - self.handle_raw_input_event(input).await; - needs_render = true; + if self.handle_raw_input_batch(input).await { + needs_render = true; + } } LoopEvent::InputClosed => { self.input_rx = None; } - LoopEvent::Idle => {} + LoopEvent::RenderRequested => { + if self.render_dirty.load(Ordering::Acquire) { + needs_render = true; + } + } } } @@ -312,58 +377,170 @@ impl App { Ok(()) } - async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) { + fn drain_api_requests(&mut self) -> bool { + let mut changed = false; + while let Ok(msg) = self.api_rx.try_recv() { + changed |= self.handle_api_request_message(msg); + } + changed + } + + fn handle_api_request_message(&mut self, msg: crate::api::ApiRequestMessage) -> bool { + let changed = api_request_changes_ui(&msg.request); + let response = self.handle_api_request(msg.request); + let _ = msg.respond_to.send(response); + changed + } + + async fn handle_raw_input_batch(&mut self, first: crate::raw_input::RawInputEvent) -> bool { + let mut changed = self.handle_raw_input_event(first).await; + + while let Some(rx) = self.input_rx.as_mut() { + match rx.try_recv() { + Ok(event) => changed |= self.handle_raw_input_event(event).await, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + self.input_rx = None; + break; + } + } + } + + changed + } + + async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) -> bool { match event { crate::raw_input::RawInputEvent::Key(key) => { if key.kind == crossterm::event::KeyEventKind::Press { self.handle_key(key).await; + true + } else { + false } } - crate::raw_input::RawInputEvent::Paste(text) => self.handle_paste(text).await, - crate::raw_input::RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse), - crate::raw_input::RawInputEvent::Unsupported => {} + crate::raw_input::RawInputEvent::Paste(text) => { + self.handle_paste(text).await; + true + } + crate::raw_input::RawInputEvent::Mouse(mouse) => { + self.handle_mouse(mouse); + true + } + crate::raw_input::RawInputEvent::Unsupported => false, } } - fn handle_resize_poll(&mut self) { - let Ok(size) = terminal::size() else { return }; + fn handle_resize_poll(&mut self) -> bool { + let Ok(size) = terminal::size() else { + return false; + }; if self.last_terminal_size != Some(size) { self.last_terminal_size = Some(size); + return true; } + false } - fn handle_frame_timers(&mut self) -> bool { + fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { let mut changed = false; + self.sync_animation_timer(now); + + if now >= self.next_resize_poll { + changed |= self.handle_resize_poll(); + self.next_resize_poll = now + RESIZE_POLL_INTERVAL; + } + if self .config_diagnostic_deadline - .is_some_and(|deadline| Instant::now() >= deadline) + .is_some_and(|deadline| now >= deadline) { self.config_diagnostic_deadline = None; self.state.config_diagnostic = None; changed = true; } - if self - .toast_deadline - .is_some_and(|deadline| Instant::now() >= deadline) - { + if self.toast_deadline.is_some_and(|deadline| now >= deadline) { self.toast_deadline = None; self.state.toast = None; changed = true; } - if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL { - for ws in &mut self.state.workspaces { - ws.refresh_git_ahead_behind(); - } - self.last_git_remote_status_refresh = Instant::now(); + if self + .next_animation_tick + .is_some_and(|deadline| now >= deadline) + { + self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); + self.next_animation_tick = Some(now + ANIMATION_INTERVAL); changed = true; } + if self + .git_refresh_deadline() + .is_some_and(|deadline| now >= deadline) + { + for ws in &mut self.state.workspaces { + ws.refresh_git_ahead_behind(); + } + self.last_git_remote_status_refresh = now; + changed = true; + } + + self.sync_animation_timer(now); changed } + fn sync_animation_timer(&mut self, now: Instant) { + if self.active_workspace_has_animation() { + self.next_animation_tick + .get_or_insert(now + ANIMATION_INTERVAL); + } else { + self.next_animation_tick = None; + } + } + + fn active_workspace_has_animation(&self) -> bool { + self.state + .active + .and_then(|idx| self.state.workspaces.get(idx)) + .is_some_and(Workspace::has_working_pane) + } + + fn can_render_now(&self, now: Instant) -> bool { + match self.last_render_at { + Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, + None => true, + } + } + + fn git_refresh_deadline(&self) -> Option { + (!self.state.workspaces.is_empty()) + .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) + } + + fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option { + let render_deadline = if needs_render { + self.last_render_at + .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL) + .filter(|deadline| *deadline > now) + } else { + None + }; + + [ + Some(self.next_resize_poll), + self.config_diagnostic_deadline, + self.toast_deadline, + self.next_animation_tick, + self.git_refresh_deadline(), + render_deadline, + ] + .into_iter() + .flatten() + .min() + } + fn drain_internal_events(&mut self) -> bool { let mut had_event = false; while let Ok(ev) = self.event_rx.try_recv() { @@ -1096,7 +1273,14 @@ impl App { focus: bool, ) -> std::io::Result { let (rows, cols) = self.state.estimate_pane_size(); - let mut ws = Workspace::new(initial_cwd, rows, cols, self.event_tx.clone())?; + let mut ws = Workspace::new( + initial_cwd, + rows, + cols, + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + )?; ws.refresh_git_ahead_behind(); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; @@ -1251,3 +1435,36 @@ fn agent_name(agent: crate::detect::Agent) -> String { } .to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn raw_input_waits_when_reader_is_gone() { + let result = + tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await; + assert!(result.is_err()); + } + + #[test] + fn read_only_api_requests_do_not_force_rerender() { + let read_only = crate::api::schema::Request { + id: "req_1".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }; + let mutating = crate::api::schema::Request { + id: "req_2".into(), + method: crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: "w_1".into(), + }, + ), + }; + + assert!(!api_request_changes_ui(&read_only)); + assert!(api_request_changes_ui(&mutating)); + } +} diff --git a/src/main.rs b/src/main.rs index 5756026b..60044981 100644 --- a/src/main.rs +++ b/src/main.rs @@ -249,7 +249,7 @@ fn main() -> io::Result<()> { init_logging(); - let (api_tx, api_rx) = std::sync::mpsc::channel(); + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let event_hub = api::EventHub::default(); let _api_server = api::start_server(api_tx, event_hub.clone())?; diff --git a/src/pane.rs b/src/pane.rs index 9d62a0c9..7a5a75dd 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,13 +1,13 @@ use std::cell::Cell; use std::io::{BufWriter, Read, Write}; use std::sync::{ - atomic::{AtomicU16, AtomicU32, Ordering}, + atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}, Arc, RwLock, }; use bytes::Bytes; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Notify}; use tracing::{debug, error, info, warn}; use crate::detect::{Agent, AgentState}; @@ -167,6 +167,8 @@ impl PaneRuntime { cols: u16, cwd: std::path::PathBuf, events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, ) -> std::io::Result { let pty_system = native_pty_system(); let pair = pty_system @@ -241,6 +243,8 @@ impl PaneRuntime { let parser = parser.clone(); let screen_content = screen_content.clone(); let response_writer = input_tx.clone(); + let render_notify = render_notify.clone(); + let render_dirty = render_dirty.clone(); tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8192]; loop { @@ -277,6 +281,9 @@ impl PaneRuntime { warn!(pane = pane_id.raw(), err = %e, "dropped terminal query response"); } } + if !render_dirty.swap(true, Ordering::AcqRel) { + render_notify.notify_one(); + } } } } diff --git a/src/persist.rs b/src/persist.rs index 9cc6205e..49a36141 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -4,12 +4,14 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; use ratatui::layout::Direction; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; @@ -132,11 +134,22 @@ pub fn restore( rows: u16, cols: u16, events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, ) -> Vec { snapshot .workspaces .iter() - .filter_map(|ws_snap| restore_workspace(ws_snap, rows, cols, events.clone())) + .filter_map(|ws_snap| { + restore_workspace( + ws_snap, + rows, + cols, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + ) + }) .collect() } @@ -145,6 +158,8 @@ fn restore_workspace( rows: u16, cols: u16, events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, ) -> Option { let (node, id_map) = restore_node_remapped(&snap.layout); let pane_ids = collect_pane_ids(&node); @@ -169,7 +184,15 @@ fn restore_workspace( .map(|p| p.cwd.clone()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - match PaneRuntime::spawn(*id, rows, cols, cwd, events.clone()) { + match PaneRuntime::spawn( + *id, + rows, + cols, + cwd, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + ) { Ok(runtime) => { panes.insert(*id, PaneState::new()); runtimes.insert(*id, runtime); @@ -205,6 +228,8 @@ fn restore_workspace( runtimes, zoomed: snap.zoomed, events, + render_notify, + render_dirty, }) } diff --git a/src/workspace.rs b/src/workspace.rs index 68b4e624..63e98912 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; use ratatui::layout::Direction; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Notify}; use tracing::info; use crate::detect::{Agent, AgentState}; @@ -29,6 +31,8 @@ pub struct Workspace { pub runtimes: HashMap, pub zoomed: bool, pub events: mpsc::Sender, + pub(crate) render_notify: Arc, + pub(crate) render_dirty: Arc, } impl Workspace { @@ -37,9 +41,19 @@ impl Workspace { rows: u16, cols: u16, events: mpsc::Sender, + render_notify: Arc, + render_dirty: Arc, ) -> std::io::Result { let (layout, root_id) = TileLayout::new(); - let runtime = PaneRuntime::spawn(root_id, rows, cols, initial_cwd, events.clone())?; + let runtime = PaneRuntime::spawn( + root_id, + rows, + cols, + initial_cwd, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + )?; let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); @@ -60,6 +74,8 @@ impl Workspace { runtimes, zoomed: false, events, + render_notify, + render_dirty, }) } @@ -74,7 +90,15 @@ impl Workspace { let new_id = self.layout.split_focused(direction); let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?; + let runtime = PaneRuntime::spawn( + new_id, + rows, + cols, + actual_cwd, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + )?; self.panes.insert(new_id, PaneState::new()); self.public_pane_numbers .insert(new_id, self.next_public_pane_number); @@ -170,6 +194,12 @@ impl Workspace { .unwrap_or((AgentState::Unknown, true)) } + pub fn has_working_pane(&self) -> bool { + self.panes + .values() + .any(|pane| pane.state == AgentState::Working) + } + /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom). #[cfg(test)] pub fn pane_states(&self) -> Vec<(AgentState, bool)> { @@ -346,6 +376,8 @@ impl Workspace { pub fn test_new(name: &str) -> Self { let (events, _) = mpsc::channel(64); let (layout, root_id) = TileLayout::new(); + let render_notify = Arc::new(Notify::new()); + let render_dirty = Arc::new(AtomicBool::new(false)); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); let mut public_pane_numbers = HashMap::new(); @@ -361,6 +393,8 @@ impl Workspace { runtimes: HashMap::new(), zoomed: false, events, + render_notify, + render_dirty, } }