use std::borrow::Cow;
use ag_tui_text::text_util::{format_duration_compact, inline_text, truncate_spans_with_ellipsis};
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use crate::domain::agent::ReasoningLevel;
use crate::domain::session::{Session, SessionSize, Status};
use crate::domain::session_order::{self, GroupedSessionRow, SessionGroup, SessionTreePosition};
use crate::presentation::help_action;
use crate::ui::input_layout::first_table_column_width;
use crate::ui::{Page, layout, markdown, style};
const ROW_HIGHLIGHT_SYMBOL: &str = "";
const TABLE_COLUMN_SPACING: u16 = 2;
const EMPTY_SESSIONS_HINT: &str = "No sessions. Press 'a' to start one.";
const TREE_BRANCH_MIDDLE: &str = "├ ";
const TREE_BRANCH_LAST: &str = "â”” ";
pub struct SessionListPage<'a> {
pub default_reasoning_level: ReasoningLevel,
pub sessions: &'a [Session],
pub table_state: &'a mut TableState,
wall_clock_unix_seconds: i64,
}
impl<'a> SessionListPage<'a> {
pub fn new(
sessions: &'a [Session],
table_state: &'a mut TableState,
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> Self {
Self {
default_reasoning_level,
sessions,
table_state,
wall_clock_unix_seconds,
}
}
}
struct PreparedSessionCells {
model_cell: Line<'static>,
model_width: usize,
status_cell: Cell<'static>,
status_width: usize,
timer_label: String,
timer_width: usize,
}
impl PreparedSessionCells {
fn new(
session: &Session,
_default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> Self {
let reasoning_level = session.effective_reasoning_level();
let model_name = session.agent.model().as_str();
let model_width = model_name
.chars()
.count()
.saturating_add(reasoning_level.as_str().chars().count())
.saturating_add(3);
let model_cell = Line::from(vec![
Span::raw(model_name),
Span::raw(" ["),
Span::styled(
reasoning_level.as_str(),
Style::default().fg(session_detail_color(
session,
reasoning_level_color(reasoning_level),
)),
),
Span::raw("]"),
]);
let status = session.status;
let status_label = session_list_status_label(session).into_owned();
let forge_indicator = session.forge_indicator();
let forge_indicator_width = if forge_indicator.is_empty() {
0
} else {
forge_indicator.chars().count().saturating_add(1)
};
let status_width = status_label
.chars()
.count()
.saturating_add(forge_indicator_width);
let status_cell = if forge_indicator.is_empty() {
Cell::from(status_label).style(
Style::default().fg(session_detail_color(session, style::status_color(status))),
)
} else {
let review_state = session.review_request.as_ref().map(|rr| rr.summary.state);
let indicator_color =
session_detail_color(session, style::forge_indicator_color(review_state));
Cell::from(Line::from(vec![
Span::styled(
format!("{status_label} "),
Style::default().fg(session_detail_color(session, style::status_color(status))),
),
Span::styled(forge_indicator, Style::default().fg(indicator_color)),
]))
};
let timer_label = if session.has_in_progress_timer() {
format_duration_compact(session.in_progress_duration_seconds(wall_clock_unix_seconds))
} else {
String::new()
};
let timer_width = timer_label.chars().count();
Self {
model_cell,
model_width,
status_cell,
status_width,
timer_label,
timer_width,
}
}
}
enum PreparedSessionRow<'a> {
GroupLabel {
group: SessionGroup,
session_count: usize,
},
Session {
adds_group_spacing: bool,
cells: PreparedSessionCells,
session: &'a Session,
tree_position: SessionTreePosition,
},
}
impl<'a> PreparedSessionRow<'a> {
fn new(
row: &GroupedSessionRow<'a>,
following_rows: &[GroupedSessionRow<'a>],
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> Self {
match row {
GroupedSessionRow::GroupLabel(group) => {
let session_count = following_rows
.iter()
.take_while(|following_row| {
matches!(following_row, GroupedSessionRow::Session { .. })
})
.count();
Self::GroupLabel {
group: *group,
session_count,
}
}
GroupedSessionRow::Session {
session,
tree_position,
..
} => Self::Session {
adds_group_spacing: matches!(
following_rows.first(),
Some(GroupedSessionRow::GroupLabel(_))
),
cells: PreparedSessionCells::new(
session,
default_reasoning_level,
wall_clock_unix_seconds,
),
session,
tree_position: *tree_position,
},
}
}
}
impl Page for SessionListPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let areas = layout::tab_page_areas(area);
let selected_style = Style::default().bg(style::palette::surface_selection());
let header_style = Style::default()
.bg(style::palette::surface())
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD);
let header_cells = ["Session", "Model", "Status", "Timer"]
.iter()
.map(|h| Cell::from(*h));
let header = Row::new(header_cells).style(header_style).height(1);
let block = Block::default()
.borders(Borders::ALL)
.title("Sessions")
.border_style(style::border_style());
let table_rows = prepared_session_rows(
self.sessions,
self.default_reasoning_level,
self.wall_clock_unix_seconds,
);
let column_constraints = [
Constraint::Fill(1),
model_column_width(&table_rows),
status_column_width(&table_rows),
timer_column_width(&table_rows),
];
let title_column_width = first_table_column_width(
block.inner(areas.main_area).width,
&column_constraints,
TABLE_COLUMN_SPACING,
0,
);
let selected_session_id = selected_session_id(self.sessions, self.table_state.selected());
let selected_row = selected_render_row(&table_rows, selected_session_id);
let is_empty = table_rows.is_empty();
let rows = table_rows
.into_iter()
.map(|table_row| render_table_row(table_row, title_column_width))
.chain(is_empty.then(render_empty_sessions_hint_row));
let table = Table::new(rows, column_constraints)
.column_spacing(TABLE_COLUMN_SPACING)
.header(header)
.block(block)
.row_highlight_style(selected_style)
.highlight_symbol(ROW_HIGHLIGHT_SYMBOL);
let previous_selection = self.table_state.selected();
prepare_grouped_table_state(self.table_state, selected_row);
f.render_stateful_widget(table, areas.main_area, self.table_state);
self.table_state.select(previous_selection);
let selected_session = self
.table_state
.selected()
.and_then(|selected_index| self.sessions.get(selected_index));
let help_message = Paragraph::new(session_list_help_line(selected_session));
f.render_widget(help_message, areas.footer_area);
}
}
fn session_list_help_line(selected_session: Option<&Session>) -> Line<'static> {
let can_cancel_selected_session = selected_session.is_some_and(Session::allows_cancel_action);
let can_open_selected_session = selected_session.is_some();
let actions = help_action::session_list_footer_actions(
can_cancel_selected_session,
can_open_selected_session,
);
crate::ui::help_format::footer_line(&actions)
}
fn prepare_grouped_table_state(table_state: &mut TableState, selected_row: Option<usize>) {
*table_state.offset_mut() = 0;
table_state.select(selected_row);
}
fn session_group_label(group: SessionGroup) -> &'static str {
match group {
SessionGroup::MergeQueue => "MERGE QUEUE",
SessionGroup::Active => "ACTIVE",
SessionGroup::Archive => "ARCHIVE",
}
}
fn tree_position_label(tree_position: SessionTreePosition) -> &'static str {
match tree_position {
SessionTreePosition::Root => "",
SessionTreePosition::Child { is_last: true } => TREE_BRANCH_LAST,
SessionTreePosition::Child { is_last: false } => TREE_BRANCH_MIDDLE,
}
}
fn tree_position_width(tree_position: SessionTreePosition) -> usize {
tree_position_label(tree_position).chars().count()
}
fn selected_session_id(sessions: &[Session], selected_index: Option<usize>) -> Option<&str> {
selected_index
.and_then(|index| sessions.get(index))
.map(|session| session.id.as_str())
}
fn selected_render_row(
rows: &[PreparedSessionRow<'_>],
selected_session_id: Option<&str>,
) -> Option<usize> {
let selected_session_id = selected_session_id?;
rows.iter().position(|row| match row {
PreparedSessionRow::GroupLabel { .. } => false,
PreparedSessionRow::Session { session, .. } => session.id == selected_session_id,
})
}
fn prepared_session_rows(
sessions: &[Session],
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> Vec<PreparedSessionRow<'_>> {
let grouped_rows = session_order::grouped_session_rows(sessions);
grouped_rows
.iter()
.enumerate()
.map(|(row_index, row)| {
let following_rows = &grouped_rows[row_index + 1..];
PreparedSessionRow::new(
row,
following_rows,
default_reasoning_level,
wall_clock_unix_seconds,
)
})
.collect()
}
fn render_table_row(row: PreparedSessionRow<'_>, title_column_width: usize) -> Row<'static> {
match row {
PreparedSessionRow::GroupLabel {
group,
session_count,
} => render_group_label_row(group, session_count),
PreparedSessionRow::Session {
adds_group_spacing,
cells,
session,
tree_position,
} => render_session_row(
session,
tree_position,
cells,
title_column_width,
adds_group_spacing,
),
}
}
fn render_group_label_row(group: SessionGroup, session_count: usize) -> Row<'static> {
let cells = vec![
Cell::from(format!(
" {} —— {session_count}",
session_group_label(group)
))
.style(Style::default().fg(style::palette::text_muted())),
Cell::from(""),
Cell::from(""),
Cell::from(""),
];
Row::new(cells).height(1)
}
fn render_empty_sessions_hint_row() -> Row<'static> {
let cells = vec![
Cell::from(EMPTY_SESSIONS_HINT).style(Style::default().fg(style::palette::text_subtle())),
Cell::from(""),
Cell::from(""),
Cell::from(""),
];
Row::new(cells).height(1)
}
fn render_session_row(
session: &Session,
tree_position: SessionTreePosition,
cells: PreparedSessionCells,
title_column_width: usize,
adds_group_spacing: bool,
) -> Row<'static> {
let title_spans = render_session_title(
session,
title_column_width.saturating_sub(tree_position_width(tree_position)),
);
let mut title_line_spans = Vec::new();
let tree_label = tree_position_label(tree_position);
if !tree_label.is_empty() {
title_line_spans.push(Span::styled(tree_label, tree_prefix_style()));
}
title_line_spans.extend(title_spans);
let cells = vec![
Cell::from(Line::from(title_line_spans)),
Cell::from(cells.model_cell),
cells.status_cell,
Cell::from(cells.timer_label),
];
Row::new(cells)
.style(session_row_style(session))
.height(1)
.bottom_margin(u16::from(adds_group_spacing))
}
fn session_row_style(session: &Session) -> Style {
let text_color = if is_archived_session(session) {
style::palette::text_muted()
} else {
style::palette::text()
};
Style::default().fg(text_color)
}
fn is_archived_session(session: &Session) -> bool {
matches!(session.status, Status::Done | Status::Canceled)
}
fn session_detail_color(session: &Session, active_color: Color) -> Color {
if is_archived_session(session) {
style::palette::text_muted()
} else {
active_color
}
}
fn session_list_status_label(session: &Session) -> Cow<'_, str> {
session
.orchestration_progress
.as_deref()
.and_then(|progress| progress.lines().next())
.filter(|label| !label.is_empty())
.map_or_else(|| Cow::Owned(session.status.to_string()), Cow::Borrowed)
}
fn tree_prefix_style() -> Style {
Style::default().fg(style::palette::text_muted())
}
fn model_column_width(rows: &[PreparedSessionRow<'_>]) -> Constraint {
column_width(
"Model",
rows.iter().filter_map(|row| match row {
PreparedSessionRow::GroupLabel { .. } => None,
PreparedSessionRow::Session { cells, .. } => Some(cells.model_width),
}),
)
}
fn reasoning_level_color(reasoning_level: ReasoningLevel) -> Color {
match reasoning_level {
ReasoningLevel::Low => style::palette::success(),
ReasoningLevel::Medium => style::palette::warning(),
ReasoningLevel::High => style::palette::warning_soft(),
ReasoningLevel::XHigh | ReasoningLevel::Max => style::palette::danger(),
}
}
fn status_column_width(rows: &[PreparedSessionRow<'_>]) -> Constraint {
let static_widths = Status::ALL
.iter()
.map(|status| status.to_string().chars().count());
let session_widths = rows.iter().filter_map(|row| match row {
PreparedSessionRow::GroupLabel { .. } => None,
PreparedSessionRow::Session { cells, .. } => Some(cells.status_width),
});
column_width("Status", static_widths.chain(session_widths))
}
fn timer_column_width(rows: &[PreparedSessionRow<'_>]) -> Constraint {
column_width(
"Timer",
rows.iter().filter_map(|row| match row {
PreparedSessionRow::GroupLabel { .. } => None,
PreparedSessionRow::Session { cells, .. } => Some(cells.timer_width),
}),
)
}
fn render_session_title(session: &Session, title_column_width: usize) -> Vec<Span<'static>> {
let mut title_spans = markdown::parse_inline_spans(
&inline_text(session.display_title()),
session_row_style(session),
);
title_spans.insert(
0,
Span::styled(
format!("[{}] ", session.size),
Style::default().fg(session_detail_color(session, size_color(session.size))),
),
);
truncate_spans_with_ellipsis(title_spans, title_column_width)
}
fn size_color(size: SessionSize) -> Color {
match size {
SessionSize::Xs => style::palette::success(),
SessionSize::S => style::palette::success_soft(),
SessionSize::M => style::palette::warning(),
SessionSize::L => style::palette::warning_soft(),
SessionSize::Xl => style::palette::danger_soft(),
SessionSize::Xxl => style::palette::danger(),
}
}
fn column_width(header: &str, widths: impl Iterator<Item = usize>) -> Constraint {
let column_width = widths.fold(header.chars().count(), usize::max);
let column_width = u16::try_from(column_width).unwrap_or(u16::MAX);
Constraint::Length(column_width)
}
#[cfg(test)]
mod tests {
use ratatui::widgets::TableState;
use super::*;
use crate::domain::agent::{AgentModel, ReasoningLevel};
use crate::domain::session::{
ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary,
};
use crate::domain::theme::ColorTheme;
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn buffer_lines(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
let width = usize::from(buffer.area.width.max(1));
buffer
.content()
.chunks(width)
.map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
.collect()
}
fn find_text_start_cell<'a>(
buffer: &'a ratatui::buffer::Buffer,
needle: &str,
) -> Option<&'a ratatui::buffer::Cell> {
let width = usize::from(buffer.area.width.max(1));
let needle_symbols = needle.chars().map(|character| character.to_string());
let needle_symbols = needle_symbols.collect::<Vec<_>>();
let content = buffer.content();
for row_start in (0..content.len()).step_by(width) {
let row_end = row_start + width.min(content.len().saturating_sub(row_start));
let row = &content[row_start..row_end];
for (index, window) in row.windows(needle_symbols.len()).enumerate() {
let window_matches = window
.iter()
.zip(&needle_symbols)
.all(|(cell, symbol)| cell.symbol() == symbol);
if window_matches {
return Some(&row[index]);
}
}
}
None
}
fn foreground_symbol_cell_count(buffer: &ratatui::buffer::Buffer, symbol: &str) -> usize {
buffer
.content()
.iter()
.filter(|cell| cell.symbol() == symbol && cell.fg == style::palette::border())
.count()
}
#[test]
fn test_status_bar_fyi_rotates_between_session_list_messages() {
let first_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
0,
);
let second_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
1,
);
let third_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
2,
);
let fourth_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
3,
);
let fifth_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
4,
);
let wrapped_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
5,
);
assert_eq!(
first_message,
Some("Use list sync before starting work when you need the newest base branch.")
);
assert_eq!(
second_message,
Some("Sessions are grouped as merge queue, active work, then archive."),
);
assert_eq!(
third_message,
Some("Session timers count active agent work and freeze between turns.")
);
assert_eq!(
fourth_message,
Some("Forge badges show whether review requests are open, merged, or closed.")
);
assert_eq!(
fifth_message,
Some(
"Session launch configurations run through tmux and use the configured Settings \
entries."
)
);
assert_eq!(
wrapped_message,
Some("Use list sync before starting work when you need the newest base branch.")
);
}
#[test]
fn test_row_highlight_symbol_uses_background_only_selection() {
let highlight_symbol = ROW_HIGHLIGHT_SYMBOL;
let is_empty_symbol = highlight_symbol.is_empty();
assert!(is_empty_symbol);
}
#[test]
fn test_table_column_spacing_is_wider_for_readability() {
let expected_spacing = 2;
let spacing = TABLE_COLUMN_SPACING;
assert_eq!(spacing, expected_spacing);
}
#[test]
fn test_render_uses_palette_border_for_sessions_table() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let sessions = vec![crate::test_support::titled_session_fixture(
"new-1",
Status::Draft,
)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let border_cell_count = foreground_symbol_cell_count(terminal.backend().buffer(), "┌");
assert_eq!(border_cell_count, 1);
}
#[test]
fn test_render_empty_session_list_shows_creation_hint_without_group_labels() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
let sessions = Vec::new();
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains(EMPTY_SESSIONS_HINT));
assert!(!text.contains("MERGE QUEUE"));
assert!(!text.contains("ACTIVE"));
assert!(!text.contains("ARCHIVE"));
}
#[test]
fn test_render_group_labels_show_counts_with_spacing_between_sections() {
let backend = ratatui::backend::TestBackend::new(100, 18);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(1));
let sessions = vec![
crate::test_support::titled_session_fixture("active-1", Status::Review),
crate::test_support::titled_session_fixture("queued-1", Status::Queued),
crate::test_support::titled_session_fixture("archive-1", Status::Done),
];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let lines = buffer_lines(terminal.backend().buffer());
let merge_queue_row = lines
.iter()
.position(|line| line.contains(" MERGE QUEUE —— 1"))
.expect("merge queue label should be visible");
let active_row = lines
.iter()
.position(|line| line.contains(" ACTIVE —— 1"))
.expect("active label should be visible");
let archive_row = lines
.iter()
.position(|line| line.contains(" ARCHIVE —— 1"))
.expect("archive label should be visible");
let is_blank_table_row = |line: &str| {
line.trim_matches(|character| matches!(character, ' ' | '│'))
.is_empty()
};
assert!(!is_blank_table_row(&lines[merge_queue_row - 1]));
assert!(is_blank_table_row(&lines[active_row - 1]));
assert!(is_blank_table_row(&lines[archive_row - 1]));
}
#[test]
fn test_render_archive_rows_use_muted_text_across_columns() {
let _theme_scope = style::scoped_active_theme(ColorTheme::DarkHorizon);
let backend = ratatui::backend::TestBackend::new(120, 16);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut active_session =
crate::test_support::titled_session_fixture("active-1", Status::Review);
active_session.title = Some("Active session title".to_string());
active_session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
active_session.reasoning_level_override = Some(ReasoningLevel::Low);
let mut archived_session =
crate::test_support::titled_session_fixture("archive-1", Status::Done);
archived_session.title = Some("Archived session title".to_string());
archived_session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeSonnet5,
);
archived_session.reasoning_level_override = Some(ReasoningLevel::High);
archived_session.size = SessionSize::Xxl;
let sessions = vec![active_session, archived_session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let buffer = terminal.backend().buffer();
let active_title_cell = find_text_start_cell(buffer, "Active session title")
.expect("active title should be visible");
let archived_title_cell = find_text_start_cell(buffer, "Archived session title")
.expect("archived title should be visible");
let archived_size_cell =
find_text_start_cell(buffer, "[XXL]").expect("archived size should be visible");
let archived_model_cell = find_text_start_cell(buffer, "claude-sonnet-5")
.expect("archived model should be visible");
let archived_reasoning_cell =
find_text_start_cell(buffer, "high").expect("archived reasoning should be visible");
let archived_status_cell =
find_text_start_cell(buffer, "Done").expect("archived status should be visible");
assert_eq!(active_title_cell.fg, style::palette::text());
for archived_cell in [
archived_title_cell,
archived_size_cell,
archived_model_cell,
archived_reasoning_cell,
archived_status_cell,
] {
assert_eq!(archived_cell.fg, style::palette::text_muted());
}
}
#[test]
fn test_selected_render_row_maps_original_selection_to_grouped_index() {
let sessions = vec![
crate::test_support::titled_session_fixture("active-1", Status::Review),
crate::test_support::titled_session_fixture("queued-1", Status::Queued),
crate::test_support::titled_session_fixture("merge-1", Status::Merging),
crate::test_support::titled_session_fixture("active-2", Status::Draft),
];
let rows = prepared_session_rows(&sessions, ReasoningLevel::default(), 0);
let selected_session_id = selected_session_id(&sessions, Some(3));
let row_index = selected_render_row(&rows, selected_session_id);
assert_eq!(row_index, Some(5));
}
#[test]
fn test_prepare_grouped_table_state_resets_offset_and_sets_selected_group_row() {
let mut table_state = TableState::default();
*table_state.offset_mut() = 24;
table_state.select(Some(7));
prepare_grouped_table_state(&mut table_state, Some(3));
assert_eq!(table_state.offset(), 0);
assert_eq!(table_state.selected(), Some(3));
}
#[test]
fn test_text_column_width_uses_longest_project_value() {
let expected_width =
u16::try_from("very-long-project-name".chars().count()).unwrap_or(u16::MAX);
let project_names = ["api", "very-long-project-name"];
let width = column_width(
"Project",
project_names
.into_iter()
.map(|project_name| project_name.chars().count()),
);
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_model_column_width_uses_longest_model_value() {
let expected_width =
u16::try_from("claude-sonnet-5 [low]".chars().count()).unwrap_or(u16::MAX);
let mut default_session =
crate::test_support::titled_session_fixture("active-1", Status::Review);
default_session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeSonnet5,
);
default_session.reasoning_level_override = Some(ReasoningLevel::Low);
let mut medium_session =
crate::test_support::titled_session_fixture("active-2", Status::Review);
medium_session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
medium_session.reasoning_level_override = Some(ReasoningLevel::Medium);
let sessions = vec![default_session, medium_session];
let rows = prepared_session_rows(&sessions, ReasoningLevel::Low, 0);
let width = model_column_width(&rows);
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_reasoning_level_color_matches_schema() {
let expected_colors = [
(ReasoningLevel::Low, style::palette::success()),
(ReasoningLevel::Medium, style::palette::warning()),
(ReasoningLevel::High, style::palette::warning_soft()),
(ReasoningLevel::XHigh, style::palette::danger()),
(ReasoningLevel::Max, style::palette::danger()),
];
for (reasoning_level, expected_color) in expected_colors {
assert_eq!(reasoning_level_color(reasoning_level), expected_color);
}
}
#[test]
fn test_render_session_row_colors_reasoning_level_within_model_column() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Review);
session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
session.reasoning_level_override = Some(ReasoningLevel::High);
let sessions = vec![session];
let expected_reasoning_color = reasoning_level_color(ReasoningLevel::High);
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::Low, 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let buffer = terminal.backend().buffer();
let fallback_cell = &buffer.content()[0];
let model_cell = find_text_start_cell(buffer, "gpt-5.6-sol").unwrap_or(fallback_cell);
let reasoning_cell = find_text_start_cell(buffer, "high").unwrap_or(fallback_cell);
assert_eq!(model_cell.fg, style::palette::text());
assert_eq!(reasoning_cell.fg, expected_reasoning_color);
}
#[test]
fn test_render_selected_session_model_uses_selection_surface() {
let _theme_scope = style::scoped_active_theme(ColorTheme::DarkHorizon);
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Review);
session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::Medium, 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let buffer = terminal.backend().buffer();
let fallback_cell = &buffer.content()[0];
let model_cell = find_text_start_cell(buffer, "gpt-5.6-sol").unwrap_or(fallback_cell);
assert_eq!(model_cell.bg, style::palette::surface_selection());
}
#[test]
fn test_render_session_row_shows_model_with_persisted_reasoning_level() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Review);
session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
session.reasoning_level_override = Some(ReasoningLevel::Medium);
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::XHigh, 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("gpt-5.6-sol [medium]"));
}
#[test]
fn test_render_session_row_connects_stacked_child_to_parent() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(1));
let mut parent_session =
crate::test_support::titled_session_fixture("parent-1", Status::Review);
parent_session.title = Some("Parent session".to_string());
let mut child_session =
crate::test_support::titled_session_fixture("child-1", Status::Draft);
child_session.parent_session_id = Some("parent-1".into());
child_session.title = Some("Child session".to_string());
let sessions = vec![parent_session, child_session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Parent session"));
assert!(text.contains("â”” [XS] Child session"));
let buffer = terminal.backend().buffer();
let fallback_cell = &buffer.content()[0];
let tree_cell = find_text_start_cell(buffer, "â””").unwrap_or(fallback_cell);
assert_eq!(tree_cell.fg, style::palette::text_muted());
assert_eq!(tree_cell.bg, style::palette::surface_selection());
assert_ne!(tree_cell.fg, tree_cell.bg);
}
#[test]
fn test_render_session_row_shows_model_with_override_reasoning_level() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Review);
session.agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt56Sol,
);
session.reasoning_level_override = Some(ReasoningLevel::High);
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::Low, 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("gpt-5.6-sol [high]"));
}
#[test]
fn test_status_column_width_uses_longest_possible_status_label() {
let expected_width = u16::try_from("AgentReview".chars().count()).unwrap_or(u16::MAX);
let width = status_column_width(&[]);
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_status_column_uses_visible_orchestration_phase_with_forge_indicator() {
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Review);
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "wt/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
let expected_title = session.display_title().to_string();
session.orchestration_progress = Some(
"Phase: Running\nParallel workers: 3 (global setting)\n- protocol: running".to_string(),
);
let sessions = vec![session];
let expected_label = "Phase: Running ⊙ #42";
let expected_width = u16::try_from(expected_label.chars().count()).unwrap_or(u16::MAX);
let backend = ratatui::backend::TestBackend::new(120, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let rows = prepared_session_rows(&sessions, ReasoningLevel::High, 0);
let width = status_column_width(&rows);
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::High, 0)
.render(frame, frame.area());
})
.expect("failed to render session list");
let rendered = buffer_text(terminal.backend().buffer());
assert_eq!(width, Constraint::Length(expected_width));
assert!(rendered.contains(expected_label));
assert!(rendered.contains(&expected_title));
}
#[test]
fn test_timer_column_width_uses_longest_rendered_timer_label() {
let mut active_session =
crate::test_support::titled_session_fixture("active-1", Status::InProgress);
active_session.in_progress_started_at = Some(100);
active_session.in_progress_total_seconds = 60;
let mut archived_session =
crate::test_support::titled_session_fixture("done-1", Status::Done);
archived_session.in_progress_total_seconds = 3_661;
let sessions = vec![active_session, archived_session];
let expected_width = u16::try_from("1h1m1s".chars().count()).unwrap_or(u16::MAX);
let rows = prepared_session_rows(&sessions, ReasoningLevel::default(), 160);
let width = timer_column_width(&rows);
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_size_color_uses_expected_palette() {
let test_cases = [
(SessionSize::Xs, style::palette::success()),
(SessionSize::S, style::palette::success_soft()),
(SessionSize::M, style::palette::warning()),
(SessionSize::L, style::palette::warning_soft()),
(SessionSize::Xl, style::palette::danger_soft()),
(SessionSize::Xxl, style::palette::danger()),
];
for (size, expected_color) in test_cases {
assert_eq!(size_color(size), expected_color);
}
}
#[test]
fn test_render_session_row_includes_size_prefix_in_title() {
let backend = ratatui::backend::TestBackend::new(120, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("new-1", Status::Draft);
session.size = SessionSize::Xxl;
session.title = Some("Update dependency graph".to_string());
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("[XXL] Update dependency graph"));
assert_eq!(
sessions[0].title.as_deref(),
Some("Update dependency graph")
);
assert!(!text.contains("Size"));
}
#[test]
fn test_session_list_help_line_includes_sync_for_non_empty_sessions() {
let session = crate::test_support::titled_session_fixture("session-1", Status::Review);
let help_text = session_list_help_line(Some(&session)).to_string();
assert!(help_text.contains("s: sync"));
}
#[test]
fn test_session_list_help_line_includes_project_switcher() {
let session = crate::test_support::titled_session_fixture("session-1", Status::Review);
let help_text = session_list_help_line(Some(&session)).to_string();
assert!(help_text.contains("p: projects"));
}
#[test]
fn test_session_list_help_line_hides_cancel_for_regular_new_session() {
let session = crate::test_support::titled_session_fixture("session-1", Status::Draft);
let help_text = session_list_help_line(Some(&session)).to_string();
assert!(!help_text.contains("c: cancel"));
}
#[test]
fn test_session_list_help_line_includes_cancel_for_draft_session() {
let mut session = crate::test_support::titled_session_fixture("session-1", Status::Draft);
session.is_draft = true;
let help_text = session_list_help_line(Some(&session)).to_string();
assert!(help_text.contains("c: cancel"));
}
#[test]
fn test_session_list_help_line_includes_open_for_canceled_session() {
let session = crate::test_support::titled_session_fixture("session-1", Status::Canceled);
let help_text = session_list_help_line(Some(&session)).to_string();
assert!(help_text.contains("Enter: open session"));
}
#[test]
fn test_render_shows_live_active_work_timer_in_grouped_session_row() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session =
crate::test_support::titled_session_fixture("active-1", Status::InProgress);
session.in_progress_started_at = Some(100);
session.in_progress_total_seconds = 60;
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 160)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("2m0s"));
}
#[test]
fn test_render_shows_frozen_completed_timer_in_grouped_session_row() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("done-1", Status::Done);
session.in_progress_total_seconds = 125;
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(
&sessions,
&mut table_state,
ReasoningLevel::default(),
9_999,
)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("2m5s"));
}
#[test]
fn test_render_shows_full_agent_review_status_label() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let sessions = vec![crate::test_support::titled_session_fixture(
"review-1",
Status::AgentReview,
)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("AgentReview"));
}
#[test]
fn test_render_flattens_multiline_session_titles_for_one_line_rows() {
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut session = crate::test_support::titled_session_fixture("draft-1", Status::Draft);
session.title = Some("First draft\n\nSecond draft".to_string());
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("First draft Second draft"));
}
#[test]
fn test_render_keeps_selected_new_status_text_visible() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = ratatui::backend::TestBackend::new(100, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let mut table_state = TableState::default();
table_state.select(Some(0));
let sessions = vec![crate::test_support::titled_session_fixture(
"new-1",
Status::Draft,
)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, ReasoningLevel::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let buffer = terminal.backend().buffer();
let fallback_cell = &buffer.content()[0];
let title_cell = find_text_start_cell(buffer, "new-1").unwrap_or(fallback_cell);
let new_cell = find_text_start_cell(buffer, "Draft").unwrap_or(fallback_cell);
assert_ne!(title_cell.fg, title_cell.bg);
assert_eq!(new_cell.fg, style::palette::text_muted());
assert_eq!(new_cell.bg, style::palette::surface_selection());
assert_ne!(new_cell.fg, new_cell.bg);
}
}