use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::fmt::Write as _;
use std::hash::Hasher;
use std::sync::Arc;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use rustc_hash::FxHasher;
use crate::app;
use crate::domain::agent::AgentModel;
use crate::domain::session::{Session, SessionId, Status};
use crate::domain::transcript_notice::TRAILING_TRANSCRIPT_NOTICE_PREFIXES;
use crate::icon::{Icon, TACHYON_LOADER_WIDTH};
use crate::ui::component::tachyon_loader::TachyonLoaderEffect;
use crate::ui::input_layout::{bottom_pinned_scroll_offset, panel_inner_width};
use crate::ui::markdown::{self, render_markdown};
use crate::ui::{Component, session_format, style, text_util};
const DRAFT_PREVIEW_HEADER: &str = "## Draft Session";
const DRAFT_PREVIEW_EMPTY_NOTE: &str = "No draft messages staged yet. Use `Enter` to stage the \
first draft locally, then press `s` in session view to \
start the bundle.";
const DRAFT_PREVIEW_STACKED_EMPTY_NOTE: &str = "No draft messages staged yet. Use `Enter` to \
stage the first draft locally. The `s` start \
action appears after the parent is review-ready.";
const DRAFT_PREVIEW_STAGED_NOTE: &str =
"Draft messages stay local until you press `s` in session view to start the staged bundle.";
const DRAFT_PREVIEW_STACKED_STAGED_NOTE: &str =
"Draft messages stay local until the parent is review-ready and you press `s` in session view \
to start the stacked bundle from its parent branch.";
const USER_PROMPT_PREFIX: &str = " › ";
const USER_PROMPT_LINE_PREFIX: &str = "\n › ";
const USER_PROMPT_CONTINUATION_PREFIX: &str = " ";
const SESSION_OUTPUT_LAYOUT_CACHE_ENTRY_LIMIT: usize = 16;
#[derive(Clone, Debug, Eq, PartialEq)]
struct SessionOutputLayoutCacheKey {
active_progress: TextFingerprint,
active_prompt_output: TextFingerprint,
draft_prompt: TextFingerprint,
is_stacked_child: bool,
markdown_render_version: u64,
output_width: u16,
queued_messages: TextFingerprint,
review_model_name: &'static str,
review_status_message: TextFingerprint,
review_text: TextFingerprint,
session_id: SessionId,
session_update_version: u64,
session_updated_at: i64,
status: Status,
workflow_notice: TextFingerprint,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TextFingerprint {
content_hash: u64,
content_len: usize,
is_some: bool,
}
impl TextFingerprint {
fn from_text(text: Option<&str>) -> Self {
let Some(text) = text else {
return Self {
content_hash: 0,
content_len: 0,
is_some: false,
};
};
let mut hasher = FxHasher::default();
hasher.write(text.as_bytes());
Self {
content_hash: hasher.finish(),
content_len: text.len(),
is_some: true,
}
}
fn from_texts<'a>(texts: impl IntoIterator<Item = &'a str>) -> Self {
let mut content_len = 0;
let mut content_count = 0;
let mut hasher = FxHasher::default();
for text in texts {
hasher.write(text.as_bytes());
hasher.write_u8(0xff);
content_len += text.len();
content_count += 1;
}
Self {
content_hash: hasher.finish(),
content_len,
is_some: content_count > 0,
}
}
}
#[derive(Clone)]
pub(crate) struct SessionOutputLayout {
pub(crate) active_loader_line_index: Option<usize>,
pub(crate) line_count: u16,
pub(crate) published_loader_line_index: Option<usize>,
pub(crate) lines: Arc<[Line<'static>]>,
}
struct SessionOutputLines {
active_loader_line_index: Option<usize>,
lines: Vec<Line<'static>>,
published_loader_line_index: Option<usize>,
}
struct SessionOutputLayoutCacheEntry {
key: SessionOutputLayoutCacheKey,
layout: SessionOutputLayout,
}
pub struct SessionOutputLayoutCache {
entries: RefCell<VecDeque<SessionOutputLayoutCacheEntry>>,
tachyon_loader_effects: RefCell<HashMap<SessionId, TachyonLoaderEffect>>,
}
impl Default for SessionOutputLayoutCache {
fn default() -> Self {
Self {
entries: RefCell::new(VecDeque::with_capacity(
SESSION_OUTPUT_LAYOUT_CACHE_ENTRY_LIMIT,
)),
tachyon_loader_effects: RefCell::new(HashMap::new()),
}
}
}
impl SessionOutputLayoutCache {
pub(crate) fn layout(
&self,
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> SessionOutputLayout {
let key = SessionOutput::layout_cache_key(
session,
output_area,
context,
markdown_render_cache.map_or(0, markdown::MarkdownRenderCache::version),
);
if let Some(layout) = self.cached_layout(&key) {
return layout;
}
let layout =
SessionOutput::derive_layout(session, output_area, context, markdown_render_cache);
self.store_entry(SessionOutputLayoutCacheEntry {
key,
layout: layout.clone(),
});
layout
}
fn cached_layout(&self, key: &SessionOutputLayoutCacheKey) -> Option<SessionOutputLayout> {
let mut entries = self.entries.borrow_mut();
let entry_index = entries.iter().position(|entry| &entry.key == key)?;
let entry = entries.remove(entry_index)?;
let layout = entry.layout.clone();
entries.push_front(entry);
Some(layout)
}
fn store_entry(&self, entry: SessionOutputLayoutCacheEntry) {
let mut evicted_session_ids = Vec::new();
{
let mut entries = self.entries.borrow_mut();
entries.push_front(entry);
while entries.len() > SESSION_OUTPUT_LAYOUT_CACHE_ENTRY_LIMIT {
let Some(evicted_entry) = entries.pop_back() else {
continue;
};
let evicted_session_id = evicted_entry.key.session_id;
if !entries
.iter()
.any(|entry| entry.key.session_id == evicted_session_id)
{
evicted_session_ids.push(evicted_session_id);
}
}
}
if evicted_session_ids.is_empty() {
return;
}
let mut tachyon_loader_effects = self.tachyon_loader_effects.borrow_mut();
for session_id in evicted_session_ids {
tachyon_loader_effects.remove(&session_id);
}
}
pub(crate) fn apply_tachyon_loader_effect(
&self,
session_id: &SessionId,
buffer: &mut Buffer,
area: Rect,
spinner_frame: usize,
) {
let mut tachyon_loader_effects = self.tachyon_loader_effects.borrow_mut();
if let Some(effect) = tachyon_loader_effects.get_mut(session_id) {
effect.apply(buffer, area, spinner_frame);
return;
}
let mut effect = TachyonLoaderEffect::new();
effect.apply(buffer, area, spinner_frame);
tachyon_loader_effects.insert(session_id.clone(), effect);
}
}
pub struct SessionOutput<'a> {
active_prompt_output: Option<&'a str>,
active_progress: Option<&'a str>,
markdown_render_cache: Option<&'a markdown::MarkdownRenderCache>,
output_layout_cache: Option<&'a SessionOutputLayoutCache>,
review_model: AgentModel,
review_status_message: Option<&'a str>,
review_text: Option<&'a str>,
scroll_offset: Option<u16>,
session: &'a Session,
session_update_version: u64,
}
#[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) review_status_message: Option<&'a str>,
pub(crate) review_model: AgentModel,
pub(crate) review_text: Option<&'a str>,
pub(crate) session_update_version: u64,
}
impl<'a> SessionOutput<'a> {
pub fn new(session: &'a Session) -> Self {
Self {
active_prompt_output: None,
active_progress: None,
markdown_render_cache: None,
output_layout_cache: None,
review_model: session.agent.model(),
review_status_message: None,
review_text: None,
scroll_offset: None,
session,
session_update_version: 0,
}
}
#[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 markdown_render_cache(mut self, cache: &'a markdown::MarkdownRenderCache) -> Self {
self.markdown_render_cache = Some(cache);
self
}
#[must_use]
pub fn output_layout_cache(mut self, cache: &'a SessionOutputLayoutCache) -> Self {
self.output_layout_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_model(mut self, review_model: AgentModel) -> Self {
self.review_model = review_model;
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 session_update_version(mut self, version: u64) -> Self {
self.session_update_version = version;
self
}
pub(crate) fn rendered_line_count(
session: &Session,
output_width: u16,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
output_layout_cache: Option<&SessionOutputLayoutCache>,
) -> u16 {
let output_area = Rect::new(0, 0, output_width, 0);
Self::rendered_layout(
session,
output_area,
context,
markdown_render_cache,
output_layout_cache,
)
.line_count
}
fn rendered_layout(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
output_layout_cache: Option<&SessionOutputLayoutCache>,
) -> SessionOutputLayout {
if let Some(cache) = output_layout_cache {
return cache.layout(session, output_area, context, markdown_render_cache);
}
Self::derive_layout(session, output_area, context, markdown_render_cache)
}
fn derive_layout(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> SessionOutputLayout {
let output_lines =
Self::output_lines_with_metadata(session, output_area, context, markdown_render_cache);
let line_count = u16::try_from(output_lines.lines.len()).unwrap_or(u16::MAX);
SessionOutputLayout {
active_loader_line_index: output_lines.active_loader_line_index,
line_count,
published_loader_line_index: output_lines.published_loader_line_index,
lines: Arc::<[Line<'static>]>::from(output_lines.lines),
}
}
fn layout_cache_key(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_version: u64,
) -> SessionOutputLayoutCacheKey {
let inner_width =
panel_inner_width(output_area, session_format::session_output_panel_borders());
SessionOutputLayoutCacheKey {
active_progress: TextFingerprint::from_text(context.active_progress),
active_prompt_output: TextFingerprint::from_text(context.active_prompt_output),
draft_prompt: Self::draft_prompt_fingerprint(session),
is_stacked_child: session.is_stacked_child(),
markdown_render_version,
output_width: u16::try_from(inner_width).unwrap_or(u16::MAX),
queued_messages: TextFingerprint::from_texts(
session.queued_messages.iter().map(String::as_str),
),
review_model_name: context.review_model.as_str(),
review_status_message: TextFingerprint::from_text(context.review_status_message),
review_text: TextFingerprint::from_text(context.review_text),
session_id: session.id.clone(),
session_update_version: context.session_update_version,
session_updated_at: session.updated_at,
status: session.status,
workflow_notice: TextFingerprint::from_text(session.workflow_notice.as_deref()),
}
}
fn draft_prompt_fingerprint(session: &Session) -> TextFingerprint {
if session.status == Status::Draft && session.is_draft_session() {
return TextFingerprint::from_text(Some(session.prompt.as_str()));
}
TextFingerprint::from_text(None)
}
fn output_lines_with_metadata(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> SessionOutputLines {
let SessionOutputLineContext {
active_prompt_output,
active_progress,
review_model,
review_status_message,
review_text,
session_update_version: _,
} = context;
let status = session.status;
let mut published_loader_line_index = None;
let output_text = Self::output_text(session);
let (completed_turn_text, active_turn_text) =
Self::transcript_sections(status, output_text.as_ref(), active_prompt_output);
let completed_turn_text =
session_format::session_output_text_with_spaced_user_input(completed_turn_text);
let active_turn_text =
active_turn_text.map(session_format::session_output_text_with_spaced_user_input);
let inner_width =
panel_inner_width(output_area, session_format::session_output_panel_borders());
let (completed_turn_text, trailing_notice_text) = text_util::split_trailing_line_block(
&completed_turn_text,
TRAILING_TRANSCRIPT_NOTICE_PREFIXES,
);
let active_turn_has_visible_text = active_turn_text
.as_deref()
.is_some_and(|text| !text.trim().is_empty());
let mut lines = Vec::new();
Self::append_markdown_lines(
&mut lines,
completed_turn_text,
inner_width,
markdown_render_cache,
);
if active_turn_has_visible_text {
Self::append_trailing_transcript_notice_lines(
&mut lines,
trailing_notice_text,
inner_width,
markdown_render_cache,
);
}
let shows_summary_block = Self::shows_summary_block(
session.status,
active_prompt_output,
active_turn_text.as_deref(),
);
if shows_summary_block {
Self::append_summary_lines(
&mut lines,
session.summary.as_deref(),
inner_width,
markdown_render_cache,
);
}
Self::append_active_turn_lines(
&mut lines,
active_turn_text.as_deref(),
inner_width,
markdown_render_cache,
);
Self::append_queued_message_lines(&mut lines, &session.queued_messages);
if Self::shows_review_lines(session.status, review_status_message, review_text) {
Self::append_review_lines(
&mut lines,
review_status_message,
review_text,
inner_width,
markdown_render_cache,
);
}
if !active_turn_has_visible_text {
Self::append_trailing_transcript_notice_lines(
&mut lines,
trailing_notice_text,
inner_width,
markdown_render_cache,
);
}
Self::append_workflow_notice_lines(
&mut lines,
session.workflow_notice.as_deref(),
inner_width,
markdown_render_cache,
);
if Self::append_published_branch_sync_lines(&mut lines, session) {
published_loader_line_index = Some(lines.len().saturating_sub(1));
}
let active_loader_line_index = Self::append_session_tail_lines(
&mut lines,
status,
active_progress,
review_status_message,
review_model,
);
SessionOutputLines {
active_loader_line_index,
lines,
published_loader_line_index,
}
}
fn append_session_tail_lines(
lines: &mut Vec<Line<'static>>,
status: Status,
active_progress: Option<&str>,
review_status_message: Option<&str>,
review_model: AgentModel,
) -> Option<usize> {
if let Some(status_line) = session_format::session_output_status_line(
status,
active_progress,
review_status_message,
review_model,
) {
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
lines.push(Line::from(""));
let active_loader_line_index =
Self::status_uses_tachyon_loader(status).then_some(lines.len());
lines.push(status_line);
return active_loader_line_index;
}
if status == Status::Done {
lines.push(Line::from(""));
lines.push(session_format::session_output_done_line());
lines.push(Line::from(""));
return None;
}
lines.push(Line::from(""));
None
}
fn append_published_branch_sync_lines(
lines: &mut Vec<Line<'static>>,
session: &Session,
) -> bool {
let Some(sync_line) = session_format::session_output_published_branch_sync_line(session)
else {
return false;
};
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
lines.push(Line::from(""));
lines.push(sync_line);
true
}
fn append_workflow_notice_lines(
lines: &mut Vec<Line<'static>>,
workflow_notice: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let Some(workflow_notice) = workflow_notice.map(str::trim) else {
return;
};
if workflow_notice.is_empty() {
return;
}
Self::append_markdown_lines(lines, workflow_notice, inner_width, markdown_render_cache);
}
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_start) =
Self::active_prompt_start(output_text, active_prompt_output)
else {
return (output_text, None);
};
(
&output_text[..active_prompt_start],
Some(&output_text[active_prompt_start..]),
)
}
fn active_prompt_start(output_text: &str, active_prompt_output: Option<&str>) -> Option<usize> {
active_prompt_output
.filter(|prompt_output| !prompt_output.is_empty())
.and_then(|prompt_output| output_text.rfind(prompt_output))
.or_else(|| Self::last_user_prompt_start(output_text))
}
fn last_user_prompt_start(output_text: &str) -> Option<usize> {
output_text
.rfind(USER_PROMPT_LINE_PREFIX)
.map(|index| index + 1)
.or_else(|| output_text.starts_with(USER_PROMPT_PREFIX).then_some(0))
}
fn output_text(session: &Session) -> Cow<'_, str> {
match session.status {
Status::Draft if session.is_draft_session() => {
return Cow::Owned(Self::render_draft_session_preview(session));
}
Status::Canceled => {
return Cow::Borrowed(session.output.as_str());
}
Status::Review
| Status::AgentReview
| Status::Question
| Status::Draft
| Status::Done
| Status::InProgress
| Status::Queued
| Status::Rebasing
| Status::Merging => {}
}
Cow::Borrowed(session.output.as_str())
}
fn render_draft_session_preview(session: &Session) -> String {
let mut output = String::from(DRAFT_PREVIEW_HEADER);
if session.has_staged_drafts() {
let draft_note = if session.is_stacked_child() {
DRAFT_PREVIEW_STACKED_STAGED_NOTE
} else {
DRAFT_PREVIEW_STAGED_NOTE
};
let _ = write!(output, "\n\n{draft_note}\n\n");
output.push_str(&Self::staged_draft_transcript_block(&session.prompt));
} else {
let draft_note = if session.is_stacked_child() {
DRAFT_PREVIEW_STACKED_EMPTY_NOTE
} else {
DRAFT_PREVIEW_EMPTY_NOTE
};
let _ = write!(output, "\n\n{draft_note}\n");
}
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 shows_summary_block(
status: Status,
active_prompt_output: Option<&str>,
active_turn_text: Option<&str>,
) -> bool {
if status == Status::Canceled {
return false;
}
active_prompt_output.is_none() && active_turn_text.is_none()
}
fn shows_review_lines(
status: Status,
review_status_message: Option<&str>,
review_text: Option<&str>,
) -> bool {
if matches!(status, Status::Done | Status::Canceled) {
return false;
}
review_status_message
.map(str::trim)
.is_some_and(|status_message| !status_message.is_empty())
|| review_text
.map(str::trim)
.is_some_and(|review_text| !review_text.is_empty())
}
fn append_review_lines(
lines: &mut Vec<Line<'static>>,
review_status_message: Option<&str>,
review_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
if let Some(review_markdown) = review_text
.map(str::trim)
.filter(|review_text| !review_text.is_empty())
.map(session_format::annotate_review_suggestions_header)
{
Self::append_markdown_lines(
lines,
&review_markdown,
inner_width,
markdown_render_cache,
);
return;
}
if let Some(status_message) = Self::visible_review_status_message(review_status_message) {
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
if !lines.is_empty() {
lines.push(Line::from(""));
}
Self::append_plain_review_status_lines(lines, status_message, inner_width);
}
}
fn visible_review_status_message(review_status_message: Option<&str>) -> Option<&str> {
review_status_message
.map(str::trim)
.filter(|status_message| !status_message.is_empty())
.filter(|status_message| !app::is_review_loading_status_message(status_message))
}
fn append_plain_review_status_lines(
lines: &mut Vec<Line<'static>>,
status_message: &str,
inner_width: usize,
) {
let rendered_lines = text_util::wrap_lines(status_message, inner_width)
.into_iter()
.map(|line| Line::from(line.to_string()));
lines.extend(rendered_lines);
}
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,
&session_format::session_output_summary_markdown(summary_text),
inner_width,
markdown_render_cache,
);
}
fn append_trailing_transcript_notice_lines(
lines: &mut Vec<Line<'static>>,
trailing_notice_text: Option<&str>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let Some(trailing_notice_text) = trailing_notice_text else {
return;
};
Self::append_markdown_lines(
lines,
trailing_notice_text,
inner_width,
markdown_render_cache,
);
}
fn append_queued_message_lines(lines: &mut Vec<Line<'static>>, queued_messages: &[String]) {
if queued_messages.is_empty() {
return;
}
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
lines.push(Line::from(""));
let queued_style = ratatui::style::Style::default()
.fg(style::palette::text_subtle())
.add_modifier(ratatui::style::Modifier::ITALIC);
for queued_text in queued_messages {
let trimmed = queued_text.trim();
if trimmed.is_empty() {
continue;
}
for (line_index, message_line) in trimmed.split('\n').enumerate() {
let prefix = if line_index == 0 {
"queued › "
} else {
" "
};
lines.push(Line::styled(
format!("{prefix}{message_line}"),
queued_style,
));
}
}
lines.push(Line::from(""));
}
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 rendered_lines =
Self::rendered_markdown_lines(markdown, inner_width, markdown_render_cache);
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.extend(rendered_lines.iter().cloned());
}
fn rendered_markdown_lines(
markdown: &str,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> Arc<[Line<'static>]> {
match markdown_render_cache {
Some(cache) => cache.render(markdown, inner_width),
None => Arc::from(render_markdown(markdown, inner_width)),
}
}
fn borrowed_paint_lines<'line>(lines: &'line [Line<'static>]) -> Vec<Line<'line>> {
lines
.iter()
.map(|line| Line {
alignment: line.alignment,
spans: line.spans.iter().map(Self::borrowed_paint_span).collect(),
style: line.style,
})
.collect()
}
fn borrowed_paint_span<'span>(span: &'span Span<'static>) -> Span<'span> {
Span {
content: Cow::Borrowed(span.content.as_ref()),
style: span.style,
}
}
fn loader_area(
output_area: Rect,
loader_line_index: Option<usize>,
final_scroll: u16,
) -> Option<Rect> {
if output_area.width < TACHYON_LOADER_WIDTH {
return None;
}
let inner_area = Self::session_output_inner_area(output_area);
if inner_area.height == 0 {
return None;
}
let status_line_index = loader_line_index?;
let first_visible_line_index = usize::from(final_scroll);
let last_visible_line_index =
first_visible_line_index.saturating_add(usize::from(inner_area.height));
if status_line_index < first_visible_line_index
|| status_line_index >= last_visible_line_index
{
return None;
}
let row_offset = u16::try_from(status_line_index - first_visible_line_index).ok()?;
Some(Rect::new(
inner_area.x,
inner_area.y.saturating_add(row_offset),
TACHYON_LOADER_WIDTH,
1,
))
}
fn session_output_inner_area(output_area: Rect) -> Rect {
Rect::new(
output_area.x,
output_area.y.saturating_add(1),
output_area.width,
output_area.height.saturating_sub(2),
)
}
fn status_uses_tachyon_loader(status: Status) -> bool {
matches!(
status,
Status::InProgress | Status::AgentReview | Status::Rebasing | Status::Merging
)
}
fn apply_tachyon_loader_effect(&self, buffer: &mut Buffer, area: Rect, spinner_frame: usize) {
if let Some(cache) = self.output_layout_cache {
cache.apply_tachyon_loader_effect(&self.session.id, buffer, area, spinner_frame);
return;
}
TachyonLoaderEffect::apply_stateless(buffer, area, spinner_frame);
}
}
impl Component for SessionOutput<'_> {
fn render(&self, f: &mut Frame, output_area: Rect) {
let status = self.session.status;
let spinner_frame = Icon::current_spinner_frame();
let layout = Self::rendered_layout(
self.session,
output_area,
SessionOutputLineContext {
active_prompt_output: self.active_prompt_output,
active_progress: self.active_progress,
review_model: self.review_model,
review_status_message: self.review_status_message,
review_text: self.review_text,
session_update_version: self.session_update_version,
},
self.markdown_render_cache,
self.output_layout_cache,
);
let final_scroll = bottom_pinned_scroll_offset(
output_area,
session_format::session_output_panel_borders(),
layout.lines.len(),
self.scroll_offset,
);
let active_loader_area = if Self::status_uses_tachyon_loader(status) {
Self::loader_area(output_area, layout.active_loader_line_index, final_scroll)
} else {
None
};
let published_loader_area = Self::loader_area(
output_area,
layout.published_loader_line_index,
final_scroll,
);
let paint_lines = SessionOutput::borrowed_paint_lines(&layout.lines);
let paragraph = Paragraph::new(paint_lines)
.block(
Block::default()
.borders(session_format::session_output_panel_borders())
.border_style(session_format::session_output_panel_border_style(status)),
)
.scroll((final_scroll, 0));
f.render_widget(paragraph, output_area);
if let Some(loader_area) = active_loader_area {
self.apply_tachyon_loader_effect(f.buffer_mut(), loader_area, spinner_frame);
}
if let Some(loader_area) = published_loader_area {
TachyonLoaderEffect::apply_stateless(f.buffer_mut(), loader_area, spinner_frame);
}
}
}
#[cfg(test)]
mod tests {
use ratatui::layout::Alignment;
use ratatui::style::Style;
use serde_json;
use super::*;
use crate::infra::agent::protocol::AgentResponseSummary;
fn line_context<'a>(
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,
review_model: AgentModel::Gpt55,
review_status_message,
review_text,
session_update_version: 0,
}
}
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 session_fixture() -> Session {
crate::domain::session::tests::SessionFixtureBuilder::new()
.status(Status::Draft)
.build()
}
fn output_lines(
session: &Session,
output_area: Rect,
context: SessionOutputLineContext<'_>,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) -> Vec<Line<'static>> {
SessionOutput::output_lines_with_metadata(
session,
output_area,
context,
markdown_render_cache,
)
.lines
}
#[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 markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let rendered_line_count = SessionOutput::rendered_line_count(
&session,
20,
line_context(None, None, None),
Some(&markdown_render_cache),
Some(&output_layout_cache),
);
assert!(rendered_line_count > raw_line_count);
}
#[test]
fn test_output_layout_cache_reuses_lines_for_matching_update_key() {
let mut session = session_fixture();
session.output = "## Heading\n\ncached body".to_string();
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = SessionOutputLineContext {
session_update_version: 7,
..line_context(None, None, None)
};
let first_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
let second_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
assert_eq!(first_layout.line_count, second_layout.line_count);
assert!(Arc::ptr_eq(&first_layout.lines, &second_layout.lines));
}
#[test]
fn test_output_layout_cache_keys_staged_draft_prompt() {
let mut session = session_fixture();
session.is_draft = true;
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = line_context(None, None, None);
let empty_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
session.prompt = "First staged draft".to_string();
let staged_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
let staged_text = staged_layout
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(!Arc::ptr_eq(&empty_layout.lines, &staged_layout.lines));
assert!(staged_text.contains("First staged draft"));
}
#[test]
fn test_output_layout_cache_keys_stacked_draft_preview() {
let mut session = session_fixture();
session.is_draft = true;
session.prompt = "First staged draft".to_string();
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = line_context(None, None, None);
let root_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 12),
context,
Some(&markdown_render_cache),
);
session.parent_session_id = Some(SessionId::from("parent-session"));
let stacked_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 12),
context,
Some(&markdown_render_cache),
);
let stacked_text = stacked_layout
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(!Arc::ptr_eq(&root_layout.lines, &stacked_layout.lines));
assert!(stacked_text.contains("start the stacked"));
assert!(stacked_text.contains("bundle from its parent"));
assert!(stacked_text.contains("parent"));
}
#[test]
fn test_output_layout_cache_keys_queued_messages() {
let mut session = session_fixture();
session.status = Status::InProgress;
session.output = " › running prompt".to_string();
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = line_context(None, None, None);
let empty_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
session.queued_messages = vec!["queued reply".to_string()];
let queued_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
let queued_text = queued_layout
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(!Arc::ptr_eq(&empty_layout.lines, &queued_layout.lines));
assert!(queued_text.contains("queued › queued reply"));
}
#[test]
fn test_output_layout_cache_keys_workflow_notice() {
let mut session = session_fixture();
session.output = "implemented the feature".to_string();
session.status = Status::Review;
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = line_context(None, None, None);
let base_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
session.workflow_notice = Some("[Commit] No changes to commit.".to_string());
let notice_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
);
let notice_text = notice_layout
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(!session.output.contains("[Commit] No changes to commit."));
assert!(!Arc::ptr_eq(&base_layout.lines, ¬ice_layout.lines));
assert!(notice_text.contains("[Commit] No changes to commit."));
}
#[test]
fn test_output_layout_cache_keys_review_text() {
let session = session_fixture();
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let base_context = SessionOutputLineContext {
session_update_version: 7,
..line_context(None, None, None)
};
let review_context = SessionOutputLineContext {
review_text: Some("## Review\n\n- Cached finding"),
..base_context
};
let base_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
base_context,
Some(&markdown_render_cache),
);
let review_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
review_context,
Some(&markdown_render_cache),
);
assert!(review_layout.line_count > base_layout.line_count);
assert!(!Arc::ptr_eq(&base_layout.lines, &review_layout.lines));
}
#[test]
fn test_output_layout_cache_reuses_active_loader_layout_across_frames() {
let mut session = session_fixture();
session.output = "active output".to_string();
session.status = Status::InProgress;
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let first_frame_context = line_context(None, None, None);
let first_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
first_frame_context,
Some(&markdown_render_cache),
);
let repeated_first_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
first_frame_context,
Some(&markdown_render_cache),
);
let repeated_frame_layout = output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
first_frame_context,
Some(&markdown_render_cache),
);
assert!(Arc::ptr_eq(
&first_layout.lines,
&repeated_first_layout.lines
));
assert!(Arc::ptr_eq(
&first_layout.lines,
&repeated_frame_layout.lines
));
assert!(
first_layout
.lines
.iter()
.any(|line| line.to_string().contains(Icon::TachyonLoader.as_str()))
);
}
#[test]
fn test_output_layout_cache_keeps_tachyon_effect_state_per_session() {
let output_layout_cache = SessionOutputLayoutCache::default();
let area = Rect::new(0, 0, TACHYON_LOADER_WIDTH, 1);
let mut first_buffer = Buffer::empty(area);
let mut second_buffer = Buffer::empty(area);
for column in 0..TACHYON_LOADER_WIDTH {
first_buffer[(column, 0)]
.set_symbol("▌")
.set_fg(style::palette::text_muted());
second_buffer[(column, 0)]
.set_symbol("▌")
.set_fg(style::palette::text_muted());
}
let first_session_id = SessionId::from("first-loader-session");
let second_session_id = SessionId::from("second-loader-session");
output_layout_cache.apply_tachyon_loader_effect(
&first_session_id,
&mut first_buffer,
area,
4,
);
output_layout_cache.apply_tachyon_loader_effect(
&second_session_id,
&mut second_buffer,
area,
4,
);
assert_eq!(output_layout_cache.tachyon_loader_effects.borrow().len(), 2);
assert!(
(0..TACHYON_LOADER_WIDTH)
.any(|column| second_buffer[(column, 0)].fg == style::palette::warning())
);
}
#[test]
fn test_output_layout_cache_evicts_tachyon_effects_with_layout_lru() {
let output_layout_cache = SessionOutputLayoutCache::default();
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let area = Rect::new(0, 0, TACHYON_LOADER_WIDTH, 1);
for session_index in 0..=SESSION_OUTPUT_LAYOUT_CACHE_ENTRY_LIMIT {
let mut session = session_fixture();
session.id = SessionId::from(format!("loader-session-{session_index:02}"));
session.output = format!("active output {session_index}");
session.status = Status::InProgress;
output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
Some(&markdown_render_cache),
);
let mut buffer = Buffer::empty(area);
for column in 0..TACHYON_LOADER_WIDTH {
buffer[(column, 0)].set_symbol("▌");
}
output_layout_cache.apply_tachyon_loader_effect(&session.id, &mut buffer, area, 4);
}
let tachyon_loader_effects = output_layout_cache.tachyon_loader_effects.borrow();
assert_eq!(
tachyon_loader_effects.len(),
SESSION_OUTPUT_LAYOUT_CACHE_ENTRY_LIMIT
);
assert!(!tachyon_loader_effects.contains_key(&SessionId::from("loader-session-00")));
assert!(tachyon_loader_effects.contains_key(&SessionId::from("loader-session-16")));
}
#[test]
fn test_output_lines_metadata_marks_status_loader_not_user_text() {
let mut session = session_fixture();
session.output = format!("{} pasted transcript glyph", Icon::TachyonLoader);
session.status = Status::InProgress;
let context = line_context(None, None, None);
let output_lines = SessionOutput::output_lines_with_metadata(
&session,
Rect::new(0, 0, 80, 8),
context,
None,
);
let loader_line_index = output_lines
.active_loader_line_index
.expect("active loader status row should be tracked");
let loader_line = output_lines.lines[loader_line_index].to_string();
assert!(loader_line.contains("Working..."));
assert!(!loader_line.contains("pasted transcript glyph"));
}
#[test]
fn test_loader_area_tracks_scrolled_row() {
let output_area = Rect::new(2, 3, 80, 10);
let loader_area = SessionOutput::loader_area(output_area, Some(19), 12);
assert_eq!(loader_area, Some(Rect::new(2, 11, TACHYON_LOADER_WIDTH, 1)));
}
#[test]
fn test_loader_area_locates_row_before_following_hint() {
let output_area = Rect::new(0, 0, 80, 8);
let loader_area = SessionOutput::loader_area(output_area, Some(1), 0);
assert_eq!(loader_area, Some(Rect::new(0, 2, TACHYON_LOADER_WIDTH, 1)));
}
#[test]
fn test_loader_area_skips_missing_line_index() {
let output_area = Rect::new(0, 0, 80, 10);
let loader_area = SessionOutput::loader_area(output_area, None, 0);
assert_eq!(loader_area, None);
}
#[test]
fn test_tachyon_loader_effect_emphasizes_loader_cells() {
let area = Rect::new(0, 0, TACHYON_LOADER_WIDTH, 1);
let mut buffer = Buffer::empty(area);
for column in 0..TACHYON_LOADER_WIDTH {
buffer[(column, 0)]
.set_symbol("▌")
.set_fg(style::palette::text_muted());
}
let mut loader_effect = TachyonLoaderEffect::new();
loader_effect.apply(&mut buffer, area, 4);
let foreground_colors = (0..TACHYON_LOADER_WIDTH)
.map(|column| buffer[(column, 0)].fg)
.collect::<Vec<_>>();
assert!(foreground_colors.contains(&style::palette::warning()));
assert!(foreground_colors.contains(&style::palette::warning_soft()));
}
#[test]
fn test_output_text_borrows_transcript_for_output_mode() {
let mut session = session_fixture();
session.output = "borrowed transcript".to_string();
let output_text = SessionOutput::output_text(&session);
assert!(matches!(output_text, Cow::Borrowed("borrowed transcript")));
}
#[test]
fn test_borrowed_paint_lines_reuse_cached_span_content() {
let cached_lines = [Line {
alignment: Some(Alignment::Center),
spans: vec![Span {
content: Cow::Owned("cached span text".to_string()),
style: Style::default(),
}],
style: Style::default(),
}];
let paint_lines = SessionOutput::borrowed_paint_lines(&cached_lines);
assert_eq!(paint_lines[0].alignment, Some(Alignment::Center));
assert!(matches!(
paint_lines[0].spans[0].content,
Cow::Borrowed("cached span text")
));
}
#[test]
fn test_output_lines_done_summary_mode_keeps_transcript_with_summary() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(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 = output_lines(
&session,
Rect::new(0, 0, 80, 12),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Draft Session"));
assert!(text.contains("Draft messages stay local until you press s in session view"));
assert!(text.contains("First draft"));
assert!(text.contains("Second draft"));
}
#[test]
fn test_output_lines_render_staged_draft_preview_for_stacked_session() {
let mut session = session_fixture();
session.is_draft = true;
session.parent_session_id = Some(SessionId::from("parent-session"));
session.prompt = "Stacked draft".to_string();
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 12),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Draft Session"));
assert!(text.contains("start the stacked"));
assert!(text.contains("bundle from its parent"));
assert!(text.contains("parent"));
assert!(text.contains("Stacked draft"));
}
#[test]
fn test_output_lines_render_empty_draft_preview_for_new_session() {
let mut session = session_fixture();
session.is_draft = true;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Draft Session"));
assert!(text.contains("No draft messages staged yet."));
assert!(text.contains("Use Enter to stage the first draft locally"));
}
#[test]
fn test_output_lines_render_empty_draft_preview_for_stacked_session() {
let mut session = session_fixture();
session.is_draft = true;
session.parent_session_id = Some(SessionId::from("parent-session"));
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Draft Session"));
assert!(text.contains("No draft messages staged yet."));
assert!(text.contains("start action appears after the parent is review-ready"));
}
#[test]
fn test_output_lines_done_output_mode_appends_structured_summary() {
let mut session = session_fixture();
session.output = "streamed output\n\n[Commit] No changes to commit.\n".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Done;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let output_index = text
.find("streamed output")
.expect("streamed output should be rendered");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
let commit_index = text
.find("[Commit] No changes to commit.")
.expect("commit footer should be rendered");
assert!(text.contains("streamed output"));
assert!(text.contains("Added the structured protocol summary."));
assert!(text.contains("Session output now renders persisted summary markdown."));
assert!(output_index < summary_index);
assert!(summary_index < commit_index);
}
#[test]
fn test_output_lines_places_summary_before_trailing_workflow_notices() {
let mut session = session_fixture();
session.output = "streamed output\n\n[Commit] No changes to commit.\n\n[Sync Assist] \
Attempt 1/3. Resolving conflicts in:\n- \
crates/agentty/src/runtime/worker.rs\n"
.to_string();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let output_index = text
.find("streamed output")
.expect("streamed output should be rendered");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
let commit_index = text
.find("[Commit] No changes to commit.")
.expect("commit notice should be rendered");
let sync_index = text
.find("[Sync Assist] Attempt 1/3.")
.expect("sync notice should be rendered");
assert!(output_index < summary_index);
assert!(summary_index < commit_index);
assert!(commit_index < sync_index);
}
#[test]
fn test_output_lines_places_review_before_trailing_workflow_notices() {
let mut session = session_fixture();
session.output = "implemented fix\n\n[Merge Error] Cannot merge branch".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let review_text = "## Review\n\n### Project Impact\n\n- Documentation-only change.";
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, Some(review_text), None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let output_index = text
.find("implemented fix")
.expect("completed output should be rendered");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
let review_index = text.find("Review").expect("review should be rendered");
let merge_error_index = text
.find("[Merge Error] Cannot merge branch")
.expect("merge error should be rendered");
assert!(output_index < summary_index);
assert!(summary_index < review_index);
assert!(review_index < merge_error_index);
}
#[test]
fn test_output_lines_done_session_hides_review_text_when_available() {
let mut session = session_fixture();
session.summary = Some("# Summary\n\nMerged session work.".to_string());
session.status = Status::Done;
let assisted_review = "## Review\n\n- Focused finding";
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(
Some("Reviewing changes with gpt-5.5"),
Some(assisted_review),
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Merged session work."));
assert!(!text.contains("Focused finding"));
assert!(!text.contains("Reviewing changes with gpt-5.5"));
}
#[test]
fn test_output_lines_canceled_session_hides_review_text_when_available() {
let mut session = session_fixture();
session.output = "interrupted transcript".to_string();
session.status = Status::Canceled;
let assisted_review = "## Review\n\n- Focused finding";
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(
Some("Reviewing changes with gpt-5.5"),
Some(assisted_review),
None,
),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("interrupted transcript"));
assert!(!text.contains("Focused finding"));
assert!(!text.contains("Reviewing changes with gpt-5.5"));
}
#[test]
fn test_output_lines_review_session_shows_review_status_message_when_text_missing() {
let mut session = session_fixture();
session.status = Status::Review;
let review_status_message = "Review assist unavailable: empty provider response";
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(Some(review_status_message), None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains(review_status_message));
}
#[test]
fn test_output_lines_review_status_message_preserves_markdown_characters() {
let mut session = session_fixture();
session.status = Status::Review;
let review_status_message = "# Review *failed* for `tool`";
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(Some(review_status_message), None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains(review_status_message));
}
#[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 = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(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_structured_summary_spaces_change_summary_header() {
let mut session = session_fixture();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let rendered_lines = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
let summary_header_index = rendered_lines
.iter()
.position(|line| line == "Change Summary")
.expect("structured summary header should be rendered");
assert_eq!(
rendered_lines
.get(summary_header_index + 1)
.map(String::as_str),
Some("")
);
assert_eq!(
rendered_lines
.get(summary_header_index + 2)
.map(String::as_str),
Some("Current Turn")
);
}
#[test]
fn test_output_lines_in_progress_session_hides_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 = output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some("\n › add hello world\n\n"),
..line_context(None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let commit_index = text
.find("[Commit] No changes to commit.")
.expect("commit footer should be rendered");
let prompt_index = text
.find(" › add hello world")
.expect("active prompt should be rendered");
assert!(!text.contains("Change Summary"));
assert!(commit_index < prompt_index);
}
#[test]
fn test_output_lines_in_progress_session_shows_queued_messages_after_active_turn() {
let mut session = session_fixture();
session.output =
" › hi\n\n[Commit] No changes to commit.\n\n › add hello world\n\nworking".to_string();
session.queued_messages = vec!["follow up\nwith context".to_string()];
session.summary = Some(summary_fixture());
session.status = Status::InProgress;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some("\n › add hello world\n\n"),
..line_context(None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let commit_index = text
.find("[Commit] No changes to commit.")
.expect("commit footer should be rendered");
let prompt_index = text
.find(" › add hello world")
.expect("active prompt should be rendered");
let queued_index = text
.find("queued › follow up")
.expect("queued message should be rendered");
assert!(!text.contains("Change Summary"));
assert!(commit_index < prompt_index);
assert!(prompt_index < queued_index);
assert!(text.contains(" with context"));
}
#[test]
fn test_output_lines_in_progress_single_prompt_hides_summary() {
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 = output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some(" › add hello world\n\n"),
..line_context(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 answer_index = text
.find("I added the README change.")
.expect("answer should be rendered");
assert!(!text.contains("Change Summary"));
assert!(prompt_index < answer_index);
}
#[test]
fn test_output_lines_in_progress_without_active_capture_hides_summary_before_last_prompt() {
let mut session = session_fixture();
session.output = " › hi\n\nHello!\n\n[Commit] No changes to commit.\n\n › review \
project\n\n"
.to_string();
session.summary = Some(summary_fixture());
session.status = Status::InProgress;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let commit_index = text
.find("[Commit] No changes to commit.")
.expect("commit footer should be rendered");
let prompt_index = text
.find(" › review project")
.expect("latest prompt should be rendered");
assert!(!text.contains("Change Summary"));
assert!(commit_index < prompt_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 = output_lines(
&session,
Rect::new(0, 0, 80, 8),
SessionOutputLineContext {
active_prompt_output: Some("\n › actual prompt\n\n"),
..line_context(None, None, None)
},
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let prompt_index = text
.find(" › actual prompt")
.expect("active prompt should be rendered");
let quoted_output_index = text
.find(" › quoted output")
.expect("assistant output that looks like a prompt should be rendered");
assert!(!text.contains("Change Summary"));
assert!(prompt_index < quoted_output_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 = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(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_output_lines_done_summary_transition_preserves_transcript() {
let mut session = session_fixture();
session.output = "streamed output".to_string();
session.summary = Some(summary_fixture());
session.status = Status::Review;
let review_lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let review_text = review_lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
session.summary = Some(
"# Summary\n\nSession now greets users on startup.\n\n# Commit\n\nRefine session \
summary"
.to_string(),
);
session.status = Status::Done;
let done_output_text = SessionOutput::output_text(&session);
let done_lines = output_lines(
&session,
Rect::new(0, 0, 80, 8),
line_context(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, "streamed output");
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("streamed output"));
}
#[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 = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(
Some("Reviewing changes with gpt-5.5"),
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_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 = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(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_use_generic_in_progress_loader() {
let mut session = session_fixture();
session.output = "some output".to_string();
session.status = Status::InProgress;
let lines = output_lines(
&session,
Rect::new(0, 0, 80, 5),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Working..."));
assert!(text.contains(Icon::TachyonLoader.as_str()));
}
}