Compare commits

..
3 changed files with 342 additions and 91 deletions
+103 -18
View File
@@ -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));
}
}
+228 -71
View File
@@ -2,8 +2,7 @@
//!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! blocked-work and exited fragments when they apply. Absent entirely when
//! it has nothing to say.
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::HashSet;
@@ -18,8 +17,8 @@ use uuid::Uuid;
use crate::palette;
/// Time-in-state alone earns a meta line only once the session has sat in
/// its current activity state this long (docs/SIDEBAR.md).
/// Time-in-state earns a duration fragment once the session has sat in its
/// current activity state this long (docs/SIDEBAR.md).
const META_LINE_AFTER_SECS: i64 = 30;
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
@@ -32,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.
@@ -53,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);
@@ -72,23 +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)));
if let Some(meta) = self.meta_row(s, width) {
lines.push(Line::from(pad_to_width(meta, width, base)));
}
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).
@@ -104,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(
@@ -123,8 +129,8 @@ impl Sidebar {
spans
}
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
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);
@@ -132,29 +138,29 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let errored = s.activity == ActivityState::Error;
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
&& blocked.is_none()
&& !dead
&& !errored
{
return None;
}
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 {
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
}
// The trailing fragments are measured up front so a long detail
// truncates ahead of them instead of pushing them off the line
// (docs/SIDEBAR.md).
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
@@ -162,17 +168,29 @@ impl Sidebar {
if dead {
tail.push(Span::styled(" exited", muted));
}
let command = Span::styled(format!(" {}", s.command), muted);
// 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 available = width.saturating_sub(head_w);
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 head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
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);
Some(fit_to_width(spans, width))
spans.extend(fit_to_width(vec![command], command_w));
spans
}
}
@@ -180,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,
@@ -282,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()
}
@@ -310,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]
@@ -406,10 +435,10 @@ mod tests {
}
}
/// A freshly-active session stays one line — never an empty placeholder
/// row (docs/SIDEBAR.md).
/// A fresh session still gets its unconditional second line, but no
/// duration until it has been held in state for 30 seconds.
#[test]
fn freshly_active_session_stays_one_line() {
fn fresh_session_renders_two_lines_without_duration() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(10);
let sb = Sidebar {
@@ -418,12 +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!(
content(&all[2]).is_empty(),
"line 2 must be absent, got {:?}",
all[2]
all[3].contains('●'),
"line 2 must carry the dot: {:?}",
all[3]
);
assert!(
all[3].contains("bash"),
"line 2 must carry the command: {:?}",
all[3]
);
assert!(
!all[3].contains("10s"),
"fresh cards have no duration: {:?}",
all[3]
);
}
@@ -553,11 +592,12 @@ mod tests {
);
}
/// The sidebar is narrow: the meta line truncates with `…` and never
/// wraps onto a third row (docs/SIDEBAR.md).
/// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_truncates_instead_of_wrapping() {
fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into();
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
@@ -573,7 +613,7 @@ mod tests {
let meta = content(&all[idx + 1]);
assert!(
meta.trim_end().ends_with('…'),
"truncated meta line must end with …: {meta:?}"
"truncated command must end with …: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -650,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:?}");
@@ -688,10 +728,6 @@ mod tests {
rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}"
);
assert!(
!rendered.contains('h') || !rendered.contains("● "),
"no duration may follow the dot for an epoch stamp:\n{rendered}"
);
for line in rendered.lines().filter(|l| l.contains('●')) {
assert!(
!line
@@ -736,6 +772,90 @@ mod tests {
);
}
#[test]
fn command_renders_on_line_two_even_when_name_is_metadata() {
let s = session_named(1, "friendly name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[2].contains("friendly name"),
"name must stay on line 1: {:?}",
all[2]
);
assert!(
all[3].contains("bash"),
"command must be on line 2: {:?}",
all[3]
);
}
#[test]
fn narrow_meta_line_keeps_dot_and_truncates_command() {
let s = session_named(1, "name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 10, 6));
assert!(
all[3].contains('●'),
"dot must survive narrow cards: {:?}",
all[3]
);
assert!(
all[3].contains('…'),
"command should truncate: {:?}",
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]
@@ -775,31 +895,68 @@ mod tests {
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
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() {
@@ -811,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),
+11 -2
View File
@@ -791,7 +791,8 @@ textarea {
flex-direction: column;
align-items: stretch;
gap: 1px;
height: 40px;
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
height: 49px;
}
.card-line1 {
@@ -854,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;
@@ -865,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 {