Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 8f499dbee3 fix(maestro): arbitrate pane size across clients
ci / web (push) Successful in 52s
ci / flake (push) Successful in 2m35s
ci / rust (push) Successful in 5m57s
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.

The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 22:28:40 +01:00
Miguel Palhas f8b434dd1d fix(maestro): keep accent fill when previewing active row
ci / web (push) Successful in 40s
ci / flake (push) Successful in 3m34s
ci / rust (push) Successful in 6m44s
Hovering the session already in the pane put the preview tint over its accent fill, so the row that is on screen looked unpicked. Tier 5 now skips `.is-active`.
2026-09-03 16:34:58 +01:00
Miguel Palhas f37ff681ea feat(maestro): fill the row for the session in the pane
ci / web (push) Successful in 34s
ci / flake (push) Successful in 2m51s
ci / rust (push) Successful in 6m54s
The web list still said "open in the focused pane" with a 1px ring, the last
edge left in it, and the TUI said it with the ● glyph alone. Both now use a
background.

Web: `--accent-fill`, the same 30% recipe `--preview-tint` uses, so the two
tiers differ by hue rather than by strength. The row rules are also rewritten
as one ordered run of equal-specificity `:where()` selectors, which is what
`docs/SIDEBAR.md` claims the ladder is — `.is-open` moves in from the bottom
of the multi-view block, where source order had been deciding its precedence
by accident.

TUI: `palette.pane_row_bg()`, blended partway from the card surface to the
selection surface, so the sidebar reads unfilled / half filled / filled. It
borrows the selection surface's foregrounds, since those are the ones picked
for a filled row, and the cursor still wins on its own row. Blended rather
than shipped as a third `Surface` — that would be eight more colours in each
of seventeen presets — with palette tests asserting it stays a visible step
from both surfaces and stays legible in every preset.
2026-09-03 15:31:44 +01:00
Miguel Palhas 46a000aa68 docs(maestro): correct the row-transition comment after the fill change
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m9s
ci / rust (push) Successful in 7m0s
2026-09-03 14:08:37 +01:00
Miguel Palhas b46ba9dd26 Merge remote-tracking branch 'origin/main' into dominant 2026-09-03 14:06:58 +01:00
Miguel Palhas cd37ac5d6d fix(maestro): say preview with a fill, not a ring
The previewed row and the keyboard cursor both drew a 2px inset ring, which
read as a border on hover rather than as a colour. Rows now carry the preview
hue as a fill: a light wash under the pointer or the cursor, deepening to an
opaque `--preview-tint` once the preview lands.

That leaves `.is-active`'s ring as the only one in the session list, where it
now means exactly one thing — open in a pane. The pane's titlebar drops its
hairline for the same reason; the dashed edges that remain belong to the
canvas and the throwaway tab.
2026-09-03 13:46:49 +01:00
Miguel Palhas b0504eb736 Merge remote-tracking branch 'origin/main' into harmony
ci / web (push) Successful in 1m2s
ci / flake (push) Successful in 3m28s
ci / rust (push) Successful in 7m48s
2026-09-03 13:38:36 +01:00
Miguel PalhasandClaude Opus 5 6075a177f5 perf(maestro): drop the sidebar preview delay
The pane waited 150 ms on hover and 60 ms on a keyboard step before
following the sidebar highlight, so every preview paid for the fast walk
it might have been. Replace both with a leading-edge 120 ms gap: the
first move shows at once, a burst is coalesced to its start and its end.

The row's own hover no longer eases either — `background` was on a 120 ms
transition, which is what read as lag on the row itself.

