use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, 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::session::{Session, SessionSize, Status};
use crate::ui::state::help_action;
use crate::ui::util::{
first_table_column_width, format_duration_compact, inline_text, truncate_spans_with_ellipsis,
};
use crate::ui::{Page, markdown, style};
const ROW_HIGHLIGHT_SYMBOL: &str = "";
const TABLE_COLUMN_SPACING: u16 = 2;
const PAGE_MARGIN: u16 = 1;
const GROUP_EMPTY_PLACEHOLDER: &str = "No sessions...";
pub struct SessionListPage<'a> {
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,
wall_clock_unix_seconds: i64,
) -> Self {
Self {
sessions,
table_state,
wall_clock_unix_seconds,
}
}
fn content_chunks(area: Rect) -> (Rect, Rect) {
let chunks = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.margin(PAGE_MARGIN)
.split(area);
(chunks[0], chunks[1])
}
}
impl Page for SessionListPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let (main_area, footer_area) = Self::content_chunks(area);
let selected_style = Style::default().bg(style::palette::surface());
let header_style = Style::default()
.bg(style::palette::surface())
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD);
let header_cells = ["Session", "Model", "Size", "Status", "Timer"]
.iter()
.map(|h| Cell::from(*h));
let header = Row::new(header_cells)
.style(header_style)
.height(1)
.bottom_margin(1);
let block = Block::default()
.borders(Borders::ALL)
.title("Sessions")
.border_style(style::border_style());
let column_constraints = [
Constraint::Fill(1),
model_column_width(self.sessions),
size_column_width(),
status_column_width(self.sessions),
timer_column_width(self.sessions, self.wall_clock_unix_seconds),
];
let title_column_width = first_table_column_width(
block.inner(main_area).width,
&column_constraints,
TABLE_COLUMN_SPACING,
0,
);
let table_rows = grouped_session_rows(self.sessions);
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 rows = table_rows.iter().map(|table_row| {
render_table_row(table_row, title_column_width, self.wall_clock_unix_seconds)
});
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, 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, 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,
);
help_action::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);
}
enum SessionTableRow<'a> {
GroupLabel(SessionGroup),
EmptyGroupPlaceholder,
Session(&'a Session),
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum SessionGroup {
ActiveSessions,
Archive,
MergeQueue,
}
impl SessionGroup {
fn label(self) -> &'static str {
match self {
Self::MergeQueue => "Merge queue",
Self::ActiveSessions => "Active sessions",
Self::Archive => "Archive",
}
}
}
pub(crate) fn preferred_initial_session_index(sessions: &[Session]) -> Option<usize> {
sessions_for_group(sessions, SessionGroup::ActiveSessions)
.map(|(index, _)| index)
.next()
.or_else(|| grouped_session_indexes(sessions).first().copied())
}
pub(crate) fn grouped_session_indexes(sessions: &[Session]) -> Vec<usize> {
let mut indexes = Vec::with_capacity(sessions.len());
indexes.extend(sessions_for_group(sessions, SessionGroup::MergeQueue).map(|(index, _)| index));
indexes
.extend(sessions_for_group(sessions, SessionGroup::ActiveSessions).map(|(index, _)| index));
indexes.extend(sessions_for_group(sessions, SessionGroup::Archive).map(|(index, _)| index));
indexes
}
fn grouped_session_rows(sessions: &[Session]) -> Vec<SessionTableRow<'_>> {
let mut rows = Vec::with_capacity(sessions.len() + 6);
append_group_rows(&mut rows, sessions, SessionGroup::MergeQueue);
append_group_rows(&mut rows, sessions, SessionGroup::ActiveSessions);
append_group_rows(&mut rows, sessions, SessionGroup::Archive);
rows
}
fn append_group_rows<'a>(
rows: &mut Vec<SessionTableRow<'a>>,
sessions: &'a [Session],
group: SessionGroup,
) {
rows.push(SessionTableRow::GroupLabel(group));
let mut group_has_sessions = false;
for (_, session) in sessions_for_group(sessions, group) {
rows.push(SessionTableRow::Session(session));
group_has_sessions = true;
}
if !group_has_sessions {
rows.push(SessionTableRow::EmptyGroupPlaceholder);
}
}
fn sessions_for_group(
sessions: &[Session],
group: SessionGroup,
) -> impl Iterator<Item = (usize, &Session)> {
sessions
.iter()
.enumerate()
.filter(move |(_, session)| session_group(session) == group)
}
fn session_group(session: &Session) -> SessionGroup {
match session.status {
Status::Queued | Status::Merging => SessionGroup::MergeQueue,
Status::Done | Status::Canceled => SessionGroup::Archive,
_ => SessionGroup::ActiveSessions,
}
}
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: &[SessionTableRow<'_>],
selected_session_id: Option<&str>,
) -> Option<usize> {
let selected_session_id = selected_session_id?;
rows.iter().position(|row| match row {
SessionTableRow::GroupLabel(_) | SessionTableRow::EmptyGroupPlaceholder => false,
SessionTableRow::Session(session) => session.id == selected_session_id,
})
}
fn render_table_row(
row: &SessionTableRow<'_>,
title_column_width: usize,
wall_clock_unix_seconds: i64,
) -> Row<'static> {
match row {
SessionTableRow::GroupLabel(group) => render_group_label_row(*group),
SessionTableRow::EmptyGroupPlaceholder => render_empty_group_placeholder_row(),
SessionTableRow::Session(session) => {
render_session_row(session, title_column_width, wall_clock_unix_seconds)
}
}
}
fn render_group_label_row(group: SessionGroup) -> Row<'static> {
let cells = vec![
Cell::from(group.label()).style(Style::default().fg(style::palette::accent())),
Cell::from(""),
Cell::from(""),
Cell::from(""),
Cell::from(""),
];
Row::new(cells).height(1)
}
fn render_empty_group_placeholder_row() -> Row<'static> {
let cells = vec![
Cell::from(GROUP_EMPTY_PLACEHOLDER)
.style(Style::default().fg(style::palette::text_subtle())),
Cell::from(""),
Cell::from(""),
Cell::from(""),
Cell::from(""),
];
Row::new(cells).height(1)
}
fn render_session_row(
session: &Session,
title_column_width: usize,
wall_clock_unix_seconds: i64,
) -> Row<'static> {
let status = session.status;
let title_text = inline_text(session.display_title());
let title_spans =
markdown::parse_inline_spans(&title_text, Style::default().fg(style::palette::text()));
let title_spans = truncate_spans_with_ellipsis(title_spans, title_column_width);
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 forge_indicator = session.forge_indicator();
let status_cell = if forge_indicator.is_empty() {
Cell::from(format!("{status}")).style(Style::default().fg(style::status_color(status)))
} else {
let review_state = session.review_request.as_ref().map(|rr| rr.summary.state);
let indicator_color = style::forge_indicator_color(review_state);
Cell::from(Line::from(vec![
Span::styled(
format!("{status} "),
Style::default().fg(style::status_color(status)),
),
Span::styled(forge_indicator, Style::default().fg(indicator_color)),
]))
};
let cells = vec![
Cell::from(Line::from(title_spans)),
Cell::from(session.model.as_str()),
Cell::from(session.size.to_string()).style(Style::default().fg(size_color(session.size))),
status_cell,
Cell::from(timer_label),
];
Row::new(cells)
.style(Style::default().fg(style::palette::text()))
.height(1)
}
pub(crate) fn project_column_width(sessions: &[Session]) -> Constraint {
text_column_width(
"Project",
sessions.iter().map(|session| session.project_name.as_str()),
)
}
pub(crate) fn model_column_width(sessions: &[Session]) -> Constraint {
text_column_width(
"Model",
sessions.iter().map(|session| session.model.as_str()),
)
}
fn size_column_width() -> Constraint {
text_column_width(
"Size",
SessionSize::ALL
.iter()
.map(std::string::ToString::to_string),
)
}
fn status_column_width(sessions: &[Session]) -> Constraint {
let static_width = text_column_width(
"Status",
Status::ALL.iter().map(std::string::ToString::to_string),
);
let session_width = text_column_width(
"Status",
sessions.iter().map(|session| {
let forge_indicator = session.forge_indicator();
if forge_indicator.is_empty() {
session.status.to_string()
} else {
format!("{} {forge_indicator}", session.status)
}
}),
);
match (static_width, session_width) {
(Constraint::Length(static_len), Constraint::Length(session_len)) => {
Constraint::Length(static_len.max(session_len))
}
_ => static_width,
}
}
fn timer_column_width(sessions: &[Session], wall_clock_unix_seconds: i64) -> Constraint {
text_column_width(
"Timer",
sessions
.iter()
.filter(|session| session.has_in_progress_timer())
.map(|session| {
format_duration_compact(
session.in_progress_duration_seconds(wall_clock_unix_seconds),
)
}),
)
}
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 text_column_width<T>(header: &str, values: impl Iterator<Item = T>) -> Constraint
where
T: AsRef<str>,
{
let column_width = values
.map(|value| value.as_ref().chars().count())
.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 std::path::PathBuf;
use ratatui::widgets::TableState;
use super::*;
use crate::agent::AgentModel;
use crate::domain::session::SessionStats;
use crate::domain::theme::ColorTheme;
fn test_session(id: &str, status: Status) -> Session {
Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: id.into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status,
summary: None,
title: Some(id.to_string()),
updated_at: 0,
workflow_notice: None,
}
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.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_content_chunks_use_shared_page_margin() {
let area = Rect::new(5, 3, 40, 10);
let (main_area, footer_area) = SessionListPage::content_chunks(area);
assert_eq!(main_area.x, area.x + PAGE_MARGIN);
assert_eq!(main_area.y, area.y + PAGE_MARGIN);
assert_eq!(main_area.width, area.width - PAGE_MARGIN.saturating_mul(2));
assert_eq!(
main_area.height,
area.height - PAGE_MARGIN.saturating_mul(2) - 1
);
assert_eq!(footer_area.x, area.x + PAGE_MARGIN);
assert_eq!(footer_area.y, area.y + area.height - PAGE_MARGIN - 1);
assert_eq!(
footer_area.width,
area.width - PAGE_MARGIN.saturating_mul(2)
);
assert_eq!(footer_area.height, 1);
}
#[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 wrapped_message = crate::ui::page::fyi::rotating_message(
crate::ui::page::fyi::session_list_messages(),
4,
);
assert_eq!(
first_message,
Some("Press Enter to open the selected session.")
);
assert_eq!(
second_message,
Some("Go to Settings to specify commands that can open within a session."),
);
assert_eq!(
third_message,
Some("Recommended: run Agentty in tmux to use session open commands.")
);
assert_eq!(
fourth_message,
Some("Agentty refreshes PR statuses every minute.")
);
assert_eq!(
wrapped_message,
Some("Press Enter to open the selected session.")
);
}
#[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![test_session("new-1", Status::Draft)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, 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_preferred_initial_session_index_prefers_active_group_when_available() {
let sessions = vec![
test_session("archive-1", Status::Done),
test_session("active-1", Status::Review),
test_session("merge-1", Status::Queued),
];
let selected_index = preferred_initial_session_index(&sessions);
assert_eq!(selected_index, Some(1));
}
#[test]
fn test_preferred_initial_session_index_falls_back_to_first_grouped_session() {
let sessions = vec![
test_session("archive-1", Status::Done),
test_session("merge-1", Status::Queued),
];
let selected_index = preferred_initial_session_index(&sessions);
assert_eq!(selected_index, Some(1));
}
#[test]
fn test_grouped_session_indexes_orders_selectable_sessions_without_headers() {
let sessions = vec![
test_session("active-1", Status::Review),
test_session("queued-1", Status::Queued),
test_session("merge-1", Status::Merging),
test_session("done-1", Status::Done),
test_session("canceled-1", Status::Canceled),
test_session("active-2", Status::Draft),
];
let indexes = grouped_session_indexes(&sessions);
let ordered_ids = indexes
.into_iter()
.map(|index| sessions[index].id.clone())
.collect::<Vec<_>>();
assert_eq!(
ordered_ids,
vec![
"queued-1".to_string(),
"merge-1".to_string(),
"active-1".to_string(),
"active-2".to_string(),
"done-1".to_string(),
"canceled-1".to_string(),
]
);
}
#[test]
fn test_grouped_session_rows_orders_merge_queue_before_active_and_archive_sessions() {
let sessions = vec![
test_session("active-1", Status::Review),
test_session("queued-1", Status::Queued),
test_session("merge-1", Status::Merging),
test_session("done-1", Status::Done),
test_session("canceled-1", Status::Canceled),
test_session("active-2", Status::Draft),
];
let rows = grouped_session_rows(&sessions);
let labels_and_ids = rows
.iter()
.map(|row| match row {
SessionTableRow::GroupLabel(group) => group.label().to_string(),
SessionTableRow::EmptyGroupPlaceholder => GROUP_EMPTY_PLACEHOLDER.to_string(),
SessionTableRow::Session(session) => session.id.to_string(),
})
.collect::<Vec<_>>();
assert_eq!(
labels_and_ids,
vec![
SessionGroup::MergeQueue.label().to_string(),
"queued-1".to_string(),
"merge-1".to_string(),
SessionGroup::ActiveSessions.label().to_string(),
"active-1".to_string(),
"active-2".to_string(),
SessionGroup::Archive.label().to_string(),
"done-1".to_string(),
"canceled-1".to_string(),
]
);
}
#[test]
fn test_grouped_session_rows_includes_placeholder_for_groups_without_sessions() {
let sessions = vec![
test_session("active-1", Status::Review),
test_session("active-2", Status::InProgress),
];
let rows = grouped_session_rows(&sessions);
let labels_and_ids = rows
.iter()
.map(|row| match row {
SessionTableRow::GroupLabel(group) => group.label().to_string(),
SessionTableRow::EmptyGroupPlaceholder => GROUP_EMPTY_PLACEHOLDER.to_string(),
SessionTableRow::Session(session) => session.id.to_string(),
})
.collect::<Vec<_>>();
assert_eq!(
labels_and_ids,
vec![
SessionGroup::MergeQueue.label().to_string(),
GROUP_EMPTY_PLACEHOLDER.to_string(),
SessionGroup::ActiveSessions.label().to_string(),
"active-1".to_string(),
"active-2".to_string(),
SessionGroup::Archive.label().to_string(),
GROUP_EMPTY_PLACEHOLDER.to_string(),
]
);
}
#[test]
fn test_selected_render_row_maps_original_selection_to_grouped_index() {
let sessions = vec![
test_session("active-1", Status::Review),
test_session("queued-1", Status::Queued),
test_session("merge-1", Status::Merging),
test_session("active-2", Status::Draft),
];
let rows = grouped_session_rows(&sessions);
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_project_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 = text_column_width("Project", project_names.into_iter());
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-4-6".chars().count()).unwrap_or(u16::MAX);
let models = ["gpt-5.4", "claude-sonnet-4-6"];
let width = text_column_width("Model", models.into_iter());
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_size_column_width_uses_header_width() {
let expected_width = u16::try_from("Size".chars().count()).unwrap_or(u16::MAX);
let width = size_column_width();
assert_eq!(width, Constraint::Length(expected_width));
}
#[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_width_expands_for_forge_indicator() {
use crate::domain::session::{
ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary,
};
let mut session = test_session("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 sessions = vec![session];
let expected_width = u16::try_from("Review ⊙ #42".chars().count()).unwrap_or(u16::MAX);
let width = status_column_width(&sessions);
assert_eq!(width, Constraint::Length(expected_width));
}
#[test]
fn test_timer_column_width_uses_longest_rendered_timer_label() {
let mut active_session = test_session("active-1", Status::InProgress);
active_session.in_progress_started_at = Some(100);
active_session.in_progress_total_seconds = 60;
let mut archived_session = test_session("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 width = timer_column_width(&sessions, 160);
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_session_list_help_line_includes_sync_for_non_empty_sessions() {
let session = test_session("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_hides_cancel_for_regular_new_session() {
let session = test_session("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 = test_session("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 = test_session("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 = test_session("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, 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 = test_session("done-1", Status::Done);
session.in_progress_total_seconds = 125;
let sessions = vec![session];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, 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![test_session("review-1", Status::AgentReview)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, 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 = test_session("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, 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 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![test_session("new-1", Status::Draft)];
terminal
.draw(|frame| {
SessionListPage::new(&sessions, &mut table_state, 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_eq!(title_cell.fg, style::palette::text());
assert_eq!(new_cell.fg, style::palette::text_muted());
assert_eq!(new_cell.bg, style::palette::surface());
assert_ne!(new_cell.fg, new_cell.bg);
}
}