Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839dd27925 | ||
|
|
a1816a0838 | ||
|
|
e01b0616b1 | ||
|
|
74d8d3c37a | ||
|
|
10a25e5b83 |
@@ -108,36 +108,29 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map.sidebar = Some(sidebar_area);
|
||||
}
|
||||
|
||||
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
|
||||
// indicator) + flex pane area below. Saves the 4 rows the old
|
||||
// `Length(3) + Borders::ALL` layout was eating before any content showed.
|
||||
// The pane always owns its full border, so focus changes only recolour
|
||||
// chrome and never change the rectangles handed to the input router.
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(right);
|
||||
let tab_area = rows[0];
|
||||
let separator_area = rows[1];
|
||||
let pane_area = rows[2];
|
||||
let pane_area = rows[1];
|
||||
|
||||
inputs.tab_bar.render(f, tab_area);
|
||||
map.tab_bar = Some(tab_area);
|
||||
|
||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||
f.render_widget(
|
||||
Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(focus_style(pane_focused)),
|
||||
separator_area,
|
||||
);
|
||||
let pane_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(focus_style(pane_focused));
|
||||
let pane_inner = pane_block.inner(pane_area);
|
||||
f.render_widget(pane_block, pane_area);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
p.render(f, pane_inner);
|
||||
}
|
||||
if let Some(d) = inputs.draft {
|
||||
d.render(f, pane_area, pane_focused);
|
||||
d.render(f, pane_inner, pane_focused);
|
||||
}
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
@@ -351,4 +344,96 @@ mod tests {
|
||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rectangles_are_stable_when_focus_moves() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::sidebar::Sidebar;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: vec!["1".into()],
|
||||
active_idx: 0,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let draw_map = |focus| {
|
||||
let mut state = AppState::new();
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let mut map = None;
|
||||
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
|
||||
map.unwrap()
|
||||
};
|
||||
|
||||
let sidebar_focus = draw_map(Focus::Sidebar);
|
||||
let pane_focus = draw_map(Focus::Pane);
|
||||
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
||||
assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar);
|
||||
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::sidebar::Sidebar;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: vec!["1".into()],
|
||||
active_idx: 0,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let render = |focus| {
|
||||
let mut state = AppState::new();
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
};
|
||||
|
||||
let sidebar_focused = render(Focus::Sidebar);
|
||||
let pane_focused = render(Focus::Pane);
|
||||
let p = palette::active();
|
||||
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
|
||||
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
|
||||
assert_eq!(
|
||||
sidebar_focused[(28, 1)].style().fg,
|
||||
Some(p.border_unfocused)
|
||||
);
|
||||
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,13 @@ const META_INDENT: &str = " ";
|
||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||
const ACTIONS: &str = "⧉ ✕";
|
||||
|
||||
/// Every card sits on the palette's card surface so cards read as cards
|
||||
/// against the panel; the highlighted card swaps in the selection surface
|
||||
/// instead (`Modifier::REVERSED` made the card unreadable against a
|
||||
/// background).
|
||||
fn card_base(highlighted: bool) -> Style {
|
||||
palette::active().surface(highlighted).text_style()
|
||||
/// Cards use the selection surface 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 {
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.text_style()
|
||||
}
|
||||
|
||||
/// Renderable sidebar state.
|
||||
@@ -52,14 +53,13 @@ impl Sidebar {
|
||||
self.render_with_focus(f, area, false);
|
||||
}
|
||||
|
||||
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
|
||||
/// focus colour when `focused`. Box borders eat too many rows/cols for
|
||||
/// the v1 layout — a single vertical rule keeps the panel boundary
|
||||
/// without stealing space.
|
||||
/// Render the panel with a persistent border. Focus changes its colour
|
||||
/// and whether the highlighted card claims the selection surface, never
|
||||
/// its geometry.
|
||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let border_style = palette::active().border_style(focused);
|
||||
let block = Block::default()
|
||||
.borders(Borders::RIGHT)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
@@ -71,22 +71,22 @@ impl Sidebar {
|
||||
))];
|
||||
for s in &self.sessions {
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
lines.push(Line::from(self.name_row(s, width)));
|
||||
let meta = self.meta_row(s, width);
|
||||
let base = card_base(highlighted, 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)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
let base = card_base(highlighted, focused);
|
||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||
// row only; they reserve their width so the name truncates with `…`
|
||||
// before it can ever reach them (docs/SIDEBAR.md).
|
||||
@@ -102,8 +102,16 @@ impl Sidebar {
|
||||
let sep = if highlighted { 1 } else { 0 };
|
||||
let name_budget = usable.saturating_sub(prefix + sep);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(INDENT, base),
|
||||
Span::styled(
|
||||
if edge { "▏ " } else { INDENT },
|
||||
if edge {
|
||||
base.fg(palette::active().focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
];
|
||||
spans.extend(
|
||||
@@ -122,7 +130,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let now = Utc::now();
|
||||
let held = held_for(s, now);
|
||||
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
||||
@@ -131,11 +139,22 @@ impl Sidebar {
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
|
||||
let muted = palette::active().surface(highlighted).muted_style();
|
||||
let base = card_base(highlighted, focused);
|
||||
let dot_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.muted_style();
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(META_INDENT, base),
|
||||
Span::styled(
|
||||
if edge { " ▏ " } else { META_INDENT },
|
||||
if edge {
|
||||
base.fg(palette::active().focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||
@@ -151,26 +170,27 @@ impl Sidebar {
|
||||
}
|
||||
let command = Span::styled(format!(" {}", s.command), muted);
|
||||
|
||||
// Reserve the trailing fragments so detail truncates before them;
|
||||
// the command is itself the final truncation target on narrow cards.
|
||||
// Reserve the triggered status fragments before detail and command.
|
||||
// If the row is exceptionally narrow, keep one column for the
|
||||
// unconditional command and truncate the status tail first. Detail
|
||||
// then receives only space left after the full command reservation;
|
||||
// when the full command cannot fit, it is the final truncation target.
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let command_w = command.width();
|
||||
let available = width.saturating_sub(head_w);
|
||||
if tail_w + command_w > available {
|
||||
spans.extend(fit_to_width(vec![command], available));
|
||||
return spans;
|
||||
}
|
||||
let command_floor = usize::from(available > 0);
|
||||
let tail = fit_to_width(tail, available.saturating_sub(command_floor));
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let command_w = command.width().min(available.saturating_sub(tail_w));
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
let budget = available.saturating_sub(tail_w + command_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
spans.push(command);
|
||||
fit_to_width(spans, width)
|
||||
spans.extend(fit_to_width(vec![command], command_w));
|
||||
spans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +198,10 @@ 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) -> Color {
|
||||
palette::active().surface(highlighted).activity(state)
|
||||
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -280,9 +302,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
||||
draw_with_focus(sidebar, w, h, true)
|
||||
}
|
||||
|
||||
fn draw_with_focus(sidebar: &Sidebar, w: u16, h: u16, focused: bool) -> Buffer {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||
terminal
|
||||
.draw(|f| sidebar.render_with_focus(f, f.area(), focused))
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
@@ -308,7 +336,10 @@ mod tests {
|
||||
|
||||
/// Row text without the trailing space padding and right border.
|
||||
fn content(row: &str) -> &str {
|
||||
row.trim_end().trim_end_matches('│').trim_end()
|
||||
row.trim()
|
||||
.trim_start_matches('│')
|
||||
.trim_end_matches('│')
|
||||
.trim()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -416,22 +447,22 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert_eq!(content(&all[1]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
all[2].contains('●'),
|
||||
all[3].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
all[3].contains("bash"),
|
||||
"line 2 must carry the command: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
!all[2].contains("10s"),
|
||||
!all[3].contains("10s"),
|
||||
"fresh cards have no duration: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -659,7 +690,7 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let all = rows(&draw(&sb, 42, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
@@ -751,14 +782,14 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
all[1].contains("friendly name"),
|
||||
all[2].contains("friendly name"),
|
||||
"name must stay on line 1: {:?}",
|
||||
all[1]
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
all[3].contains("bash"),
|
||||
"command must be on line 2: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -772,17 +803,59 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 10, 6));
|
||||
assert!(
|
||||
all[2].contains('●'),
|
||||
all[3].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains('…'),
|
||||
all[3].contains('…'),
|
||||
"command should truncate: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_warning_survives_before_a_truncated_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.command = "a command that cannot fit".into();
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
let meta = content(&all[3]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_detail_yields_to_status_and_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.activity_detail = Some("a very long agent-reported detail".into());
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
assert!(
|
||||
all[3].contains("bash"),
|
||||
"command must survive: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||
#[test]
|
||||
@@ -826,27 +899,64 @@ mod tests {
|
||||
|
||||
let p = palette::active();
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().bg,
|
||||
buf[(1, claude_y as u16)].style().bg,
|
||||
Some(p.selection.bg),
|
||||
"focused card must sit on the selection surface"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().fg,
|
||||
buf[(1, claude_y as u16)].style().fg,
|
||||
Some(p.selection.fg),
|
||||
"focused card text must use the selection foreground role"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, bash_y as u16)].style().bg,
|
||||
buf[(1, bash_y as u16)].style().bg,
|
||||
Some(p.card.bg),
|
||||
"unfocused cards must still sit on the card surface"
|
||||
);
|
||||
// The panel itself (header row) stays on the terminal default.
|
||||
assert!(
|
||||
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
|
||||
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
|
||||
"panel must keep the terminal default background"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||
let y = (0..buf.area.height)
|
||||
.find(|&y| (0..buf.area.width).any(|x| buf[(x, y)].symbol() == "c"))
|
||||
.expect("highlighted session row");
|
||||
assert_eq!(
|
||||
buf[(1, y)].style().bg,
|
||||
Some(palette::active().card.bg),
|
||||
"unfocused highlight must not paint the selection surface"
|
||||
);
|
||||
assert!(
|
||||
(0..buf.area.width).any(|x| buf[(x, y)].symbol() == "▏"),
|
||||
"unfocused highlight must retain a visible edge marker"
|
||||
);
|
||||
let ordinary = draw_with_focus(
|
||||
&Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
},
|
||||
28,
|
||||
8,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!(0..ordinary.area.height)
|
||||
.any(|row| (0..ordinary.area.width).any(|x| ordinary[(x, row)].symbol() == "▏")),
|
||||
"ordinary unhighlighted cards must not carry the cursor marker"
|
||||
);
|
||||
}
|
||||
|
||||
/// The card background paints the full card width, not just the glyphs.
|
||||
#[test]
|
||||
fn card_background_covers_the_full_card_width() {
|
||||
@@ -858,7 +968,7 @@ mod tests {
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||
for x in 0..27 {
|
||||
for x in 1..27 {
|
||||
assert_eq!(
|
||||
buf[(x, y)].style().bg,
|
||||
Some(palette::active().card.bg),
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
showsMetaLine,
|
||||
} from "./activity.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
describe("activityHeldMs", () => {
|
||||
it("measures time spent in the current state", () => {
|
||||
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
|
||||
expect(activityColorSlug("Teleported")).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
activity: "Active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("showsMetaLine", () => {
|
||||
const fresh = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
it("stays hidden for a freshly-active session", () => {
|
||||
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
|
||||
});
|
||||
|
||||
it("appears once the state has been held past 30 seconds", () => {
|
||||
const held = new Date(Date.now() - 31_000).toISOString();
|
||||
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
|
||||
});
|
||||
|
||||
it("appears for blocked work, exited sessions and reported errors", () => {
|
||||
expect(
|
||||
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
|
||||
).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("needs no activity timestamp when another trigger holds", () => {
|
||||
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-15
@@ -2,11 +2,10 @@
|
||||
// the shared duration format. Both clients compute it identically; the TUI
|
||||
// mirrors this in crates/maestro-tui.
|
||||
|
||||
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
|
||||
import { sessionBlockers } from "./types.js";
|
||||
import type { ActivityState, IsoTimestamp } from "./types.js";
|
||||
|
||||
/** A state held shorter than this keeps the card to one line (contract rule 1). */
|
||||
const META_LINE_MIN_HOLD_MS = 30_000;
|
||||
/** A state held shorter than this has no duration fragment. */
|
||||
export const META_LINE_MIN_HOLD_MS = 30_000;
|
||||
|
||||
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
||||
// implausibly old is treated as "never observed" rather than a huge duration.
|
||||
@@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
/** Line 2 renders only when at least one contract trigger holds. */
|
||||
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
return (
|
||||
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
|
||||
sessionBlockers(s) !== null ||
|
||||
s.status === "Dead" ||
|
||||
s.activity === "Error"
|
||||
);
|
||||
}
|
||||
|
||||
+16
-2
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("stays a single line while freshly active", () => {
|
||||
it("always renders line 2 for a freshly spawned session", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("renders the command when the session name equals it", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||
command: "claude",
|
||||
metadata: { name: "claude" },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
|
||||
+11
-14
@@ -4,7 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
@@ -28,16 +33,13 @@ const STATE_WORDS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const name = sessionName(s);
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration = held !== null ? activityDurationLabel(held) : null;
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
@@ -49,9 +51,7 @@ function metaLine(s: Session, now: number): string {
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
showCmd
|
||||
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
|
||||
: "",
|
||||
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
@@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
|
||||
// the actions reserve their width, so the name truncates before it can ever
|
||||
// reach them. Line 2 renders only when a contract trigger holds.
|
||||
const showMeta = showsMetaLine(s, now);
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
@@ -118,7 +115,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${showMeta ? metaLine(s, now) : ""}
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
|
||||
+12
-4
@@ -736,7 +736,6 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 30px;
|
||||
margin: 1px var(--sp-2);
|
||||
padding: 4px var(--sp-3);
|
||||
border-radius: var(--r-1);
|
||||
@@ -787,12 +786,13 @@ textarea {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
|
||||
always, line 2 (meta) only when it has something to say. */
|
||||
/* #120: every session card has two lines, so its row height is stable. */
|
||||
.tree-leaf.session-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
|
||||
height: 49px;
|
||||
}
|
||||
|
||||
.card-line1 {
|
||||
@@ -855,7 +855,8 @@ textarea {
|
||||
ellipsis so it never pushes the trailing fragments off the line. */
|
||||
.activity-detail {
|
||||
color: var(--faint);
|
||||
flex: 0 1 auto;
|
||||
/* Consume only the space left after the trailing status + command. */
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -866,12 +867,19 @@ textarea {
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
color: var(--faint);
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status fragments stay intact. The unconditional command is the final
|
||||
truncation target when even the complete trailing set cannot fit. */
|
||||
.leaf-cmd {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||
name (#107). They reserve their width even while hover-faded. */
|
||||
.leaf-actions {
|
||||
|
||||
Reference in New Issue
Block a user