Compare commits

...
10 Commits
Author SHA1 Message Date
Miguel Palhas f37ff681ea feat(maestro): fill the row for the session in the pane
ci / web (push) Successful in 34s
ci / flake (push) Successful in 2m51s
ci / rust (push) Successful in 6m54s
The web list still said "open in the focused pane" with a 1px ring, the last
edge left in it, and the TUI said it with the ● glyph alone. Both now use a
background.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:36:51 +01:00
Miguel Palhas 6f0ac2bf4a docs(maestro): add the command bar contract
ci / rust (push) Successful in 10m8s
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m6s
Specifies the ctrl-k command bar for both clients: opening and the
leader escape hatch for a literal Ctrl-K, sigils, empty-query recency,
the key model including the session action page, and touch entry.

The match-and-rank algorithm is pinned exactly — normalization, word
boundaries, the per-character score, embedding choice, tie-break — and
closed with twelve fixtures the TypeScript (#146) and Rust (#147)
matchers are both tested against, so their orders cannot drift.
2026-09-03 13:36:15 +01:00
Miguel Palhas 7a3287f231 feat(maestro): colour the previewed pane and its sidebar row
A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
2026-09-03 13:35:55 +01:00
Miguel Palhas f443f625a8 Merge remote-tracking branch 'origin/main' into coda
ci / web (push) Successful in 30s
ci / flake (push) Successful in 2m48s
ci / rust (push) Successful in 6m3s
2026-09-03 13:21:53 +01:00
Miguel PalhasandClaude Opus 5 655b7b790e fix(maestro): let Escape reach the session in the TUI
Insert mode bound `esc` to LeaveInsert, so full-screen apps in a session
(vim, Claude Code) never saw Escape — the only way out of Insert is now
the leader, which rebinding still follows.

A key bound to `Noop` in Insert mode is also forwarded to the pty now.
`Noop` documents itself as the way to suppress a default binding, and in
Insert mode an unbound key belongs to the session, so this is the escape
hatch for taking any other default back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:32:46 +01:00
20 changed files with 1473 additions and 143 deletions
+5
View File
@@ -87,6 +87,11 @@ or is upgraded under them — the build stamp on `/api/info`, the status
indicator's states, the blocking overlay, grace and poll timings. Read it
before changing reconnect behaviour on either client.
`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both
clients — how it opens, the sigils, the key model, the match-and-rank algorithm
the two matchers must agree on, and the fixture table they are tested against.
Read it before changing either bar or either matcher.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+157 -35
View File
@@ -87,10 +87,13 @@ pub struct App {
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>,
/// A sidebar highlight waiting out its debounce before it becomes a
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
/// not open an attach per row it passes over.
/// A sidebar highlight waiting for the pane's minimum gap before it
/// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
/// down must not open an attach per row it passes over.
pending_preview: Option<(Uuid, std::time::Instant)>,
/// When the pane last swapped what it previews. `None` until the first
/// one, which is why a highlight landing on a still sidebar shows at once.
last_preview_at: Option<std::time::Instant>,
/// Sessions attached only because they were previewed, oldest first. Kept
/// connected past the preview so stepping back onto a row is instant, and
/// bounded so a long walk doesn't leave a socket per session behind.
@@ -125,10 +128,11 @@ struct FrameKey {
/// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
/// How long a highlight has to stand still before the pane previews it
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
/// attach to every row it crosses, short enough to read as immediate.
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
/// highlight to land shows at once; this only bounds how often the pane may
/// follow after that, so a fast walk down the list attaches to the row it
/// started on and the row it stopped on rather than every row between.
const PREVIEW_GAP: Duration = Duration::from_millis(120);
/// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3;
@@ -213,6 +217,7 @@ impl App {
clicks: ClickTracker::default(),
focused_session: None,
pending_preview: None,
last_preview_at: None,
preview_lru: Vec::new(),
last_frame: None,
last_drawn_at: std::time::Instant::now(),
@@ -311,16 +316,26 @@ impl App {
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
ChordResult::Resolved(canon) => {
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
if matches!(act, Action::Quit) {
return Ok(true);
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
let effects = self.dispatch(act);
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
None => {}
}
Ok(false)
}
@@ -516,9 +531,10 @@ impl App {
}
/// Recompute what the pane should be previewing. A session highlighted in
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
/// group row, the pane holding the keyboard, or the highlight already
/// sitting on the attached session — clears the preview at once.
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
/// last swap — immediately, for a highlight landing on a still list.
/// Anything else — a group row, the pane holding the keyboard, or the
/// highlight already sitting on the attached session — clears at once.
fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
@@ -527,7 +543,12 @@ impl App {
match wanted {
Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) {
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
let now = std::time::Instant::now();
let due = match self.last_preview_at {
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
_ => now,
};
self.pending_preview = Some((sid, due));
}
}
_ => {
@@ -537,8 +558,8 @@ impl App {
}
}
/// The debounce elapsed: show the highlighted session, attaching it if
/// nothing already is.
/// The pane is free to follow: show the highlighted session, attaching it
/// if nothing already is.
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let Some((sid, due)) = self.pending_preview else {
return Vec::new();
@@ -550,6 +571,7 @@ impl App {
if self.state.preview == Some(sid) {
return Vec::new();
}
self.last_preview_at = Some(now);
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
@@ -1157,6 +1179,10 @@ impl App {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight.clone(),
attached,
// The active tab's session, not the displayed one: a preview is
// shown in the pane but never becomes what the operator picked,
// so it must not move this fill (`docs/PREVIEW.md`).
pane_session: self.state.active_session(),
};
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
@@ -1247,6 +1273,7 @@ fn visible_sidebar_order(
sessions: snap.sessions.clone(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
}
.rendered_items_with_collapsed(collapsed)
}
@@ -1661,24 +1688,22 @@ mod tests {
}
}
fn after_debounce() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
fn after_gap() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
}
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
/// tab taking it on.
#[test]
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
fn highlighting_a_session_previews_it_without_waiting() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
// Nothing yet: the highlight has to stand still first.
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
assert_eq!(app.state.preview, None);
// The list was still, so the pane follows on the next poll — no gap to
// wait out first (docs/PREVIEW.md).
assert_eq!(
app.poll_due_preview_at(after_debounce()),
app.poll_due_preview_at(std::time::Instant::now()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
@@ -1687,6 +1712,28 @@ mod tests {
assert_eq!(app.state.active_tab().session(), None);
}
/// The gap only bounds the *second* swap, so a burst costs two mounts.
#[test]
fn a_second_move_inside_the_gap_waits_for_it() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
let now = std::time::Instant::now();
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
app.dispatch(Action::SidebarNext);
assert_eq!(app.poll_due_preview_at(now), vec![]);
assert_eq!(app.state.preview, Some(a));
assert_eq!(
app.poll_due_preview_at(after_gap()),
vec![Effect::Attach(b)]
);
assert_eq!(app.state.preview, Some(b));
}
/// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed.
#[test]
@@ -1697,7 +1744,7 @@ mod tests {
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce());
let effects = app.poll_due_preview_at(after_gap());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
@@ -1713,7 +1760,7 @@ mod tests {
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
@@ -1733,7 +1780,7 @@ mod tests {
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
@@ -1746,7 +1793,7 @@ mod tests {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce());
app.poll_due_preview_at(after_gap());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
@@ -1765,10 +1812,13 @@ mod tests {
.map(Uuid::from_u128)
.collect();
let mut app = app_with_sessions(&ids);
// Each row is previewed in its own gap window, so all of them land.
let mut clock = std::time::Instant::now();
for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
clock += PREVIEW_GAP + Duration::from_millis(1);
app.poll_due_preview_at(clock);
app.emulators.insert(*id, fake_handle());
}
@@ -2522,6 +2572,78 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Drive one key event through an app in Insert mode with a session
/// attached, and report the bytes that reached that session.
async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option<Vec<u8>> {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(41);
let mut app = App::new(
TuiConfig::load(section).unwrap(),
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
DaemonLink::default(),
);
let (tx, mut rx) = mpsc::channel(4);
app.emulators.insert(
sid,
SessionHandle {
tx,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
},
);
app.state.attach_to_focused(sid);
app.state.mode = Mode::Insert;
app.state.focus = Focus::Pane;
app.handle_event(ev).await.unwrap();
match rx.try_recv() {
Ok(SessionInput::Bytes(bytes)) => Some(bytes),
_ => None,
}
}
fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
}
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
/// it constantly, so no Insert-mode default may swallow it. The leader is
/// the way back to the sidebar.
#[tokio::test]
async fn escape_reaches_the_attached_session() {
let sent = bytes_sent_for(
&TuiSection::default(),
key_event(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
)
.await;
assert_eq!(sent, Some(vec![0x1b]));
}
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty.
#[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
let sent = bytes_sent_for(
&section,
key_event(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::CONTROL,
),
)
.await;
assert_eq!(sent, Some(vec![0x01]));
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
+9 -9
View File
@@ -75,10 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
/// Default Insert-mode bindings — deliberately empty. Every key typed in
/// Insert mode belongs to the session, `esc` included: full-screen apps read
/// it constantly, so binding it here would make it unreachable. The only way
/// back to the sidebar is the leader (`LeaveInsert`), which
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
/// the Insert escape with it.
const DEFAULT_INSERT: &[(&str, &str)] = &[];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
@@ -232,7 +235,7 @@ mod tests {
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
/// mode only exists while the sidebar is focused, so neither can leak
/// into a pty; `esc` is a second way out of Insert alongside the leader.
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
#[test]
fn aoe_style_defaults_are_bound() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
@@ -241,10 +244,7 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "tab"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "esc"),
Some(Action::LeaveInsert),
);
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
}
#[test]
+62
View File
@@ -813,6 +813,21 @@ impl Palette {
}
}
/// Background for the card whose session the pane is showing
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
/// selection surface, so the sidebar reads as one ladder: unfilled, half
/// filled for the session in the pane, fully filled under the cursor.
///
/// Blended rather than added as a third `Surface` — a new surface means
/// eight more colours hand-picked seventeen times — which is also why it
/// borrows the *selection* surface's foregrounds: those are the ones
/// already picked for a filled row. A preset whose colours the terminal
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
/// says "in the pane" with the filled glyph alone.
pub fn pane_row_bg(&self) -> Color {
mix(self.card.bg, self.selection.bg, 0.55)
}
/// Border style for a panel, by whether it holds focus.
pub fn border_style(&self, focused: bool) -> Style {
if focused {
@@ -873,6 +888,16 @@ pub fn active() -> Palette {
*active_slot().read().expect("active palette lock poisoned")
}
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
/// there is nothing to blend and `from` comes back unchanged.
fn mix(from: Color, to: Color, amount: f64) -> Color {
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
return from;
};
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
/// luminance — they are whatever the terminal theme says — so they return
/// `None` and cannot be contrast-checked.
@@ -937,6 +962,43 @@ mod tests {
}
}
/// The pane row is the middle rung: a visible fill against the card, still
/// a different surface from the cursor's, and carrying the selection
/// surface's foregrounds, which it borrows rather than shipping its own.
#[test]
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
for (preset, p) in rgb_presets() {
let bg = p.pane_row_bg();
let from_card = contrast_ratio(p.card.bg, bg)
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
assert!(
from_card >= 1.15,
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
);
let from_selection =
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
assert!(
from_selection >= 1.15,
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
);
for (role, color) in p.selection.roles() {
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
);
}
}
}
/// A preset the terminal themes has nothing to blend from.
#[test]
fn ansi_keeps_the_card_background_for_the_pane_row() {
let p = Palette::ANSI;
assert_eq!(p.pane_row_bg(), p.card.bg);
}
#[test]
fn every_preset_supplies_every_role() {
for (name, p) in Palette::presets() {
+4
View File
@@ -415,6 +415,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
@@ -511,6 +512,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
@@ -561,6 +563,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
@@ -601,6 +604,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
+173 -19
View File
@@ -37,14 +37,39 @@ const META_INDENT: &str = " ";
/// both card rows, replacing the old inline dot glyph on the meta line.
const RAIL: &str = "▌";
/// Cards use the selection surface only while the sidebar owns focus. An
/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins
/// on its own row: a highlight that lands on the session already in the pane
/// still has to be visible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CardFill {
/// An ordinary card, on the card surface.
None,
/// The session the pane is showing.
Pane,
/// Under the cursor, with the sidebar holding the keyboard.
Cursor,
}
impl CardFill {
/// Both filled tiers carry the selection surface's foregrounds — those are
/// the colours picked for a filled row, and a colour chosen for the card
/// background is not legible on one.
fn filled(self) -> bool {
self != CardFill::None
}
}
/// Cards take the cursor's fill only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
fn card_base(fill: CardFill, focused: bool) -> Style {
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
let mut style = palette.surface(fill.filled()).text_style();
match fill {
CardFill::Cursor => {}
CardFill::Pane => style = style.bg(palette.pane_row_bg()),
CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg),
CardFill::None => {}
}
style
}
@@ -55,6 +80,9 @@ pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
/// The session the active tab's pane is showing. It wears a fill of its
/// own, so "in the pane" is not carried by the ● glyph alone.
pub pane_session: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -113,8 +141,7 @@ impl Sidebar {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let base = card_base(self.card_fill(s, focused), focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
lines.push(Line::from(pad_to_width(meta, width, base)));
@@ -226,6 +253,18 @@ impl Sidebar {
pad_to_width(spans, width, base)
}
/// Which fill this session's card wears. The cursor wins on its own row,
/// so a highlight parked on the session already in the pane stays visible.
fn card_fill(&self, s: &Session, focused: bool) -> CardFill {
if focused && self.highlight == Some(SidebarItem::Session(s.id)) {
CardFill::Cursor
} else if self.pane_session == Some(s.id) {
CardFill::Pane
} else {
CardFill::None
}
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
@@ -233,12 +272,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
let rail_style = base.fg(activity_color(s.activity, fill));
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -257,7 +297,7 @@ impl Sidebar {
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
@@ -283,11 +323,11 @@ impl Sidebar {
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let rail_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
let muted = palette::active()
.surface(highlighted && focused)
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
@@ -308,7 +348,7 @@ impl Sidebar {
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
let palette = palette::active();
let surface = palette.surface(highlighted && focused);
let surface = palette.surface(fill.filled());
for (kind, text) in b.stats() {
let style = match kind {
StatKind::Added => muted.fg(surface.active),
@@ -352,10 +392,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
fn activity_color(state: ActivityState, fill: CardFill) -> Color {
palette::active().surface(fill.filled()).activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -520,6 +558,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 12));
assert!(
@@ -544,6 +583,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
})
.groups(&HashSet::new());
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
@@ -555,6 +595,7 @@ mod tests {
sessions: Vec::new(),
highlight: None,
attached: Default::default(),
pane_session: None,
};
assert!(sidebar.groups(&HashSet::new()).is_empty());
assert!(sidebar.rendered_items().is_empty());
@@ -566,6 +607,7 @@ mod tests {
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/tmp");
let mut collapsed = HashSet::new();
@@ -590,6 +632,7 @@ mod tests {
sessions: vec![session],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
let collapsed = HashSet::from([key]);
@@ -620,6 +663,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sidebar, 40, 8));
assert!(all[1].contains("/tmp"));
@@ -635,6 +679,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude");
@@ -645,6 +690,87 @@ mod tests {
);
}
/// Whether any cell in the buffer is painted on `bg`.
fn any_cell_on(buf: &Buffer, bg: Color) -> bool {
(0..buf.area.height)
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg)))
}
/// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is
/// not the ● glyph alone carrying "this is the one on screen".
#[test]
fn the_session_in_the_pane_wears_the_pane_fill() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg()));
let none = Sidebar {
pane_session: None,
..sb
};
assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg()));
}
/// The cursor's fill wins on its own row: a highlight that lands on the
/// session already in the pane has to stay visible.
#[test]
fn the_cursor_outranks_the_pane_fill_on_its_own_row() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(sid)),
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw_with_focus(&sb, 28, 6, true);
assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing");
assert!(
!any_cell_on(&buf, p.pane_row_bg()),
"the pane fill is showing through the cursor's own row"
);
}
/// A card the pane is showing is a filled row, so it takes the selection
/// surface's foregrounds — a colour picked for the card background is not
/// legible on a fill.
#[test]
fn the_pane_row_carries_selection_foregrounds() {
let sid = Uuid::from_u128(1);
let p = palette::active();
let mut sess = session_named(1, "claude");
sess.activity = ActivityState::AwaitingInput;
let sb = Sidebar {
sessions: vec![sess],
highlight: None,
attached: [sid].into_iter().collect(),
pane_session: Some(sid),
};
let buf = draw(&sb, 28, 6);
let on_pane_row = |fg: Color| {
(0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| {
let st = buf[(x, y)].style();
st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg)
})
})
};
assert!(
on_pane_row(p.selection.activity(ActivityState::AwaitingInput)),
"the rail must carry the state in the filled surface's colours"
);
assert!(
!on_pane_row(p.card.activity(ActivityState::AwaitingInput)),
"a card surface colour is painted onto the pane fill"
);
}
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "claude");
@@ -653,6 +779,7 @@ mod tests {
sessions: vec![sess],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 6);
let all = rows(&buf);
@@ -681,6 +808,7 @@ mod tests {
sessions: vec![blocked, clear, unprobed],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 44, 10));
@@ -727,6 +855,7 @@ mod tests {
sessions: vec![generated, settled],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -759,6 +888,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
@@ -785,6 +915,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
@@ -816,6 +947,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -855,6 +987,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height)
@@ -883,6 +1016,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let on_selection = |fg: Color| {
@@ -922,6 +1056,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -941,6 +1076,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
@@ -969,6 +1105,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -998,6 +1135,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1025,6 +1163,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1053,6 +1192,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 42, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
@@ -1086,6 +1226,7 @@ mod tests {
sessions: vec![legacy],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 40, 8));
assert!(
@@ -1128,6 +1269,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let rendered = render_to_string(&draw(&sb, 28, 6));
assert!(
@@ -1143,6 +1285,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(
@@ -1164,6 +1307,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 9, 6));
assert!(
@@ -1190,6 +1334,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1214,6 +1359,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
@@ -1235,6 +1381,7 @@ mod tests {
sessions: vec![highlighted],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height {
@@ -1260,6 +1407,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
@@ -1295,6 +1443,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height)
@@ -1314,6 +1463,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
pane_session: None,
},
28,
8,
@@ -1334,6 +1484,7 @@ mod tests {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
@@ -1352,6 +1503,7 @@ mod tests {
sessions: vec![session_named(1, "claude")],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
@@ -1369,6 +1521,7 @@ mod tests {
sessions: vec![one, two],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash");
@@ -1388,6 +1541,7 @@ mod tests {
sessions: vec![s],
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
+479
View File
@@ -0,0 +1,479 @@
# COMMAND-BAR.md — the command bar contract
One rule for the `ctrl-k` command bar in both clients, so the web bar and the
TUI bar open the same way, rank the same results in the same order, and act on
the same keys. Read this before changing either. Both clients implement exactly
what is written here; if the behaviour needs to change, change it here first.
The bar is one surface with three jobs: **go to a session**, **run a command**,
**spawn into a directory**. It is not a new place to configure anything and it
does not own any action — it chooses one and hands off to the flow that already
implements it.
## Opening
`ctrl-k` opens the bar from anywhere:
| Client | From | Notes |
| --- | --- | --- |
| TUI | Normal mode | Sidebar has the keyboard. |
| TUI | Insert mode | The pane has the keyboard; the bar still opens. |
| Web | Sidebar focused | Ordinary global binding. |
| Web | Terminal focused | Intercepted before xterm sees it. |
It is a **keymap entry like any other**, not a hard-wired key. In the TUI it is
bound in both `DEFAULT_NORMAL` and `DEFAULT_INSERT` and is rebindable through
`[tui.keymap.*]`; because the help overlay builds its lines from the live
keymap, it appears there without a second list to maintain. In the web it is an
entry in the command registry, so the `?` overlay lists it for the same reason.
An attached xterm swallows every keystroke it is given, so the web binding is
handled in the **capture phase**, ahead of the terminal — the same way the
restart overlay swallows the app's shortcuts (`docs/RESTART.md`).
### Escape hatch — sending a literal Ctrl-K
Binding `ctrl-k` takes readline's kill-to-end-of-line away from every pane. The
leader gives it back: **leader then `ctrl-k`** — `ctrl-a ctrl-k` by default —
sends the literal `0x0B` byte to the focused pane and does not open the bar.
The TUI already has a leader (`ctrl-a`, `[tui].leader`), and this chord goes
through the existing chord resolution, so rebinding the leader rebinds the
escape hatch with it — exactly as the leader's Insert-mode escape already
works. **The web gains a leader for this**, defaulting to `ctrl-a` and
mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
`preventDefault`s it, since selecting all of a terminal pane does nothing
useful.
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
none of them needs one.
### Focus round-trip
Closing the bar returns the keyboard to whatever held it when the bar opened,
the terminal included. A round trip through the bar never silently moves focus,
and never counts as a focus change for the daemon: opening the bar does not
report the pane unfocused, and closing it does not report a new focus. The bar
is a view over the app, not a place in it.
Opening the bar while a session is previewed (`docs/PREVIEW.md`) discards the
preview first, like any other real change; the bar is not previewed into.
## Structure
The bar is a query line over a result list. By default the list is **flat and
mixed** — sessions and commands ranked together — and rendered under **kind
headers**: a header row naming the kind, then that kind's results.
Grouping is a rendering of the ranking, never a reordering of it:
- Results are ranked as one list by the algorithm below.
- A kind's group holds its matches in that rank order.
- **Groups are ordered by their best-scoring member.** The first row under the
first header is therefore the globally best result, and the highlight starts
on it.
Two sigils, typed as the **first character** of the query, narrow the list to
one kind:
| Sigil | Kind | Candidates | Enter |
| --- | --- | --- | --- |
| `>` | commands | Every entry in the client's command registry — the web registry from the `?` overlay, the TUI's live `(chord, Action)` keymap for the current mode. | Runs the command. |
| `~` | directories | Recent dirs (`GET /api/recent-dirs`) and the project list the spawn form already uses, with `GET /api/dirs` filling in as a path is typed. | Spawns a session there with every default resolved. |
While a sigil is in force there is one kind, so the kind header is dropped. A
command row shows its **key chord** beside its label, so the bar teaches the
keymap instead of replacing it. A `~` spawn takes the same fast-forward path as
`N` / `FastForwardSpawn`: an unresolvable default opens the pre-filled spawn
form rather than failing silently.
**Backspacing past a sigil returns to the mixed list.** The sigil is one
character of the query like any other — deleting it is not a special key, it is
the query becoming empty again.
The sigil is only a sigil in first position. `>` or `~` typed anywhere else is
a literal query character (a directory query is where a `~` is most likely to
be typed twice, and the second one must reach the matcher).
## Empty query
With no sigil and an empty query the bar lists **sessions only, most recently
focused first**, so `ctrl-k enter` is "back to where I just was". Commands and
directories need a sigil or a query to appear; an empty bar is a session
switcher.
**Recency comes from the daemon**, not from client state — the same order in
every client, on every device, including one that just loaded the page. The
daemon stamps a last-focused time per session and ships it on the session like
any other field (#145). Both of the ways focus moves stamp the same field: a
client opening `/api/attach/:id`, and a client reporting focus explicitly at
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
**A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live
ones regardless of recency: a session you cannot attach to is not where you
just were.
Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list.
## Keys
| Key | Action |
| --- | --- |
| `enter` | Open the highlighted result — attach the session, run the command, spawn into the directory. |
| `tab` | Drill into the highlighted **session**'s action page. No effect on any other kind. |
| `esc` | At the top level, close the bar. On an action page, return to the result list. |
| `backspace` | Ordinary editing; at an empty query on an action page, return to the result list. |
| `up` / `down` | Move the highlight. |
| `ctrl-p` / `ctrl-n` | Move the highlight. |
The highlight does not wrap: it stops at the first and last row. Kind headers
are never landed on — they are labels, not rows.
Enter on a session goes through the **same open path** the sidebar's click and
the hash router already share, so a bar attach can never leave the layout in a
state a click could not produce.
### Drill-in — the session action page
`tab` on a highlighted session opens that session's action page inside the same
overlay: **kill**, **rename**, **spawn similar**. The page is still fuzzy
searchable — the query line keeps working, now over those actions.
- The overlay names the session being acted on, so the target is never
ambiguous.
- Escape, or backspace at an empty query, returns to the result list **with the
previous query intact**. Drilling in and backing out is free.
- Each action raises the flow it already has: kill goes through the existing
confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the
existing prompt pre-filled with the session's own name
(`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the
session's directory and command. The bar picks the action; it never
reimplements one.
No actions beyond those three, and no action across more than one session.
## Touch entry
On the web, **tapping or focusing the sidebar's search field opens the bar**
with an empty query. There is no `ctrl-k` on a phone and the product is
mobile-first, so the field the operator already reaches for is the entry point.
This **replaces** the sidebar's inline substring filter — one search affordance
in the header, not two beside each other. The tree's "No matches" empty state
belonged to that filter and goes with it; the bar's own no-results state takes
over (see the fixture `F4`).
At phone width the bar follows the bottom-sheet treatment the spawn modal
already uses below 600px. Visuals otherwise come from `DESIGN.md`'s existing
overlay tokens — the bar is the same family of surface as the spawn modal and
the help overlay, not a new one.
## What a candidate is
The matcher is a pure function: `(query, candidates) → ranked subset with
matched ranges`. It knows nothing about sessions, panes or the network. A
candidate is a **kind** plus a set of **named text fields**, each with a fixed
weight.
| Kind | Field | Weight | Text |
| --- | --- | --- | --- |
| session | `name` | `0` | The name as the sidebar shows it: `metadata.name` if set, else the command line (`docs/SIDEBAR.md`'s fallback). |
| session | `directory` | `-24` | The directory the sidebar groups it under, abbreviated: `~/dev/api`, not `/home/me/dev/api`. |
| session | `command` | `-32` | The full command line, arguments included. |
| session | `tool` | `-40` | The tool id the card's icon stands for (`claude`, `codex`, `opencode`, …), absent when the program is not one we have a mark for. |
| command | `label` | `0` | The plain-language label — the web registry's label, the TUI's `Action::help_label`. |
| directory | `path` | `0` | The path as displayed, abbreviated the same way. |
A command's **key chord is displayed, not matched**. It is there to teach the
keymap; matching it would make `>` results depend on a user's rebinds, and the
two clients do not share a keymap.
A candidate **matches** if at least one of its fields matches. Its score is the
**best** `field score + field weight` across its fields, and the matched ranges
it returns are those of that winning field — so a session found through its
directory highlights inside the directory, not inside its name.
## Match and rank
Two independent implementations (#146 in TypeScript, #147 in Rust) must produce
the same order for the same input. Everything below is exact.
### Normalization
- **Query**: lowercase, then remove every whitespace character. The result is
the *normalized query*. If it is empty, see "Empty query" below.
- **Field text**: matched against its lowercased form. Bonuses are computed
against the **original** text, because case is a signal (`camelCase`) that
lowercasing destroys.
- **Compact form** of a string: lowercase with whitespace removed. Used only
for the exact and prefix tests.
Lowercasing is per-character simple lowercase (`char::to_lowercase` /
`String.prototype.toLowerCase`). Positions are **character** positions —
Unicode scalar values in Rust, UTF-16 code units in TypeScript, which agree for
every character these fields realistically hold; ranges are returned in the
host language's own unit, since they exist to slice that language's string.
### What a match is
Case-insensitive **subsequence**: every character of the normalized query
appears in the lowercased field text, in order, not necessarily adjacent. A
field with no such embedding does not match.
### Word boundaries
A matched character at position `p` earns exactly one character bonus, taking
the first rule that applies:
| Rule | Bonus |
| --- | --- |
| `p == 0` — first character of the field | `+18` |
| The character before it is a **separator**: `/` `\` `-` `_` `.` `:` or a space | `+16` |
| It is uppercase and the character before it is lowercase (a camelCase hump) | `+12` |
| It is a digit and the character before it is not | `+12` |
| Otherwise | `0` |
That is one rule for both shapes the doc has to cover. In a **path**, `/` is a
separator, so the first character of every segment is a word start: `foo` in
`~/dev/foobar` starts a segment and outscores `foo` in `~/src/myfoo`, which
starts nothing. In a **session name**, `-`, `_`, `.` and spaces do the same
work, so `ws` reaches `web-search` more cheaply than `websearch`.
### Score
Let the query characters be matched at positions `p₀ < p₁ < … < pₘ₋₁`, and let
`p₋₁ = -1`. For each matched character `i`, with `gap = pᵢ - pᵢ₋₁ - 1`:
```
contribution(i) = 16 // every matched character
+ bonus(pᵢ) // the table above
+ (gap == 0 ? +8 // adjacent to the previous match
: -min(gap, 12))
```
`gap` for `i = 0` is the number of characters skipped before the first match, so
a match that starts late is penalised exactly like an internal gap, and the cap
is the same one.
The **field score** is the sum of the contributions, plus at most one whole-field
adjustment:
- compact field text **equals** the normalized query → `+64`
- else compact field text **starts with** the normalized query → `+32`
Length is deliberately not in the score — it is a tie-break, so a long field
never loses to a short one that matched worse.
### Which embedding
A query can sit in a field many ways. The one that counts is the
**highest-scoring** embedding; among embeddings that score the same, the
**leftmost** — compare position vectors element by element, smaller wins.
Greedy leftmost-first matching is not sufficient and must not be used: in
`maestro-tui`, `mtu` greedily takes `m₀ a₁ …` and never reaches `tui`. Both
implementations use the obvious dynamic program over
`(query index, text position)`, carrying the best score and its position
vector, since the gap term depends only on the previous matched position.
The whole-field adjustment is constant for a `(query, field)` pair, so adding it
after the dynamic program cannot change which embedding wins.
### Ordering
Sort by score, highest first. Equal scores break in this order:
1. **Kind** — `session`, then `command`, then `directory`.
2. **Length of the matched field text**, shorter first.
3. **The matched field text**, ascending, compared by code point, case
sensitive.
4. **Input order.** The sort is stable, so candidates the caller ordered
deliberately — sessions most recently focused first, commands in registry
order — keep that order when nothing else separates them.
Kind leads because that is the ruling the ambiguous cases need: a session named
`kill` and the `kill` command that score alike put the session first, because
the operator who typed a session's name meant the session.
### Empty query
A normalized query that is empty **matches every candidate**, each with score
`0` and no ranges, in input order. The bar's own rules decide what is in the
candidate list at that point (sessions only at the top level); the matcher does
not filter by kind.
## Fixtures
These are the contract. Both matchers are tested against exactly these cases;
the prose above is the explanation of why they come out this way.
Candidates are written as `kind(fields)`; unlisted fields are absent.
`session(name, dir, command, tool)`. Scores are shown for diagnosis — **the
order is what is being asserted**, and an implementation that ranks correctly
with different absolute numbers has still failed, because the numbers are how
the next fixture stays predictable.
### F1 — empty query keeps input order
```
query: ""
candidates: session("kill", "~/dev/api", "bash")
session("killer-app", "~/dev/killer-app", "claude --resume", "claude")
command("Kill selected session")
expected: session("kill") score 0
session("killer-app") score 0
command("Kill selected session") score 0
```
Every candidate matches, in the order given. No ranges.
### F2 — a session named `kill` against the `kill` command
```
query: "kill"
candidates: (as F1)
expected: session("kill") 178 name
session("killer-app") 146 name
command("Kill selected session") 146 label
```
`kill` wins on the exact bonus. The other two **tie at 146** — both are a
contiguous prefix match — and the kind tie-break puts the session first.
### F3 — a fragment matching mid-segment inside a path
```
query: "foo"
candidates: directory("~/dev/foobar")
directory("~/src/myfoo")
directory("~/foo")
expected: directory("~/foo") 78 ranges [2,3,4]
directory("~/dev/foobar") 74 ranges [6,7,8]
directory("~/src/myfoo") 56 ranges [8,9,10]
```
The first two start a path segment; the third is mid-segment and loses by the
whole boundary bonus. `~/foo` beats `~/dev/foobar` only because less was
skipped before the match.
### F4 — a query matching nothing
```
query: "zzz"
candidates: session("kill", "~/dev/api", "bash")
command("Kill selected session")
expected: (empty)
```
The matcher returns an empty list, never a list of zero-scored candidates. The
bar renders its no-results state.
### F5 — a non-contiguous subsequence
```
query: "mtu"
candidates: session("maestro-tui")
session("maestro-daemon")
session("mtui-scratch")
expected: session("mtui-scratch") 122 ranges [0,1,2]
session("maestro-tui") 91 ranges [0,8,9]
```
`maestro-daemon` has no `u` after its `t` and does not match. `maestro-tui`
does, across a gap — which greedy matching would miss entirely.
### F6 — a name beats a directory
```
query: "api"
candidates: session("api", "~/dev/web", "bash")
session("worker", "~/dev/api", "bash")
expected: session("api") 154 name
session("worker") 50 directory
```
The second session is found through its directory, and highlights there.
### F7 — case-insensitive, tie broken by length
```
query: "api"
candidates: session("API-Gateway")
session("apiary")
expected: session("apiary") 122 name
session("API-Gateway") 122 name
```
Both are prefix matches and score identically. Same kind, so length decides:
6 characters before 11. Input order does not save `API-Gateway`.
### F8 — the tool field
```
query: "claude"
candidates: session("nameless", "~/dev/x", "claude --resume", "claude")
session("clay", "~/dev/y", "bash")
expected: session("nameless") 186 tool
```
`clay` has no `claude` subsequence anywhere and drops out. `nameless` matches
its `tool` exactly (`+64`, weight `-40`, giving 186) which beats the prefix
match on its `command` field (`+32`, weight `-32`, giving 162).
### F9 — a word boundary inside a session name
```
query: "ws"
candidates: session("websearch")
session("web-search")
expected: session("web-search") 71 ranges [0,4]
session("websearch") 56 ranges [0,3]
```
The `s` after `-` earns the boundary bonus; the `s` inside `websearch` earns
nothing.
### F10 — command labels
```
query: "se"
candidates: command("Kill selected session")
command("Create a session")
command("Show help")
expected: command("Show help") 53 ranges [0,6]
command("Kill selected session") 51 ranges [5,6]
command("Create a session") 47 ranges [9,10]
```
`Show help` starts at position 0; the other two start at a word boundary but
later, and pay for the skipped characters.
### F11 — a query against session directories
```
query: "dev"
candidates: session("api", "~/dev/api", "bash")
session("notes", "~/documents/dev-notes", "nvim")
session("scratch", "~/srv/devops", "bash")
expected: session("api") 54 directory, ranges [2,3,4]
session("scratch") 50 directory, ranges [6,7,8]
session("notes") 44 directory, ranges [12,13,14]
```
All three start a path segment. They separate purely on how much was skipped
first, and `notes` hits the `-12` gap cap.
### F12 — exact beats prefix
```
query: "quit"
candidates: command("Quit Maestro")
command("Quit")
expected: command("Quit") 178 label
command("Quit Maestro") 146 label
```
The 32-point difference between the exact and prefix adjustments is the whole
gap; nothing else about the two matches differs.
+39 -8
View File
@@ -32,6 +32,33 @@ The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
## How a preview reads
A preview is provisional, so it never wears the accent — the accent is what
says "this is the one you picked". It has a hue of its own, and every surface
involved in the preview carries that same hue, so the pane and the row it came
from read as one thing rather than two unrelated highlights.
Web client (`--preview` and friends in `web/src/styles.css`):
| Surface | While previewing |
|---|---|
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
| A throwaway tab | Dashed border, italic label. |
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
Rows say it with fill, not with an edge — see the tier ladder in
`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges
belong to the pane: the canvas ring and the throwaway tab.
The hue is a palette token, not a fixed colour: a palette whose accent already
sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide.
The TUI marks a preview in text only — the title bar reads `<name> · preview`
— and has no preview colour role yet.
## What ends a preview
- The highlight moves to another session — that session is previewed instead.
@@ -50,15 +77,19 @@ Nothing moves a second time on commit.
## Timing
A preview follows the highlight closely enough to be usable as a way to look
around, and lags a pointer enough not to flash rows crossed on the way
somewhere else.
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
shows it on the next frame, and so does leaving every row. There is no
settling period in front of the first preview, because that period would sit
in front of the one preview the operator actually meant.
| Trigger | Delay |
|---|---|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
| Pointer hover (web, mouse only — never touch) | 150 ms |
| Clearing (highlight left every session) | same delay as the trigger that set it |
What is bounded is how *often* the pane may swap: at most once per 120 ms.
A burst inside one window — `j` held down, or the pointer sweeping the length
of the sidebar — shows the row it started on, then the row it ended on, and
none of the rows in between. So a walk costs a couple of pane mounts rather
than one per row, without the operator ever waiting on one.
The highlight itself is not bounded by any of this: the row lights up on the
same frame as the input, whatever the pane behind it is doing.
Touch has no hover, and a tap already opens a session, so touch never previews.
+32
View File
@@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the
selection background. Contrast is a property of the palette (values are RGB)
and is asserted in the palette's tests, not left to the terminal theme.
## Which row is filled
Both clients say every one of these with a **background fill**, never an edge.
A row is usually several of them at once, so they form a ladder: the tier
furthest down wins the paint.
| Tier | Web | TUI |
|---|---|---|
| Alive | 6% accent wash | — (the rail carries it) |
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
The web ladder is written as one ordered run of equal-specificity rules in
`styles.css`, every qualifier inside `:where()`, so the order they appear in
*is* the precedence. Add a tier where it belongs in that run, never by piling
on another class to out-weigh the rule above it.
The TUI's two filled tiers both carry the **selection** surface's foregrounds,
since those are the colours picked for a filled row. `pane_row_bg()` is
blended from the two surfaces rather than shipped as a third one — a third
surface would mean eight more colours hand-picked seventeen times — and the
palette tests assert it stays a visible step from both. The cursor's fill wins
on its own row, so a highlight parked on the session already in the pane is
still visible.
The one edge left in either list is the TUI's `▏` marker, which stands in for
the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to.
The duration text inherits its state's colour; the exited and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
+86 -11
View File
@@ -164,6 +164,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return wrap;
}
// The row outlives the snapshot it was built from: a refresh that changes
// nothing structural patches it in place rather than replacing it, so the
// handlers below have to read the current session, not the one captured here.
let cur = node.session;
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
@@ -197,33 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
void requestKill(s, name);
void requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, s) },
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
onSelect: () => void requestKill(cur, sessionName(cur)),
},
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]);
});
leafPatches.set(row, (next) => {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
if (meta) meta.outerHTML = metaLine(next, Date.now());
});
return row;
}
/** Refresh a standing card from a newer snapshot of its session, keeping the
* element (and so the pointer's `:hover`) exactly where it is. Registered by
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
/**
* Everything about the tree that a patch cannot fix up.
*
* The daemon re-sends a session on every activity, foreground or cwd shift —
* a couple of times a minute per live session — and rebuilding the sidebar for
* each one drops `:hover` from the row under the pointer. Refreshes whose
* signature is unchanged are patched into the standing rows instead; anything
* that moves, renames, appears or dies changes the signature and rebuilds.
*/
function treeSignature(node: TreeNode): string {
if (node.kind === "folder") {
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
if (node.name && collapsed.has(node.path)) return head;
return `${head}(${node.children.map(treeSignature).join(",")})`;
}
const s = node.session;
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
}
/**
* Kill on the first click (#81): the daemon is the one that decides whether a
* session may go, and it refuses over a worktree holding unsaved work. Only
@@ -342,6 +387,9 @@ export interface SidebarHandle {
setActive(id: string | null): void;
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
@@ -368,7 +416,7 @@ export interface SidebarOptions {
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
onPreview?: (sessionId: string | null) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -501,6 +549,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -509,6 +561,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
@@ -547,7 +600,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key");
opts.onPreview?.(sessionOf(navKey));
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -578,7 +631,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id, "hover");
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") return;
@@ -595,7 +648,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
@@ -635,12 +688,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
e.preventDefault();
});
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir, filter);
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
if (s) leafPatches.get(row)?.(s);
}
return;
}
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
@@ -664,7 +729,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
/* absolute paths are a fine fallback */
});
const rerenderHandler = () => refresh();
// Dispatched when something outside the store changed what the tree should
// look like — a folder collapsed, a rename ended — none of which the store
// snapshot can show, so the signature check has to be bypassed.
const rerenderHandler = () => {
renderedSignature = null;
refresh();
};
document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
@@ -692,6 +763,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
+105
View File
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
window.fetch = () => Promise.reject(new Error("offline"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
+1
View File
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
"--muted",
"--faint",
"--accent",
"--preview",
"--danger",
"--warn",
];
+10 -15
View File
@@ -29,6 +29,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
@@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog";
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
// mouse sweep waits long enough that rows crossed on the way somewhere else
// never flash up. The same delay applies to clearing, so the gap between two
// rows doesn't blink the previous view back.
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
let previewTimer: number | undefined;
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
// — j held down, or the pointer sweeping the whole list — is coalesced so it
// costs a couple of pane mounts instead of one per row walked.
const schedulePreview = createPreviewSchedule((sessionId) => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
});
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
@@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
clearTimeout(previewTimer);
schedulePreview.cancel();
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
+1
View File
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview() {},
focusList() {},
hasListFocus() {
return false;
+12
View File
@@ -221,6 +221,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
@@ -229,6 +235,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
open: realOpenSessions(),
focused: focusedLeafSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
@@ -549,6 +558,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
persist();
return;
}
@@ -588,6 +598,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
@@ -598,6 +609,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
+24
View File
@@ -45,11 +45,17 @@ function fakePool(): Spy {
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview(id: string | null) {
previewRow.id = id;
},
focusList() {},
hasListFocus() {
return false;
@@ -88,6 +94,7 @@ describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
});
afterEach(() => {
@@ -110,6 +117,23 @@ describe("sidebar preview", () => {
expect(serializeLayout(layout)).toEqual(before);
});
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(previewRow.id).toBe("s1");
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
expect(
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
).toBe("s1");
mv.previewSession(null);
expect(previewRow.id).toBeNull();
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
function harness() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let nextHandle = 1;
const emitted: (string | null)[] = [];
const schedule = createPreviewSchedule((id) => emitted.push(id), {
now: () => now,
setTimer: (fn, ms) => {
const h = nextHandle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h) => {
timers.delete(h);
},
});
/** Advance the clock, firing any timer that comes due. */
const advance = (ms: number) => {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
t.fn();
}
}
};
return { schedule, emitted, advance };
}
describe("preview schedule", () => {
it("shows the first preview immediately", () => {
const { schedule, emitted } = harness();
schedule("s1");
expect(emitted).toEqual(["s1"]);
});
it("coalesces a burst into one trailing preview", () => {
const { schedule, emitted, advance } = harness();
// A pointer sweep across the list, or j held down: every row in between
// is walked well inside one gap.
schedule("s1");
schedule("s2");
schedule("s3");
expect(emitted).toEqual(["s1"]);
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s3"]);
});
it("shows the next preview immediately once the gap has passed", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
advance(PREVIEW_MIN_GAP_MS);
schedule("s2");
expect(emitted).toEqual(["s1", "s2"]);
});
it("drops a superseded trailing preview rather than replaying it", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule("s1"); // back where we started inside the same gap
advance(PREVIEW_MIN_GAP_MS);
// s1 is already on screen, so the trailing edge has nothing to do.
expect(emitted).toEqual(["s1"]);
});
it("clears through the same gap, so leaving and re-entering does not blink", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule(null); // pointer crosses the gap between two rows
schedule("s2");
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1", "s2"]);
});
it("cancel drops a pending trailing preview", () => {
const { schedule, emitted, advance } = harness();
schedule("s1");
schedule("s2");
schedule.cancel();
advance(PREVIEW_MIN_GAP_MS);
expect(emitted).toEqual(["s1"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
*
* The highlight itself is never scheduled — that is a class on a row and lands
* on the same frame as the input. This governs only the pane swap behind it,
* which mounts a terminal and, for a session the connection pool isn't already
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
*
* A plain debounce would put that cost in front of every preview, including the
* one preview the operator actually meant. Instead the first move shows at once
* and the rest of a burst is coalesced: walking a list with `j` held down, or
* sweeping the pointer from the top of the sidebar to the bottom, costs one
* preview at the start and one at the row you stopped on, not one per row.
*/
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
* couple of terminals rather than one per row; short enough that a deliberate
* second move never waits on it. */
export const PREVIEW_MIN_GAP_MS = 120;
export interface PreviewScheduleDeps {
now?: () => number;
setTimer?: (fn: () => void, ms: number) => number;
clearTimer?: (handle: number) => void;
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
minGapMs?: number;
}
export interface PreviewSchedule {
(sessionId: string | null): void;
/** Drop a pending trailing preview without showing it. */
cancel(): void;
}
/**
* Wrap `show` so it fires on the leading edge and at most once per gap.
*
* `show` is called with the session to preview, or `null` to put back whatever
* the operator actually opened.
*/
export function createPreviewSchedule(
show: (sessionId: string | null) => void,
deps: PreviewScheduleDeps = {},
): PreviewSchedule {
const now = deps.now ?? (() => Date.now());
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
let lastShownAt = Number.NEGATIVE_INFINITY;
let shown: string | null | undefined;
let pendingTimer: number | undefined;
let pendingId: string | null = null;
const emit = (id: string | null) => {
// Nothing to do when the burst ended on the view already up: re-showing it
// would tear down and re-mount the pane for no visible change.
if (shown === id) return;
shown = id;
lastShownAt = now();
show(id);
};
const cancelPending = () => {
if (pendingTimer === undefined) return;
clearTimer(pendingTimer);
pendingTimer = undefined;
};
const schedule = ((sessionId: string | null) => {
pendingId = sessionId;
const waited = now() - lastShownAt;
if (waited >= minGap) {
cancelPending();
emit(sessionId);
return;
}
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
pendingTimer = setTimer(() => {
pendingTimer = undefined;
emit(pendingId);
}, minGap - waited);
}) as PreviewSchedule;
schedule.cancel = cancelPending;
return schedule;
}
+8 -11
View File
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([
[A, "key"],
[B, "key"],
]);
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]);
expect(seen.at(-1)).toBe(null);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]);
expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]);
expect(seen.at(-1)).toBe(null);
});
it("ignores touch, where a tap already opens the session", () => {
+87 -35
View File
@@ -87,6 +87,30 @@
--accent-tint: oklch(0.92 0.005 256 / 0.1);
--accent-ring: oklch(0.92 0.005 256 / 0.5);
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
the accent, because the accent is what says "this is the one you picked".
Defined once, off a single hue variable, so a palette whose accent already
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
keeps the light pair here instead of in all eighteen theme blocks. */
--preview-h: 305;
--preview: light-dark(
oklch(0.55 0.16 var(--preview-h)),
oklch(0.78 0.14 var(--preview-h))
);
--preview-ink: light-dark(
oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h))
);
/* Opaque, not a wash: session rows read as fills, and a fill has to land on
the same colour whatever tier the row was already painted. `--accent-fill`
is the same recipe on the accent, so the two tiers differ by hue alone. */
--preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2));
--accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2));
--preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6)
);
--live: oklch(0.84 0.17 152);
--live-glow: oklch(0.84 0.17 152 / 0.55);
@@ -256,6 +280,9 @@
--accent-ink: oklch(0.18 0.04 278);
--accent-tint: oklch(0.75 0.125 275 / 0.14);
--accent-ring: oklch(0.75 0.125 275 / 0.55);
/* This accent is already violet, so preview moves to cyan to stay legible
as "not the accent". Set once here: the light blocks below don't touch it. */
--preview-h: 195;
--term-bg: oklch(0.155 0.018 278);
}
:root[data-palette="periwinkle"][data-theme="light"],
@@ -437,6 +464,8 @@
--accent-ink: oklch(0.22 0.05 310);
--accent-tint: oklch(0.78 0.13 310 / 0.14);
--accent-ring: oklch(0.78 0.13 310 / 0.55);
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
--preview-h: 195;
--term-bg: oklch(0.155 0.014 305);
}
:root[data-palette="orchid"][data-theme="light"],
@@ -1064,7 +1093,6 @@ textarea {
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-12);
transition: color var(--dur-fast) var(--ease-out);
}
.tree-folder-header:hover {
@@ -1076,9 +1104,9 @@ textarea {
text-align: center;
color: var(--faint);
font-size: var(--fs-11);
transition:
transform var(--dur) var(--ease-out),
color var(--dur-fast) var(--ease-out);
/* The rotation is the collapse animation and stays; the hover colour is
immediate, like the row's own hover. */
transition: transform var(--dur) var(--ease-out);
}
.tree-folder-header[aria-expanded="false"] .chevron {
@@ -1115,33 +1143,42 @@ textarea {
border-radius: var(--r-1);
color: var(--ink);
position: relative;
transition:
background var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
/* No transition on `background`: it is what hover, the keyboard cursor and
the preview all paint, and easing it puts a visible delay between the input
landing on a row and the row admitting it. `.is-active`'s ring still eases
— being open in a pane is a state, not a response to the pointer. */
transition: box-shadow var(--dur-fast) var(--ease-out);
}
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
.tree-leaf[data-status="running"] {
/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at
once, so every qualifier below sits in `:where()`: each rule is worth the
same specificity and the ladder is decided purely by the order they appear
in, latest wins. Add a tier where it belongs in that order, not by piling on
another class to out-weigh the rule above it. Each tier paints a background
and nothing else — the list says all of this with fill, never an edge. */
/* 1. Alive. A faint wash on the row itself — no per-row status icon. */
.tree-leaf:where([data-status="running"]) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf:hover {
background: var(--surface-2);
/* 2. Under the pointer, or under the keyboard walk. Both are what previews a
row (docs/PREVIEW.md), so both read in the preview hue — never the accent,
which belongs to the row actually open. */
.tree-leaf:where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf[data-status="running"]:hover {
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) {
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-leaf:focus-visible {
.tree-leaf:where(:focus-visible) {
outline: none;
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
}
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
/* A folder row previews nothing, so its cursor stays a ring. */
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
@@ -1150,17 +1187,26 @@ textarea {
outline: none;
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active,
.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
box-shadow: inset 0 0 0 1px var(--accent-ring);
/* 3. Open in some pane, but not the focused one (issue #33). */
.tree-leaf:where(.is-open) {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* 4. The session currently mounted in the content pane. */
.tree-leaf:where(.is-active) {
background: var(--accent-fill);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
/* 5. The row the content pane is previewing right now. Last, so previewing a
session that is already open still reads as provisional. */
.tree-leaf:where(.is-previewing) {
background: var(--preview-tint);
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
@@ -2884,10 +2930,14 @@ input.leaf-rename:focus {
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — a dashed tab and a
dashed inset ring on the canvas — and reverts when the highlight moves. */
operator only highlighted, so it reads as provisional — the preview hue on
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
reverts when the highlight moves. The hue is deliberately not the accent:
the accent means "this is what you picked", which a preview is not. */
.mv-tab.is-preview {
border-style: dashed;
border-color: var(--preview-ring);
color: var(--preview);
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
@@ -2900,8 +2950,8 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--accent-ink);
background: var(--accent);
color: var(--preview-ink);
background: var(--preview);
opacity: 0.85;
pointer-events: none;
}
@@ -2910,7 +2960,7 @@ input.leaf-rename:focus {
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--accent-ring);
border: 1px dashed var(--preview-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
@@ -3028,6 +3078,15 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
only thing saying the pane is provisional — and so it is never mistaken for
the accent tint of the pane the operator actually focused. It keeps full
strength even though a preview never takes focus. */
.mv-pane.is-preview .attach-header,
.mv-pane.is-preview.is-focused .attach-header {
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
opacity: 1;
}
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
.mv-pane-empty {
flex: 1;
@@ -3117,13 +3176,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* =========================================================================
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
bar, no pane switcher; the sidebar drawer (☰) is the navigation.