diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 1f62f0a0..b7b21b54 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -1472,6 +1472,10 @@ pub struct App { /// top. Mouse wheel over the list adjusts this; keyboard selection still /// lets ratatui pull the selected item back into view when needed. pub list_scroll_offset: usize, + /// How the session list draws each session row (one-line compact vs + /// two-line full cards) — toggled from the pane's top border, mirroring + /// the lineage section's full/compact pair. Persisted across launches. + pub list_mode: SessionListViewMode, /// Scrollback offset (in rows) applied to the active/focused session's PTY /// parser when rendering zoomed or single-window views. Split windows keep /// their own offsets in `window_scrollback` so mouse-wheel scrolling one @@ -2969,6 +2973,36 @@ impl From for RemoteControlOk { } } +/// How the session list draws each session row — toggled from the pane's +/// top border, mirroring the lineage section's full/compact mode pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SessionListViewMode { + /// One line per session: markers + status glyph + name + harness. + #[default] + Compact, + /// Two lines per session: the compact line plus a muted detail line + /// (model·effort, context gauge, activity, tokens, cost). + Full, +} + +impl SessionListViewMode { + pub fn toggled(self) -> Self { + match self { + SessionListViewMode::Compact => SessionListViewMode::Full, + SessionListViewMode::Full => SessionListViewMode::Compact, + } + } + + /// One-word name shown on the pane's top-border toggle, next to the + /// ` + sessions ` title that already names the pane. + pub fn short_label(self) -> &'static str { + match self { + SessionListViewMode::Compact => "compact", + SessionListViewMode::Full => "full", + } + } +} + /// Smallest list-pane width that still leaves room for the session /// status glyph + a couple chars of name. Below this drag is clamped. pub const LIST_PANEL_W_MIN: u16 = 18; @@ -3126,6 +3160,16 @@ pub struct ListScrollbarHit { pub max_scroll: usize, } +/// One rendered display row of the session list: which item it belongs to +/// and whether it is that item's first line. Gutter affordances (disclosure +/// triangle, pin diamond) live on the first line only; a click anywhere in +/// the item's rows selects it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ListRowHit { + pub item_index: usize, + pub first_line: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LineageScrollbarHit { pub area: ratatui::layout::Rect, @@ -3284,6 +3328,16 @@ pub struct LayoutSnapshot { /// inside the matrix panel don't mis-fire as row selections, and /// it also bounds the visible window when the list scrolls. pub list_items_area: Option, + /// Display-row → list-item map for the rows drawn inside + /// `list_items_area` this frame. One entry per screen row, top to + /// bottom. In full mode a session item spans two display rows, so + /// clicks can no longer assume one row per item; when this is empty + /// (list not rendered this frame, e.g. in tests that fabricate the + /// layout) hit-testing falls back to the 1:1 compact mapping. + pub list_visible_rows: Vec, + /// Clickable full/compact view-mode label on the list pane's top + /// border, when there was room to draw it. + pub list_mode_toggle_hit: Option, /// Scroll offset of the session list (number of items scrolled /// off the top). Captured from `ListState::offset()` after the /// last render so click-to-row mapping stays correct when the @@ -3884,6 +3938,7 @@ async fn run_with_socket_initial_selection( window_pane_sizes: HashMap::new(), zoom: initial_zoom, list_scroll_offset: 0, + list_mode: SessionListViewMode::Compact, view_scrollback: 0, window_scrollback: HashMap::new(), window_views: HashMap::new(), @@ -3991,6 +4046,11 @@ async fn run_with_socket_initial_selection( } else { crate::lineage::LineageViewMode::Boxes }; + app.list_mode = if persisted.session_list_full { + SessionListViewMode::Full + } else { + SessionListViewMode::Compact + }; // Subscribe to all session events. if let Err(e) = client.subscribe(None).await { @@ -4129,6 +4189,7 @@ async fn run_with_socket_initial_selection( lineage_collapsed: app.lineage_collapsed, lineage_h: app.lineage_h, lineage_compact: app.lineage_mode == crate::lineage::LineageViewMode::Rails, + session_list_full: app.list_mode == SessionListViewMode::Full, }); result @@ -9594,12 +9655,34 @@ impl App { .list_items_area .map(|area| area.height as usize) .unwrap_or(0); - self.list_scroll_offset = adjusted_list_scroll_offset( - self.list_scroll_offset, - delta, - self.list_items().len(), - visible_h, - ); + let max_scroll = self.list_max_scroll(&self.list_items(), visible_h); + self.list_scroll_offset = + adjusted_scrollback(self.list_scroll_offset, delta).min(max_scroll); + } + + /// A list item's display height under the current view mode: full-mode + /// session cards take two rows, everything else (group headers, archived + /// disclosure rows, every compact-mode row) takes one. + pub(crate) fn list_item_display_height(&self, item: &ListItem) -> usize { + match item { + ListItem::Session { .. } if self.list_mode == SessionListViewMode::Full => 2, + _ => 1, + } + } + + /// Largest useful `list_scroll_offset`: the smallest offset that still + /// fills the viewport down to the last item. Measured in display rows so + /// full-mode's two-row cards count double; with uniform one-row items + /// this reduces to `len − visible_rows`. + pub(crate) fn list_max_scroll(&self, items: &[ListItem], visible_rows: usize) -> usize { + let mut rows = 0usize; + for (idx, item) in items.iter().enumerate().rev() { + rows += self.list_item_display_height(item); + if rows > visible_rows { + return idx + 1; + } + } + 0 } pub(crate) fn is_orchestrator_panel_open(&self) -> bool { @@ -13595,6 +13678,8 @@ mod tests { modeline_theme_hit: None, list_row_count: 0, list_items_area: None, + list_visible_rows: Vec::new(), + list_mode_toggle_hit: None, list_scroll_offset: 0, list_scrollbar: None, shortcut_hints: Vec::new(), @@ -13729,6 +13814,7 @@ mod tests { window_pane_sizes: HashMap::new(), zoom: ZoomMode::None, list_scroll_offset: 0, + list_mode: SessionListViewMode::Compact, view_scrollback: 0, window_scrollback: HashMap::new(), window_views: HashMap::new(), @@ -27841,6 +27927,164 @@ mod tests { server.abort(); } + #[tokio::test] + async fn full_mode_list_max_scroll_counts_cards_as_two_rows() { + let (mut app, _dir, server) = empty_app().await; + app.sessions = (0..10) + .map(|index| { + let mut session = summary_with_kind(construct_protocol::SessionKind::User); + session.id = format!("s{index}"); + session.position = index; + session + }) + .collect(); + let items = app.list_items(); + assert_eq!( + app.list_max_scroll(&items, 4), + 6, + "compact mode reduces to len − visible_rows" + ); + app.list_mode = SessionListViewMode::Full; + // Four rows fit two 2-row cards, so the last page starts at item 8. + assert_eq!(app.list_max_scroll(&items, 4), 8); + server.abort(); + } + + #[tokio::test] + async fn full_mode_click_maps_detail_rows_to_their_card() { + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let (mut app, _dir, server) = empty_app().await; + let mut s1 = summary_with_kind(construct_protocol::SessionKind::User); + s1.id = "s1".into(); + s1.position = 0; + let mut s2 = summary_with_kind(construct_protocol::SessionKind::User); + s2.id = "s2".into(); + s2.position = 1; + app.sessions = vec![s1, s2]; + app.selection = Selection::Session("s1".into()); + app.main_windows = MainWindowTree::single(1, Selection::Session("s1".into())); + app.active_window_id = 1; + app.list_mode = SessionListViewMode::Full; + + let backend = ratatui::backend::TestBackend::new(120, 40); + let mut term = ratatui::Terminal::new(backend).expect("terminal"); + term.draw(|f| crate::ui::render(f, &mut app)) + .expect("render"); + + let items_area = app.layout.list_items_area.expect("list items area"); + // Each session card spans two display rows: s1 at rows 0-1, s2 at + // rows 2-3. + assert_eq!( + app.layout.list_visible_rows[..4].to_vec(), + vec![ + ListRowHit { + item_index: 0, + first_line: true + }, + ListRowHit { + item_index: 0, + first_line: false + }, + ListRowHit { + item_index: 1, + first_line: true + }, + ListRowHit { + item_index: 1, + first_line: false + }, + ] + ); + + let click = |col: u16, row: u16| MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: col, + row, + modifiers: crossterm::event::KeyModifiers::empty(), + }; + let release = |col: u16, row: u16| MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: col, + row, + modifiers: crossterm::event::KeyModifiers::empty(), + }; + // A click on s2's DETAIL line (row 3) selects s2 — under the old 1:1 + // row mapping it would have landed past the end of the list. + let col = items_area.x + items_area.width / 2; + app.on_mouse(click(col, items_area.y + 3)).await; + app.on_mouse(release(col, items_area.y + 3)).await; + assert_eq!(app.selection, Selection::Session("s2".into())); + + // The pin gutter only exists on a card's first line: the same + // columns on s1's detail line are a plain row selection, not a pin + // toggle. + app.on_mouse(click(items_area.x + 2, items_area.y + 1)) + .await; + app.on_mouse(release(items_area.x + 2, items_area.y + 1)) + .await; + assert_eq!(app.selection, Selection::Session("s1".into())); + assert!( + !app.sessions.iter().any(|s| s.pinned), + "a detail-line click must not toggle pinning" + ); + server.abort(); + } + + #[tokio::test] + async fn clicking_list_mode_label_toggles_full_rows() { + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let (mut app, _dir, server) = empty_app().await; + let mut s1 = summary_with_kind(construct_protocol::SessionKind::User); + s1.id = "s1".into(); + app.sessions = vec![s1]; + app.selection = Selection::Session("s1".into()); + app.main_windows = MainWindowTree::single(1, Selection::Session("s1".into())); + app.active_window_id = 1; + + let backend = ratatui::backend::TestBackend::new(120, 40); + let mut term = ratatui::Terminal::new(backend).expect("terminal"); + term.draw(|f| crate::ui::render(f, &mut app)) + .expect("render"); + let hit = app + .layout + .list_mode_toggle_hit + .expect("mode label rendered on a 40-cell sidebar"); + + app.on_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: crossterm::event::KeyModifiers::empty(), + }) + .await; + app.on_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: crossterm::event::KeyModifiers::empty(), + }) + .await; + assert_eq!(app.list_mode, SessionListViewMode::Full); + + // The next frame renders the session as a two-row card. + term.draw(|f| crate::ui::render(f, &mut app)) + .expect("render"); + assert_eq!( + app.layout.list_visible_rows[..2].to_vec(), + vec![ + ListRowHit { + item_index: 0, + first_line: true + }, + ListRowHit { + item_index: 0, + first_line: false + }, + ] + ); + server.abort(); + } + #[tokio::test] async fn mouse_list_click_swaps_session_already_visible_in_another_split() { let (mut app, _dir, server) = captured_app().await; @@ -37379,16 +37623,6 @@ mod tests { assert_eq!(adjusted_scrollback(5, 3), 8); assert_eq!(adjusted_scrollback(SCROLLBACK_MAX - 1, 10), SCROLLBACK_MAX); } - - #[test] - fn adjusted_list_scroll_offset_clamps_to_visible_range() { - assert_eq!(adjusted_list_scroll_offset(0, 3, 10, 4), 3); - assert_eq!(adjusted_list_scroll_offset(3, -1, 10, 4), 2); - assert_eq!(adjusted_list_scroll_offset(0, -99, 10, 4), 0); - assert_eq!(adjusted_list_scroll_offset(0, 99, 10, 4), 6); - assert_eq!(adjusted_list_scroll_offset(9, 0, 10, 4), 6); - assert_eq!(adjusted_list_scroll_offset(2, 1, 3, 4), 0); - } } fn adjusted_scrollback(current: usize, delta: i32) -> usize { @@ -37400,16 +37634,6 @@ fn adjusted_scroll_offset(current: usize, delta: i32, max_scroll: usize) -> usiz next.max(0).min(max_scroll as i32) as usize } -fn adjusted_list_scroll_offset( - current: usize, - delta: i32, - item_count: usize, - visible_rows: usize, -) -> usize { - let max_scroll = item_count.saturating_sub(visible_rows); - adjusted_scrollback(current, delta).min(max_scroll) -} - fn json_escape(s: &str) -> String { serde_json::to_string(s) .unwrap_or_else(|_| "\"\"".to_string()) diff --git a/crates/cli/src/app/matrix_clicks.rs b/crates/cli/src/app/matrix_clicks.rs index dc3b0316..20bad687 100644 --- a/crates/cli/src/app/matrix_clicks.rs +++ b/crates/cli/src/app/matrix_clicks.rs @@ -242,8 +242,9 @@ impl App { return; } self.focus = PaneFocus::List; - // Title bar buttons: `+` (left, new session) and `«` - // (right, collapse). Both live on the top border row. + // Title bar buttons: `+` (left, new session), the view-mode label + // (after the title), and `«` (right, collapse). All live on the top + // border row. if row == list.y { if let Some((xs, xe, y)) = crate::ui::list_plus_button_range(list) { if row == y && col >= xs && col < xe { @@ -252,6 +253,14 @@ impl App { return; } } + if self + .layout + .list_mode_toggle_hit + .is_some_and(|r| Self::rect_contains(r, col, row)) + { + self.list_mode = self.list_mode.toggled(); + return; + } if let Some((xs, xe, y)) = crate::ui::list_collapse_button_range(list) { if row == y && col >= xs && col < xe { self.list_collapsed = true; @@ -285,7 +294,18 @@ impl App { return; } let visible_row = (row - items_area.y) as usize; - let idx = visible_row + self.layout.list_scroll_offset; + // Display rows and items are 1:1 only in compact mode; full mode's + // two-row session cards need the render-time row map. An empty map + // (layouts fabricated without a render, e.g. in tests) falls back to + // the 1:1 compact mapping. + let (idx, first_line) = match self.layout.list_visible_rows.get(visible_row) { + Some(hit) => (hit.item_index, hit.first_line), + None if self.layout.list_visible_rows.is_empty() => { + (visible_row + self.layout.list_scroll_offset, true) + } + // Blank row past the last fully-visible item. + None => return, + }; let items = self.list_items(); if idx >= items.len() { return; @@ -293,6 +313,8 @@ impl App { // Session rows reserve disclosure before the 4-cell pin/status gutter. // Disclosure clicks toggle nested subagents/forks; the gutter toggles // pinning. Must stay in lockstep with `hovered_diamond` in ui.rs. + // Both affordances are drawn on a card's first line only — a click in + // the same columns of the detail line is a plain row selection. if let ListItem::Session { summary, indented, @@ -300,24 +322,26 @@ impl App { .. } = &items[idx] { - let indent = list_session_indent_cells(summary, *indented, *has_children); - let disclosure_col = list.x + 1 + indent; - if *has_children && col == disclosure_col { - let id = summary.id.clone(); - if !self.children_collapsed.insert(id.clone()) { - self.children_collapsed.remove(&id); + if first_line { + let indent = list_session_indent_cells(summary, *indented, *has_children); + let disclosure_col = list.x + 1 + indent; + if *has_children && col == disclosure_col { + let id = summary.id.clone(); + if !self.children_collapsed.insert(id.clone()) { + self.children_collapsed.remove(&id); + } + return; } - return; - } - let zone_start = disclosure_col + u16::from(*has_children); - let zone_end = zone_start + 4; - if col >= zone_start && col < zone_end { - let id = summary.id.clone(); - let next = !summary.pinned; - if let Err(e) = self.client.set_pinned(&id, next).await { - self.set_status(format!("set_pinned failed: {e}")); + let zone_start = disclosure_col + u16::from(*has_children); + let zone_end = zone_start + 4; + if col >= zone_start && col < zone_end { + let id = summary.id.clone(); + let next = !summary.pinned; + if let Err(e) = self.client.set_pinned(&id, next).await { + self.set_status(format!("set_pinned failed: {e}")); + } + return; } - return; } } match &items[idx] { diff --git a/crates/cli/src/tui_state.rs b/crates/cli/src/tui_state.rs index 26ca8b3c..972137bb 100644 --- a/crates/cli/src/tui_state.rs +++ b/crates/cli/src/tui_state.rs @@ -83,6 +83,10 @@ pub struct TuiState { /// ignored rather than migrated. #[serde(default)] pub lineage_compact: bool, + /// Whether the session list is in the two-line full-card view. The + /// default is the one-line compact view. + #[serde(default)] + pub session_list_full: bool, } impl Default for TuiState { @@ -106,6 +110,7 @@ impl Default for TuiState { lineage_collapsed: false, lineage_h: None, lineage_compact: false, + session_list_full: false, } } } @@ -257,6 +262,22 @@ mod tests { assert!(!legacy.lineage_compact); } + #[test] + fn state_round_trips_session_list_view_mode() { + let state = TuiState { + session_list_full: true, + ..TuiState::default() + }; + let json = serde_json::to_string(&state).expect("serialize"); + let restored: TuiState = serde_json::from_str(&json).expect("deserialize"); + assert!(restored.session_list_full); + + // Legacy blobs default to the compact one-line view. + let legacy: TuiState = + serde_json::from_str(r#"{"last_selected_session_id": "s1"}"#).expect("legacy"); + assert!(!legacy.session_list_full); + } + #[test] fn legacy_state_defaults_tutorial_step_to_none() { let state: TuiState = serde_json::from_str(r#"{"last_selected_session_id": "s1"}"#) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 6765820a..5c797d1a 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -152,6 +152,8 @@ pub fn render(f: &mut Frame, app: &mut App) { app.layout.main_window_dividers.clear(); app.layout.session_title_name_hits.clear(); app.layout.session_harness_hits.clear(); + app.layout.list_visible_rows.clear(); + app.layout.list_mode_toggle_hit = None; app.layout.lineage_area = None; app.layout.lineage_header_hit = None; app.layout.lineage_collapse_hit = None; @@ -610,8 +612,17 @@ fn hovered_diamond(app: &App) -> Option<(u16, u16, &SessionSummary)> { return None; } let row = (my - list_area.y - 1) as usize; + // Same display-row → item mapping as `App::click_list`: the render-time + // row map when the list was drawn this frame (full-mode cards span two + // rows; the diamond gutter lives on the first), 1:1 otherwise. + let idx = match app.layout.list_visible_rows.get(row) { + Some(hit) if hit.first_line => hit.item_index, + Some(_) => return None, + None if app.layout.list_visible_rows.is_empty() => row, + None => return None, + }; let items = app.list_items(); - let item = items.into_iter().nth(row)?; + let item = items.into_iter().nth(idx)?; let (summary, indented, has_children) = match item { AppListItem::Session { summary, @@ -653,6 +664,36 @@ pub fn list_plus_button_range(list_area: Rect) -> Option<(u16, u16, u16)> { Some((list_area.x + 1, list_area.x + 3, list_area.y)) } +/// Label text of the session list's view-mode toggle: the current mode's +/// one-word name plus a swap glyph, exactly like the lineage header's +/// ` full ⇄ ` / ` compact ⇄ ` toggle. The surrounding spaces are part of +/// the click target. +pub fn list_mode_button_label(mode: crate::app::SessionListViewMode) -> String { + format!(" {} \u{21c4} ", mode.short_label()) +} + +/// Hit zone for the view-mode toggle label on the session list's top +/// border: right-aligned immediately left of the `«` collapse button, +/// mirroring the lineage header's layout. Returns +/// `(x_start, x_end_exclusive, y)`, or `None` when the pane is too narrow +/// to fit the label after the ` + sessions ` title. +pub fn list_mode_button_range( + list_area: Rect, + mode: crate::app::SessionListViewMode, +) -> Option<(u16, u16, u16)> { + let label_w = UnicodeWidthStr::width(list_mode_button_label(mode).as_str()) as u16; + // The ` « ` collapse button occupies the 3 cells before the corner + // (`list_collapse_button_range`); the label ends flush against it. + let x_end = list_area.x + list_area.width.saturating_sub(4); + let x_start = x_end.saturating_sub(label_w); + // The left title ` + sessions ` renders from `x + 1` through `x + 12`; + // keep a one-cell gap so the two never collide. + if x_start <= list_area.x.saturating_add(13) { + return None; + } + Some((x_start, x_end, list_area.y)) +} + /// Hit zone for the right-aligned `«` button that collapses the /// session list. Returns `(x_start, x_end_exclusive, y)`. Sits one /// cell inset from the right corner so the corner glyph stays @@ -835,6 +876,16 @@ fn render_list_title_button_tooltips(f: &mut Frame, app: &App) { return; } } + if let Some(hit) = app.layout.list_mode_toggle_hit { + if my == hit.y && mx >= hit.x && mx < hit.x + hit.width { + let label = match app.list_mode { + crate::app::SessionListViewMode::Compact => " Switch to full rows ", + crate::app::SessionListViewMode::Full => " Switch to compact rows ", + }; + render_button_tooltip(f, &app.theme, label, hit.x, hit.y); + return; + } + } if let Some((xs, xe, y)) = list_collapse_button_range(list) { if my == y && mx >= xs && mx < xe { render_button_tooltip(f, &app.theme, " Collapse list ", xs, y); @@ -1930,6 +1981,161 @@ fn session_list_secondary_style(theme: &Theme) -> Style { Style::default().fg(theme.muted) } +/// The muted second row of a full-mode session card, aligned under the +/// name: model·effort, context gauge, activity, tokens — dropping the +/// least important segments first when the pane is narrow. +fn session_detail_line( + app: &App, + s: &SessionSummary, + prefix_w: usize, + row_w: usize, +) -> Line<'static> { + let now_ms = chrono::Utc::now().timestamp_millis(); + let avail = row_w.saturating_sub(prefix_w); + let text = fit_detail_segments(session_detail_segments(s, now_ms), avail); + let style = if s.archived { + Style::default() + .fg(app.theme.dim) + .add_modifier(Modifier::DIM) + } else { + session_list_secondary_style(&app.theme) + }; + Line::from(vec![ + Span::raw(" ".repeat(prefix_w)), + Span::styled(text, style), + ]) +} + +/// `(text, keep-priority)` segments of a full-mode detail line, in display +/// order. Higher priority survives a narrow pane longer: the context gauge +/// ("how full is it") outranks the model, which outranks activity and +/// tokens. Absent data is omitted rather than shown as placeholders; a +/// session reporting nothing at all (e.g. a plain shell) falls back to +/// where it lives. +fn session_detail_segments(s: &SessionSummary, now_ms: i64) -> Vec<(String, u8)> { + let mut segs: Vec<(String, u8)> = Vec::new(); + if let Some(model) = s.model.as_deref() { + let mut label = short_model_label(model); + if let Some(effort) = s.effort.as_deref() { + label = format!("{label}\u{00b7}{effort}"); + } + segs.push((label, 3)); + } + match (s.context_used, s.context_window.filter(|w| *w > 0)) { + (Some(used), Some(window)) => segs.push((context_gauge_label(used, window), 4)), + (Some(used), None) => segs.push(( + format!("{} ctx", crate::lineage::format_token_count(used)), + 4, + )), + _ => {} + } + if s.state == SessionState::Running { + let since = s.busy_running_since_ms.unwrap_or(now_ms); + segs.push(( + format!( + "busy {}", + crate::lineage::format_duration_ms(now_ms.saturating_sub(since).max(0) as u64) + ), + 2, + )); + } else if let Some(at) = s.last_event_at { + segs.push(( + format!( + "{} ago", + format_age_ms(now_ms.saturating_sub(at.timestamp_millis()).max(0) as u64) + ), + 2, + )); + } + if !s.tokens.is_zero() { + segs.push(( + format!( + "{} tok", + crate::lineage::format_token_count(s.tokens.total()) + ), + 1, + )); + } + if segs.is_empty() { + let dir = s.worktree.as_deref().unwrap_or(&s.cwd); + let tail = std::path::Path::new(dir) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(dir); + segs.push((tail.to_string(), 0)); + } + segs +} + +/// Join segments with two-space gaps, dropping the lowest-priority +/// segment until the line fits `avail` cells. A single over-wide +/// survivor is ellipsized instead of overflowing. +fn fit_detail_segments(mut segs: Vec<(String, u8)>, avail: usize) -> String { + loop { + let text = segs + .iter() + .map(|(t, _)| t.as_str()) + .collect::>() + .join(" "); + if UnicodeWidthStr::width(text.as_str()) <= avail { + return text; + } + if segs.len() <= 1 { + return fit_name(&text, avail, None); + } + let drop = segs + .iter() + .enumerate() + .min_by_key(|(_, (_, p))| *p) + .map(|(i, _)| i) + .expect("segs is non-empty"); + segs.remove(drop); + } +} + +/// Four-cell context-window gauge plus percentage, e.g. `▰▰▱▱ 52%`. +/// Fill rounds to the NEAREST quarter so the bar tracks the percentage — +/// 54% reads as two cells (just over half), not three as ceiling would give. +fn context_gauge_label(used: u64, window: u64) -> String { + let pct = used.saturating_mul(100) / window; + let filled = ((used.min(window).saturating_mul(4) + window / 2) / window) as usize; + let mut bar = String::new(); + for i in 0..4 { + bar.push(if i < filled { '▰' } else { '▱' }); + } + format!("{bar} {pct}%") +} + +/// Compact model name for the detail line: drops the vendor prefix and a +/// trailing date stamp — `claude-haiku-4-5-20251001` → `haiku-4-5`. +fn short_model_label(model: &str) -> String { + let base = model.strip_prefix("claude-").unwrap_or(model); + match base.rsplit_once('-') { + Some((head, tail)) + if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) && !head.is_empty() => + { + head.to_string() + } + _ => base.to_string(), + } +} + +/// Coarse single-unit age label: `42s`, `12m`, `3h`, `2d`. Unlike +/// `format_duration_ms`, ages beyond an hour stay one short unit instead +/// of growing into minute counts. +fn format_age_ms(ms: u64) -> String { + let secs = ms / 1000; + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86_400 { + format!("{}h", secs / 3600) + } else { + format!("{}d", secs / 86_400) + } +} + fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { // Tutorial pane highlight (spec 0077, step 4 "get around"): reuses // `pane_border_style`'s focused styling as the highlight rather than @@ -1957,10 +2163,12 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { clear_pane_side_borders(f, area, app); return; } - // Expanded render path: title is ` + sessions ` with a - // right-aligned ` « ` for collapse. Both are clickable; the - // click handler in `App::click_list` consults - // `list_title_button_hit` for the geometry. + // Expanded render path: title is ` + sessions ` at the left; at the + // right sit the view-mode toggle label (` compact ⇄ ` / ` full ⇄ `) + // and the ` « ` collapse button — the same right-aligned pairing as + // the lineage section header. All are clickable; the click handler in + // `App::click_list` consults `list_title_button_hit` / + // `list_mode_toggle_hit` for the geometry. let plus_style = Style::default() .fg(app.theme.accent) .add_modifier(Modifier::BOLD); @@ -1969,6 +2177,24 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { Span::styled("+", plus_style), Span::raw(" sessions "), ]); + let mode_range = list_mode_button_range(area, app.list_mode); + let mode_hovered = match (app.mouse_pos, mode_range) { + (Some((mx, my)), Some((xs, xe, y))) => my == y && mx >= xs && mx < xe, + _ => false, + }; + let mode_style = if mode_hovered { + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.muted) + }; + app.layout.list_mode_toggle_hit = mode_range.map(|(xs, xe, y)| Rect { + x: xs, + y, + width: xe.saturating_sub(xs), + height: 1, + }); let minus_hovered = match app.mouse_pos { Some((mx, my)) => list_collapse_button_range(area) .map(|(xs, xe, y)| my == y && mx >= xs && mx < xe) @@ -1982,8 +2208,16 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { } else { Style::default().fg(app.theme.muted) }; + let mut collapse_spans = Vec::new(); + if mode_range.is_some() { + collapse_spans.push(Span::styled( + list_mode_button_label(app.list_mode), + mode_style, + )); + } + collapse_spans.push(Span::styled(" « ", minus_style)); let collapse_line = - Line::from(Span::styled(" « ", minus_style)).alignment(ratatui::layout::Alignment::Right); + Line::from(collapse_spans).alignment(ratatui::layout::Alignment::Right); let block = Block::default() .borders(Borders::ALL) .border_style(pane_border_style(&app.theme, focused)) @@ -1995,7 +2229,7 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { let row_w = (area.width as usize).saturating_sub(2); let app_items = app.list_items(); let mut selected_idx: Option = None; - let items: Vec = app_items + let item_lines: Vec> = app_items .iter() .enumerate() .map(|(i, item)| { @@ -2109,7 +2343,11 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { Span::raw(gap_str), Span::styled(harness, harness_style(&app.theme)), ]); - ListItem::new(Line::from(spans)) + let mut lines = vec![Line::from(spans)]; + if app.list_mode == crate::app::SessionListViewMode::Full { + lines.push(session_detail_line(app, s, prefix_w, row_w)); + } + lines } AppListItem::GroupHeader { group, @@ -2117,7 +2355,7 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { attention_rollup, } => { let glyph = if group.collapsed { "▶" } else { "▼" }; - ListItem::new(Line::from(vec![ + vec![Line::from(vec![ Span::styled(format!("{glyph} "), Style::default().fg(app.theme.group)), Span::styled(group.name.clone(), group_name_style(&app.theme)), Span::styled( @@ -2129,7 +2367,7 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { format!("({member_count})"), session_list_secondary_style(&app.theme), ), - ])) + ])] } AppListItem::ArchivedRow { section, @@ -2155,10 +2393,10 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { *indented, parent_grouped, ) as usize); - ListItem::new(Line::from(Span::styled( + vec![Line::from(Span::styled( format!("{indent}{disclosure} {count} archived"), session_list_secondary_style(&app.theme), - ))) + ))] } } }) @@ -2207,14 +2445,34 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { app.lineage_h, lineage_h_scrollbar, ); - let max_scroll = app_items - .len() - .saturating_sub(list_items_area.height as usize); + let max_scroll = app.list_max_scroll(&app_items, list_items_area.height as usize); app.list_scroll_offset = app.list_scroll_offset.min(max_scroll); let visible_start = app.list_scroll_offset; - let visible_end = visible_start - .saturating_add(list_items_area.height as usize) - .min(items.len()); + // Take items until the next would overflow the viewport, recording the + // display-row → item map for click/hover hit-testing as we go. Every + // included item is fully visible, so ratatui's `ListState` never scrolls + // internally and the map stays authoritative. A viewport shorter than a + // single item still includes it (clipped) rather than showing nothing. + let mut visible_end = visible_start; + let mut used_rows = 0usize; + app.layout.list_visible_rows.clear(); + for (i, item) in app_items.iter().enumerate().skip(visible_start) { + let h = app.list_item_display_height(item); + if visible_end > visible_start && used_rows + h > list_items_area.height as usize { + break; + } + for line_no in 0..h { + app.layout.list_visible_rows.push(crate::app::ListRowHit { + item_index: i, + first_line: line_no == 0, + }); + } + used_rows += h; + visible_end = i + 1; + } + app.layout + .list_visible_rows + .truncate(list_items_area.height as usize); let selected_visible_idx = selected_idx.and_then(|idx| { if idx >= visible_start && idx < visible_end { Some(idx - visible_start) @@ -2222,10 +2480,9 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { None } }); - let items: Vec = items - .into_iter() - .skip(visible_start) - .take(list_items_area.height as usize) + let items: Vec = item_lines[visible_start..visible_end] + .iter() + .map(|lines| ListItem::new(lines.clone())) .collect(); let mut state = ListState::default(); state.select(if matches!(app.selection, Selection::None) { @@ -2263,7 +2520,11 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { if show_list_scrollbar && max_scroll > 0 && list_items_area.width > 0 { let track_h = list_items_area.height as usize; if track_h > 0 { - let thumb_h = (track_h * track_h / app_items.len().max(1)).clamp(1, track_h); + let total_rows: usize = app_items + .iter() + .map(|item| app.list_item_display_height(item)) + .sum(); + let thumb_h = (track_h * track_h / total_rows.max(1)).clamp(1, track_h); let max_top = track_h - thumb_h; let top = (app.list_scroll_offset * max_top + max_scroll / 2) / max_scroll; let x = list_items_area.x + list_items_area.width - 1; @@ -2304,7 +2565,8 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { // threading a push through the item-building closure above) — recomputes // the harness width already computed there, mirroring the same tradeoff // `hovered_diamond` already makes for its own hit zone. - for (offset, item) in app_items[visible_start..visible_end].iter().enumerate() { + let mut hit_y = list_items_area.y; + for item in app_items[visible_start..visible_end].iter() { if let AppListItem::Session { summary, .. } = item { let harness_w = harness_label(summary).chars().count() as u16; let x_end = list_items_area.x + list_items_area.width; @@ -2314,9 +2576,10 @@ fn render_sessions(f: &mut Frame, area: Rect, app: &mut App) { session_id: summary.id.clone(), x_start: x_end.saturating_sub(harness_w), x_end, - y: list_items_area.y + offset as u16, + y: hit_y, }); } + hit_y = hit_y.saturating_add(app.list_item_display_height(item) as u16); } clear_pane_side_borders(f, area, app); if let (Some(rect), Some((id, mut rows))) = (lineage_rect, lineage) { @@ -17837,6 +18100,117 @@ mod tests { serde_json::from_value(json).expect("valid SessionSummary") } + #[test] + fn detail_segments_show_model_gauge_activity_and_tokens() { + let mut s = lineage_test_summary("s1"); + s.model = Some("claude-opus-4-8".into()); + s.effort = Some("high".into()); + s.context_used = Some(100_000); + s.context_window = Some(200_000); + s.busy_running_since_ms = Some(1_000); // fixture state is `running` + s.tokens = construct_protocol::TokenTally { + input: 200_000, + output: 13_000, + cached: 0, + }; + // Cost is deliberately NOT part of the detail line, even when the + // session reports one. + s.cost_usd = Some(1.42); + let segs = session_detail_segments(&s, 61_000); + let texts: Vec<&str> = segs.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!( + texts, + vec!["opus-4-8\u{00b7}high", "▰▰▱▱ 50%", "busy 1m00s", "213k tok"] + ); + // The gauge outlives everything else on a narrow pane; tokens drop + // first. + let gauge_priority = segs.iter().map(|(_, p)| *p).max().unwrap(); + assert_eq!(segs[1].1, gauge_priority); + let min_priority = segs.iter().map(|(_, p)| *p).min().unwrap(); + assert_eq!(segs.last().unwrap().1, min_priority); + } + + #[test] + fn detail_segments_idle_age_and_shell_fallback() { + use chrono::TimeZone; + let mut s = lineage_test_summary("s1"); + s.state = SessionState::Done; + s.last_event_at = Some(chrono::Utc.timestamp_millis_opt(0).unwrap()); + let segs = session_detail_segments(&s, 7_200_000); + assert_eq!(segs[0].0, "2h ago"); + + // A session reporting no model/usage data at all (a plain shell) + // falls back to where it lives. + let mut sh = lineage_test_summary("sh"); + sh.state = SessionState::AwaitingInput; + sh.cwd = "/Users/moon/agentd".into(); + let segs = session_detail_segments(&sh, 0); + assert_eq!(segs, vec![("agentd".to_string(), 0)]); + } + + #[test] + fn fit_detail_segments_drops_lowest_priority_first_then_ellipsizes() { + let segs = vec![ + ("aaaa".to_string(), 3), + ("bbbb".to_string(), 4), + ("cccc".to_string(), 0), + ]; + assert_eq!(fit_detail_segments(segs.clone(), 20), "aaaa bbbb cccc"); + assert_eq!(fit_detail_segments(segs.clone(), 12), "aaaa bbbb"); + assert_eq!(fit_detail_segments(segs.clone(), 5), "bbbb"); + assert_eq!(fit_detail_segments(segs, 2), "b…"); + } + + #[test] + fn short_model_label_strips_vendor_prefix_and_date_stamp() { + assert_eq!(short_model_label("claude-opus-4-8"), "opus-4-8"); + assert_eq!(short_model_label("claude-haiku-4-5-20251001"), "haiku-4-5"); + assert_eq!(short_model_label("gpt-5.2-codex"), "gpt-5.2-codex"); + assert_eq!(short_model_label("20251001"), "20251001"); + } + + #[test] + fn format_age_stays_one_short_unit() { + assert_eq!(format_age_ms(42_000), "42s"); + assert_eq!(format_age_ms(12 * 60_000), "12m"); + assert_eq!(format_age_ms(3 * 3_600_000), "3h"); + assert_eq!(format_age_ms(2 * 86_400_000), "2d"); + } + + #[test] + fn context_gauge_fill_rounds_to_nearest_quarter() { + assert_eq!(context_gauge_label(50_000, 200_000), "▰▱▱▱ 25%"); + assert_eq!(context_gauge_label(200_000, 200_000), "▰▰▰▰ 100%"); + // Just over half fills two cells, not three — the bar tracks the + // percentage instead of ceiling up. + assert_eq!(context_gauge_label(54_000, 100_000), "▰▰▱▱ 54%"); + // ...and rounds up only past the midpoint of a quarter. + assert_eq!(context_gauge_label(63_000, 100_000), "▰▰▰▱ 63%"); + assert_eq!(context_gauge_label(1, 200_000), "▱▱▱▱ 0%"); + } + + #[test] + fn list_mode_button_right_aligns_before_collapse_and_hides_when_narrow() { + let list = Rect::new(0, 0, 40, 20); + let (xs, xe, y) = list_mode_button_range(list, crate::app::SessionListViewMode::Compact) + .expect("wide pane fits the label"); + assert_eq!(y, list.y); + // The label ends flush against the ` « ` collapse button (which + // occupies the 3 cells before the corner), like the lineage header. + let (collapse_xs, _, _) = list_collapse_button_range(list).expect("collapse button"); + assert_eq!(xe, collapse_xs); + // ` compact ⇄ ` is 11 cells. + assert_eq!(xs, xe - 11); + // Too narrow to clear the ` + sessions ` title → no label at all. + assert_eq!( + list_mode_button_range( + Rect::new(0, 0, 26, 20), + crate::app::SessionListViewMode::Compact + ), + None + ); + } + /// A root + fork + subagent fixture, flattened into diagram rows. fn lineage_test_rows() -> (Vec, Vec) { let root = lineage_test_summary("root"); diff --git a/specs/0106-session-list-view-modes.md b/specs/0106-session-list-view-modes.md new file mode 100644 index 00000000..5ba5cede --- /dev/null +++ b/specs/0106-session-list-view-modes.md @@ -0,0 +1,68 @@ +# 0106-session-list-view-modes + +Status: accepted +Date: 2026-07-21 +Area: tui +Scope: The sidebar session list offers a compact one-line view and a full two-line card view per session, toggled from the pane's border and persisted per user. + +## Decision + +The session list renders in one of two user-selectable view modes: + +- **Compact** (the default): one line per session — lineage/pin markers, the + status glyph, the session name, an attention marker, and the right-aligned + harness label. +- **Full**: the compact line plus a muted second detail line aligned under the + name, showing (in display order) the model and reasoning effort, a small + context-window gauge with percentage, current activity (live busy time while + running, coarse age since last activity otherwise), and lifetime token + volume. Cost is deliberately excluded. The gauge's fill rounds to the + nearest step so the bar tracks the percentage (just over half reads as + half, not three quarters). + +Rules both modes must preserve: + +- The toggle is a small labeled control on the list pane's border, + right-aligned immediately before the pane's collapse control — the same + placement and label shape as the lineage section's full/compact toggle. The + choice persists across launches; legacy state restores to compact. +- The detail line only ever shows data the session actually reported — absent + fields are omitted, never rendered as placeholders. A session reporting no + model/usage data at all (e.g. a plain shell) falls back to showing where it + lives (its worktree or working directory). +- On a narrow sidebar the detail line drops segments rather than wrapping, + least important first: tokens, then activity, then model, keeping the + context gauge longest. Full mode never forces the sidebar wider and never + horizontally scrolls. +- Group headers and archived-disclosure rows stay one line in both modes. +- Selection, keyboard navigation, and scrolling operate on items, not display + rows; a click anywhere within a card selects it, while gutter affordances + (disclosure triangle, pin target) live on the card's first line only. + Hit-testing must consult the rendered row-to-item mapping, never assume one + display row per item. + +## Reason + +The compact list answers "which session" but not "how is it doing" — model, +context pressure, activity, and spend previously required selecting each +session and reading the modeline. Scanning a fleet benefits from a denser +per-session summary, but permanently taller rows would halve the visible +session count, so the density is a user choice, mirroring the precedent the +lineage section set for a full/compact pair. + +## Consequences + +- Display rows and list items are no longer 1:1; any future list interaction + (hover zones, drag targets, new gutter affordances) must go through the + row-to-item mapping and declare which line of a card it lives on. +- Scroll limits and scrollbar geometry are measured in display rows, so + mixed-height items (one-line headers among two-line cards) stay correct. +- Adding new per-session data to the detail line means placing it in the + existing drop-priority order, not appending unconditionally. + +## Non-Goals + +- No third, denser-still or taller-still mode; two modes keep the toggle a + binary. +- The web UI's session list is out of scope; this decision covers the TUI. +- The detail line is a summary, not a control surface — it hosts no buttons.