feat(maestro): preview the highlighted session in the TUI pane

The sidebar highlight now shows its session in the pane after a 120ms
debounce, attaching if nothing already is, without the tab taking it on
and without reporting focus to the daemon. Preview-only attaches stay
warm behind the current one, bounded at three.

docs/PREVIEW.md.
This commit is contained in:
Miguel Palhas
2026-09-03 10:46:45 +01:00
parent f6b2cf3ab4
commit 6635cff46e
2 changed files with 307 additions and 14 deletions
+291 -14
View File
@@ -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<Uuid>,
/// 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<Uuid>,
/// 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<Effect> {
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<Uuid> = 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<Uuid> = (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;
+16
View File
@@ -63,6 +63,11 @@ pub struct AppState {
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<SidebarItem>,
/// 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<Uuid>,
/// 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<PathBuf>,
@@ -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<Uuid> {
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.