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
12 changed files with 627 additions and 64 deletions
+9 -1
View File
@@ -26,11 +26,17 @@ pub(crate) async fn serve(
) -> Result<(), axum::Error> { ) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split(); 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 // NAP-229: send buffered scrollback up-front so the client sees the last
// screenful immediately instead of a blank terminal. The replay lock the // screenful immediately instead of a blank terminal. The replay lock the
// bridge held during snapshot+subscribe guarantees no byte appears both // bridge held during snapshot+subscribe guarantees no byte appears both
// here and on the live receiver, and none is dropped at the seam. // 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() { if !replay.is_empty() && sink.send(Message::Binary(replay)).await.is_err() {
manager.release_viewport(id, viewport).await;
return Ok(()); return Ok(());
} }
@@ -70,7 +76,7 @@ pub(crate) async fn serve(
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) { if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl { match ctrl {
ClientControl::Resize { cols, rows } => { ClientControl::Resize { cols, rows } => {
let _ = manager.resize(id, cols, rows).await; let _ = manager.set_viewport(id, viewport, cols, rows).await;
} }
ClientControl::Ping => { ClientControl::Ping => {
let pong = serde_json::to_string(&ClientControl::Pong) 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 { if session_ended {
let _ = sink.send(Message::Close(None)).await; let _ = sink.send(Message::Close(None)).await;
} }
+1
View File
@@ -30,6 +30,7 @@ pub mod spawn_history;
#[cfg(feature = "web")] #[cfg(feature = "web")]
pub mod static_bundle; pub mod static_bundle;
pub mod tmux; pub mod tmux;
pub mod viewport;
pub mod worktree; pub mod worktree;
pub mod worktree_probe; pub mod worktree_probe;
+58
View File
@@ -7,6 +7,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -23,6 +24,7 @@ use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore}; use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord}; use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError; use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
use crate::worktree::{WorktreeError, WorktreeManager}; use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. A subscriber that falls /// Capacity of the session-events broadcast channel. A subscriber that falls
@@ -166,6 +168,14 @@ struct Inner {
report_ttl: Duration, report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>, sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>, 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 { impl SessionManager {
@@ -206,6 +216,8 @@ impl SessionManager {
report_ttl: crate::activity::REPORT_TTL, report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
events, events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
}), }),
} }
} }
@@ -751,6 +763,52 @@ impl SessionManager {
.map_err(SessionError::Tmux) .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 /// Rename a session (#125): set `metadata.name`, or clear it when the
/// requested name is blank so the session falls back to its command. /// 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()) 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)]);
}
+5
View File
@@ -1179,6 +1179,10 @@ impl App {
sessions: snap.sessions.clone(), sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight.clone(), highlight: self.state.sidebar_highlight.clone(),
attached, 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 shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some(); let previewing = self.state.preview.is_some();
@@ -1269,6 +1273,7 @@ fn visible_sidebar_order(
sessions: snap.sessions.clone(), sessions: snap.sessions.clone(),
highlight: None, highlight: None,
attached: HashSet::new(), attached: HashSet::new(),
pane_session: None,
} }
.rendered_items_with_collapsed(collapsed) .rendered_items_with_collapsed(collapsed)
} }
+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. /// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style { pub fn border_style(&self, focused: bool) -> Style {
if focused { if focused {
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned") *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 /// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return /// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked. /// `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] #[test]
fn every_preset_supplies_every_role() { fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() { for (name, p) in Palette::presets() {
+4
View File
@@ -415,6 +415,7 @@ mod tests {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
attached: HashSet::new(), attached: HashSet::new(),
pane_session: None,
}; };
let help = HelpOverlay { lines: Vec::new() }; let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new(); let state = AppState::new();
@@ -511,6 +512,7 @@ mod tests {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
attached: HashSet::new(), attached: HashSet::new(),
pane_session: None,
}; };
let help = HelpOverlay { lines: Vec::new() }; let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs { let inputs = RenderInputs {
@@ -561,6 +563,7 @@ mod tests {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
attached: HashSet::new(), attached: HashSet::new(),
pane_session: None,
}; };
let help = HelpOverlay { lines: Vec::new() }; let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| { let draw_map = |focus| {
@@ -601,6 +604,7 @@ mod tests {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
attached: HashSet::new(), attached: HashSet::new(),
pane_session: None,
}; };
let help = HelpOverlay { lines: Vec::new() }; let help = HelpOverlay { lines: Vec::new() };
let render = |focus| { 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. /// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌"; 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 /// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background. /// 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 palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style(); let mut style = palette.surface(fill.filled()).text_style();
if focused && !highlighted { match fill {
style = style.bg(palette.sidebar_focus_bg); 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 style
} }
@@ -55,6 +80,9 @@ pub struct Sidebar {
pub sessions: Vec<Session>, pub sessions: Vec<Session>,
pub highlight: Option<SidebarItem>, pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,8 +141,7 @@ impl Sidebar {
lines.push(Line::from(self.group_row(&group, width, focused, home))); lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed { if !group.collapsed {
for s in &group.sessions { for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(self.card_fill(s, focused), focused);
let base = card_base(highlighted, focused);
lines.push(Line::from(self.name_row(s, width, focused))); lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused); let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base))); lines.push(Line::from(pad_to_width(meta, width, base)));
@@ -226,6 +253,18 @@ impl Sidebar {
pad_to_width(spans, width, base) 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>> { fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) { let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗", (SessionStatus::Dead, _) => "✗",
@@ -233,12 +272,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○", (SessionStatus::Running, false) => "○",
}; };
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); 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 prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix); let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused; 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![ let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style), Span::styled(RAIL, rail_style),
Span::styled( Span::styled(
@@ -257,7 +297,7 @@ impl Sidebar {
let name_style = if maestro_protocol::display::is_provisional_name(s) { let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch( base.patch(
palette::active() palette::active()
.surface(highlighted && focused) .surface(fill.filled())
.muted_style() .muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)), .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 blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead; let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused); let fill = self.card_fill(s, focused);
let rail_style = let base = card_base(fill, focused);
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active() let muted = palette::active()
.surface(highlighted && focused) .surface(fill.filled())
.muted_style() .muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)); .bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused; let edge = highlighted && !focused;
@@ -308,7 +348,7 @@ impl Sidebar {
let mut tail: Vec<Span<'static>> = Vec::new(); let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked { if let Some(b) = blocked {
let palette = palette::active(); let palette = palette::active();
let surface = palette.surface(highlighted && focused); let surface = palette.surface(fill.filled());
for (kind, text) in b.stats() { for (kind, text) in b.stats() {
let style = match kind { let style = match kind {
StatKind::Added => muted.fg(surface.active), StatKind::Added => muted.fg(surface.active),
@@ -352,10 +392,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted /// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked /// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background. /// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color { fn activity_color(state: ActivityState, fill: CardFill) -> Color {
palette::active() palette::active().surface(fill.filled()).activity(state)
.surface(highlighted && focused)
.activity(state)
} }
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours, /// 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")], sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let rendered = render_to_string(&draw(&sb, 28, 12)); let rendered = render_to_string(&draw(&sb, 28, 12));
assert!( assert!(
@@ -544,6 +583,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}) })
.groups(&HashSet::new()); .groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro")); assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
@@ -555,6 +595,7 @@ mod tests {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
assert!(sidebar.groups(&HashSet::new()).is_empty()); assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty()); assert!(sidebar.rendered_items().is_empty());
@@ -566,6 +607,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")], sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let key = PathBuf::from("/tmp"); let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new(); let mut collapsed = HashSet::new();
@@ -590,6 +632,7 @@ mod tests {
sessions: vec![session], sessions: vec![session],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name"); let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]); let collapsed = HashSet::from([key]);
@@ -620,6 +663,7 @@ mod tests {
sessions: vec![session_named(1, "claude")], sessions: vec![session_named(1, "claude")],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sidebar, 40, 8)); let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp")); assert!(all[1].contains("/tmp"));
@@ -635,6 +679,7 @@ mod tests {
sessions: vec![session_named(1, "claude")], sessions: vec![session_named(1, "claude")],
highlight: None, highlight: None,
attached: [sid].into_iter().collect(), attached: [sid].into_iter().collect(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 6)); let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude"); 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] #[test]
fn dead_session_uses_cross_glyph() { fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "claude"); let mut sess = session_named(1, "claude");
@@ -653,6 +779,7 @@ mod tests {
sessions: vec![sess], sessions: vec![sess],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 6); let buf = draw(&sb, 28, 6);
let all = rows(&buf); let all = rows(&buf);
@@ -681,6 +808,7 @@ mod tests {
sessions: vec![blocked, clear, unprobed], sessions: vec![blocked, clear, unprobed],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 44, 10)); let all = rows(&draw(&sb, 44, 10));
@@ -727,6 +855,7 @@ mod tests {
sessions: vec![generated, settled], sessions: vec![generated, settled],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 44, 8); let buf = draw(&sb, 44, 8);
let palette = palette::active(); let palette = palette::active();
@@ -759,6 +888,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 44, 8); let buf = draw(&sb, 44, 8);
let palette = palette::active(); let palette = palette::active();
@@ -785,6 +915,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions"); assert_eq!(content(&all[0]), "sessions");
@@ -816,6 +947,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -855,6 +987,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 8); let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height) let found = (0..buf.area.height)
@@ -883,6 +1016,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 8); let buf = draw(&sb, 28, 8);
let on_selection = |fg: Color| { let on_selection = |fg: Color| {
@@ -922,6 +1056,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -941,6 +1076,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 60, 6)); let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]); let meta = content(&all[3]);
@@ -969,6 +1105,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -998,6 +1135,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 40, 8)); let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1025,6 +1163,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 40, 8)); let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1053,6 +1192,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 42, 8)); let all = rows(&draw(&sb, 42, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1086,6 +1226,7 @@ mod tests {
sessions: vec![legacy], sessions: vec![legacy],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let rendered = render_to_string(&draw(&sb, 40, 8)); let rendered = render_to_string(&draw(&sb, 40, 8));
assert!( assert!(
@@ -1128,6 +1269,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let rendered = render_to_string(&draw(&sb, 28, 6)); let rendered = render_to_string(&draw(&sb, 28, 6));
assert!( assert!(
@@ -1143,6 +1285,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 40, 6)); let all = rows(&draw(&sb, 40, 6));
assert!( assert!(
@@ -1164,6 +1307,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 9, 6)); let all = rows(&draw(&sb, 9, 6));
assert!( assert!(
@@ -1190,6 +1334,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 29, 6)); let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]); assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1214,6 +1359,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 40, 6)); let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]); assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1235,6 +1381,7 @@ mod tests {
sessions: vec![highlighted], sessions: vec![highlighted],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 8); let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height { for y in 0..buf.area.height {
@@ -1260,6 +1407,7 @@ mod tests {
sessions: vec![one, two], sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 8); let buf = draw(&sb, 28, 8);
let all = rows(&buf); let all = rows(&buf);
@@ -1295,6 +1443,7 @@ mod tests {
sessions: vec![session_named(1, "claude")], sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw_with_focus(&sb, 28, 8, false); let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height) let y = (0..buf.area.height)
@@ -1314,6 +1463,7 @@ mod tests {
sessions: vec![session_named(1, "claude")], sessions: vec![session_named(1, "claude")],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}, },
28, 28,
8, 8,
@@ -1334,6 +1484,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: None, highlight: None,
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let buf = draw(&sb, 28, 8); let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width; 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")], sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude")); let line = content(row_containing(&all, "claude"));
@@ -1369,6 +1521,7 @@ mod tests {
sessions: vec![one, two], sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash"); let line = row_containing(&all, "bash");
@@ -1388,6 +1541,7 @@ mod tests {
sessions: vec![s], sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(), attached: Default::default(),
pane_session: None,
}; };
let all = rows(&draw(&sb, 28, 8)); let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably"); let line = row_containing(&all, "an-unreasonably");
+4 -4
View File
@@ -46,12 +46,12 @@ Web client (`--preview` and friends in `web/src/styles.css`):
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. | | 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. | | The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. | | A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. | | 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. | | 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: the only ring left in the session list Rows say it with fill, not with an edge — see the tier ladder in
is `.is-active`'s, which means "open in a pane". The dashed edges belong to the `docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
pane — the canvas ring and the throwaway tab. 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 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. sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
+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) 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. 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 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 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 `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
+33 -40
View File
@@ -101,9 +101,11 @@
oklch(0.99 0.005 var(--preview-h)), oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h)) oklch(0.19 0.045 var(--preview-h))
); );
/* Opaque, not a wash: the previewed row reads as a fill, and a fill has to /* Opaque, not a wash: session rows read as fills, and a fill has to land on
land on the same colour whatever tier the row was already painted. */ 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)); --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( --preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5), oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6) oklch(0.78 0.14 var(--preview-h) / 0.6)
@@ -1148,38 +1150,35 @@ textarea {
transition: box-shadow var(--dur-fast) var(--ease-out); transition: box-shadow var(--dur-fast) var(--ease-out);
} }
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */ /* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
.tree-leaf[data-status="running"] { 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); background: color-mix(in oklab, var(--accent) 6%, transparent);
} }
/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the /* 2. Under the pointer, or under the keyboard walk. Both are what previews a
preview hue — never the accent, which belongs to the row actually open. */ row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
.tree-leaf:hover { which belongs to the row actually open. */
.tree-leaf:where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2)); background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
} }
.tree-leaf[data-status="running"]:hover { .tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2)); background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
} }
.tree-leaf:focus-visible { .tree-leaf:where(:focus-visible) {
outline: none; outline: none;
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2)); background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
} }
/* Where the keyboard walk (j/k) is standing. On a session row the cursor is /* A folder row previews nothing, so its cursor stays a ring. */
also what previews it, so it reads exactly like a hover — the preview hue,
deepening to the full fill once the preview lands. A folder row previews
nothing, so it keeps the plain cursor ring. */
.tree-leaf.is-highlighted {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf.is-highlighted[data-status="running"] {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-folder-header.is-highlighted { .tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring); box-shadow: inset 0 0 0 2px var(--accent-ring);
} }
@@ -1188,25 +1187,26 @@ textarea {
outline: none; outline: none;
} }
/* The session currently mounted in the content pane. */ /* 3. Open in some pane, but not the focused one (issue #33). */
.tree-leaf.is-active, .tree-leaf:where(.is-open) {
.tree-leaf.is-active[data-status="running"] { background: color-mix(in oklab, var(--accent) 6%, transparent);
background: var(--accent-tint); }
box-shadow: inset 0 0 0 1px var(--accent-ring);
/* 4. The session currently mounted in the content pane. */
.tree-leaf:where(.is-active) {
background: var(--accent-fill);
} }
.tree-leaf.is-active .leaf-name { .tree-leaf.is-active .leaf-name {
color: var(--ink); color: var(--ink);
} }
/* The row the content pane is previewing right now — after `.is-active` so a /* 5. The row the content pane is previewing right now. Last, so previewing a
preview of an already-open session still reads as provisional. */ session that is already open still reads as provisional — except the session
.tree-leaf.is-previewing, already mounted in the pane (tier 4). Previewing that one changes nothing on
.tree-leaf.is-previewing[data-status="running"], screen, so its accent stays put instead of blinking out under the pointer. */
.tree-leaf.is-previewing.is-active, .tree-leaf:where(.is-previewing):where(:not(.is-active)) {
.tree-leaf.is-previewing.is-highlighted {
background: var(--preview-tint); background: var(--preview-tint);
box-shadow: none;
} }
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's /* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
@@ -3178,13 +3178,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1); 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 multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation. bar, no pane switcher; the sidebar drawer (☰) is the navigation.