diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d8ce619..0ef9e71 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -87,6 +87,14 @@ pub struct App { /// that can move `Focus::Pane` (attach, tab switch, sidebar toggle, /// mouse) is covered without each one remembering to report it. focused_session: Option, + /// A sidebar highlight waiting out its debounce before it becomes a + /// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must + /// not open an attach per row it passes over. + pending_preview: Option<(Uuid, std::time::Instant)>, + /// Sessions attached only because they were previewed, oldest first. Kept + /// connected past the preview so stepping back onto a row is instant, and + /// bounded so a long walk doesn't leave a socket per session behind. + preview_lru: Vec, /// Fingerprint of the last frame drawn. The loop compares a freshly /// computed key against this and skips the repaint when they match — an /// idle TUI used to emit a full diff 30 times a second forever, which over @@ -117,6 +125,14 @@ struct FrameKey { /// else changed. const CLOCK_REFRESH: Duration = Duration::from_secs(1); +/// How long a highlight has to stand still before the pane previews it +/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't +/// attach to every row it crosses, short enough to read as immediate. +const PREVIEW_DELAY: Duration = Duration::from_millis(120); + +/// How many preview-only attaches to keep connected behind the current one. +const PREVIEW_BUDGET: usize = 3; + impl std::fmt::Debug for App { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("App") @@ -196,6 +212,8 @@ impl App { last_sent_size: HashMap::new(), clicks: ClickTracker::default(), focused_session: None, + pending_preview: None, + preview_lru: Vec::new(), last_frame: None, last_drawn_at: std::time::Instant::now(), epoch: 0, @@ -234,7 +252,9 @@ impl App { self.epoch = self.epoch.wrapping_add(1); } _ = tokio::time::sleep(Duration::from_millis(33)) => { - let effects = self.poll_due_probes_at(std::time::Instant::now()); + let now = std::time::Instant::now(); + let mut effects = self.poll_due_probes_at(now); + effects.extend(self.poll_due_preview_at(now)); if !effects.is_empty() { self.epoch = self.epoch.wrapping_add(1); } @@ -332,6 +352,7 @@ impl App { self.state.sidebar_highlight = Some(SidebarItem::Session(id)); } } + self.refresh_preview(); true } @@ -453,8 +474,15 @@ impl App { if let Some(previous) = self.state.active_session() { self.release_session(previous); } - effects.push(Effect::Attach(sid)); + // A previewed session is already attached: committing + // to it keeps that stream rather than reopening one. + if self.emulators.get(sid).is_none() { + effects.push(Effect::Attach(sid)); + } } + // It is the pane's own session now, not a loan from the + // preview budget. + self.preview_lru.retain(|s| *s != sid); self.state.attach_to_focused(sid); } } @@ -481,9 +509,89 @@ impl App { } } } + // Every action can move the highlight or the focus, and the preview is + // a function of both (`docs/PREVIEW.md`). + self.refresh_preview(); effects } + /// Recompute what the pane should be previewing. A session highlighted in + /// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a + /// group row, the pane holding the keyboard, or the highlight already + /// sitting on the attached session — clears the preview at once. + fn refresh_preview(&mut self) { + let wanted = match (self.state.focus, &self.state.sidebar_highlight) { + (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), + _ => None, + }; + match wanted { + Some(sid) if self.state.active_session() != Some(sid) => { + if self.state.preview != Some(sid) { + self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY)); + } + } + _ => { + self.pending_preview = None; + self.clear_preview(); + } + } + } + + /// The debounce elapsed: show the highlighted session, attaching it if + /// nothing already is. + fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec { + let Some((sid, due)) = self.pending_preview else { + return Vec::new(); + }; + if now < due { + return Vec::new(); + } + self.pending_preview = None; + if self.state.preview == Some(sid) { + return Vec::new(); + } + self.state.preview = Some(sid); + // Set outside the input path, so nothing else marks the frame stale. + self.epoch = self.epoch.wrapping_add(1); + let fresh = self.emulators.get(sid).is_none(); + if fresh { + self.preview_lru.retain(|s| *s != sid); + self.preview_lru.push(sid); + } + self.trim_preview_budget(); + if fresh { + vec![Effect::Attach(sid)] + } else { + Vec::new() + } + } + + fn clear_preview(&mut self) { + if self.state.preview.take().is_some() { + self.epoch = self.epoch.wrapping_add(1); + } + self.trim_preview_budget(); + } + + /// Drop preview-only attaches past the budget. What a tab holds, and what + /// is on screen right now, are never candidates. + fn trim_preview_budget(&mut self) { + while self.preview_lru.len() > PREVIEW_BUDGET { + let held: HashSet = self + .state + .tabs + .iter() + .filter_map(crate::state::Tab::session) + .chain(self.state.preview) + .collect(); + let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else { + break; + }; + let victim = self.preview_lru.remove(pos); + self.release_session(victim); + } + } + /// Open a stream for `sid` if one isn't already running. fn attach(&mut self, sid: Uuid) { if self.emulators.get(sid).is_some() { @@ -1000,7 +1108,7 @@ impl App { /// Sample everything a frame depends on. Cheap: two atomic loads, an /// `RwLock` read of a `u64`, and the backend's size. fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey { - let pane = self.state.active_session().and_then(|sid| { + let pane = self.state.displayed_session().and_then(|sid| { self.emulators .get(sid) .map(|h| (sid, h.revision(), h.state())) @@ -1050,21 +1158,31 @@ impl App { highlight: self.state.sidebar_highlight.clone(), attached, }; - let active_sid = self.state.active_session(); - let pane = active_sid.and_then(|sid| { + let shown_sid = self.state.displayed_session(); + let previewing = self.state.preview.is_some(); + let pane = shown_sid.and_then(|sid| { self.emulators.get(sid).map(|h| Pane { screen: h.screen.clone(), status: PaneStatus::from(h.state()), - focused: matches!(self.state.focus, Focus::Pane), + // A preview never owns the keyboard, so it never draws a + // cursor either (`docs/PREVIEW.md`). + focused: matches!(self.state.focus, Focus::Pane) && !previewing, }) }); - let pane_title = match active_sid { - Some(sid) => snap - .sessions - .iter() - .find(|session| session.id == sid) - .map(crate::sidebar::name_for) - .unwrap_or_else(|| "session".into()), + let pane_title = match shown_sid { + Some(sid) => { + let name = snap + .sessions + .iter() + .find(|session| session.id == sid) + .map(crate::sidebar::name_for) + .unwrap_or_else(|| "session".into()); + if previewing { + format!("{name} · preview") + } else { + name + } + } None => "session".into(), }; @@ -1092,7 +1210,7 @@ impl App { // Resize propagation: record the active pane's rect, recompute the // per-dimension min for every attached session, send Resize when // changed. - if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) { + if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) { self.emulators.resize_tracker_mut().set_pane_size( sid, "active", @@ -1501,6 +1619,165 @@ mod tests { assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); } + /// Seed the catalog with `ids` as sessions in one directory. + fn app_with_sessions(ids: &[Uuid]) -> App { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let app = make_app(); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: ids + .iter() + .map(|id| Session { + id: *id, + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata: Default::default(), + worktree: None, + status: SessionStatus::Running, + created_at: Utc::now(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), + current_cwd: None, + blocked_work: None, + }) + .collect(), + }); + app + } + + fn fake_handle() -> crate::session_task::SessionHandle { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::{AtomicU64, AtomicU8}; + use std::sync::{Arc, Mutex}; + SessionHandle { + tx: tokio::sync::mpsc::channel(1).0, + screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + revision: Arc::new(AtomicU64::new(0)), + } + } + + fn after_debounce() -> std::time::Instant { + std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1) + } + + /// docs/PREVIEW.md: the highlight shows a session in the pane without the + /// tab taking it on. + #[test] + fn highlighting_a_session_previews_it_once_the_debounce_elapses() { + let a = Uuid::from_u128(1); + let mut app = app_with_sessions(&[a]); + app.dispatch(Action::SidebarNext); + + // Nothing yet: the highlight has to stand still first. + assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]); + assert_eq!(app.state.preview, None); + + assert_eq!( + app.poll_due_preview_at(after_debounce()), + vec![Effect::Attach(a)] + ); + assert_eq!(app.state.preview, Some(a)); + assert_eq!(app.state.displayed_session(), Some(a)); + // The tab is untouched — the preview is a view, not a layout change. + assert_eq!(app.state.active_tab().session(), None); + } + + /// A fast walk down the list attaches to where it stopped, not to every + /// row it crossed. + #[test] + fn walking_past_rows_only_previews_the_one_it_lands_on() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.dispatch(Action::SidebarNext); + app.dispatch(Action::SidebarNext); + + let effects = app.poll_due_preview_at(after_debounce()); + assert_eq!(effects, vec![Effect::Attach(b)]); + assert_eq!(app.state.preview, Some(b)); + } + + /// The pane's own session is not something to preview. + #[test] + fn highlighting_the_attached_session_clears_the_preview() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.state.attach_to_focused(a); + app.state.focus = Focus::Sidebar; + + app.state.sidebar_highlight = Some(SidebarItem::Session(b)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + assert_eq!(app.state.preview, Some(b)); + + app.state.sidebar_highlight = Some(SidebarItem::Session(a)); + app.refresh_preview(); + assert_eq!(app.state.preview, None); + assert_eq!(app.state.displayed_session(), Some(a)); + } + + /// Moving off every session — onto a group row, or into the pane — puts + /// back what the tab was showing. + #[test] + fn leaving_the_session_rows_ends_the_preview() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.state.attach_to_focused(a); + app.state.focus = Focus::Sidebar; + app.state.sidebar_highlight = Some(SidebarItem::Session(b)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + + app.dispatch(Action::FocusPane(crate::action::Direction::Right)); + assert_eq!(app.state.preview, None); + assert_eq!(app.state.displayed_session(), Some(a)); + } + + /// Committing keeps the stream the preview already opened. + #[test] + fn attaching_to_a_previewed_session_reuses_its_stream() { + let a = Uuid::from_u128(1); + let mut app = app_with_sessions(&[a]); + app.dispatch(Action::SidebarNext); + app.poll_due_preview_at(after_debounce()); + // Stand in for the attach the effect would have opened. + app.emulators.insert(a, fake_handle()); + + let effects = app.dispatch(Action::AttachHighlighted); + assert_eq!(effects, vec![]); + assert_eq!(app.state.active_tab().session(), Some(a)); + assert_eq!(app.state.preview, None); + // No longer on loan from the preview budget. + assert!(!app.preview_lru.contains(&a)); + } + + /// Preview-only attaches are kept for a step back, but bounded. + #[test] + fn preview_attaches_are_dropped_past_the_budget() { + let ids: Vec = (1..=(PREVIEW_BUDGET as u128 + 2)) + .map(Uuid::from_u128) + .collect(); + let mut app = app_with_sessions(&ids); + for id in &ids { + app.state.sidebar_highlight = Some(SidebarItem::Session(*id)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + app.emulators.insert(*id, fake_handle()); + } + + assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET); + // The oldest rows previewed are gone; the newest are still warm. + assert!(app.emulators.get(ids[0]).is_none()); + assert!(app.emulators.get(*ids.last().unwrap()).is_some()); + } + /// Seed the catalog with one session and highlight it. fn app_with_highlighted_session(sid: Uuid) -> App { use chrono::Utc; diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 1e3d7cc..6614cec 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -63,6 +63,11 @@ pub struct AppState { pub mode: Mode, pub sidebar_visible: bool, pub sidebar_highlight: Option, + /// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because + /// the sidebar highlight is on it, not because a tab holds it. It never + /// enters `tabs`, is never reported to the daemon as focused, and is + /// dropped the moment the highlight moves on. + pub preview: Option, /// Group paths whose cards are hidden. This is UI state rather than /// catalog state, so it survives every catalog redraw. pub collapsed_sidebar_groups: HashSet, @@ -91,6 +96,7 @@ impl AppState { mode: Mode::Normal, sidebar_visible: true, sidebar_highlight: None, + preview: None, collapsed_sidebar_groups: HashSet::new(), overlay: None, } @@ -126,6 +132,9 @@ impl AppState { /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. pub fn attach_to_focused(&mut self, session_id: Uuid) { + // Committing to what was previewed: the pane owns it now, so the + // preview has nothing left to say. + self.preview = None; self.active_tab_mut().pane = Some(session_id); self.focus = Focus::Pane; self.mode = Mode::Insert; @@ -150,6 +159,13 @@ impl AppState { self.active_tab().session() } + /// What the pane actually draws: the preview when one is up, otherwise the + /// active tab's session. A preview covers the pane without replacing it, + /// so ending one gives back exactly what was there (`docs/PREVIEW.md`). + pub fn displayed_session(&self) -> Option { + self.preview.or_else(|| self.active_session()) + } + /// Move the sidebar highlight one step through the rendered item /// sequence, in the direction of `delta` (positive = down), landing only /// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.