Compare commits

...
Author SHA1 Message Date
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
7 changed files with 313 additions and 63 deletions
+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.