Compare commits

...
Author SHA1 Message Date
Miguel Palhas 60655a1843 docs(maestro): describe the TUI's resume keybinding 2026-09-04 09:43:33 +01:00
Miguel Palhas 2c0b96411c fix(maestro): gate the sidebar preview attach on Running
The TUI's preview attached without checking status, so after a host reboot —
every session dormant — moving the sidebar highlight tried a live attach on
each one in turn; the daemon has no bridge to answer, so it 404/409s.

Gate every attach (preview, committed AttachHighlighted, and the reconciling
poll after a resume completes) on status == Running, and show a metadata +
resume placeholder in the pane instead, per docs/PREVIEW.md.
2026-09-04 09:43:31 +01:00
Miguel Palhas 8f1b234a11 feat(maestro): bind a TUI key to resume a dormant session
Adds DaemonClient::resume_session (POST /api/sessions/:id/resume) and a new
ResumeHighlighted action, bound to `u` by default (#166, #169).
2026-09-04 09:43:27 +01:00
Miguel Palhas ec7728afb1 feat(maestro): render dormant sidebar cards muted and sorted last
docs/SIDEBAR.md's dormant card contract (#168) applied to the TUI: name and
glyph render in the muted role, the activity rail carries no colour, and a
dormant session sorts after every live one in its folder, name notwithstanding.
2026-09-04 09:43:25 +01:00
7 changed files with 449 additions and 32 deletions
+10
View File
@@ -141,6 +141,16 @@ impl DaemonClient {
json(check(resp).await?).await
}
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session
/// under its own uuid and sidecar. Refused with `409` (`already_running`
/// / `cwd_missing`) when there is nothing a respawn could fix.
pub async fn resume_session(&self, id: Uuid) -> Result<Session, ClientError> {
let resp = self
.send(Method::POST, &routes::session_resume(id), &[], None::<&()>)
.await?;
json(check(resp).await?).await
}
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
/// state. The daemon holds it above its own inference for a TTL, then
/// resumes inference. Answers `204 No Content`.
+15
View File
@@ -51,6 +51,9 @@ pub enum Action {
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
/// Resume the highlighted dormant session (#166, #169): respawns it
/// under its own uuid and sidecar via `POST /api/sessions/:id/resume`.
ResumeHighlighted,
// Chrome
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
@@ -105,6 +108,7 @@ impl Action {
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::ResumeHighlighted => "Resume dormant session".into(),
Action::OpenCommandBar => "Go to a session".into(),
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
Action::SendLiteralLeader => "Send literal leader key to session".into(),
@@ -134,6 +138,7 @@ impl Action {
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ResumeHighlighted" => return Ok(Action::ResumeHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
@@ -192,6 +197,16 @@ mod tests {
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
}
/// #169: resume is its own verb, distinct from rename/kill, so a dormant
/// session's sidebar card has a keyboard-reachable resume.
#[test]
fn parses_resume_highlighted() {
assert_eq!(
Action::parse("ResumeHighlighted").unwrap(),
Action::ResumeHighlighted
);
}
#[test]
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
+163 -20
View File
@@ -34,7 +34,7 @@ use crate::input::{
use crate::keymap::Mode;
use crate::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::render::{draw, PanePlaceholder, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
@@ -190,6 +190,10 @@ pub enum Effect {
id: Uuid,
name: String,
},
/// Resume a dormant session (#166, #169): respawns it under its own
/// uuid and sidecar. The catalog picks up the resulting `Running` status
/// from the daemon's own `SessionUpdated` broadcast.
Resume(Uuid),
}
impl App {
@@ -263,6 +267,11 @@ impl App {
}
_ = self.catalog.changed() => {
// Picked up by the frame key's catalog version.
let effects = self.poll_resumed_attach();
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
self.perform(effects).await;
}
_ = self.link.changed() => {
// The status indicator and the restart overlay both read
@@ -414,6 +423,7 @@ impl App {
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Resume(id) => self.submit_resume(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -587,7 +597,10 @@ impl App {
}
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if self.emulators.get(sid).is_none() {
// A session that isn't Running has no bridge to
// attach to (`docs/PREVIEW.md`) — the pane falls back
// to its dormant/exited placeholder instead.
if self.emulators.get(sid).is_none() && self.session_running(sid) {
effects.push(Effect::Attach(sid));
}
}
@@ -607,6 +620,11 @@ impl App {
self.prompt_rename(sid);
}
}
Action::ResumeHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Resume(sid));
}
}
}
// Every action can move the highlight or the focus, and the preview is
// a function of both (`docs/PREVIEW.md`).
@@ -663,13 +681,41 @@ impl App {
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
let fresh = self.emulators.get(sid).is_none();
if fresh {
// A session that isn't Running has no bridge to preview-attach to
// (`docs/PREVIEW.md`): the pane falls back to its dormant/exited
// placeholder, and the preview budget has nothing of its own to track.
let attach = self.emulators.get(sid).is_none() && self.session_running(sid);
if attach {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
}
self.trim_preview_budget();
if fresh {
if attach {
vec![Effect::Attach(sid)]
} else {
Vec::new()
}
}
/// Whether `sid` is currently `Running`, per the catalog — the only
/// status a live pane attach is allowed for (`docs/PREVIEW.md`).
fn session_running(&self, sid: Uuid) -> bool {
self.catalog
.snapshot()
.sessions
.iter()
.any(|s| s.id == sid && s.status == maestro_protocol::SessionStatus::Running)
}
/// A previewed or committed session gates its attach on `Running`
/// (`docs/PREVIEW.md`) and may only become `Running` later — most
/// commonly a resume completing. Catch that up on a catalog change
/// rather than waiting on an unrelated highlight move.
fn poll_resumed_attach(&mut self) -> Vec<Effect> {
let Some(sid) = self.state.displayed_session() else {
return Vec::new();
};
if self.emulators.get(sid).is_none() && self.session_running(sid) {
vec![Effect::Attach(sid)]
} else {
Vec::new()
@@ -1327,6 +1373,15 @@ impl App {
}
}
/// Resume a dormant session (#166, #169). The catalog's own
/// `SessionUpdated` broadcast carries the `Running` status back; nothing
/// is patched locally here.
async fn submit_resume(&mut self, sid: Uuid) {
if let Err(e) = self.daemon.resume_session(sid).await {
self.state.toast(format!("resume failed: {e}"));
}
}
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
/// session counts — the sidebar highlight is never a "focused pane".
@@ -1416,21 +1471,32 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
if previewing {
format!("{name} · preview")
} else {
name
}
}
None => "session".into(),
let shown_session = shown_sid.and_then(|sid| snap.sessions.iter().find(|s| s.id == sid));
let shown_name = shown_session
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
let pane_title = if previewing && shown_sid.is_some() {
format!("{shown_name} · preview")
} else {
shown_name.clone()
};
// docs/PREVIEW.md: a session that isn't Running never gets a live
// attach, so an empty pane here means "nothing to attach to", not
// merely "not yet connected" — the pane names it and offers resume
// instead of a terminal that would die on the daemon's 409.
let shown_cwd = shown_session
.map(|s| s.cwd.display().to_string())
.unwrap_or_default();
let pane_placeholder = if pane.is_none() {
shown_session
.filter(|s| s.status != maestro_protocol::SessionStatus::Running)
.map(|s| PanePlaceholder {
name: &shown_name,
cwd: &shown_cwd,
dormant: s.status == maestro_protocol::SessionStatus::Dormant,
})
} else {
None
};
let link_status = self.link.status();
@@ -1439,6 +1505,7 @@ impl App {
home_dir: &self.home_dir,
pane_title: &pane_title,
pane: pane.as_ref(),
pane_placeholder,
state: &self.state,
draft: self.draft.as_ref(),
help: &self.help,
@@ -2124,6 +2191,82 @@ mod tests {
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
}
/// #169, docs/PREVIEW.md: a session that isn't `Running` never gets a
/// live attach — the daemon has nothing to answer a 409 with. Committing
/// to it still moves the tab, so the pane can show its placeholder.
#[test]
fn attach_highlighted_never_attaches_a_dormant_session() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(11);
let mut app = make_app();
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
app.dispatch(Action::SidebarNext);
assert_eq!(app.dispatch(Action::AttachHighlighted), Vec::new());
assert_eq!(app.state.active_tab().session(), Some(sid));
}
/// #169, docs/PREVIEW.md: moving the highlight across dormant sessions —
/// the sharp case after a reboot — must never try a live preview attach.
#[test]
fn preview_never_attaches_a_dormant_session() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(12);
let mut app = make_app();
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
app.dispatch(Action::SidebarNext);
assert_eq!(app.poll_due_preview_at(after_gap()), Vec::new());
assert_eq!(app.state.preview, Some(sid));
}
/// #169: a session that becomes `Running` while already shown — a resume
/// completing, most commonly — attaches without waiting on an unrelated
/// highlight move.
#[test]
fn a_shown_session_attaches_once_it_turns_running() {
use maestro_protocol::{CatalogEvent, SessionStatus};
let sid = Uuid::from_u128(13);
let mut app = make_app();
let mut dormant = test_session(sid, "/repo");
dormant.status = SessionStatus::Dormant;
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![dormant.clone()],
});
app.state.attach_to_focused(sid);
assert_eq!(app.poll_resumed_attach(), Vec::new());
let mut running = dormant;
running.status = SessionStatus::Running;
app.catalog
.apply_event(CatalogEvent::SessionUpdated { session: running });
assert_eq!(app.poll_resumed_attach(), vec![Effect::Attach(sid)]);
}
// -- resume (#166, #169) -------------------------------------------------
#[test]
fn resume_highlighted_asks_for_a_resume() {
use maestro_protocol::SessionStatus;
let mut app = make_app();
let sid = Uuid::from_u128(14);
let mut s = test_session(sid, "/repo");
s.status = SessionStatus::Dormant;
app.catalog
.apply_event(maestro_protocol::CatalogEvent::Snapshot { sessions: vec![s] });
assert_eq!(
app.dispatch(Action::ResumeHighlighted),
vec![Effect::Resume(sid)]
);
}
#[test]
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
use maestro_protocol::CatalogEvent;
+6
View File
@@ -59,6 +59,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("N", "FastForwardSpawn"),
("x", "KillHighlighted"),
("r", "RenameHighlighted"),
// Resume a dormant session (#166, #169).
("u", "ResumeHighlighted"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
@@ -257,6 +259,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "N"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "u"),
Some(Action::ResumeHighlighted),
);
}
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
+107
View File
@@ -72,6 +72,18 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
/// What the pane shows in place of a live attach when the session behind it
/// isn't `Running` (`docs/PREVIEW.md`): a session that is not `Running` never
/// gets a live attach, since the daemon has no bridge to answer one.
#[derive(Debug, Clone, Copy)]
pub struct PanePlaceholder<'a> {
pub name: &'a str,
pub cwd: &'a str,
/// `true` for a dormant session (offers resume), `false` for a dead one
/// (`docs/SIDEBAR.md`'s `exited`/`dormant` fragments never both show).
pub dormant: bool,
}
/// Bundle of references the renderer reads from. Built by `App::redraw` each
/// frame.
#[derive(Debug)]
@@ -80,6 +92,9 @@ pub struct RenderInputs<'a> {
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
/// What to show instead, when `pane` is `None` because the shown session
/// isn't `Running` rather than merely not yet connected.
pub pane_placeholder: Option<PanePlaceholder<'a>>,
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
@@ -147,6 +162,8 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
} else if let Some(ph) = inputs.pane_placeholder {
render_pane_placeholder(f, pane_area, ph);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator.
@@ -239,6 +256,44 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// docs/PREVIEW.md / docs/SIDEBAR.md: what the pane can honestly show for a
/// session with no bridge behind it — its metadata and a resume affordance
/// for a dormant one, never a terminal that would die on the daemon's 409.
fn render_pane_placeholder(f: &mut Frame, area: Rect, ph: PanePlaceholder<'_>) {
let palette = palette::active();
let surface = palette.surface(false);
let glyph = if ph.dormant { "◌" } else { "✗" };
let status = if ph.dormant {
"dormant — resume from the sidebar"
} else {
"exited"
};
let mid = area.y + area.height / 2;
let lines = [
(format!("{glyph} {}", ph.name), surface.fg),
(ph.cwd.to_string(), surface.muted),
(status.to_string(), surface.muted),
];
for (i, (text, colour)) in lines.iter().enumerate() {
let Some(y) = mid.checked_sub(1).map(|y| y + i as u16) else {
continue;
};
if y >= area.y + area.height {
continue;
}
let x = area.x + (area.width.saturating_sub(text.chars().count() as u16)) / 2;
f.render_widget(
Paragraph::new(text.as_str()).style(Style::default().bg(surface.bg).fg(*colour)),
Rect::new(
x,
y,
text.chars().count().min(area.width as usize) as u16,
1,
),
);
}
}
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
@@ -435,6 +490,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -532,6 +588,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: Some(&draft),
state: &state,
help: &help,
@@ -587,6 +644,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -629,6 +687,7 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
pane_placeholder: None,
draft: None,
state: &state,
help: &help,
@@ -656,4 +715,52 @@ mod tests {
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
assert!(title.contains("session"), "missing title: {title:?}");
}
/// docs/PREVIEW.md: a session that isn't `Running` never gets a live
/// attach — the pane names it and offers resume instead of a terminal
/// that would die on the daemon's 409.
#[test]
fn a_dormant_pane_placeholder_names_the_session_and_offers_resume() {
use crate::overlays::HelpOverlay;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "worker",
pane: None,
pane_placeholder: Some(PanePlaceholder {
name: "worker",
cwd: "/home/n/work",
dormant: true,
}),
draft: None,
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(text.contains("worker"), "{text}");
assert!(text.contains("/home/n/work"), "{text}");
assert!(text.contains("dormant"), "{text}");
assert!(text.contains("resume"), "{text}");
}
}
+143 -12
View File
@@ -184,8 +184,16 @@ impl Sidebar {
grouped
.into_iter()
.map(|(key, mut sessions)| {
sessions
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
// docs/SIDEBAR.md, "Dormant cards": a dormant session takes
// the lowest precedence, name notwithstanding, so a reboot
// that leaves a tree dormant never buries live work spawned
// into it.
sessions.sort_by(|a, b| {
dormant_rank(a)
.cmp(&dormant_rank(b))
.then_with(|| name_for(a).cmp(&name_for(b)))
.then_with(|| a.id.cmp(&b.id))
});
SidebarGroup {
collapsed: collapsed.contains(&key),
key,
@@ -276,11 +284,28 @@ impl Sidebar {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
let palette = palette::active();
let surface = palette.surface(fill.filled());
let prefix = 1 + INDENT.chars().count() + 2;
let name_budget = width.saturating_sub(prefix);
// docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant
// session's activity to Idle and there is nothing running to report
// otherwise, so the rail carries no colour — the muted role, not the
// idle one.
let dormant = s.status == SessionStatus::Dormant;
let muted_style = base.patch(
surface
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
);
let edge = highlighted && !focused;
let rail_style = base.fg(activity_color(s.activity, fill));
let rail_style = if dormant {
base.fg(surface.muted)
} else {
base.fg(activity_color(s.activity, fill))
};
let mut spans: Vec<Span<'static>> = vec![
Span::styled(RAIL, rail_style),
Span::styled(
@@ -291,18 +316,19 @@ impl Sidebar {
base
},
),
Span::styled(format!("{glyph} "), base),
Span::styled(
format!("{glyph} "),
if dormant { muted_style } else { base },
),
];
// #131: a name the daemon generated is provisional — the session's
// first prompt may replace it — so it reads muted, like the meta line,
// rather than as a name someone chose. See docs/SIDEBAR.md.
let name_style = if maestro_protocol::display::is_provisional_name(s) {
base.patch(
palette::active()
.surface(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
)
// A dormant card (#168, #169) is a muted variant of the whole card —
// the same muted foreground `exited` already uses — regardless of
// whether the name itself is provisional.
let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) {
muted_style
} else {
base
};
@@ -328,11 +354,18 @@ impl Sidebar {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
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(fill.filled())
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
// docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the
// duration that follows it (rendered in the rail's own colour) is
// muted rather than the idle role.
let rail_style = if dormant {
base.patch(muted)
} else {
base.patch(Style::default().fg(activity_color(s.activity, fill)))
};
let edge = highlighted && !focused;
let mut spans = vec![
Span::styled(RAIL, rail_style),
@@ -458,6 +491,10 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
spans
}
fn dormant_rank(s: &Session) -> u8 {
u8::from(s.status == SessionStatus::Dormant)
}
/// `metadata.name` if present, else the command line.
pub(crate) fn name_for(s: &Session) -> String {
custom_name_for(s).unwrap_or_else(|| s.command.clone())
@@ -1108,6 +1145,100 @@ mod tests {
);
}
/// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts
/// after every live one in its folder, name notwithstanding.
#[test]
fn dormant_sessions_sort_after_live_ones_in_their_folder() {
let mut dormant = session_named(1, "aaa-dormant");
dormant.status = SessionStatus::Dormant;
let live = session_named(2, "zzz-live");
let sb = Sidebar {
sessions: vec![dormant, live],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let names: Vec<String> = sb.groups(&HashSet::new())[0]
.sessions
.iter()
.map(name_for)
.collect();
assert_eq!(names, vec!["zzz-live", "aaa-dormant"]);
}
/// Dormant sessions still sort by name against each other.
#[test]
fn dormant_sessions_still_sort_by_name_against_each_other() {
let mut b = session_named(1, "b");
b.status = SessionStatus::Dormant;
let mut a = session_named(2, "a");
a.status = SessionStatus::Dormant;
let sb = Sidebar {
sessions: vec![b, a],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let names: Vec<String> = sb.groups(&HashSet::new())[0]
.sessions
.iter()
.map(name_for)
.collect();
assert_eq!(names, vec!["a", "b"]);
}
/// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of
/// the normal card — the same muted foreground `exited` already uses —
/// covering the glyph and the name, not only the meta line.
#[test]
fn dormant_card_renders_its_name_and_glyph_muted() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let colour_of = |needle: char| {
(0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
.map(|(x, y)| buf[(x, y)].fg)
.expect("must be drawn")
};
assert_eq!(colour_of('c'), surface.muted, "the dormant name");
assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph");
}
/// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle
/// and there is nothing running to report, so the rail is driven by the
/// muted role directly rather than `activity_color`.
#[test]
fn dormant_card_rail_carries_no_colour() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
s.activity = ActivityState::Idle;
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let buf = draw(&sb, 44, 8);
let palette = palette::active();
let surface = palette.surface(false);
let rail_colour = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buf[(x, y)].symbol() == RAIL)
.map(|(x, y)| buf[(x, y)].fg)
.expect("rail must be drawn");
assert_eq!(rail_colour, surface.muted);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
+5
View File
@@ -358,6 +358,11 @@ The TUI binds these through its keymap, so a user can rebind any of them; the
web client's are fixed. A binding added to one client belongs in this table and
in both help overlays.
The TUI additionally binds `u` to resume the highlighted dormant session
(#166, #169). The web client has no keystroke for it — its own resume
affordance is the card's ↻ button and the context menu's **Resume** entry, per
"Dormant cards" above — so this one has no web-side pair.
## Killing a session
**Every kill confirms first, in both clients** (#160). A kill is destructive