use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::domain::agent::ReasoningLevel;
use crate::domain::input::{self, extract_at_mention_query};
use crate::domain::session::{Session, Status};
use crate::infra::agent::protocol::QuestionItem;
use crate::infra::file_index;
use crate::ui::component::chat_input::{ChatInput, SuggestionItem, SuggestionList};
use crate::ui::component::session_output::{SessionOutput, SessionOutputLineContext};
use crate::ui::state::app_mode::{AppMode, DoneSessionOutputMode, QuestionFocus};
use crate::ui::state::help_action::{self, ViewHelpState, ViewSessionState};
use crate::ui::state::prompt::{
PromptAtMentionState, PromptSlashState, build_prompt_slash_suggestion_list,
};
use crate::ui::util::{
calculate_input_height, format_duration_compact, format_token_count, inline_text,
question_panel_layout, suggestion_dropdown_height, truncate_spans_with_ellipsis,
truncate_with_ellipsis, wrap_lines,
};
use crate::ui::{Component, Page, markdown, style};
const CHAT_INPUT_MAX_PANEL_HEIGHT: u16 = 10;
const SESSION_HEADER_HEIGHT: u16 = 2;
struct PreparedPromptPanel {
footer_text: Line<'static>,
suggestion_list: Option<SuggestionList>,
title: String,
total_height: u16,
}
impl PreparedPromptPanel {
fn panel_height(&self) -> u16 {
self.total_height
}
}
pub struct SessionChatPage<'a> {
pub active_prompt_output: Option<&'a str>,
pub active_progress: Option<&'a str>,
pub can_open_worktree: bool,
pub default_reasoning_level: ReasoningLevel,
pub markdown_render_cache: Option<&'a markdown::MarkdownRenderCache>,
pub mode: &'a AppMode,
pub selected_follow_up_task_position: Option<usize>,
pub scroll_offset: Option<u16>,
pub session_index: usize,
pub sessions: &'a [Session],
pub wall_clock_unix_seconds: i64,
}
#[derive(Clone, Copy)]
pub struct SessionChatPageInput<'a> {
pub active_prompt_output: Option<&'a str>,
pub active_progress: Option<&'a str>,
pub default_reasoning_level: ReasoningLevel,
pub markdown_render_cache: &'a markdown::MarkdownRenderCache,
pub mode: &'a AppMode,
pub scroll_offset: Option<u16>,
pub session_index: usize,
pub sessions: &'a [Session],
pub wall_clock_unix_seconds: i64,
}
impl<'a> SessionChatPage<'a> {
const NEW_SESSION_PROMPT_FOOTER_ACTIONS: [help_action::HelpAction; 4] = [
help_action::HelpAction::new("stage draft", "Enter", "Stage draft"),
help_action::HelpAction::new("newline", "Alt+Enter", "Insert newline"),
help_action::HelpAction::new("paste image", "Ctrl+V/Alt+V", "Paste image"),
help_action::HelpAction::new("cancel", "Esc", "Cancel prompt"),
];
const PROMPT_FOOTER_ACTIONS: [help_action::HelpAction; 4] = [
help_action::HelpAction::new("submit", "Enter", "Submit prompt"),
help_action::HelpAction::new("newline", "Alt+Enter", "Insert newline"),
help_action::HelpAction::new("paste image", "Ctrl+V/Alt+V", "Paste image"),
help_action::HelpAction::new("cancel", "Esc", "Cancel prompt"),
];
pub fn new(input: SessionChatPageInput<'a>) -> Self {
let SessionChatPageInput {
active_prompt_output,
active_progress,
default_reasoning_level,
markdown_render_cache,
mode,
scroll_offset,
session_index,
sessions,
wall_clock_unix_seconds,
} = input;
Self {
active_prompt_output,
active_progress,
can_open_worktree: false,
default_reasoning_level,
markdown_render_cache: Some(markdown_render_cache),
mode,
selected_follow_up_task_position: None,
scroll_offset,
session_index,
sessions,
wall_clock_unix_seconds,
}
}
#[must_use]
pub fn selected_follow_up_task_position(mut self, position: Option<usize>) -> Self {
self.selected_follow_up_task_position = position;
self
}
#[must_use]
pub fn can_open_worktree(mut self, can_open_worktree: bool) -> Self {
self.can_open_worktree = can_open_worktree;
self
}
pub(crate) fn rendered_output_line_count(
session: &Session,
output_width: u16,
context: SessionOutputLineContext<'_>,
) -> u16 {
SessionOutput::rendered_line_count(session, output_width, context)
}
fn done_session_output_mode(&self) -> DoneSessionOutputMode {
match self.mode {
AppMode::View {
done_session_output_mode,
..
} => *done_session_output_mode,
AppMode::OpenCommandSelector { restore_view, .. }
| AppMode::PublishBranchInput { restore_view, .. } => {
restore_view.done_session_output_mode
}
AppMode::ViewInfoPopup { restore_view, .. } => restore_view.done_session_output_mode,
AppMode::List
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Prompt { .. }
| AppMode::Question { .. }
| AppMode::Diff { .. }
| AppMode::Help { .. } => DoneSessionOutputMode::Summary,
}
}
fn review_status_message(&self) -> Option<&str> {
match self.mode {
AppMode::View {
review_status_message,
..
}
| AppMode::Prompt {
review_status_message,
..
} => review_status_message.as_deref(),
AppMode::OpenCommandSelector { restore_view, .. }
| AppMode::PublishBranchInput { restore_view, .. }
| AppMode::ViewInfoPopup { restore_view, .. } => {
restore_view.review_status_message.as_deref()
}
AppMode::List
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Question { .. }
| AppMode::Diff { .. }
| AppMode::Help { .. } => None,
}
}
fn review_text(&self) -> Option<&str> {
match self.mode {
AppMode::View { review_text, .. } | AppMode::Prompt { review_text, .. } => {
review_text.as_deref()
}
AppMode::OpenCommandSelector { restore_view, .. }
| AppMode::PublishBranchInput { restore_view, .. }
| AppMode::ViewInfoPopup { restore_view, .. } => restore_view.review_text.as_deref(),
AppMode::List
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Question { .. }
| AppMode::Diff { .. }
| AppMode::Help { .. } => None,
}
}
fn build_at_mention_suggestion_list(
input_text: &str,
cursor: usize,
at_mention_state: &PromptAtMentionState,
) -> Option<SuggestionList> {
build_at_mention_suggestion_list_with_capacity(
input_text,
cursor,
at_mention_state,
AT_MENTION_DEFAULT_MAX_VISIBLE,
)
}
fn build_prompt_suggestion_list(
input: &input::InputState,
slash_state: &PromptSlashState,
at_mention_state: Option<&PromptAtMentionState>,
session: &Session,
) -> Option<SuggestionList> {
let input_text = input.text();
let cursor = input.cursor;
if input_text.starts_with('/') {
return build_prompt_slash_suggestion_list(
input_text,
slash_state,
session.model.kind(),
)
.map(Self::render_suggestion_list);
}
at_mention_state
.and_then(|state| Self::build_at_mention_suggestion_list(input_text, cursor, state))
}
fn prepare_prompt_panel(&self, area: Rect, session: &Session) -> Option<PreparedPromptPanel> {
let AppMode::Prompt {
at_mention_state,
attachment_state,
input,
slash_state,
..
} = self.mode
else {
return None;
};
let suggestion_list = Self::build_prompt_suggestion_list(
input,
slash_state,
at_mention_state.as_ref(),
session,
);
let dropdown_row_count = suggestion_list
.as_ref()
.map_or(0, |list| list.items.len().saturating_add(2));
let input_height = calculate_input_height(area.width.saturating_sub(2), input.text())
.min(CHAT_INPUT_MAX_PANEL_HEIGHT);
let desired_bottom_height = input_height
.saturating_add(u16::try_from(dropdown_row_count).unwrap_or(u16::MAX))
.saturating_add(1);
let max_bottom_height = area.height.saturating_sub(1);
Some(PreparedPromptPanel {
footer_text: Self::prompt_footer_line(session, attachment_state.attachments.len()),
suggestion_list,
title: format!(" [{}] ", session.model.as_str()),
total_height: desired_bottom_height.min(max_bottom_height),
})
}
fn render_suggestion_list(
suggestion_list: crate::ui::state::prompt::PromptSuggestionList,
) -> SuggestionList {
SuggestionList {
items: suggestion_list
.items
.into_iter()
.map(|item| SuggestionItem {
badge: item.badge,
detail: item.detail,
label: item.label,
metadata: item.metadata,
})
.collect(),
selected_index: suggestion_list.selected_index,
title: suggestion_list.title,
}
}
fn render_session(&self, f: &mut Frame, area: Rect, session: &Session) {
let prepared_prompt_panel = self.prepare_prompt_panel(area, session);
let bottom_height = prepared_prompt_panel.as_ref().map_or_else(
|| self.bottom_height(area),
PreparedPromptPanel::panel_height,
);
let chunks = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
.margin(1)
.split(area);
let output_chunks = Layout::default()
.constraints([
Constraint::Length(SESSION_HEADER_HEIGHT),
Constraint::Min(0),
])
.split(chunks[0]);
let mut output =
SessionOutput::new(session).done_session_output_mode(self.done_session_output_mode());
if let Some(cache) = self.markdown_render_cache {
output = output.markdown_render_cache(cache);
}
output = output.active_prompt_output(self.active_prompt_output);
output = output.review_status_message(self.review_status_message());
output = output.review_text(self.review_text());
output = output.selected_follow_up_task_position(self.selected_follow_up_task_position);
if let Some(scroll_offset) = self.scroll_offset {
output = output.scroll_offset(scroll_offset);
}
if let Some(active_progress) = self.active_progress {
output = output.active_progress(active_progress);
}
Self::render_session_header(
f,
output_chunks[0],
session,
self.default_reasoning_level,
self.wall_clock_unix_seconds,
);
output.render(f, output_chunks[1]);
self.render_bottom_panel(f, chunks[1], session, prepared_prompt_panel.as_ref());
}
fn render_session_header(
f: &mut Frame,
header_area: Rect,
session: &Session,
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) {
let header = Paragraph::new(Self::session_header_lines(
session,
header_area.width,
default_reasoning_level,
wall_clock_unix_seconds,
));
f.render_widget(header, header_area);
}
fn session_header_lines(
session: &Session,
header_width: u16,
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> Vec<Line<'static>> {
let title_width = usize::from(header_width);
let title_text = inline_text(session.display_title());
let base_style = Style::default()
.fg(session.status.color())
.add_modifier(Modifier::BOLD);
let title_spans = markdown::parse_inline_spans(&title_text, base_style);
let title_spans = truncate_spans_with_ellipsis(title_spans, title_width);
let metadata_text = Self::session_metadata_text(
session,
header_width,
default_reasoning_level,
wall_clock_unix_seconds,
);
vec![
Line::from(title_spans),
Line::from(Span::styled(
metadata_text,
Style::default().fg(style::palette::TEXT_MUTED),
)),
]
}
fn session_metadata_text(
session: &Session,
header_width: u16,
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
) -> String {
let added_lines = session.stats.added_lines;
let deleted_lines = session.stats.deleted_lines;
let timer =
format_duration_compact(session.in_progress_duration_seconds(wall_clock_unix_seconds));
let reasoning_level = session.effective_reasoning_level(default_reasoning_level);
let input_tokens = format_token_count(session.stats.input_tokens);
let output_tokens = format_token_count(session.stats.output_tokens);
let metadata = format!(
"Size: {} Lines: +{added_lines} / -{deleted_lines} Timer: {timer} Model: {} \
Reasoning: {} Tokens: {input_tokens}/{output_tokens}",
session.size,
session.model.as_str(),
reasoning_level.as_str(),
);
let metadata_width = usize::from(header_width);
truncate_with_ellipsis(&metadata, metadata_width)
}
fn bottom_height(&self, area: Rect) -> u16 {
if matches!(self.mode, AppMode::Prompt { .. }) {
let Some(session) = self.sessions.get(self.session_index) else {
return 1;
};
return self
.prepare_prompt_panel(area, session)
.map_or(1, |prepared_prompt_panel| {
prepared_prompt_panel.panel_height()
});
}
if let AppMode::Question {
questions,
current_index,
input,
selected_option_index,
..
} = self.mode
{
let question_item = questions.get(*current_index);
let question = question_item.map_or("", |item| item.text.as_str());
let options = question_item
.map(|item| item.options.as_slice())
.unwrap_or_default();
let is_free_text_mode = selected_option_index.is_none();
let options_height = question_options_height(options, area.height.saturating_sub(1));
let input_text = if is_free_text_mode { input.text() } else { "" };
let layout_available_height =
area.height.saturating_sub(1).saturating_sub(options_height);
let panel_layout = question_panel_layout(
area.width,
layout_available_height,
question,
input_text,
CHAT_INPUT_MAX_PANEL_HEIGHT,
);
return panel_layout
.question_height
.saturating_add(options_height)
.saturating_add(panel_layout.spacer_height)
.saturating_add(panel_layout.input_height)
.saturating_add(panel_layout.help_height);
}
1
}
fn render_bottom_panel(
&self,
f: &mut Frame,
bottom_area: Rect,
session: &Session,
prepared_prompt_panel: Option<&PreparedPromptPanel>,
) {
if let AppMode::Prompt { input, .. } = self.mode {
let Some(prepared_prompt_panel) = prepared_prompt_panel else {
return;
};
let mut chat_input =
ChatInput::new(&prepared_prompt_panel.title, input.text(), input.cursor)
.placeholder("Type your message");
if let Some(suggestion_list) = &prepared_prompt_panel.suggestion_list {
chat_input = chat_input.suggestion_list(suggestion_list);
}
if bottom_area.height <= 1 {
chat_input.render(f, bottom_area);
return;
}
let sections = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(bottom_area);
chat_input.render(f, sections[0]);
f.render_widget(
Paragraph::new(prepared_prompt_panel.footer_text.clone()),
sections[1],
);
return;
}
if let AppMode::Question {
at_mention_state,
focus,
questions,
current_index,
input,
selected_option_index,
..
} = self.mode
{
render_question_panel(
f,
bottom_area,
&QuestionPanelState {
at_mention_state: at_mention_state.as_ref(),
current_index: *current_index,
focus: *focus,
input,
questions,
selected_option_index: *selected_option_index,
},
);
return;
}
let help_actions = Self::view_footer_actions(
session,
self.can_open_worktree,
self.done_session_output_mode(),
self.selected_follow_up_task_position,
);
let help_message = Paragraph::new(help_action::footer_line(&help_actions));
f.render_widget(help_message, bottom_area);
}
fn view_footer_actions(
session: &Session,
can_open_worktree: bool,
done_session_output_mode: DoneSessionOutputMode,
selected_follow_up_task_position: Option<usize>,
) -> Vec<help_action::HelpAction> {
let session_state = help_action::session_view_state(session);
let selected_follow_up_task_position =
Self::resolved_follow_up_task_position(session, selected_follow_up_task_position);
let selected_follow_up_task_action = selected_follow_up_task_position
.and_then(|position| session.follow_up_task(position))
.map(crate::domain::session::SessionFollowUpTask::action);
let mut actions = help_action::view_footer_actions(ViewHelpState {
can_open_worktree,
can_sync_review_request: session.can_sync_review_request(),
follow_up_task_action: selected_follow_up_task_action,
has_multiple_follow_up_tasks: session.follow_up_tasks.len() > 1,
publish_pull_request_action: session.publish_pull_request_action(),
session_state,
});
if session_state == ViewSessionState::Done {
let toggle_action_label = Self::done_toggle_action_label(done_session_output_mode);
if let Some(toggle_action_index) = actions.iter().position(|action| action.key == "t") {
actions[toggle_action_index] =
help_action::HelpAction::new(toggle_action_label, "t", "Switch summary/output");
}
}
actions
}
fn resolved_follow_up_task_position(
session: &Session,
selected_follow_up_task_position: Option<usize>,
) -> Option<usize> {
if let Some(selected_follow_up_task_position) = selected_follow_up_task_position {
return Some(selected_follow_up_task_position);
}
session.follow_up_tasks.first().map(|task| task.position)
}
fn done_toggle_action_label(done_session_output_mode: DoneSessionOutputMode) -> &'static str {
match done_session_output_mode {
DoneSessionOutputMode::Summary => "output",
DoneSessionOutputMode::Output | DoneSessionOutputMode::Review => "summary",
}
}
fn prompt_footer_line(session: &Session, attachment_count: usize) -> Line<'static> {
let mut footer_line = help_action::footer_line(Self::prompt_footer_actions(session));
if attachment_count > 0 {
let suffix = if attachment_count == 1 { "" } else { "s" };
Self::append_prompt_footer_note(
&mut footer_line,
format!("{attachment_count} image{suffix} ready"),
);
}
footer_line
}
fn prompt_footer_actions(session: &Session) -> &'static [help_action::HelpAction] {
if session.status == Status::New && session.is_draft_session() {
return &Self::NEW_SESSION_PROMPT_FOOTER_ACTIONS;
}
&Self::PROMPT_FOOTER_ACTIONS
}
fn append_prompt_footer_note(footer_line: &mut Line<'static>, note: String) {
footer_line.spans.push(help_action::footer_separator_span());
footer_line.spans.push(help_action::footer_muted_span(note));
}
}
#[derive(Clone, Copy)]
struct QuestionPanelState<'a> {
at_mention_state: Option<&'a PromptAtMentionState>,
current_index: usize,
focus: QuestionFocus,
input: &'a input::InputState,
questions: &'a [QuestionItem],
selected_option_index: Option<usize>,
}
fn render_question_panel(f: &mut Frame, bottom_area: Rect, state: &QuestionPanelState<'_>) {
let QuestionPanelState {
at_mention_state,
current_index,
focus,
input,
questions,
selected_option_index,
} = *state;
let question_item = questions.get(current_index);
let question = question_item.map_or("", |item| item.text.as_str());
let options = question_item
.map(|item| item.options.as_slice())
.unwrap_or_default();
let is_free_text_mode = selected_option_index.is_none();
let options_height = question_options_height(options, bottom_area.height);
let layout_available_height = bottom_area.height.saturating_sub(options_height);
let input_text = if is_free_text_mode { input.text() } else { "" };
let panel_layout = question_panel_layout(
bottom_area.width,
layout_available_height,
question,
input_text,
CHAT_INPUT_MAX_PANEL_HEIGHT,
);
let chunks = Layout::default()
.constraints([
Constraint::Length(panel_layout.question_height),
Constraint::Length(options_height),
Constraint::Length(panel_layout.spacer_height),
Constraint::Length(panel_layout.input_height),
Constraint::Length(panel_layout.help_height),
])
.split(bottom_area);
let is_chat_focused = focus == QuestionFocus::Chat;
let question_title = format!("Question {}/{}", current_index + 1, questions.len());
if panel_layout.question_height > 0 {
let title_color = if is_chat_focused {
style::palette::TEXT_MUTED
} else {
style::palette::QUESTION
};
let title_line = Line::from(Span::styled(
&question_title,
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
));
let text_color = if is_chat_focused {
style::palette::TEXT_MUTED
} else {
Color::Yellow
};
let mut lines = vec![title_line];
lines.extend(
wrap_lines(question, usize::from(bottom_area.width.max(1)))
.into_iter()
.map(|line| line.style(Style::default().fg(text_color))),
);
let question_para = Paragraph::new(lines);
f.render_widget(question_para, chunks[0]);
}
if options_height > 0 {
render_question_options(
f,
chunks[1],
options,
selected_option_index,
is_chat_focused,
);
}
let (display_text, display_cursor) = if is_free_text_mode {
(input.text(), input.cursor)
} else {
("", 0)
};
let input_placeholder = "Type answer (Enter: send, Esc: end turn)";
let available_above = usize::from(chunks[3].y.saturating_sub(bottom_area.y));
let at_mention_max_visible = available_above
.saturating_sub(2)
.clamp(1, AT_MENTION_DEFAULT_MAX_VISIBLE);
let at_mention_menu = if is_free_text_mode {
at_mention_state.and_then(|state| {
build_at_mention_suggestion_list_with_capacity(
display_text,
display_cursor,
state,
at_mention_max_visible,
)
})
} else {
None
};
let chat_input = ChatInput::new("Answer", display_text, display_cursor)
.placeholder(input_placeholder)
.active(is_free_text_mode && !is_chat_focused);
if panel_layout.input_height > 0 {
chat_input.render(f, chunks[3]);
}
render_question_at_mention_overlay(f, bottom_area, chunks[3], at_mention_menu);
render_question_help_footer(f, chunks[4], panel_layout.help_height, focus);
}
fn render_question_help_footer(f: &mut Frame, area: Rect, help_height: u16, focus: QuestionFocus) {
if help_height == 0 {
return;
}
let is_chat_focused = focus == QuestionFocus::Chat;
let mut help_actions = Vec::new();
if is_chat_focused {
help_actions.push(help_action::HelpAction::new("scroll", "j/k", "Scroll chat"));
help_actions.push(help_action::HelpAction::new("diff", "d", "Diff"));
help_actions.push(help_action::HelpAction::new(
"answer",
"Esc/Enter",
"Answer",
));
} else {
help_actions.push(help_action::HelpAction::new("send", "Enter", "Submit"));
}
let focus_label = if is_chat_focused { "Answer" } else { "Chat" };
help_actions.push(help_action::HelpAction::new("focus", "Tab", focus_label));
if !is_chat_focused {
help_actions.push(help_action::HelpAction::new("end turn", "Esc", "End turn"));
}
let help_para = Paragraph::new(help_action::footer_line(&help_actions))
.alignment(ratatui::layout::Alignment::Right);
f.render_widget(help_para, area);
}
fn render_question_at_mention_overlay(
f: &mut Frame,
bottom_area: Rect,
input_area: Rect,
at_mention_menu: Option<SuggestionList>,
) {
let Some(menu) = at_mention_menu else {
return;
};
let dropdown_height = suggestion_dropdown_height(menu.items.len());
let available_above = input_area.y.saturating_sub(bottom_area.y);
let clamped_height = dropdown_height.min(available_above);
if clamped_height > 0 {
let dropdown_area = Rect::new(
input_area.x,
input_area.y.saturating_sub(clamped_height),
input_area.width,
clamped_height,
);
ChatInput::render_suggestion_dropdown(f, dropdown_area, &menu);
}
}
const AT_MENTION_DEFAULT_MAX_VISIBLE: usize = 10;
fn build_at_mention_suggestion_list_with_capacity(
input_text: &str,
cursor: usize,
at_mention_state: &PromptAtMentionState,
max_visible: usize,
) -> Option<SuggestionList> {
let (_, query) = extract_at_mention_query(input_text, cursor)?;
let filtered = file_index::filter_entries(&at_mention_state.all_entries, &query);
if filtered.is_empty() {
return None;
}
let window_start = at_mention_state
.selected_index
.saturating_sub(max_visible / 2);
let window_end = filtered.len().min(window_start + max_visible);
let window_start = window_end.saturating_sub(max_visible);
let items: Vec<SuggestionItem> = filtered[window_start..window_end]
.iter()
.map(|entry| {
let label = if entry.is_dir {
format!("{}/", entry.path)
} else {
entry.path.clone()
};
SuggestionItem {
badge: None,
detail: entry.is_dir.then(|| "folder".to_string()),
label,
metadata: None,
}
})
.collect();
let display_index = at_mention_state
.selected_index
.min(filtered.len().saturating_sub(1))
.saturating_sub(window_start);
Some(SuggestionList {
items,
selected_index: display_index,
title: "Files (\u{2191}\u{2193} move, Enter select, Esc dismiss)".to_string(),
})
}
fn question_options_height(options: &[String], max_height: u16) -> u16 {
if options.is_empty() {
return 0;
}
u16::try_from(options.len())
.unwrap_or(u16::MAX)
.saturating_add(1) .min(max_height)
}
fn render_question_options(
f: &mut Frame,
area: Rect,
options: &[String],
selected_option_index: Option<usize>,
dimmed: bool,
) {
let mut lines: Vec<Line<'_>> = Vec::with_capacity(options.len() + 1);
let header_color = if dimmed {
style::palette::TEXT_MUTED
} else {
Color::Yellow
};
lines.push(Line::from(Span::styled(
"Options:",
Style::default().fg(header_color),
)));
for (option_index, option_text) in options.iter().enumerate() {
let is_selected = selected_option_index == Some(option_index);
let prefix = if is_selected { "â–¸ " } else { " " };
let label = format!("{prefix}{}. {option_text}", option_index + 1);
let style = if dimmed {
Style::default().fg(style::palette::TEXT_MUTED)
} else if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(label, style)));
}
f.render_widget(Paragraph::new(lines), area);
}
impl Page for SessionChatPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
if let Some(session) = self.sessions.get(self.session_index) {
self.render_session(f, area, session);
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::agent::AgentModel;
use crate::domain::agent::{AgentKind, ReasoningLevel};
use crate::domain::input::InputState;
use crate::domain::session::{SessionSize, SessionStats};
use crate::infra::agent::protocol::QuestionItem;
use crate::infra::file_index::FileEntry;
use crate::ui::state::app_mode::QuestionFocus;
use crate::ui::state::prompt::{PromptAttachmentState, PromptHistoryState, PromptSlashState};
fn session_fixture() -> Session {
Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: std::env::temp_dir(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
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(),
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: Status::New,
summary: None,
title: None,
updated_at: 0,
}
}
fn test_session_chat_page<'a>(session: &'a Session, mode: &'a AppMode) -> SessionChatPage<'a> {
SessionChatPage::new(SessionChatPageInput {
active_prompt_output: None,
active_progress: None,
default_reasoning_level: ReasoningLevel::default(),
markdown_render_cache: test_markdown_render_cache(),
mode,
scroll_offset: None,
session_index: 0,
sessions: std::slice::from_ref(session),
wall_clock_unix_seconds: 0,
})
}
fn test_markdown_render_cache() -> &'static markdown::MarkdownRenderCache {
Box::leak(Box::new(markdown::MarkdownRenderCache::default()))
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn buffer_row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String {
let start = usize::from(row) * usize::from(width);
let end = start + usize::from(width);
buffer.content()[start..end]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn view_help_text(
session: &Session,
can_open_worktree: bool,
done_session_output_mode: DoneSessionOutputMode,
) -> String {
help_action::footer_line(&SessionChatPage::view_footer_actions(
session,
can_open_worktree,
done_session_output_mode,
None,
))
.to_string()
}
#[test]
fn test_view_help_text_defaults_first_follow_up_task_to_launch_action() {
let mut session = session_fixture();
session.follow_up_tasks = vec![crate::domain::session::SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Launch the sibling task.".to_string(),
}];
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(help_text.contains("launch task"));
assert!(help_text.contains('l'));
}
#[test]
fn test_build_slash_suggestion_list_for_command_stage_has_description() {
let slash_state = PromptSlashState::new();
let menu = SessionChatPage::render_suggestion_list(
build_prompt_slash_suggestion_list("/m", &slash_state, AgentKind::Codex)
.expect("expected suggestion list"),
);
assert_eq!(menu.items.len(), 1);
assert_eq!(menu.items[0].label, "/model");
assert_eq!(
menu.items[0].detail,
Some("Choose an agent and model for this session.".to_string())
);
}
#[test]
fn test_build_slash_suggestion_list_for_agent_stage_has_agent_descriptions() {
let mut slash_state = PromptSlashState::new();
slash_state.stage = crate::ui::state::prompt::PromptSlashStage::Agent;
let menu = SessionChatPage::render_suggestion_list(
build_prompt_slash_suggestion_list("/model", &slash_state, AgentKind::Codex)
.expect("expected suggestion list"),
);
assert_eq!(menu.items.len(), AgentKind::ALL.len());
assert_eq!(menu.items[0].label, "gemini");
assert_eq!(
menu.items[0].detail,
Some("Google Gemini CLI agent.".to_string())
);
}
#[test]
fn test_build_slash_suggestion_list_for_agent_stage_filters_available_agents() {
let mut slash_state = PromptSlashState::with_available_agent_kinds(vec![AgentKind::Codex]);
slash_state.stage = crate::ui::state::prompt::PromptSlashStage::Agent;
let menu = SessionChatPage::render_suggestion_list(
build_prompt_slash_suggestion_list("/model", &slash_state, AgentKind::Codex)
.expect("expected suggestion list"),
);
assert_eq!(menu.items.len(), 1);
assert_eq!(menu.items[0].label, "codex");
}
#[test]
fn test_build_slash_suggestion_list_for_model_stage_has_model_descriptions() {
let mut slash_state = PromptSlashState::new();
slash_state.stage = crate::ui::state::prompt::PromptSlashStage::Model;
slash_state.selected_agent = Some(AgentKind::Codex);
let menu = SessionChatPage::render_suggestion_list(
build_prompt_slash_suggestion_list("/model", &slash_state, AgentKind::Codex)
.expect("expected suggestion list"),
);
assert_eq!(menu.items.len(), AgentKind::Codex.models().len());
assert_eq!(menu.items[0].label, "gpt-5.4");
assert_eq!(
menu.items[0].detail,
Some("Latest Codex model for coding quality.".to_string())
);
}
fn file_entries_fixture() -> Vec<FileEntry> {
vec![
FileEntry {
is_dir: true,
path: "src".to_string(),
},
FileEntry {
is_dir: true,
path: "tests".to_string(),
},
FileEntry {
is_dir: false,
path: "Cargo.toml".to_string(),
},
FileEntry {
is_dir: false,
path: "README.md".to_string(),
},
FileEntry {
is_dir: false,
path: "src/lib.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "tests/integration.rs".to_string(),
},
]
}
#[test]
fn test_build_at_mention_suggestion_list_with_matches() {
let state = PromptAtMentionState::new(file_entries_fixture());
let menu = SessionChatPage::build_at_mention_suggestion_list("@src", 4, &state)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 3);
assert_eq!(menu.items[0].label, "src/");
assert_eq!(menu.items[0].detail, Some("folder".to_string()));
assert_eq!(menu.items[1].label, "src/lib.rs");
assert_eq!(menu.items[2].label, "src/main.rs");
}
#[test]
fn test_build_at_mention_suggestion_list_with_trailing_slash_includes_exact_directory() {
let state = PromptAtMentionState::new(file_entries_fixture());
let menu = SessionChatPage::build_at_mention_suggestion_list("@src/", 5, &state)
.expect("expected suggestion list");
assert_eq!(menu.items[0].label, "src/");
assert_eq!(menu.items[0].detail, Some("folder".to_string()));
assert_eq!(menu.items[1].label, "src/lib.rs");
assert_eq!(menu.items[2].label, "src/main.rs");
}
#[test]
fn test_build_at_mention_suggestion_list_no_matches() {
let state = PromptAtMentionState::new(file_entries_fixture());
let menu = SessionChatPage::build_at_mention_suggestion_list("@nonexistent", 12, &state);
assert!(menu.is_none());
}
#[test]
fn test_build_at_mention_suggestion_list_empty_query_returns_all() {
let state = PromptAtMentionState::new(file_entries_fixture());
let menu = SessionChatPage::build_at_mention_suggestion_list("@", 1, &state)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 7);
}
#[test]
fn test_build_at_mention_suggestion_list_caps_at_10() {
let entries: Vec<FileEntry> = (0..20)
.map(|index| FileEntry {
is_dir: false,
path: format!("file_{index:02}.rs"),
})
.collect();
let state = PromptAtMentionState::new(entries);
let menu = SessionChatPage::build_at_mention_suggestion_list("@", 1, &state)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 10);
}
#[test]
fn test_build_at_mention_suggestion_list_respects_capacity() {
let entries: Vec<FileEntry> = (0..20)
.map(|index| FileEntry {
is_dir: false,
path: format!("file_{index:02}.rs"),
})
.collect();
let state = PromptAtMentionState::new(entries);
let menu = build_at_mention_suggestion_list_with_capacity("@", 1, &state, 5)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 5);
}
#[test]
fn test_build_at_mention_suggestion_list_capacity_scroll_keeps_selection_visible() {
let entries: Vec<FileEntry> = (0..20)
.map(|index| FileEntry {
is_dir: false,
path: format!("file_{index:02}.rs"),
})
.collect();
let mut state = PromptAtMentionState::new(entries);
state.selected_index = 18;
let menu = build_at_mention_suggestion_list_with_capacity("@", 1, &state, 5)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 5);
assert!(
menu.selected_index < menu.items.len(),
"selected_index {} must be < items.len() {}",
menu.selected_index,
menu.items.len()
);
assert_eq!(menu.items[0].label, "file_15.rs");
assert_eq!(menu.items[4].label, "file_19.rs");
}
#[test]
fn test_build_at_mention_suggestion_list_clamps_selected_index() {
let mut state = PromptAtMentionState::new(file_entries_fixture());
state.selected_index = 100;
let menu = SessionChatPage::build_at_mention_suggestion_list("@src", 4, &state)
.expect("expected suggestion list");
assert_eq!(menu.selected_index, 2);
}
#[test]
fn test_build_at_mention_suggestion_list_scroll_window() {
let entries: Vec<FileEntry> = (0..20)
.map(|index| FileEntry {
is_dir: false,
path: format!("file_{index:02}.rs"),
})
.collect();
let mut state = PromptAtMentionState::new(entries);
state.selected_index = 15;
let menu = SessionChatPage::build_at_mention_suggestion_list("@", 1, &state)
.expect("expected suggestion list");
assert_eq!(menu.items.len(), 10);
assert_eq!(menu.items[0].label, "file_10.rs");
assert_eq!(menu.items[9].label, "file_19.rs");
assert_eq!(menu.selected_index, 5); }
#[test]
fn test_build_at_mention_suggestion_list_directory_has_trailing_slash() {
let entries = vec![
FileEntry {
is_dir: true,
path: "src".to_string(),
},
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
];
let state = PromptAtMentionState::new(entries);
let menu = SessionChatPage::build_at_mention_suggestion_list("@src", 4, &state)
.expect("expected suggestion list");
assert_eq!(menu.items[0].label, "src/");
assert_eq!(menu.items[0].detail, Some("folder".to_string()));
assert_eq!(menu.items[1].label, "src/main.rs");
assert_eq!(menu.items[1].detail, None);
}
#[test]
fn test_build_slash_suggestion_list_for_command_stage_includes_commands() {
let menu = SessionChatPage::render_suggestion_list(
build_prompt_slash_suggestion_list("/", &PromptSlashState::new(), AgentKind::Codex)
.expect("expected suggestion list"),
);
let labels: Vec<&str> = menu.items.iter().map(|opt| opt.label.as_str()).collect();
assert!(labels.contains(&"/model"));
assert!(labels.contains(&"/reasoning"));
assert!(labels.contains(&"/stats"));
}
#[test]
fn test_rendered_output_line_count_counts_wrapped_content() {
let mut session = session_fixture();
session.output = "word ".repeat(40);
let raw_line_count = u16::try_from(session.output.lines().count()).unwrap_or(u16::MAX);
let rendered_line_count = SessionChatPage::rendered_output_line_count(
&session,
20,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
done_session_output_mode: DoneSessionOutputMode::Summary,
review_status_message: None,
review_text: None,
selected_follow_up_task_position: None,
},
);
assert!(rendered_line_count > raw_line_count);
}
#[test]
fn test_rendered_output_line_count_review_mode_includes_follow_up_tasks() {
let mut session = session_fixture();
session.status = Status::Review;
session.follow_up_tasks = vec![crate::domain::session::SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Run cargo test -q.".to_string(),
}];
let rendered_line_count = SessionChatPage::rendered_output_line_count(
&session,
80,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
done_session_output_mode: DoneSessionOutputMode::Review,
review_status_message: Some("Reviewing changes with gpt-5.4"),
review_text: None,
selected_follow_up_task_position: None,
},
);
assert!(rendered_line_count > 2);
}
#[test]
fn test_view_help_text_in_progress_shows_stop_and_open_and_hides_diff() {
let mut session = session_fixture();
session.status = Status::InProgress;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(help_text.contains("q: back"));
assert!(help_text.contains("Ctrl+c: stop"));
assert!(help_text.contains("j/k: scroll"));
assert!(help_text.contains("o: open"));
assert!(!help_text.contains("d: diff"));
assert!(!help_text.contains("Enter: reply"));
}
#[test]
fn test_view_help_text_rebasing_keeps_open() {
let mut session = session_fixture();
session.status = Status::Rebasing;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(help_text.contains("q: back"));
assert!(help_text.contains("j/k: scroll"));
assert!(help_text.contains("o: open"));
assert!(!help_text.contains("Ctrl+c: stop"));
assert!(!help_text.contains("Enter: reply"));
assert!(!help_text.contains("d: diff"));
}
#[test]
fn test_view_help_text_merge_queue_statuses_hide_worktree_open_hint() {
let merge_queue_statuses = [Status::Queued, Status::Merging];
let help_texts: Vec<String> = merge_queue_statuses
.iter()
.map(|session_status| {
let mut session = session_fixture();
session.status = *session_status;
view_help_text(&session, true, DoneSessionOutputMode::Summary)
})
.collect();
for help_text in help_texts {
assert!(help_text.contains("q: back"));
assert!(help_text.contains("j/k: scroll"));
assert!(!help_text.contains("o: open"));
assert!(!help_text.contains("Ctrl+c: stop"));
}
}
#[test]
fn test_view_help_text_canceled_shows_only_back_scroll_and_help() {
let mut session = session_fixture();
session.status = Status::Canceled;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert_eq!(help_text, "q: back | j/k: scroll | ?: help");
}
#[test]
fn test_view_help_text_new_session_shows_draft_and_start_actions() {
let mut session = session_fixture();
session.folder = PathBuf::new();
session.is_draft = true;
let help_text = view_help_text(&session, false, DoneSessionOutputMode::Summary);
assert!(help_text.contains("Enter: add draft"));
assert!(help_text.contains("s: start"));
assert!(!help_text.contains("o: open"));
assert!(help_text.contains("m: add to merge queue"));
assert!(help_text.contains("r: rebase"));
assert!(help_text.contains("/: commands menu"));
assert!(!help_text.contains("d: diff"));
}
#[test]
fn test_view_help_text_review_shows_review_and_hides_diff_hint() {
let mut session = session_fixture();
session.status = Status::Review;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(!help_text.contains("d: diff"));
assert!(help_text.contains("f: review"));
assert!(help_text.contains("Enter: reply"));
assert!(help_text.contains("/: commands menu"));
}
#[test]
fn test_view_help_text_review_without_link_shows_push_branch_hint() {
let mut session = session_fixture();
session.status = Status::Review;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(help_text.contains("p: PR"));
}
#[test]
fn test_view_help_text_agent_review_hides_rebase_hint() {
let mut session = session_fixture();
session.status = Status::AgentReview;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(help_text.contains("f: review"));
assert!(help_text.contains("Enter: reply"));
assert!(help_text.contains("m: add to merge queue"));
assert!(help_text.contains("p: PR"));
assert!(!help_text.contains("r: rebase"));
}
#[test]
fn test_view_help_text_done_hides_open_hint() {
let mut session = session_fixture();
session.status = Status::Done;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Summary);
assert!(!help_text.contains("o: open"));
assert!(help_text.contains("t: output"));
assert!(help_text.contains("j/k: scroll"));
}
#[test]
fn test_view_help_text_done_output_mode_shows_summary_toggle_hint() {
let mut session = session_fixture();
session.status = Status::Done;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Output);
assert!(help_text.contains("t: summary"));
}
#[test]
fn test_view_help_text_done_review_mode_shows_summary_toggle_hint() {
let mut session = session_fixture();
session.status = Status::Done;
let help_text = view_help_text(&session, true, DoneSessionOutputMode::Review);
assert!(help_text.contains("t: summary"));
}
#[test]
fn test_bottom_height_caps_prompt_input_panel_to_ten_lines() {
let session = session_fixture();
let mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::default(),
review_status_message: None,
review_text: None,
slash_state: PromptSlashState::new(),
session_id: "session-id".to_string(),
input: InputState::with_text("line\n".repeat(80)),
scroll_offset: None,
};
let page = SessionChatPage::new(SessionChatPageInput {
active_prompt_output: None,
active_progress: None,
default_reasoning_level: ReasoningLevel::default(),
markdown_render_cache: test_markdown_render_cache(),
mode: &mode,
scroll_offset: None,
session_index: 0,
sessions: std::slice::from_ref(&session),
wall_clock_unix_seconds: 0,
});
let area = Rect::new(0, 0, 120, 30);
let bottom_height = page.bottom_height(area);
assert_eq!(bottom_height, CHAT_INPUT_MAX_PANEL_HEIGHT + 1);
}
#[test]
fn test_bottom_height_preserves_space_for_output_area() {
let session = session_fixture();
let mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::default(),
review_status_message: None,
review_text: None,
slash_state: PromptSlashState::new(),
session_id: "session-id".to_string(),
input: InputState::with_text("line\n".repeat(80)),
scroll_offset: None,
};
let page = test_session_chat_page(&session, &mode);
let area = Rect::new(0, 0, 120, 8);
let bottom_height = page.bottom_height(area);
assert_eq!(bottom_height, 7);
}
#[test]
fn test_review_text_reads_preserved_prompt_review_output() {
let session = session_fixture();
let mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::default(),
review_status_message: None,
review_text: Some("Focused review".to_string()),
slash_state: PromptSlashState::new(),
session_id: "session-id".to_string(),
input: InputState::default(),
scroll_offset: None,
};
let page = test_session_chat_page(&session, &mode);
let review_text = page.review_text();
assert_eq!(review_text, Some("Focused review"));
}
#[test]
fn test_prompt_footer_line_shows_highlighted_actions_and_attachment_count() {
let session = session_fixture();
let attachment_count = 2;
let footer_line = SessionChatPage::prompt_footer_line(&session, attachment_count);
assert_eq!(
footer_line.to_string(),
"Enter: submit | Alt+Enter: newline | Ctrl+V/Alt+V: paste image | Esc: cancel | 2 \
images ready"
);
assert_eq!(
footer_line.spans[0].style,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
);
assert_eq!(footer_line.spans[1].style, Style::default().fg(Color::Gray));
assert_eq!(
footer_line.spans[footer_line.spans.len() - 2].style,
Style::default().fg(Color::DarkGray)
);
assert_eq!(
footer_line.spans[footer_line.spans.len() - 1].style,
Style::default().fg(Color::Gray)
);
assert!(!footer_line.to_string().contains("send images with Codex"));
}
#[test]
fn test_prompt_footer_line_omits_legacy_backend_warning() {
let session = session_fixture();
let attachment_count = 1;
let footer_line = SessionChatPage::prompt_footer_line(&session, attachment_count);
assert!(footer_line.to_string().contains("1 image ready"));
assert!(!footer_line.to_string().contains("send images with Codex"));
}
#[test]
fn test_prompt_footer_line_uses_stage_label_for_new_sessions() {
let mut session = session_fixture();
session.status = Status::New;
session.is_draft = true;
let footer_line = SessionChatPage::prompt_footer_line(&session, 0);
assert!(footer_line.to_string().contains("Enter: stage draft"));
assert!(!footer_line.to_string().contains("Enter: submit"));
}
#[test]
fn test_bottom_height_question_mode_includes_question_input_and_help_rows() {
let session = session_fixture();
let question = "Need an explicit migration plan?".to_string();
let answer = "Use two phases: schema and runtime.";
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: Vec::new(),
text: question.clone(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::with_text(answer.to_string()),
scroll_offset: None,
selected_option_index: None,
};
let page = test_session_chat_page(&session, &mode);
let area = Rect::new(0, 0, 120, 30);
let options_height = question_options_height(&[], area.height.saturating_sub(1));
let layout_available = area.height.saturating_sub(1).saturating_sub(options_height);
let panel_layout = question_panel_layout(
area.width,
layout_available,
&question,
answer,
CHAT_INPUT_MAX_PANEL_HEIGHT,
);
let expected_height = panel_layout
.question_height
.saturating_add(options_height)
.saturating_add(panel_layout.spacer_height)
.saturating_add(panel_layout.input_height)
.saturating_add(panel_layout.help_height);
let bottom_height = page.bottom_height(area);
assert_eq!(bottom_height, expected_height);
}
#[test]
fn test_bottom_height_question_mode_preserves_space_for_output_area() {
let session = session_fixture();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Need details?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::with_text("answer\n".repeat(50)),
scroll_offset: None,
selected_option_index: None,
};
let page = test_session_chat_page(&session, &mode);
let area = Rect::new(0, 0, 120, 8);
let bottom_height = page.bottom_height(area);
assert_eq!(bottom_height, 7);
}
#[test]
fn test_bottom_height_question_mode_includes_options_height() {
let session = session_fixture();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Continue?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let page = test_session_chat_page(&session, &mode);
let area = Rect::new(0, 0, 80, 20);
let bottom_height = page.bottom_height(area);
let options_height: u16 = 3;
let layout_available = area.height.saturating_sub(1).saturating_sub(options_height);
let panel_layout = question_panel_layout(
area.width,
layout_available,
"Continue?",
"",
CHAT_INPUT_MAX_PANEL_HEIGHT,
);
let expected = panel_layout
.question_height
.saturating_add(options_height)
.saturating_add(panel_layout.spacer_height)
.saturating_add(panel_layout.input_height)
.saturating_add(panel_layout.help_height);
assert_eq!(bottom_height, expected);
assert!(
bottom_height > 3,
"should have room for question, options, input and help"
);
}
#[test]
fn test_render_places_session_header_above_output_border() {
let mut session = session_fixture();
session.title = Some("Header Session".to_string());
session.model = AgentModel::Gpt54;
session.stats.added_lines = 12;
session.stats.deleted_lines = 4;
session.stats.input_tokens = 1_250;
session.stats.output_tokens = 2_500;
let mode = AppMode::List;
let mut page = test_session_chat_page(&session, &mode);
let width = 120;
let backend = ratatui::backend::TestBackend::new(width, 20);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw session chat page");
let header_row = buffer_row_text(terminal.backend().buffer(), 1, width);
let metadata_row = buffer_row_text(terminal.backend().buffer(), 2, width);
let output_border_row = buffer_row_text(terminal.backend().buffer(), 3, width);
assert!(header_row.trim_start().starts_with("Header Session"));
assert!(header_row.contains("Header Session"));
assert!(metadata_row.trim_start().starts_with("Size: XS"));
assert!(metadata_row.contains("Size: XS"));
assert!(metadata_row.contains("Lines: +12 / -4"));
assert!(metadata_row.contains("Timer: 0s"));
assert!(metadata_row.contains("Model: gpt-5.4"));
assert!(metadata_row.contains("Reasoning: high"));
assert!(metadata_row.contains("Tokens: 1.3k/2.5k"));
assert!(
metadata_row.find("Model: gpt-5.4") < metadata_row.find("Reasoning: high"),
"model should appear before reasoning in the metadata row"
);
assert!(!output_border_row.contains("Header Session"));
}
#[test]
fn test_render_truncates_long_session_header_title() {
let mut session = session_fixture();
let long_title = "This is a very long session title for truncation behavior validation";
session.title = Some(long_title.to_string());
session.model = AgentModel::Gpt54;
let mode = AppMode::List;
let mut page = test_session_chat_page(&session, &mode);
let backend = ratatui::backend::TestBackend::new(28, 20);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw session chat page");
let text = buffer_text(terminal.backend().buffer());
assert!(!text.contains(long_title));
assert!(text.contains("..."));
}
#[test]
fn test_render_keeps_session_header_title_without_review_request_metadata() {
let mut session = session_fixture();
session.title = Some("Header Session".to_string());
let mode = AppMode::List;
let mut page = test_session_chat_page(&session, &mode);
let width = 120;
let backend = ratatui::backend::TestBackend::new(width, 20);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw session chat page");
let header_row = buffer_row_text(terminal.backend().buffer(), 1, width);
let metadata_row = buffer_row_text(terminal.backend().buffer(), 2, width);
assert!(header_row.contains("Header Session"));
assert!(!header_row.contains("GitHub"));
assert!(metadata_row.contains("Model: gemini-3-flash-preview"));
assert!(metadata_row.contains("Reasoning: high"));
assert!(metadata_row.contains("Tokens:"));
}
#[test]
fn test_session_metadata_text_ticks_live_in_progress_timer() {
let mut session = session_fixture();
session.model = AgentModel::Gpt54;
session.stats.added_lines = 9;
session.stats.deleted_lines = 3;
session.status = Status::InProgress;
session.title = Some("Timer Session".to_string());
session.in_progress_started_at = Some(60);
let early_metadata =
SessionChatPage::session_metadata_text(&session, 120, ReasoningLevel::default(), 90);
let later_metadata =
SessionChatPage::session_metadata_text(&session, 120, ReasoningLevel::default(), 3_720);
assert!(early_metadata.contains("Lines: +9 / -3"));
assert!(early_metadata.contains("Timer: 30s"));
assert!(early_metadata.contains("Model: gpt-5.4"));
assert!(early_metadata.contains("Tokens: 0/0"));
assert!(
early_metadata.find("Model: gpt-5.4") < early_metadata.find("Reasoning: high"),
"model should appear before reasoning in metadata text"
);
assert!(later_metadata.contains("Timer: 1h1m0s"));
}
#[test]
fn test_session_metadata_text_freezes_timer_after_in_progress_ends() {
let mut session = session_fixture();
session.status = Status::Review;
session.title = Some("Frozen Timer".to_string());
session.in_progress_total_seconds = 3_660;
let earlier_metadata =
SessionChatPage::session_metadata_text(&session, 80, ReasoningLevel::default(), 4_000);
let later_metadata =
SessionChatPage::session_metadata_text(&session, 80, ReasoningLevel::default(), 40_000);
assert_eq!(earlier_metadata, later_metadata);
assert!(earlier_metadata.contains("Timer: 1h1m0s"));
}
#[test]
fn test_render_truncates_long_session_header_title_and_keeps_timer_visible() {
let mut session = session_fixture();
session.status = Status::InProgress;
session.title = Some("This is a very long timer-aware session header title".to_string());
session.model = AgentModel::Gpt54;
session.in_progress_started_at = Some(0);
let mode = AppMode::List;
let mut page = SessionChatPage::new(SessionChatPageInput {
active_prompt_output: None,
active_progress: None,
default_reasoning_level: ReasoningLevel::default(),
markdown_render_cache: test_markdown_render_cache(),
mode: &mode,
scroll_offset: None,
session_index: 0,
sessions: std::slice::from_ref(&session),
wall_clock_unix_seconds: 3_660,
});
let backend = ratatui::backend::TestBackend::new(50, 20);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw session chat page");
let header_row = buffer_row_text(terminal.backend().buffer(), 1, 50);
let metadata_row = buffer_row_text(terminal.backend().buffer(), 2, 50);
assert!(header_row.contains("..."));
assert!(metadata_row.contains("Timer: 1h1m0s"));
}
#[test]
fn test_render_question_mode_keeps_typed_answer_visible_in_tight_layout() {
let session = session_fixture();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Need a detailed migration plan with rollback guidance?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::with_text("typed answer".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let mut page = test_session_chat_page(&session, &mode);
let backend = ratatui::backend::TestBackend::new(32, 8);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw question mode");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("typed answer"));
}
#[test]
fn test_render_question_mode_includes_blank_row_between_question_and_input() {
let session = session_fixture();
let question = "Need a detailed migration plan with rollback guidance?".to_string();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: Vec::new(),
text: question.clone(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::with_text("typed answer".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let mut page = test_session_chat_page(&session, &mode);
let width = 40;
let height = 12;
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw question mode");
let area = Rect::new(0, 0, width, height);
let bottom_height = page.bottom_height(area);
let bottom_top = 1 + height.saturating_sub(2).saturating_sub(bottom_height);
let panel_layout = question_panel_layout(
width.saturating_sub(2),
bottom_height,
&question,
"typed answer",
CHAT_INPUT_MAX_PANEL_HEIGHT,
);
let options_height = question_options_height(&[], bottom_height);
let spacer_row = bottom_top + panel_layout.question_height + options_height;
let spacer_text = buffer_row_text(terminal.backend().buffer(), spacer_row, width);
assert!(spacer_text.trim().is_empty());
}
#[test]
fn test_render_question_mode_with_options_shows_option_text() {
let session = session_fixture();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Continue?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
};
let mut page = test_session_chat_page(&session, &mode);
let width = 50;
let height = 14;
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw question mode with options");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Options:"), "should render options header");
assert!(text.contains("Yes"), "should render first option");
assert!(text.contains("No"), "should render second option");
}
#[test]
fn test_render_question_mode_with_options_in_small_terminal_does_not_panic() {
let session = session_fixture();
let mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".to_string(),
questions: vec![QuestionItem {
options: vec![
"A".to_string(),
"B".to_string(),
"C".to_string(),
"D".to_string(),
"E".to_string(),
],
text: "Pick one of the many options?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let mut page = test_session_chat_page(&session, &mode);
let backend = ratatui::backend::TestBackend::new(30, 6);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw question mode with many options in small terminal");
}
}