The sidebar also rebuilt its whole tree on every `SessionUpdated`, a
couple of times a minute per live session, dropping `:hover` from the row
under the pointer. Refreshes that change nothing structural now patch the
activity rail and meta line into the standing rows instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:36:51 +01:00
Miguel Palhas 7a3287f231 feat(maestro): colour the previewed pane and its sidebar row
A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
2026-09-03 13:35:55 +01:00
22 changed files with 1205 additions and 126 deletions
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split();
// This client's claim on the shared window size. Registered up front and
// released on every exit below, so a socket that dies mid-session stops
// constraining the size for the clients still attached.
let viewport = manager.attach_viewport();
// NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam.
if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(());
}
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await;
let _ = manager.set_viewport(id, viewport, cols, rows).await;
}
ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong)
@@ -88,6 +94,8 @@ pub(crate) async fn serve(
}
}
manager.release_viewport(id, viewport).await;
if session_ended {
let _ = sink.send(Message::Close(None)).await;
}
+1
View File
@@ -30,6 +30,7 @@ pub mod spawn_history;
#[cfg(feature = "web")]
pub mod static_bundle;
pub mod tmux;
pub mod viewport;
pub mod worktree;
pub mod worktree_probe;
+58
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. A subscriber that falls
@@ -166,6 +168,14 @@ struct Inner {
report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
/// Sizes reported by the clients attached to each session, and what tmux
/// was last told. See [`crate::viewport`] for why the window is not simply
/// set to whatever arrived most recently.
viewports: Mutex<Viewports>,
/// Hands out [`ViewportId`]s. Monotonic for the daemon's lifetime, so a
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
}
impl SessionManager {
@@ -206,6 +216,8 @@ impl SessionManager {
report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()),
events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
}),
}
}
@@ -751,6 +763,52 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Register a client that is about to start reporting its display size,
/// and hand back the handle it reports under.
///
/// Cheap and infallible: the session it will report against is not
/// consulted until the first [`Self::set_viewport`].
pub fn attach_viewport(&self) -> ViewportId {
self.inner.next_viewport.fetch_add(1, Ordering::Relaxed)
}
/// A client reported the size it can display.
///
/// The shared tmux window is resized only when the size arbitrated across
/// every attached client actually changes — see [`crate::viewport`]. Two
/// clients of different sizes used to resize the window past each other on
/// every report, which leaves a full-screen agent repainting into rows it
/// never clears and looking, to the operator, like a pane that has stopped
/// accepting input.
pub async fn set_viewport(
&self,
id: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Result<(), SessionError> {
let target = self
.inner
.viewports
.lock()
.await
.set(id, viewport, cols, rows);
match target {
Some((cols, rows)) => self.resize(id, cols, rows).await,
None => Ok(()),
}
}
/// Drop a detached client's claim on the window size, resizing to what the
/// clients still attached can display. Errors are the caller's to ignore:
/// a socket closing because its session died has nothing left to resize.
pub async fn release_viewport(&self, id: Uuid, viewport: ViewportId) {
let target = self.inner.viewports.lock().await.release(id, viewport);
if let Some((cols, rows)) = target {
let _ = self.resize(id, cols, rows).await;
}
}
/// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command.
///
+186
View File
@@ -0,0 +1,186 @@
//! What size the shared tmux window should be, given every client watching it.
//!
//! A tmux window has one size, but a session can have several clients attached
//! at once — two browser panes, a browser and the TUI. Each reports the size
//! *it* can display, and applying whichever arrived last makes the window
//! flip-flop. Every flip forces a full-screen application (Claude Code, vim) to
//! repaint at a new height, and the rows it drew at the old height are never
//! cleared: the operator ends up looking at a stale input box, types into a
//! box that is not where the screen says it is, and concludes the pane is
//! stuck.
//!
//! So the window is sized to the smallest attached client on each axis — the
//! same rule tmux uses for its own multiple clients, and the only size every
//! client can render without truncation. The size last handed to tmux is
//! remembered, so a report that changes nothing costs no `tmux` fork at all.
use std::collections::HashMap;
use uuid::Uuid;
/// One attached client within a session. Allocated by
/// [`crate::session::SessionManager`] and opaque to everything else.
pub type ViewportId = u64;
/// Per-session client sizes, and what tmux was last told.
#[derive(Debug, Default)]
pub struct Viewports {
clients: HashMap<Uuid, HashMap<ViewportId, (u16, u16)>>,
applied: HashMap<Uuid, (u16, u16)>,
}
impl Viewports {
pub fn new() -> Self {
Self::default()
}
/// Record what `viewport` can display. Returns the size tmux must be set
/// to, or `None` when the window already has it.
pub fn set(
&mut self,
session: Uuid,
viewport: ViewportId,
cols: u16,
rows: u16,
) -> Option<(u16, u16)> {
self.clients
.entry(session)
.or_default()
.insert(viewport, (cols, rows));
self.settle(session)
}
/// Drop a client that has detached. Returns the size tmux must be set to
/// now that it no longer constrains the window.
///
/// The last client leaving settles nothing: the window keeps the size it
/// has rather than snapping to some remembered default no one is watching.
pub fn release(&mut self, session: Uuid, viewport: ViewportId) -> Option<(u16, u16)> {
let clients = self.clients.get_mut(&session)?;
clients.remove(&viewport);
if clients.is_empty() {
self.clients.remove(&session);
self.applied.remove(&session);
return None;
}
self.settle(session)
}
/// The smallest size every attached client can render, but only when it
/// differs from what tmux was last told.
///
/// A zero on either axis is a client that cannot display anything yet — a
/// pane still laying out, a hidden tab — and must not drag the shared
/// window down with it, so it is held but left out of the arbitration.
fn settle(&mut self, session: Uuid) -> Option<(u16, u16)> {
let clients = self.clients.get(&session)?;
let mut cols = u16::MAX;
let mut rows = u16::MAX;
for &(c, r) in clients.values() {
if c == 0 || r == 0 {
continue;
}
cols = cols.min(c);
rows = rows.min(r);
}
if cols == u16::MAX || rows == u16::MAX {
return None;
}
let target = (cols, rows);
if self.applied.get(&session) == Some(&target) {
return None;
}
self.applied.insert(session, target);
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_report_settles_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
}
#[test]
fn a_report_that_changes_nothing_costs_no_resize() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.set(s, 1, 80, 24), None);
assert_eq!(v.set(s, 1, 80, 24), None);
}
/// The bug this module exists for: two clients of different sizes used to
/// resize the window past each other on every report.
#[test]
fn two_clients_settle_on_the_smaller_instead_of_fighting() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 53, 46), Some((53, 46)));
assert_eq!(v.set(s, 2, 53, 20), Some((53, 20)));
// Whatever order they re-report in, the answer stays the smaller one.
assert_eq!(v.set(s, 1, 53, 46), None);
assert_eq!(v.set(s, 2, 53, 20), None);
assert_eq!(v.set(s, 1, 53, 46), None);
}
#[test]
fn each_axis_is_arbitrated_on_its_own() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 200, 20);
assert_eq!(v.set(s, 2, 53, 46), Some((53, 20)));
}
#[test]
fn a_zero_sized_client_does_not_shrink_the_window() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.set(s, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(s, 2, 0, 0), None);
assert_eq!(v.set(s, 2, 0, 30), None);
}
#[test]
fn releasing_the_small_client_gives_the_window_back() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 46);
v.set(s, 2, 80, 20);
assert_eq!(v.release(s, 2), Some((80, 46)));
}
#[test]
fn releasing_the_last_client_settles_nothing() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 1), None);
// ...and the session is forgotten, so a fresh attach settles again.
assert_eq!(v.set(s, 2, 80, 24), Some((80, 24)));
}
#[test]
fn releasing_an_unknown_viewport_is_harmless() {
let mut v = Viewports::new();
let s = Uuid::new_v4();
assert_eq!(v.release(s, 7), None);
v.set(s, 1, 80, 24);
assert_eq!(v.release(s, 7), None);
}
#[test]
fn sessions_do_not_arbitrate_against_each_other() {
let mut v = Viewports::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(v.set(a, 1, 80, 24), Some((80, 24)));
assert_eq!(v.set(b, 2, 53, 20), Some((53, 20)));
assert_eq!(v.set(a, 1, 80, 24), None);
}
}
@@ -555,3 +555,63 @@ async fn spawn_overrides_client_supplied_maestro_variables() {
Some(&"http://127.0.0.1:7070".to_string())
);
}
/// Two clients attached to one session used to resize the shared window past
/// each other on every report they made. Every flip repaints a full-screen
/// agent at a new height and orphans the rows it drew at the old one, which
/// reads to the operator as a pane that has stopped taking input.
#[tokio::test]
async fn clients_of_different_sizes_do_not_resize_the_window_past_each_other() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
// Both keep reporting, as a browser's ResizeObserver and the TUI's own
// redraw loop do. Neither report changes the arbitrated size.
for _ in 0..5 {
fx.manager.set_viewport(s.id, web, 53, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 53, 20).await.unwrap();
}
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(
resizes,
vec![(53, 46), (53, 20)],
"the window was resized more than the arbitrated size actually changed"
);
}
/// The smaller client leaving hands the window back to the one still attached.
#[tokio::test]
async fn releasing_a_client_resizes_to_what_is_left() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let web = fx.manager.attach_viewport();
let tui = fx.manager.attach_viewport();
fx.manager.set_viewport(s.id, web, 80, 46).await.unwrap();
fx.manager.set_viewport(s.id, tui, 80, 20).await.unwrap();
fx.manager.release_viewport(s.id, tui).await;
let resizes: Vec<_> = fx
.fake
.calls()
.into_iter()
.filter_map(|c| match c {
Call::ResizeWindow { cols, rows, .. } => Some((cols, rows)),
_ => None,
})
.collect();
assert_eq!(resizes, vec![(80, 46), (80, 20), (80, 46)]);
}
+66 -26
View File
@@ -87,10 +87,13 @@ 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.
/// A sidebar highlight waiting for the pane's minimum gap 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)>,
/// When the pane last swapped what it previews. `None` until the first
/// one, which is why a highlight landing on a still sidebar shows at once.
last_preview_at: Option<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.
@@ -125,10 +128,11 @@ 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);
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
/// highlight to land shows at once; this only bounds how often the pane may
/// follow after that, so a fast walk down the list attaches to the row it
/// started on and the row it stopped on rather than every row between.
const PREVIEW_GAP: Duration = Duration::from_millis(120);
/// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3;
@@ -213,6 +217,7 @@ impl App {
clicks: ClickTracker::default(),
focused_session: None,
pending_preview: None,
last_preview_at: None,
preview_lru: Vec::new(),
last_frame: None,
last_drawn_at: std::time::Instant::now(),
@@ -526,9 +531,10 @@ impl App {
}
/// 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.
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
/// last swap — immediately, for a highlight landing on a still list.
/// Anything else — a group row, the pane holding the keyboard, or the
/// highlight already sitting on the attached session — clears 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),
@@ -537,7 +543,12 @@ impl App {
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));
let now = std::time::Instant::now();
let due = match self.last_preview_at {
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
_ => now,
};
self.pending_preview = Some((sid, due));
}
}
_ => {
@@ -547,8 +558,8 @@ impl App {
}
}
/// The debounce elapsed: show the highlighted session, attaching it if
/// nothing already is.
/// The pane is free to follow: 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();
@@ -560,6 +571,7 @@ impl App {
if self.state.preview == Some(sid) {
return Vec::new();
}
self.last_preview_at = Some(now);
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
@@ -1167,6 +1179,10 @@ impl App {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight.clone(),
attached,
// The active tab's session, not the displayed one: a preview is
// shown in the pane but never becomes what the operator picked,
// so it must not move this fill (`docs/PREVIEW.md`).
pane_session: self.state.active_session(),
};
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
@@ -1257,6 +1273,7 @@ fn visible_sidebar_order(
sessions: snap.sessions.clone(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
}
.rendered_items_with_collapsed(collapsed)
}
@@ -1671,24 +1688,22 @@ mod tests {
}
}
fn after_debounce() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
fn after_gap() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_GAP + 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() {
fn highlighting_a_session_previews_it_without_waiting() {
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);
// The list was still, so the pane follows on the next poll — no gap to
// wait out first (docs/PREVIEW.md).
assert_eq!(
app.poll_due_preview_at(after_debounce()),
app.poll_due_preview_at(std::time::Instant::now()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
@@ -1697,6 +1712,28 @@ mod tests {
assert_eq!(app.state.active_tab().session(), None);
}
/// The gap only bounds the *second* swap, so a burst costs two mounts.
#[test]
fn a_second_move_inside_the_gap_waits_for_it() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
let now = std::time::Instant::now();
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
app.dispatch(Action::SidebarNext);
assert_eq!(app.poll_due_preview_at(now), vec![]);
assert_eq!(app.state.preview, Some(a));
assert_eq!(
app.poll_due_preview_at(after_gap()),
vec![Effect::Attach(b)]
);
assert_eq!(app.state.preview, Some(b));
}
/// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed.
#[test]
@@ -1707,7 +1744,7 @@ mod tests {
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce());
let effects = app.poll_due_preview_at(after_gap());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
@@ -1723,7 +1760,7 @@ mod tests {
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
@@ -1743,7 +1780,7 @@ mod tests {
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
@@ -1756,7 +1793,7 @@ mod tests {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
@@ -1775,10 +1812,13 @@ mod tests {
.map(Uuid::from_u128)
.collect();
let mut app = app_with_sessions(&ids);
// Each row is previewed in its own gap window, so all of them land.
let mut clock = std::time::Instant::now();
for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
clock += PREVIEW_GAP + Duration::from_millis(1);
app.poll_due_preview_at(clock);
app.emulators.insert(*id, fake_handle());
}
+62
View File
@@ -813,6 +813,21 @@ impl Palette {
}
}
/// Background for the card whose session the pane is showing
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
/// selection surface, so the sidebar reads as one ladder: unfilled, half
/// filled for the session in the pane, fully filled under the cursor.
///
/// Blended rather than added as a third `Surface` — a new surface means
/// eight more colours hand-picked seventeen times — which is also why it
/// borrows the *selection* surface's foregrounds: those are the ones
/// already picked for a filled row. A preset whose colours the terminal
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
/// says "in the pane" with the filled glyph alone.
pub fn pane_row_bg(&self) -> Color {
mix(self.card.bg, self.selection.bg, 0.55)
}
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned")
}
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
/// there is nothing to blend and `from` comes back unchanged.
fn mix(from: Color, to: Color, amount: f64) -> Color {
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
return from;
};
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked.
@@ -937,6 +962,43 @@ mod tests {
}
}
/// The pane row is the middle rung: a visible fill against the card, still
/// a different surface from the cursor's, and carrying the selection
/// surface's foregrounds, which it borrows rather than shipping its own.
#[test]
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
for (preset, p) in rgb_presets() {
let bg = p.pane_row_bg();
let from_card = contrast_ratio(p.card.bg, bg)
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
assert!(
from_card >= 1.15,
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
);
let from_selection =
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
assert!(
from_selection >= 1.15,
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
);
for (role, color) in p.selection.roles() {
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
);
}
}
}
/// A preset the terminal themes has nothing to blend from.
#[test]
fn ansi_keeps_the_card_background_for_the_pane_row() {
let p = Palette::ANSI;
assert_eq!(p.pane_row_bg(), p.card.bg);
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
+4
View File
@@ -415,6 +415,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
@@ -511,6 +512,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
@@ -561,6 +563,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
@@ -601,6 +604,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
+173 -19
View File
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
/// on its own row: a highlight that lands on the session already in the pane
/// still has to be visible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CardFill {
/// An ordinary card, on the card surface.
None,
/// The session the pane is showing.
Pane,
/// Under the cursor, with the sidebar holding the keyboard.
Cursor,
}
impl CardFill {
/// Both filled tiers carry the selection surface's foregrounds — those are
/// the colours picked for a filled row, and a colour chosen for the card
/// background is not legible on one.
fn filled(self) -> bool {
self != CardFill::None
}
}
/// Cards take the cursor's fill only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
fn card_base(fill: CardFill, focused: bool) -> Style {
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
let mut style = palette.surface(fill.filled()).text_style();
match fill {
CardFill::Cursor => {}
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
CardFill::None => {}
}
style
}
@@ -55,6 +80,9 @@ pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
/// The session the active tab's pane is showing. It wears a fill of its
/// own, so "in the pane" is not carried by the ● glyph alone.
pub pane_session: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,8 +141,7 @@ impl Sidebar {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let base = card_base(self.card_fill(s, focused), focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
@@ -226,6 +253,18 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// Which fill this session's card wears. The cursor wins on its own row,
/// so a highlight parked on the session already in the pane stays visible.
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
CardFill::Cursor
} else if self.pane_session == Some(s.id) {
CardFill::Pane
} else {
CardFill::None
}
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
@@ -233,12 +272,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let rail_style = base.fg(activity_color(s.activity, fill));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -257,7 +297,7 @@ impl Sidebar {
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
@@ -283,11 +323,11 @@ impl Sidebar {
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
@@ -308,7 +348,7 @@ impl Sidebar {
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
let surface = palette.surface(fill.filled());
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
@@ -352,10 +392,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
palette::active().surface(fill.filled()).activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -520,6 +558,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 12));
assert!(
@@ -544,6 +583,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
})
.groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
@@ -555,6 +595,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: Default::default(),
pane_session: None,
};
assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty());
@@ -566,6 +607,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new();
@@ -590,6 +632,7 @@ mod tests {
sessions: vec![session],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]);
@@ -620,6 +663,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp"));
@@ -635,6 +679,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude");
@@ -645,6 +690,87 @@ mod tests {
);
}
/// Whether any cell in the buffer is painted on `bg`.
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
(0..buf.area.height)
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
}
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
/// not the ● glyph alone carrying "this is the one on screen".
#[test]
fn the_session_in_the_pane_wears_the_pane_fill() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
let none = Sidebar {
pane_session: None,
..sb
};
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
}
/// The cursor's fill wins on its own row: a highlight that lands on the
/// session already in the pane has to stay visible.
#[test]
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(sid)),
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw_with_focus(&sb, 28, 6, true);
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
assert!(
!any_cell_on(&buf, p.pane_row_bg()),
"the pane fill is showing through the cursor's own row"
);
}
/// A card the pane is showing is a filled row, so it takes the selection
/// surface's foregrounds — a colour picked for the card background is not
/// legible on a fill.
#[test]
fn the_pane_row_carries_selection_foregrounds() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let mut sess = session_named(1, "claude");
sess.activity = ActivityState::AwaitingInput;
let sb = Sidebar {
sessions: vec![sess],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw(&sb, 28, 6);
let on_pane_row = |fg: Color| {
(0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| {
let st = buf[(x, y)].style();
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
})
})
};
assert!(
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
"the rail must carry the state in the filled surface's colours"
);
assert!(
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
"a card surface colour is painted onto the pane fill"
);
}
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "claude");
@@ -653,6 +779,7 @@ mod tests {
sessions: vec![sess],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 6);
let all = rows(&buf);
@@ -681,6 +808,7 @@ mod tests {
sessions: vec![blocked, clear, unprobed],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 44, 10));
@@ -727,6 +855,7 @@ mod tests {
sessions: vec![generated, settled],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -759,6 +888,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -785,6 +915,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
@@ -816,6 +947,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -855,6 +987,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height)
@@ -883,6 +1016,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let on_selection = |fg: Color| {
@@ -922,6 +1056,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -941,6 +1076,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
@@ -969,6 +1105,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -998,6 +1135,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1025,6 +1163,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1053,6 +1192,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 42, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1086,6 +1226,7 @@ mod tests {
sessions: vec![legacy],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 40, 8));
assert!(
@@ -1128,6 +1269,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 6));
assert!(
@@ -1143,6 +1285,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(
@@ -1164,6 +1307,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 9, 6));
assert!(
@@ -1190,6 +1334,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1214,6 +1359,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1235,6 +1381,7 @@ mod tests {
sessions: vec![highlighted],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height {
@@ -1260,6 +1407,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
@@ -1295,6 +1443,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height)
@@ -1314,6 +1463,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
},
28,
8,
@@ -1334,6 +1484,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
@@ -1352,6 +1503,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
@@ -1369,6 +1521,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash");
@@ -1388,6 +1541,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
+39 -8
View File
@@ -32,6 +32,33 @@ The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
## How a preview reads
A preview is provisional, so it never wears the accent — the accent is what
says "this is the one you picked". It has a hue of its own, and every surface
involved in the preview carries that same hue, so the pane and the row it came
from read as one thing rather than two unrelated highlights.
Web client (`--preview` and friends in `web/src/styles.css`):
| Surface | While previewing |
|---|---|
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
belong to the pane: the canvas ring and the throwaway tab.
The hue is a palette token, not a fixed colour: a palette whose accent already
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
The TUI marks a preview in text only — the title bar reads `<name> · preview`
— and has no preview colour role yet.
## What ends a preview
- The highlight moves to another session — that session is previewed instead.
@@ -50,15 +77,19 @@ Nothing moves a second time on commit.
## Timing
A preview follows the highlight closely enough to be usable as a way to look
around, and lags a pointer enough not to flash rows crossed on the way
somewhere else.
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
shows it on the next frame, and so does leaving every row. There is no
settling period in front of the first preview, because that period would sit
in front of the one preview the operator actually meant.
| Trigger | Delay |
|---|---|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
| Pointer hover (web, mouse only — never touch) | 150 ms |
| Clearing (highlight left every session) | same delay as the trigger that set it |
What is bounded is how *often* the pane may swap: at most once per 120 ms.
A burst inside one window — `j` held down, or the pointer sweeping the length
of the sidebar — shows the row it started on, then the row it ended on, and
none of the rows in between. So a walk costs a couple of pane mounts rather
than one per row, without the operator ever waiting on one.
The highlight itself is not bounded by any of this: the row lights up on the
same frame as the input, whatever the pane behind it is doing.
Touch has no hover, and a tap already opens a session, so touch never previews.
+32
View File
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
selection background. Contrast is a property of the palette (values are RGB)
and is asserted in the palette's tests, not left to the terminal theme.
## Which row is filled
Both clients say every one of these with a **background fill**, never an edge.
A row is usually several of them at once, so they form a ladder: the tier
furthest down wins the paint.
| Tier | Web | TUI |
|---|---|---|
| Alive | 6% accent wash | — (the rail carries it) |
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in
*is* the precedence. Add a tier where it belongs in that run, never by piling
on another class to out-weigh the rule above it.
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
since those are the colours picked for a filled row. `pane_row_bg()` is
blended from the two surfaces rather than shipped as a third one — a third
surface would mean eight more colours hand-picked seventeen times — and the
palette tests assert it stays a visible step from both. The cursor's fill wins
on its own row, so a highlight parked on the session already in the pane is
still visible.
The one edge left in either list is the TUI's `▏` marker, which stands in for
the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
+86 -11
View File
@@ -164,6 +164,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return wrap;
}
// The row outlives the snapshot it was built from: a refresh that changes
// nothing structural patches it in place rather than replacing it, so the
// handlers below have to read the current session, not the one captured here.
let cur = node.session;
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
@@ -197,33 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
void requestKill(s, name);
void requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) },
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)),
},
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]);
});
leafPatches.set(row, (next) => {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
if (meta) meta.outerHTML = metaLine(next, Date.now());
});
return row;
}
/** Refresh a standing card from a newer snapshot of its session, keeping the
* element (and so the pointer's `:hover`) exactly where it is. Registered by
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
/**
* Everything about the tree that a patch cannot fix up.
*
* The daemon re-sends a session on every activity, foreground or cwd shift —
* a couple of times a minute per live session — and rebuilding the sidebar for
* each one drops `:hover` from the row under the pointer. Refreshes whose
* signature is unchanged are patched into the standing rows instead; anything
* that moves, renames, appears or dies changes the signature and rebuilds.
*/
function treeSignature(node: TreeNode): string {
if (node.kind === "folder") {
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
if (node.name && collapsed.has(node.path)) return head;
return `${head}(${node.children.map(treeSignature).join(",")})`;
}
const s = node.session;
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
}
/**
* Kill on the first click (#81): the daemon is the one that decides whether a
* session may go, and it refuses over a worktree holding unsaved work. Only
@@ -342,6 +387,9 @@ export interface SidebarHandle {
setActive(id: string | null): void;
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
@@ -368,7 +416,7 @@ export interface SidebarOptions {
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
onPreview?: (sessionId: string | null) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -501,6 +549,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -509,6 +561,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
@@ -547,7 +600,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key");
opts.onPreview?.(sessionOf(navKey));
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -578,7 +631,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id, "hover");
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
@@ -595,7 +648,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
@@ -635,12 +688,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
e.preventDefault();
});
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter);
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
if (s) leafPatches.get(row)?.(s);
}
return;
}
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
@@ -664,7 +729,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
/* absolute paths are a fine fallback */
});
const rerenderHandler = () => refresh();
// Dispatched when something outside the store changed what the tree should
// look like — a folder collapsed, a rename ended — none of which the store
// snapshot can show, so the signature check has to be bypassed.
const rerenderHandler = () => {
renderedSignature = null;
refresh();
};
document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
@@ -692,6 +763,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
+105
View File
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
+1
View File
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
"--muted",
"--faint",
"--accent",
"--preview",
"--danger",
"--warn",
];
+10 -15
View File
@@ -29,6 +29,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
@@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog";
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
// mouse sweep waits long enough that rows crossed on the way somewhere else
// never flash up. The same delay applies to clearing, so the gap between two
// rows doesn't blink the previous view back.
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
let previewTimer: number | undefined;
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
// — j held down, or the pointer sweeping the whole list — is coalesced so it
// costs a couple of pane mounts instead of one per row walked.
const schedulePreview = createPreviewSchedule((sessionId) => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
});
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
@@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
clearTimeout(previewTimer);
schedulePreview.cancel();
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
+1
View File
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview() {},
focusList() {},
hasListFocus() {
return false;
+12
View File
@@ -221,6 +221,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
@@ -229,6 +235,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
open: realOpenSessions(),
focused: focusedLeafSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
@@ -549,6 +558,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
persist();
return;
}
@@ -588,6 +598,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
@@ -598,6 +609,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
+24
View File
@@ -45,11 +45,17 @@ function fakePool(): Spy {
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview(id: string | null) {
previewRow.id = id;
},
focusList() {},
hasListFocus() {
return false;
@@ -88,6 +94,7 @@ describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
});
afterEach(() => {
@@ -110,6 +117,23 @@ describe("sidebar preview", () => {
expect(serializeLayout(layout)).toEqual(before);
});
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(previewRow.id).toBe("s1");
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
expect(
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
).toBe("s1");
mv.previewSession(null);
expect(previewRow.id).toBeNull();
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
function harness() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let nextHandle = 1;
const emitted: (string | null)[] = [];
const schedule = createPreviewSchedule((id) => emitted.push(id), {
now: () => now,
setTimer: (fn, ms) => {
const h = nextHandle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h) => {
timers.delete(h);
},
});
/** Advance the clock, firing any timer that comes due. */
const advance = (ms: number) => {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
t.fn();
}
}
};
return { schedule, emitted, advance };
}
describe("preview schedule", () => {
it("shows the first preview immediately", () => {
const { schedule, emitted } = harness();
schedule("s1");
expect(emitted).toEqual(["s1"]);
});
it("coalesces a burst into one trailing preview", () => {
const { schedule, emitted, advance } = harness();
// A pointer sweep across the list, or j held down: every row in between
// is walked well inside one gap.
schedule("s1");
schedule("s2");
schedule("s3");
expect(emitted).toEqual(["s1"]);
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s3"]);
});
it("shows the next preview immediately once the gap has passed", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
advance(PREVIEW_MIN_GAP_MS);
schedule("s2");
expect(emitted).toEqual(["s1", "s2"]);
});
it("drops a superseded trailing preview rather than replaying it", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule("s1"); // back where we started inside the same gap
advance(PREVIEW_MIN_GAP_MS);
// s1 is already on screen, so the trailing edge has nothing to do.
expect(emitted).toEqual(["s1"]);
});
it("clears through the same gap, so leaving and re-entering does not blink", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule(null); // pointer crosses the gap between two rows
schedule("s2");
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s2"]);
});
it("cancel drops a pending trailing preview", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule.cancel();
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
*
* The highlight itself is never scheduled — that is a class on a row and lands
* on the same frame as the input. This governs only the pane swap behind it,
* which mounts a terminal and, for a session the connection pool isn't already
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
*
* A plain debounce would put that cost in front of every preview, including the
* one preview the operator actually meant. Instead the first move shows at once
* and the rest of a burst is coalesced: walking a list with `j` held down, or
* sweeping the pointer from the top of the sidebar to the bottom, costs one
* preview at the start and one at the row you stopped on, not one per row.
*/
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
* couple of terminals rather than one per row; short enough that a deliberate
* second move never waits on it. */
export const PREVIEW_MIN_GAP_MS = 120;
export interface PreviewScheduleDeps {
now?: () => number;
setTimer?: (fn: () => void, ms: number) => number;
clearTimer?: (handle: number) => void;
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
minGapMs?: number;
}
export interface PreviewSchedule {
(sessionId: string | null): void;
/** Drop a pending trailing preview without showing it. */
cancel(): void;
}
/**
* Wrap `show` so it fires on the leading edge and at most once per gap.
*
* `show` is called with the session to preview, or `null` to put back whatever
* the operator actually opened.
*/
export function createPreviewSchedule(
show: (sessionId: string | null) => void,
deps: PreviewScheduleDeps = {},
): PreviewSchedule {
const now = deps.now ?? (() => Date.now());
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
let lastShownAt = Number.NEGATIVE_INFINITY;
let shown: string | null | undefined;
let pendingTimer: number | undefined;
let pendingId: string | null = null;
const emit = (id: string | null) => {
// Nothing to do when the burst ended on the view already up: re-showing it
// would tear down and re-mount the pane for no visible change.
if (shown === id) return;
shown = id;
lastShownAt = now();
show(id);
};
const cancelPending = () => {
if (pendingTimer === undefined) return;
clearTimer(pendingTimer);
pendingTimer = undefined;
};
const schedule = ((sessionId: string | null) => {
pendingId = sessionId;
const waited = now() - lastShownAt;
if (waited >= minGap) {
cancelPending();
emit(sessionId);
return;
}
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
pendingTimer = setTimer(() => {
pendingTimer = undefined;
emit(pendingId);
}, minGap - waited);
}) as PreviewSchedule;
schedule.cancel = cancelPending;
return schedule;
}
+8 -11
View File
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([
[A, "key"],
[B, "key"],
]);
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]);
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]);
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]);
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
+89 -35
View File
@@ -87,6 +87,30 @@
--accent-tint: oklch(0.92 0.005 256 / 0.1);
--accent-ring: oklch(0.92 0.005 256 / 0.5);
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
the accent, because the accent is what says "this is the one you picked".
Defined once, off a single hue variable, so a palette whose accent already
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
keeps the light pair here instead of in all eighteen theme blocks. */
--preview-h: 305;
--preview: light-dark(
oklch(0.55 0.16 var(--preview-h)),
oklch(0.78 0.14 var(--preview-h))
);
--preview-ink: light-dark(
oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h))
);
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
the same colour whatever tier the row was already painted. `--accent-fill`
is the same recipe on the accent, so the two tiers differ by hue alone. */
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
--preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6)
);
--live: oklch(0.84 0.17 152);
--live-glow: oklch(0.84 0.17 152 / 0.55);
@@ -256,6 +280,9 @@
--accent-ink: oklch(0.18 0.04 278);
--accent-tint: oklch(0.75 0.125 275 / 0.14);
--accent-ring: oklch(0.75 0.125 275 / 0.55);
/* This accent is already violet, so preview moves to cyan to stay legible
as "not the accent". Set once here: the light blocks below don't touch it. */
--preview-h: 195;
--term-bg: oklch(0.155 0.018 278);
}
:root[data-palette="periwinkle"][data-theme="light"],
@@ -437,6 +464,8 @@
--accent-ink: oklch(0.22 0.05 310);
--accent-tint: oklch(0.78 0.13 310 / 0.14);
--accent-ring: oklch(0.78 0.13 310 / 0.55);
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
--preview-h: 195;
--term-bg: oklch(0.155 0.014 305);
}
:root[data-palette="orchid"][data-theme="light"],
@@ -1064,7 +1093,6 @@ textarea {
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-12);
transition: color var(--dur-fast) var(--ease-out);
}
.tree-folder-header:hover {
@@ -1076,9 +1104,9 @@ textarea {
text-align: center;
color: var(--faint);
font-size: var(--fs-11);
transition:
transform var(--dur) var(--ease-out),
color var(--dur-fast) var(--ease-out);
/* The rotation is the collapse animation and stays; the hover colour is
immediate, like the row's own hover. */
transition: transform var(--dur) var(--ease-out);
}
.tree-folder-header[aria-expanded="false"] .chevron {
@@ -1115,33 +1143,42 @@ textarea {
border-radius: var(--r-1);
color: var(--ink);
position: relative;
transition:
background var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
/* No transition on `background`: it is what hover, the keyboard cursor and
the preview all paint, and easing it puts a visible delay between the input
landing on a row and the row admitting it. `.is-active`'s ring still eases
— being open in a pane is a state, not a response to the pointer. */
transition: box-shadow var(--dur-fast) var(--ease-out);
}
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
.tree-leaf[data-status="running"] {
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
once, so every qualifier below sits in `:where()`: each rule is worth the
same specificity and the ladder is decided purely by the order they appear
in, latest wins. Add a tier where it belongs in that order, not by piling on
another class to out-weigh the rule above it. Each tier paints a background
and nothing else — the list says all of this with fill, never an edge. */
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
.tree-leaf:where([data-status="running"]) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf:hover {
background: var(--surface-2);
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
which belongs to the row actually open. */
.tree-leaf:where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf[data-status="running"]:hover {
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-leaf:focus-visible {
.tree-leaf:where(:focus-visible) {
outline: none;
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
/* A folder row previews nothing, so its cursor stays a ring. */
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
@@ -1150,17 +1187,28 @@ textarea {
outline: none;
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active,
.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
box-shadow: inset 0 0 0 1px var(--accent-ring);
/* 3. Open in some pane, but not the focused one (issue #33). */
.tree-leaf:where(.is-open) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* 4. The session currently mounted in the content pane. */
.tree-leaf:where(.is-active) {
background: var(--accent-fill);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
/* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional — except the session
already mounted in the pane (tier 4). Previewing that one changes nothing on
screen, so its accent stays put instead of blinking out under the pointer. */
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
background: var(--preview-tint);
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
@@ -2884,10 +2932,14 @@ input.leaf-rename:focus {
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — a dashed tab and a
dashed inset ring on the canvas — and reverts when the highlight moves. */
operator only highlighted, so it reads as provisional — the preview hue on
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
reverts when the highlight moves. The hue is deliberately not the accent:
the accent means "this is what you picked", which a preview is not. */
.mv-tab.is-preview {
border-style: dashed;
border-color: var(--preview-ring);
color: var(--preview);
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
@@ -2900,8 +2952,8 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--accent-ink);
background: var(--accent);
color: var(--preview-ink);
background: var(--preview);
opacity: 0.85;
pointer-events: none;
}
@@ -2910,7 +2962,7 @@ input.leaf-rename:focus {
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--accent-ring);
border: 1px dashed var(--preview-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
@@ -3028,6 +3080,15 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
only thing saying the pane is provisional — and so it is never mistaken for
the accent tint of the pane the operator actually focused. It keeps full
strength even though a preview never takes focus. */
.mv-pane.is-preview .attach-header,
.mv-pane.is-preview.is-focused .attach-header {
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
opacity: 1;
}
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
.mv-pane-empty {
flex: 1;
@@ -3117,13 +3178,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* =========================================================================
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.