use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use serde_json;
use crate::domain::session::{
FollowUpTaskAction, PublishedBranchSyncStatus, Session, SessionFollowUpTask, Status,
};
use crate::icon::Icon;
use crate::infra::agent::protocol::AgentResponseSummary;
use crate::ui::markdown::{self, render_markdown};
use crate::ui::state::app_mode::DoneSessionOutputMode;
use crate::ui::{Component, style, text_util};
const USER_PROMPT_PREFIX: &str = " › ";
const USER_PROMPT_CONTINUATION_PREFIX: &str = " ";
const CLARIFICATION_HEADER_LINE: &str = " › Clarifications:";
const DRAFT_PREVIEW_HEADER: &str = "## Staged Drafts [Preview]";
const DRAFT_PREVIEW_NOTE: &str = "Press `s` in session view to start the staged bundle.";
const FOLLOW_UP_TASK_HEADER: &str = "Follow-Up Tasks";
const TRANSCRIPT_FOOTER_PREFIXES: &[&str] = &["[Commit]", "[Commit Error]"];
pub struct SessionOutput<'a> {
active_prompt_output: Option<&'a str>,
active_progress: Option<&'a str>,
done_session_output_mode: DoneSessionOutputMode,
markdown_render_cache: Option<&'a markdown::MarkdownRenderCache>,
review_status_message: Option<&'a str>,
review_text: Option<&'a str>,
scroll_offset: Option<u16>,
selected_follow_up_task_position: Option<usize>,
session: &'a Session,
}
#[derive(Clone, Copy)]
pub(crate) struct SessionOutputLineContext<'a> {
pub(crate) active_prompt_output: Option<&'a str>,
pub(crate) active_progress: Option<&'a str>,
pub(crate) done_session_output_mode: DoneSessionOutputMode,
pub(crate) review_status_message: Option<&'a str>,
pub(crate) review_text: Option<&'a str>,
pub(crate) selected_follow_up_task_position: Option<usize>,
}
impl<'a> SessionOutput<'a> {
const DEFAULT_SUMMARY_TEXT: &'static str = "No changes";
pub fn new(session: &'a Session) -> Self {
Self {
active_prompt_output: None,
active_progress: None,
done_session_output_mode: DoneSessionOutputMode::Summary,
markdown_render_cache: None,
review_status_message: None,
review_text: None,
scroll_offset: None,
selected_follow_up_task_position: None,
session,
}
}
#[must_use]
pub fn active_prompt_output(mut self, active_prompt_output: Option<&'a str>) -> Self {
self.active_prompt_output = active_prompt_output;
self
}
#[must_use]
pub fn active_progress(mut self, active_progress: &'a str) -> Self {
self.active_progress = Some(active_progress);
self
}
#[must_use]
pub fn done_session_output_mode(mut self, mode: DoneSessionOutputMode) -> Self {
self.done_session_output_mode = mode;
self
}
#[must_use]
pub fn markdown_render_cache(mut self, cache: &'a markdown::MarkdownRenderCache) -> Self {
self.markdown_render_cache = Some(cache);
self
}
#[must_use]
pub fn review_status_message(mut self, status_message: Option<&'a str>) -> Self {
self.review_status_message = status_message;
self
}
#[must_use]
pub fn review_text(mut self, review_text: Option<&'a str>) -> Self {
self.review_text = review_text;
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: u16) -> Self {
self.scroll_offset = Some(offset);
self
}
#[must_use]
pub fn selected_follow_up_task_position(mut self, position: Option<usize>) -> Self {
self.selected_follow_up_task_position = position;
self
}
pub(crate) fn rendered_line_count(
session: &Session,
output_width: u16,
context: SessionOutputLineContext<'_>,
) -> u16 {
let output_area = Rect::new(0, 0, output_width, 0);
let lines = Self::output_lines(session, output_area, context, None);
u16::try_from(lines.len()).unwrap_or(u16::MAX)
}
fn output_lines(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> Vec<Line<'static>> {
let SessionOutputLineContext {
active_prompt_output,
active_progress,
done_session_output_mode,
review_status_message,
review_text,
selected_follow_up_task_position,
} = context;
let status = session.status;
let output_text = Self::output_text(session, done_session_output_mode);
let (completed_turn_text, active_turn_text) =
Self::transcript_sections(status, &output_text, active_prompt_output);
let completed_turn_text = Self::output_text_with_spaced_user_input(completed_turn_text);
let active_turn_text = active_turn_text.map(Self::output_text_with_spaced_user_input);
let inner_width = output_area
.width
.saturating_sub(Self::output_horizontal_border_width())
as usize;
let (completed_turn_text, trailing_footer_text) =
text_util::split_trailing_line_block(&completed_turn_text, TRANSCRIPT_FOOTER_PREFIXES);
let mut lines = Vec::new();
Self::append_markdown_lines(
&mut lines,
completed_turn_text,
inner_width,
markdown_render_cache,
);
if Self::shows_summary_block(session.status, done_session_output_mode) {
Self::append_summary_lines(
&mut lines,
session.summary.as_deref(),
inner_width,
markdown_render_cache,
);
}
if Self::shows_follow_up_tasks() {
Self::append_follow_up_task_lines(
&mut lines,
&session.follow_up_tasks,
selected_follow_up_task_position,
inner_width,
markdown_render_cache,
);
}
Self::append_transcript_footer_lines(
&mut lines,
trailing_footer_text,
inner_width,
markdown_render_cache,
);
Self::append_active_turn_lines(
&mut lines,
active_turn_text.as_deref(),
inner_width,
markdown_render_cache,
);
if Self::shows_review_lines(session.status) {
Self::append_review_lines(&mut lines, review_text, inner_width, markdown_render_cache);
}
Self::append_published_branch_sync_lines(&mut lines, session);
if matches!(
status,
Status::InProgress
| Status::AgentReview
| Status::Queued
| Status::Rebasing
| Status::Merging
) {
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
lines.push(Line::from(""));
let status_icon = Self::status_icon(status);
let status_message =
Self::status_message(status, active_progress, review_status_message);
lines.push(Line::from(vec![Span::styled(
format!("{status_icon} {status_message}"),
Style::default().fg(style::status_color(status)),
)]));
} else if status == Status::Done {
lines.push(Line::from(""));
lines.push(Self::done_output_toggle_line(done_session_output_mode));
lines.push(Line::from(""));
} else {
lines.push(Line::from(""));
}
lines
}
fn append_published_branch_sync_lines(lines: &mut Vec<Line<'static>>, session: &Session) {
let Some(sync_message) = session.published_branch_sync_message() else {
return;
};
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(
"{} {sync_message}",
Self::published_branch_sync_icon(session.published_branch_sync_status)
),
Style::default().fg(Self::published_branch_sync_color(
session.published_branch_sync_status,
)),
)]));
}
fn transcript_sections<'text>(
status: Status,
output_text: &'text str,
active_prompt_output: Option<&str>,
) -> (&'text str, Option<&'text str>) {
if !matches!(
status,
Status::InProgress | Status::Queued | Status::Rebasing | Status::Merging
) {
return (output_text, None);
}
let Some(active_prompt_output) = active_prompt_output else {
return (output_text, None);
};
let Some(active_prompt_start) = output_text.rfind(active_prompt_output) else {
return (output_text, None);
};
if active_prompt_start == 0 {
return (output_text, None);
}
(
&output_text[..active_prompt_start],
Some(&output_text[active_prompt_start..]),
)
}
fn output_horizontal_border_width() -> u16 {
let output_panel_borders = Self::output_panel_borders();
let left_border_width = u16::from(output_panel_borders.intersects(Borders::LEFT));
let right_border_width = u16::from(output_panel_borders.intersects(Borders::RIGHT));
left_border_width + right_border_width
}
fn output_panel_borders() -> Borders {
Borders::TOP | Borders::BOTTOM
}
fn output_panel_border_style(status: Status) -> Style {
Style::default().fg(style::status_color(status))
}
fn done_output_toggle_line(done_session_output_mode: DoneSessionOutputMode) -> Line<'static> {
let toggle_target = match done_session_output_mode {
DoneSessionOutputMode::Summary => "output",
DoneSessionOutputMode::Output | DoneSessionOutputMode::Review => "summary",
};
Line::from(vec![Span::styled(
format!("Press t to switch to {toggle_target}."),
Style::default().fg(style::palette::TEXT_SUBTLE),
)])
}
fn output_text(session: &Session, done_session_output_mode: DoneSessionOutputMode) -> String {
match session.status {
Status::New if session.is_draft_session() && session.has_staged_drafts() => {
return Self::render_staged_draft_preview(session);
}
Status::Done if done_session_output_mode == DoneSessionOutputMode::Summary => {
return Self::render_summary_text(Self::session_summary_text(session));
}
Status::Canceled => {
return session.output.clone();
}
Status::Review
| Status::AgentReview
| Status::Question
| Status::New
| Status::Done
| Status::InProgress
| Status::Queued
| Status::Rebasing
| Status::Merging => {}
}
session.output.clone()
}
fn render_staged_draft_preview(session: &Session) -> String {
let mut output = format!("{DRAFT_PREVIEW_HEADER}\n\n{DRAFT_PREVIEW_NOTE}\n\n");
output.push_str(&Self::staged_draft_transcript_block(&session.prompt));
output
}
fn staged_draft_transcript_block(prompt_text: &str) -> String {
let prompt_lines = prompt_text.split('\n').collect::<Vec<_>>();
let mut formatted_lines = Vec::with_capacity(prompt_lines.len());
for (index, prompt_line) in prompt_lines.into_iter().enumerate() {
let prefix = if index == 0 {
USER_PROMPT_PREFIX
} else {
USER_PROMPT_CONTINUATION_PREFIX
};
formatted_lines.push(format!("{prefix}{prompt_line}"));
}
format!("{}\n\n", formatted_lines.join("\n"))
}
fn session_summary_text(session: &Session) -> &str {
session
.summary
.as_deref()
.map(str::trim)
.filter(|summary| !summary.is_empty())
.unwrap_or("")
}
fn render_summary_text(summary_text: &str) -> String {
let trimmed_summary = summary_text.trim();
if let Ok(summary_payload) = serde_json::from_str::<AgentResponseSummary>(trimmed_summary) {
return format!(
"## Change Summary\n### Current Turn\n{}\n\n### Session Changes\n{}",
Self::summary_section_text(&summary_payload.turn),
Self::summary_section_text(&summary_payload.session)
);
}
if !trimmed_summary.is_empty() {
return trimmed_summary.to_string();
}
format!(
"## Change Summary\n### Current Turn\n{}\n\n### Session Changes\n{}",
Self::DEFAULT_SUMMARY_TEXT,
Self::DEFAULT_SUMMARY_TEXT
)
}
fn summary_section_text(summary_text: &str) -> &str {
let trimmed_summary = summary_text.trim();
if trimmed_summary.is_empty() {
return Self::DEFAULT_SUMMARY_TEXT;
}
trimmed_summary
}
fn shows_follow_up_tasks() -> bool {
true
}
fn shows_summary_block(
status: Status,
done_session_output_mode: DoneSessionOutputMode,
) -> bool {
if status == Status::Canceled {
return false;
}
!(status == Status::Done && done_session_output_mode == DoneSessionOutputMode::Summary)
}
fn shows_review_lines(status: Status) -> bool {
status != Status::Done
}
fn append_review_lines(
lines: &mut Vec<Line<'static>>,
review_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let review_markdown = review_text
.map(str::trim)
.filter(|review_text| !review_text.is_empty())
.map(str::to_string);
let Some(review_markdown) = review_markdown else {
return;
};
Self::append_markdown_lines(lines, &review_markdown, inner_width, markdown_render_cache);
}
fn append_summary_lines(
lines: &mut Vec<Line<'static>>,
summary_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let Some(summary_text) = summary_text else {
return;
};
if summary_text.trim().is_empty() {
return;
}
Self::append_markdown_lines(
lines,
&Self::render_summary_text(summary_text),
inner_width,
markdown_render_cache,
);
}
fn append_follow_up_task_lines(
lines: &mut Vec<Line<'static>>,
follow_up_tasks: &[SessionFollowUpTask],
selected_follow_up_task_position: Option<usize>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
if follow_up_tasks.is_empty() {
return;
}
Self::append_markdown_lines(
lines,
&format!("## {FOLLOW_UP_TASK_HEADER}"),
inner_width,
markdown_render_cache,
);
let selected_follow_up_task_position = Self::resolved_follow_up_task_position(
follow_up_tasks,
selected_follow_up_task_position,
);
for follow_up_task in follow_up_tasks {
let is_selected = selected_follow_up_task_position == Some(follow_up_task.position);
Self::append_follow_up_task_item_line(lines, follow_up_task, is_selected, inner_width);
}
}
fn append_transcript_footer_lines(
lines: &mut Vec<Line<'static>>,
trailing_footer_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let Some(trailing_footer_text) = trailing_footer_text else {
return;
};
Self::append_markdown_lines(
lines,
trailing_footer_text,
inner_width,
markdown_render_cache,
);
}
fn append_active_turn_lines(
lines: &mut Vec<Line<'static>>,
active_turn_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let Some(active_turn_text) = active_turn_text else {
return;
};
if active_turn_text.trim().is_empty() {
return;
}
Self::append_markdown_lines(lines, active_turn_text, inner_width, markdown_render_cache);
}
fn append_markdown_lines(
lines: &mut Vec<Line<'static>>,
markdown: &str,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let mut rendered_lines = match markdown_render_cache {
Some(cache) => cache.render(markdown, inner_width),
None => render_markdown(markdown, inner_width),
};
if rendered_lines.is_empty() {
return;
}
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.append(&mut rendered_lines);
}
fn resolved_follow_up_task_position(
follow_up_tasks: &[SessionFollowUpTask],
selected_follow_up_task_position: Option<usize>,
) -> Option<usize> {
if follow_up_tasks.is_empty() {
return None;
}
if let Some(selected_follow_up_task_position) = selected_follow_up_task_position {
return Some(selected_follow_up_task_position);
}
Some(follow_up_tasks[0].position)
}
fn append_follow_up_task_item_line(
lines: &mut Vec<Line<'static>>,
follow_up_task: &SessionFollowUpTask,
is_selected: bool,
inner_width: usize,
) {
let action_label = match (is_selected, follow_up_task.action()) {
(true, FollowUpTaskAction::Launch) => "[Launch] ",
(true, FollowUpTaskAction::Open) => "[Open] ",
(false, FollowUpTaskAction::Open) => "[Launched] ",
(false, FollowUpTaskAction::Launch) => "",
};
let bullet_prefix = if is_selected { "> " } else { "- " };
let line_text = format!("{bullet_prefix}{action_label}{}", follow_up_task.text);
for wrapped_line in text_util::wrap_lines(&line_text, inner_width.max(1)) {
lines.push(Line::from(wrapped_line.to_string()));
}
}
fn output_text_with_spaced_user_input(output_text: &str) -> String {
let raw_lines = output_text.split('\n').collect::<Vec<_>>();
let mut formatted_lines = Vec::with_capacity(raw_lines.len());
let mut line_index = 0;
while line_index < raw_lines.len() {
let line = raw_lines[line_index];
if !line.starts_with(USER_PROMPT_PREFIX) {
formatted_lines.push(line.to_string());
line_index += 1;
continue;
}
if formatted_lines
.last()
.is_some_and(|item: &String| !item.is_empty())
{
formatted_lines.push(String::new());
}
let block_end_index = Self::user_prompt_block_end_index(&raw_lines, line_index);
formatted_lines.extend(Self::format_prompt_block_lines(
&raw_lines[line_index..=block_end_index],
));
line_index = block_end_index + 1;
let next_line_is_empty = raw_lines
.get(line_index)
.is_none_or(|next_line| next_line.is_empty());
if !next_line_is_empty {
formatted_lines.push(String::new());
}
}
formatted_lines.join("\n")
}
fn format_prompt_block_lines(raw_block_lines: &[&str]) -> Vec<String> {
if !Self::is_clarification_prompt_block(raw_block_lines) {
return raw_block_lines.iter().map(ToString::to_string).collect();
}
let mut formatted_block_lines = Vec::with_capacity(raw_block_lines.len() + 2);
for (block_line_index, raw_block_line) in raw_block_lines.iter().enumerate() {
if block_line_index > 0 && Self::is_clarification_question_line(raw_block_line) {
formatted_block_lines.push(USER_PROMPT_CONTINUATION_PREFIX.to_string());
}
formatted_block_lines.push((*raw_block_line).to_string());
}
formatted_block_lines
}
fn is_clarification_prompt_block(raw_block_lines: &[&str]) -> bool {
raw_block_lines
.first()
.is_some_and(|line| line.trim_end() == CLARIFICATION_HEADER_LINE)
}
fn is_clarification_question_line(raw_block_line: &str) -> bool {
let Some(line_without_prefix) =
raw_block_line.strip_prefix(USER_PROMPT_CONTINUATION_PREFIX)
else {
return false;
};
let trimmed_line = line_without_prefix.trim_start();
let digit_count = trimmed_line
.chars()
.take_while(char::is_ascii_digit)
.count();
if digit_count == 0 {
return false;
}
let (_, suffix) = trimmed_line.split_at(digit_count);
suffix.starts_with(". Q: ")
}
fn user_prompt_block_end_index(raw_lines: &[&str], start_index: usize) -> usize {
let mut candidate_index = start_index + 1;
while candidate_index < raw_lines.len() {
let candidate_line = raw_lines[candidate_index];
if candidate_line.is_empty() || candidate_line.starts_with(USER_PROMPT_PREFIX) {
break;
}
candidate_index += 1;
}
if raw_lines
.get(candidate_index)
.is_some_and(|candidate_line| candidate_line.is_empty())
{
return candidate_index.saturating_sub(1).max(start_index);
}
start_index
}
fn status_message(
status: Status,
active_progress: Option<&str>,
review_status_message: Option<&str>,
) -> String {
match status {
Status::InProgress => active_progress
.map(str::trim)
.filter(|progress| !progress.is_empty())
.map_or_else(
|| "Working...".to_string(),
|progress| format!("Working... {progress}"),
),
Status::AgentReview => review_status_message
.map(str::trim)
.filter(|status_message| !status_message.is_empty())
.map_or_else(|| "Preparing review...".to_string(), ToString::to_string),
Status::Queued => "Waiting in merge queue...".to_string(),
Status::Rebasing => "Rebasing...".to_string(),
Status::Merging => "Merging...".to_string(),
Status::New | Status::Review | Status::Question | Status::Done | Status::Canceled => {
String::new()
}
}
}
fn status_icon(status: Status) -> Icon {
match status {
Status::InProgress | Status::AgentReview | Status::Rebasing | Status::Merging => {
Icon::current_spinner()
}
Status::Queued
| Status::New
| Status::Review
| Status::Question
| Status::Done
| Status::Canceled => Icon::Pending,
}
}
fn published_branch_sync_icon(sync_status: PublishedBranchSyncStatus) -> Icon {
match sync_status {
PublishedBranchSyncStatus::Idle => Icon::Pending,
PublishedBranchSyncStatus::InProgress => Icon::current_spinner(),
PublishedBranchSyncStatus::Succeeded => Icon::Check,
PublishedBranchSyncStatus::Failed => Icon::Warn,
}
}
fn published_branch_sync_color(
sync_status: PublishedBranchSyncStatus,
) -> ratatui::style::Color {
match sync_status {
PublishedBranchSyncStatus::Idle => style::palette::TEXT_MUTED,
PublishedBranchSyncStatus::InProgress | PublishedBranchSyncStatus::Failed => {
style::palette::WARNING
}
PublishedBranchSyncStatus::Succeeded => style::palette::SUCCESS,
}
}
fn final_scroll_offset(&self, output_area: Rect, line_count: usize) -> u16 {
if let Some(scroll_offset) = self.scroll_offset {
return scroll_offset;
}
let inner_height = output_area.height.saturating_sub(2) as usize;
u16::try_from(line_count.saturating_sub(inner_height)).unwrap_or(u16::MAX)
}
}
impl Component for SessionOutput<'_> {
fn render(&self, f: &mut Frame, output_area: Rect) {
let status = self.session.status;
let lines = Self::output_lines(
self.session,
output_area,
SessionOutputLineContext {
active_prompt_output: self.active_prompt_output,
active_progress: self.active_progress,
done_session_output_mode: self.done_session_output_mode,
review_status_message: self.review_status_message,
review_text: self.review_text,
selected_follow_up_task_position: self.selected_follow_up_task_position,
},
self.markdown_render_cache,
);
let final_scroll = self.final_scroll_offset(output_area, lines.len());
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Self::output_panel_borders())
.border_style(Self::output_panel_border_style(status)),
)
.scroll((final_scroll, 0));
f.render_widget(paragraph, output_area);
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use ratatui::style::Modifier;
use serde_json;
use super::*;
use crate::agent::AgentModel;
use crate::domain::session::{SessionSize, SessionStats};
use crate::infra::agent::protocol::AgentResponseSummary;
fn line_context<'a>(
done_session_output_mode: DoneSessionOutputMode,
selected_follow_up_task_position: Option<usize>,
review_status_message: Option<&'a str>,
review_text: Option<&'a str>,
active_progress: Option<&'a str>,
) -> SessionOutputLineContext<'a> {
SessionOutputLineContext {
active_prompt_output: None,
active_progress,
done_session_output_mode,
review_status_message,
review_text,
selected_follow_up_task_position,
}
}
fn summary_fixture() -> String {
serde_json::to_string(&AgentResponseSummary {
turn: "- Added the structured protocol summary.".to_string(),
session: "- Session output now renders persisted summary markdown.".to_string(),
})
.expect("summary fixture should serialize")
}
fn done_summary_fixture() -> String {
"# Summary\n\nSession now greets users on startup.\n\n# Commit\n\nRefine session summary"
.to_string()
}
fn session_fixture() -> Session {
Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
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 buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[test]
fn test_rendered_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 = SessionOutput::rendered_line_count(
&session,
20,
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
);
assert!(rendered_line_count > raw_line_count);
}
#[test]
fn test_output_lines_show_published_branch_sync_message() {
let mut session = session_fixture();
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.published_branch_sync_status = PublishedBranchSyncStatus::InProgress;
session.status = Status::Review;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Auto-pushing published branch after completed turn..."));
}
#[test]
fn test_output_lines_show_completed_published_branch_sync_message() {
let mut session = session_fixture();
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.published_branch_sync_status = PublishedBranchSyncStatus::Succeeded;
session.status = Status::Review;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Auto-pushed published branch after completed turn."));
}
#[test]
fn test_output_lines_uses_summary_for_done_session() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Added the structured protocol summary."));
assert!(text.contains("Session output now renders persisted summary markdown."));
assert!(!text.contains("streamed output"));
}
#[test]
fn test_output_lines_render_staged_draft_preview_for_new_session() {
let mut session = session_fixture();
session.is_draft = true;
session.prompt = "First draft\n\nSecond draft".to_string();
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Staged Drafts [Preview]"));
assert!(text.contains("Press s in session view to start the staged bundle."));
assert!(text.contains("First draft"));
assert!(text.contains("Second draft"));
}
#[test]
fn test_output_lines_append_follow_up_task_section_without_mutating_output() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.follow_up_tasks = vec![
SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Document the new shortcut.".to_string(),
},
SessionFollowUpTask {
id: 2,
launched_session_id: None,
position: 1,
text: "Add a regression test.".to_string(),
},
];
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert_eq!(session.output, "streamed output");
assert!(text.contains("streamed output"));
assert!(text.contains(FOLLOW_UP_TASK_HEADER));
assert!(text.contains("Document the new shortcut."));
assert!(text.contains("Add a regression test."));
}
#[test]
fn test_output_lines_defaults_first_follow_up_task_to_selected() {
let mut session = session_fixture();
session.follow_up_tasks = vec![
SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Launch the sibling session.".to_string(),
},
SessionFollowUpTask {
id: 2,
launched_session_id: None,
position: 1,
text: "Document the outcome.".to_string(),
},
];
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("> [Launch] Launch the sibling session."));
assert!(text.contains("- Document the outcome."));
}
#[test]
fn test_output_lines_marks_selected_follow_up_task_with_launch_affordance() {
let mut session = session_fixture();
session.follow_up_tasks = vec![SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Launch the sibling session.".to_string(),
}];
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, Some(0), None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[Launch] Launch the sibling session."));
}
#[test]
fn test_output_lines_renders_trailing_commit_footer_after_follow_up_tasks() {
let mut session = session_fixture();
session.output =
"Implemented the change.\n\n[Commit] committed with hash `abc1234`\n".to_string();
session.follow_up_tasks = vec![SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Run a broader regression pass.".to_string(),
}];
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let follow_up_index = text
.find(FOLLOW_UP_TASK_HEADER)
.expect("follow-up task header should be rendered");
let commit_index = text
.find("[Commit] committed with hash abc1234")
.expect("commit footer should be rendered");
assert!(text.contains("Implemented the change."));
assert!(text.contains("Run a broader regression pass."));
assert!(follow_up_index < commit_index);
}
#[test]
fn test_output_lines_done_output_mode_appends_structured_summary() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Output, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("streamed output"));
assert!(text.contains("Added the structured protocol summary."));
assert!(text.contains("Session output now renders persisted summary markdown."));
}
#[test]
fn test_output_lines_review_session_appends_structured_summary() {
let mut session = session_fixture();
session.output = "implemented the feature".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("implemented the feature"));
assert!(text.contains("Added the structured protocol summary."));
assert!(text.contains("Session output now renders persisted summary markdown."));
}
#[test]
fn test_output_lines_in_progress_session_keeps_summary_before_active_prompt() {
let mut session = session_fixture();
session.output =
" › hi\n\n[Commit] No changes to commit.\n\n › add hello world\n\n".to_string();
session.summary = Some(summary_fixture());
session.status = Status::InProgress;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some("\n › add hello world\n\n"),
..line_context(DoneSessionOutputMode::Summary, None, None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
let prompt_index = text
.find(" › add hello world")
.expect("active prompt should be rendered");
assert!(text.contains("[Commit] No changes to commit."));
assert!(summary_index < prompt_index);
}
#[test]
fn test_output_lines_in_progress_single_prompt_keeps_summary_after_transcript() {
let mut session = session_fixture();
session.output = " › add hello world\n\nI added the README change.\n".to_string();
session.summary = Some(summary_fixture());
session.status = Status::InProgress;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some(" › add hello world\n\n"),
..line_context(DoneSessionOutputMode::Summary, None, None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let prompt_index = text
.find(" › add hello world")
.expect("prompt should be rendered");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
assert!(text.contains("I added the README change."));
assert!(prompt_index < summary_index);
}
#[test]
fn test_output_lines_in_progress_ignores_assistant_lines_that_look_like_prompts() {
let mut session = session_fixture();
session.output = " › hi\n\nprevious answer\n\n › actual prompt\n\nstreaming answer\n › \
quoted output\n"
.to_string();
session.summary = Some(summary_fixture());
session.status = Status::InProgress;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some("\n › actual prompt\n\n"),
..line_context(DoneSessionOutputMode::Summary, None, None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
let prompt_index = text
.find(" › actual prompt")
.expect("active prompt should be rendered");
assert!(text.contains(" › quoted output"));
assert!(summary_index < prompt_index);
}
#[test]
fn test_output_lines_review_session_without_summary_keeps_transcript_only() {
let mut session = session_fixture();
session.output = "implemented the feature".to_string();
session.status = Status::Review;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("implemented the feature"));
assert!(!text.contains("No changes"));
assert!(!text.contains("Current Turn"));
assert!(!text.contains("Session Changes"));
}
#[test]
fn test_render_summary_text_uses_plain_text_fallback_for_non_protocol_summary() {
let rendered_summary = SessionOutput::render_summary_text("plain summary");
assert_eq!(rendered_summary, "plain summary");
}
#[test]
fn test_output_lines_done_summary_mode_shows_output_toggle_action() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Press t to switch to output."));
}
#[test]
fn test_output_lines_done_summary_transition_renders_rewritten_summary() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let review_lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let review_text = review_lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
session.summary = Some(done_summary_fixture());
session.status = Status::Done;
let done_output_text = SessionOutput::output_text(&session, DoneSessionOutputMode::Summary);
let done_lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let done_text = done_lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(review_text.contains("Change Summary"));
assert!(review_text.contains("Added the structured protocol summary."));
assert_eq!(done_output_text, done_summary_fixture());
assert!(done_text.contains("Summary"));
assert!(done_text.contains("Session now greets users on startup."));
assert!(done_text.contains("Commit"));
assert!(done_text.contains("Refine session summary"));
assert!(!done_text.contains("Change Summary"));
assert!(!done_text.contains("streamed output"));
}
#[test]
fn test_output_lines_done_output_mode_shows_summary_toggle_action() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Output, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Press t to switch to summary."));
}
#[test]
fn test_output_lines_review_mode_keeps_transcript_when_review_output_is_missing() {
let mut session = session_fixture();
session.status = Status::Review;
session.output = "streamed output".to_string();
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Review, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("streamed output"));
assert!(!text.contains("Review is not available."));
}
#[test]
fn test_output_lines_done_review_mode_shows_summary_toggle_action() {
let mut session = session_fixture();
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Review, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Press t to switch to summary."));
}
#[test]
fn test_output_lines_done_session_hides_cached_review_text() {
let mut session = session_fixture();
session.output = "merged transcript".to_string();
session.status = Status::Done;
let assisted_text = "## Review\n\n### Project Impact\n\n- Review summary";
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
DoneSessionOutputMode::Output,
None,
Some("Reviewing changes with gpt-5.4"),
Some(assisted_text),
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("merged transcript"));
assert!(!text.contains("Review summary"));
assert!(!text.contains("Project Impact"));
}
#[test]
fn test_output_lines_review_mode_prefers_assisted_text() {
let mut session = session_fixture();
session.status = Status::Review;
let assisted_text = "## Review\n\n- Assisted insight";
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
DoneSessionOutputMode::Review,
None,
Some("Reviewing changes with gpt-5.4"),
Some(assisted_text),
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Assisted insight"));
assert!(!text.contains("Reviewing changes with gpt-5.4"));
}
#[test]
fn test_output_lines_agent_review_mode_shows_loader_message() {
let mut session = session_fixture();
session.status = Status::AgentReview;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
DoneSessionOutputMode::Review,
None,
Some("Reviewing changes with gpt-5.4"),
None,
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Reviewing changes with gpt-5.4"));
assert!(!text.contains("Review is not available."));
}
#[test]
fn test_output_lines_agent_review_mode_shows_assisted_text() {
let mut session = session_fixture();
session.status = Status::AgentReview;
let assisted_text = "## Review\n\n- Focused finding";
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
DoneSessionOutputMode::Review,
None,
Some("Reviewing changes with gpt-5.4"),
Some(assisted_text),
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Focused finding"));
assert!(!text.contains("Review is not available."));
}
#[test]
fn test_output_lines_agent_review_mode_keeps_follow_up_tasks() {
let mut session = session_fixture();
session.status = Status::AgentReview;
session.follow_up_tasks = vec![SessionFollowUpTask {
id: 1,
launched_session_id: None,
position: 0,
text: "Run cargo test -q.".to_string(),
}];
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
DoneSessionOutputMode::Review,
None,
Some("Reviewing changes with gpt-5.4"),
None,
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Reviewing changes with gpt-5.4"));
assert!(text.contains(FOLLOW_UP_TASK_HEADER));
assert!(text.contains("Run cargo test -q."));
}
#[test]
fn test_output_lines_uses_transcript_for_canceled_session() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Canceled;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(!text.contains("Added the structured protocol summary."));
assert!(text.contains("streamed output"));
}
#[test]
fn test_output_lines_render_user_prompt_with_cyan_bold_styling() {
let mut session = session_fixture();
session.output = " › /model gemini".to_string();
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let prompt_line = lines
.iter()
.find(|line| line.to_string().trim_end().starts_with(" › "))
.expect("expected user prompt line");
assert_eq!(prompt_line.to_string().trim_end(), " › /model gemini");
assert_eq!(
prompt_line.width(),
80 - usize::from(SessionOutput::output_horizontal_border_width())
);
assert_eq!(prompt_line.spans[0].style.fg, Some(style::palette::ACCENT));
assert!(
prompt_line.spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
fn test_output_lines_use_full_panel_width_without_vertical_borders() {
let mut session = session_fixture();
session.output = "abcdef".to_string();
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 6, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
assert_eq!(
lines.first().expect("expected output line").to_string(),
"abcdef"
);
}
#[test]
fn test_output_horizontal_border_width_is_zero_without_vertical_borders() {
let horizontal_border_width = SessionOutput::output_horizontal_border_width();
let output_panel_borders = SessionOutput::output_panel_borders();
assert_eq!(horizontal_border_width, 0);
assert!(!output_panel_borders.intersects(Borders::LEFT));
assert!(!output_panel_borders.intersects(Borders::RIGHT));
}
#[test]
fn test_output_lines_appends_empty_line_when_done() {
let mut session = session_fixture();
session.output = "some output".to_string();
session.status = Status::Done;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
assert!(lines.last().expect("lines").to_string().is_empty());
assert!(lines.len() >= 2);
}
#[test]
fn test_output_lines_appends_empty_line_before_spinner() {
let mut session = session_fixture();
session.output = "some output".to_string();
session.status = Status::InProgress;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
assert!(lines.len() >= 3);
let len = lines.len();
assert!(lines[len - 2].to_string().is_empty());
assert!(lines[len - 1].to_string().contains("Working..."));
}
#[test]
fn test_output_text_with_spaced_user_input_adds_empty_line_before_and_after() {
let output = "assistant output\n › user prompt\nagent response";
let spaced = SessionOutput::output_text_with_spaced_user_input(output);
assert_eq!(
spaced,
"assistant output\n\n › user prompt\n\nagent response"
);
}
#[test]
fn test_output_text_with_spaced_user_input_keeps_existing_empty_lines() {
let output = "assistant output\n\n › user prompt\n\nagent response";
let spaced = SessionOutput::output_text_with_spaced_user_input(output);
assert_eq!(spaced, output);
}
#[test]
fn test_output_text_with_spaced_user_input_keeps_multiline_user_prompt_together() {
let output = "assistant output\n › first line\nsecond line\n\nagent response";
let spaced = SessionOutput::output_text_with_spaced_user_input(output);
assert_eq!(
spaced,
"assistant output\n\n › first line\nsecond line\n\nagent response"
);
}
#[test]
fn test_output_text_with_spaced_user_input_adds_question_group_spacing_for_clarifications() {
let output = "assistant output\n › Clarifications:\n 1. Q: Need target branch?\n \
A: main\n 2. Q: Need tests?\n A: yes\n\nagent response";
let spaced = SessionOutput::output_text_with_spaced_user_input(output);
assert_eq!(
spaced,
"assistant output\n\n › Clarifications:\n \n 1. Q: Need target branch?\n A: \
main\n \n 2. Q: Need tests?\n A: yes\n\nagent response"
);
}
#[test]
fn test_status_message_for_merging() {
let message = SessionOutput::status_message(Status::Merging, None, None);
assert_eq!(message, "Merging...");
}
#[test]
fn test_status_message_for_queued() {
let message = SessionOutput::status_message(Status::Queued, None, None);
assert_eq!(message, "Waiting in merge queue...");
}
#[test]
fn test_status_message_for_agent_review_uses_review_loader_text() {
let message = SessionOutput::status_message(
Status::AgentReview,
None,
Some("Reviewing changes with gpt-5.4"),
);
assert_eq!(message, "Reviewing changes with gpt-5.4");
}
#[test]
fn test_output_lines_use_generic_in_progress_loader() {
let mut session = session_fixture();
session.output = "some output".to_string();
session.status = Status::InProgress;
let lines = SessionOutput::output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(DoneSessionOutputMode::Summary, None, None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Working..."));
}
#[test]
fn test_status_message_for_in_progress_includes_progress_text() {
let message = SessionOutput::status_message(
Status::InProgress,
Some("Inspecting changed files"),
None,
);
assert_eq!(message, "Working... Inspecting changed files");
}
#[test]
fn test_builder_methods() {
let session = session_fixture();
let active_progress = "Inspecting changed files";
let review_status_message = "Preparing review...";
let review_text = "Assisted review";
let output = SessionOutput::new(&session)
.active_progress(active_progress)
.done_session_output_mode(DoneSessionOutputMode::Output)
.review_status_message(Some(review_status_message))
.review_text(Some(review_text))
.scroll_offset(10);
assert_eq!(output.active_progress, Some(active_progress));
assert_eq!(
output.done_session_output_mode,
DoneSessionOutputMode::Output
);
assert_eq!(output.review_status_message, Some(review_status_message));
assert_eq!(output.review_text, Some(review_text));
assert_eq!(output.scroll_offset, Some(10));
}
#[test]
fn test_render_does_not_draw_status_title_in_output_border() {
let mut session = session_fixture();
session.title = Some("Header Session".to_string());
session.status = Status::Review;
let output = SessionOutput::new(&session);
let backend = ratatui::backend::TestBackend::new(80, 10);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
output.render(frame, area);
})
.expect("failed to draw session output");
let text = buffer_text(terminal.backend().buffer());
assert!(!text.contains("Review - Header Session"));
}
}