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::style::{Modifier, Style};
use ratatui::text::Line;
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::session_message::{SessionMessage, SessionMessageKind, SessionTranscript};
use crate::ui::component::tachyon_loader::TachyonLoaderEffect;
use crate::ui::icon::{Icon, TACHYON_LOADER_WIDTH};
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_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,
theme_cache_version: u64,
transcript: TranscriptFingerprint,
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, Copy, Debug, Eq, PartialEq)]
struct TranscriptFingerprint {
content_len: usize,
is_some: bool,
last_kind: &'static str,
last_position: i64,
message_count: usize,
}
impl TranscriptFingerprint {
fn from_session(session: &Session) -> Self {
let Some(transcript) = session.transcript.as_ref() else {
return Self {
content_len: 0,
is_some: false,
last_kind: "",
last_position: 0,
message_count: 0,
};
};
let messages = transcript.messages();
let Some(last_message) = messages.last() else {
return Self {
content_len: 0,
is_some: false,
last_kind: "",
last_position: 0,
message_count: 0,
};
};
Self {
content_len: transcript.total_content_len(),
is_some: true,
last_kind: last_message.kind.as_str(),
last_position: last_message.position,
message_count: messages.len(),
}
}
}
#[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>,
}
#[derive(Clone, Copy)]
enum SessionOutputBlock {
ActiveTurn,
CompletedTranscript,
PublishedBranchSync,
QueuedMessage,
Review,
SessionTail,
Summary,
TrailingTranscriptNotice(TrailingTranscriptNoticePlacement),
WorkflowNotice,
}
#[derive(Clone, Copy)]
enum TrailingTranscriptNoticePlacement {
AfterReview,
BeforeActiveTurn,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum SessionOutputSeparator {
Always,
AfterPreviousContent,
}
const SESSION_OUTPUT_BLOCK_ORDER: [SessionOutputBlock; 10] = [
SessionOutputBlock::CompletedTranscript,
SessionOutputBlock::TrailingTranscriptNotice(
TrailingTranscriptNoticePlacement::BeforeActiveTurn,
),
SessionOutputBlock::Summary,
SessionOutputBlock::ActiveTurn,
SessionOutputBlock::QueuedMessage,
SessionOutputBlock::Review,
SessionOutputBlock::TrailingTranscriptNotice(TrailingTranscriptNoticePlacement::AfterReview),
SessionOutputBlock::WorkflowNotice,
SessionOutputBlock::PublishedBranchSync,
SessionOutputBlock::SessionTail,
];
struct SessionOutputAssembly<'a> {
active_loader_line_index: Option<usize>,
active_progress: Option<&'a str>,
active_prompt_output: Option<&'a str>,
active_turn_has_visible_text: bool,
active_turn_section: SessionOutputTranscriptSection<'a>,
completed_turn_section: SessionOutputTranscriptSection<'a>,
inner_width: usize,
lines: Vec<Line<'static>>,
markdown_render_cache: Option<&'a markdown::MarkdownRenderCache>,
published_loader_line_index: Option<usize>,
review_model: AgentModel,
review_status_message: Option<&'a str>,
review_text: Option<&'a str>,
session: &'a Session,
status: Status,
trailing_notice_section: SessionOutputTranscriptSection<'a>,
}
struct SessionOutputTextSections<'a> {
active_turn: SessionOutputTranscriptSection<'a>,
completed_turn: SessionOutputTranscriptSection<'a>,
trailing_notice: SessionOutputTranscriptSection<'a>,
}
enum SessionOutputTranscriptSection<'a> {
Empty,
Markdown(String),
Messages(&'a [SessionMessage]),
}
impl SessionOutputTranscriptSection<'_> {
fn is_empty(&self) -> bool {
match self {
Self::Empty => true,
Self::Markdown(text) => text.trim().is_empty(),
Self::Messages(messages) => messages
.iter()
.all(|message| message.content.trim().is_empty()),
}
}
}
impl SessionOutputAssembly<'_> {
fn into_output_lines(mut self) -> SessionOutputLines {
for block in SESSION_OUTPUT_BLOCK_ORDER {
self.append_block(block);
}
SessionOutputLines {
active_loader_line_index: self.active_loader_line_index,
lines: self.lines,
published_loader_line_index: self.published_loader_line_index,
}
}
fn append_block(&mut self, block: SessionOutputBlock) {
match block {
SessionOutputBlock::CompletedTranscript => self.append_completed_transcript(),
SessionOutputBlock::TrailingTranscriptNotice(placement) => {
self.append_trailing_transcript_notice(placement);
}
SessionOutputBlock::Summary => self.append_summary(),
SessionOutputBlock::ActiveTurn => self.append_active_turn(),
SessionOutputBlock::QueuedMessage => self.append_queued_messages(),
SessionOutputBlock::Review => self.append_review(),
SessionOutputBlock::WorkflowNotice => self.append_workflow_notice(),
SessionOutputBlock::PublishedBranchSync => self.append_published_branch_sync(),
SessionOutputBlock::SessionTail => self.append_session_tail(),
}
}
fn append_completed_transcript(&mut self) {
SessionOutput::append_transcript_section_lines(
&mut self.lines,
&self.completed_turn_section,
self.inner_width,
self.markdown_render_cache,
);
}
fn append_trailing_transcript_notice(&mut self, placement: TrailingTranscriptNoticePlacement) {
let should_append = match placement {
TrailingTranscriptNoticePlacement::BeforeActiveTurn => {
self.active_turn_has_visible_text
}
TrailingTranscriptNoticePlacement::AfterReview => !self.active_turn_has_visible_text,
};
if !should_append {
return;
}
SessionOutput::append_transcript_section_lines(
&mut self.lines,
&self.trailing_notice_section,
self.inner_width,
self.markdown_render_cache,
);
}
fn append_summary(&mut self) {
if !SessionOutput::shows_summary_block(
self.status,
self.active_prompt_output,
&self.active_turn_section,
) {
return;
}
SessionOutput::append_summary_lines(
&mut self.lines,
self.session.summary.as_deref(),
self.inner_width,
self.markdown_render_cache,
);
}
fn append_active_turn(&mut self) {
SessionOutput::append_transcript_section_lines(
&mut self.lines,
&self.active_turn_section,
self.inner_width,
self.markdown_render_cache,
);
}
fn append_queued_messages(&mut self) {
SessionOutput::append_queued_message_lines(&mut self.lines, &self.session.queued_messages);
}
fn append_review(&mut self) {
if !SessionOutput::shows_review_lines(
self.status,
self.review_status_message,
self.review_text,
) {
return;
}
SessionOutput::append_review_lines(
&mut self.lines,
self.review_status_message,
self.review_text,
self.inner_width,
self.markdown_render_cache,
);
}
fn append_workflow_notice(&mut self) {
SessionOutput::append_workflow_notice_lines(
&mut self.lines,
self.session.workflow_notice.as_deref(),
self.inner_width,
self.markdown_render_cache,
);
}
fn append_published_branch_sync(&mut self) {
if SessionOutput::append_published_branch_sync_lines(&mut self.lines, self.session) {
self.published_loader_line_index = Some(self.lines.len().saturating_sub(1));
}
}
fn append_session_tail(&mut self) {
self.active_loader_line_index = SessionOutput::append_session_tail_lines(
&mut self.lines,
self.status,
self.active_progress,
self.review_status_message,
self.review_model,
);
}
}
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,
theme_cache_version: style::active_theme_cache_version(),
transcript: TranscriptFingerprint::from_session(session),
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 transcript_sections = Self::output_text_sections(session, status);
let inner_width =
panel_inner_width(output_area, session_format::session_output_panel_borders());
let active_turn_has_visible_text = !transcript_sections.active_turn.is_empty();
SessionOutputAssembly {
active_loader_line_index: None,
active_progress,
active_prompt_output,
active_turn_has_visible_text,
active_turn_section: transcript_sections.active_turn,
completed_turn_section: transcript_sections.completed_turn,
inner_width,
lines: Vec::new(),
markdown_render_cache,
published_loader_line_index: None,
review_model,
review_status_message,
review_text,
session,
status,
trailing_notice_section: transcript_sections.trailing_notice,
}
.into_output_lines()
}
fn append_block_separator(lines: &mut Vec<Line<'static>>, separator: SessionOutputSeparator) {
Self::trim_trailing_blank_lines(lines);
if separator == SessionOutputSeparator::Always || !lines.is_empty() {
lines.push(Line::from(""));
}
}
fn trim_trailing_blank_lines(lines: &mut Vec<Line<'static>>) {
while lines.last().is_some_and(|line| line.width() == 0) {
lines.pop();
}
}
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,
) {
Self::append_block_separator(lines, SessionOutputSeparator::Always);
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;
};
Self::append_block_separator(lines, SessionOutputSeparator::Always);
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 output_text_sections(session: &Session, status: Status) -> SessionOutputTextSections<'_> {
let is_draft_preview = session.status == Status::Draft && session.is_draft_session();
if is_draft_preview {
return SessionOutputTextSections {
active_turn: SessionOutputTranscriptSection::Empty,
completed_turn: SessionOutputTranscriptSection::Markdown(
Self::render_draft_session_preview(session),
),
trailing_notice: SessionOutputTranscriptSection::Empty,
};
}
if let Some(transcript) = session
.transcript
.as_ref()
.filter(|transcript| !transcript.is_empty())
{
return Self::typed_transcript_sections(status, transcript);
}
SessionOutputTextSections {
active_turn: SessionOutputTranscriptSection::Empty,
completed_turn: SessionOutputTranscriptSection::Empty,
trailing_notice: SessionOutputTranscriptSection::Empty,
}
}
fn typed_transcript_sections(
status: Status,
transcript: &SessionTranscript,
) -> SessionOutputTextSections<'_> {
let messages = transcript.messages();
let active_prompt_index =
Self::active_prompt_message_index(status, messages).unwrap_or(messages.len());
let (completed_messages, active_messages) = messages.split_at(active_prompt_index);
let trailing_notice_start = Self::trailing_workflow_notice_start(completed_messages)
.unwrap_or(completed_messages.len());
let (completed_messages, trailing_notice_messages) =
completed_messages.split_at(trailing_notice_start);
SessionOutputTextSections {
active_turn: Self::messages_section(active_messages),
completed_turn: Self::messages_section(completed_messages),
trailing_notice: Self::messages_section(trailing_notice_messages),
}
}
fn messages_section(messages: &[SessionMessage]) -> SessionOutputTranscriptSection<'_> {
if messages.is_empty() {
return SessionOutputTranscriptSection::Empty;
}
SessionOutputTranscriptSection::Messages(messages)
}
fn active_prompt_message_index(status: Status, messages: &[SessionMessage]) -> Option<usize> {
if !matches!(
status,
Status::InProgress | Status::Queued | Status::Rebasing | Status::Merging
) {
return None;
}
messages
.iter()
.rposition(|message| message.kind == SessionMessageKind::UserPrompt)
}
fn trailing_workflow_notice_start(messages: &[SessionMessage]) -> Option<usize> {
if messages.is_empty() {
return None;
}
let Some(first_non_notice_from_end) = messages
.iter()
.rposition(|message| message.kind != SessionMessageKind::WorkflowNotice)
else {
return Some(0);
};
let notice_start = first_non_notice_from_end.saturating_add(1);
(notice_start < messages.len()).then_some(notice_start)
}
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");
}
if let Some(transcript_text) = session
.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
{
let _ = write!(output, "\n\n{transcript_text}");
}
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_section: &SessionOutputTranscriptSection<'_>,
) -> bool {
if status == Status::Canceled {
return false;
}
active_prompt_output.is_none() && active_turn_section.is_empty()
}
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) {
Self::append_block_separator(lines, SessionOutputSeparator::AfterPreviousContent);
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_transcript_section_lines(
lines: &mut Vec<Line<'static>>,
section: &SessionOutputTranscriptSection<'_>,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
match section {
SessionOutputTranscriptSection::Empty => {}
SessionOutputTranscriptSection::Markdown(markdown) => {
Self::append_markdown_lines(lines, markdown, inner_width, markdown_render_cache);
}
SessionOutputTranscriptSection::Messages(messages) => {
Self::append_transcript_message_lines(
lines,
messages,
inner_width,
markdown_render_cache,
);
}
}
}
fn append_transcript_message_lines(
lines: &mut Vec<Line<'static>>,
messages: &[SessionMessage],
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
for message in messages {
match message.kind {
SessionMessageKind::UserPrompt => Self::append_user_prompt_markdown_lines(
lines,
&message.content,
inner_width,
markdown_render_cache,
),
SessionMessageKind::AssistantAnswer | SessionMessageKind::WorkflowNotice => {
Self::append_markdown_lines(
lines,
&message.content,
inner_width,
markdown_render_cache,
);
}
}
}
}
fn append_queued_message_lines(lines: &mut Vec<Line<'static>>, queued_messages: &[String]) {
if queued_messages.is_empty() {
return;
}
Self::append_block_separator(lines, SessionOutputSeparator::Always);
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_user_prompt_markdown_lines(
lines: &mut Vec<Line<'static>>,
prompt_text: &str,
inner_width: usize,
markdown_render_cache: Option<&markdown::MarkdownRenderCache>,
) {
let prompt_text = prompt_text.trim();
if prompt_text.is_empty() {
return;
}
let prompt_prefix_width = USER_PROMPT_PREFIX.chars().count();
let prompt_content_width = inner_width.saturating_sub(prompt_prefix_width).max(1);
let rendered_lines =
Self::rendered_markdown_lines(prompt_text, prompt_content_width, markdown_render_cache);
if rendered_lines.is_empty() {
return;
}
Self::append_block_separator(lines, SessionOutputSeparator::AfterPreviousContent);
lines.push(Self::user_prompt_padding_line(inner_width));
let mut has_rendered_content_line = false;
for rendered_line in rendered_lines.iter() {
if rendered_line.width() == 0 {
lines.push(Self::user_prompt_padding_line(inner_width));
continue;
}
let prefix = if has_rendered_content_line {
USER_PROMPT_CONTINUATION_PREFIX
} else {
USER_PROMPT_PREFIX
};
let prefix_style = if has_rendered_content_line {
Self::user_prompt_content_style()
} else {
Self::user_prompt_prefix_style()
};
lines.push(Self::user_prompt_markdown_line(
rendered_line,
prefix,
prefix_style,
inner_width,
));
has_rendered_content_line = true;
}
lines.push(Self::user_prompt_padding_line(inner_width));
}
fn user_prompt_padding_line(width: usize) -> Line<'static> {
Line::styled(" ".repeat(width), Self::user_prompt_content_style())
}
fn user_prompt_markdown_line(
rendered_line: &Line<'static>,
prefix: &str,
prefix_style: Style,
width: usize,
) -> Line<'static> {
let content_style = Self::user_prompt_content_style();
let mut spans = vec![ratatui::text::Span::styled(
prefix.to_string(),
prefix_style,
)];
spans.extend(
rendered_line
.spans
.iter()
.cloned()
.map(Self::user_prompt_content_span),
);
let mut line = Line::from(spans);
let line_width = line.width();
if line_width > width {
line.spans =
text_util::truncate_spans_with_ellipsis(std::mem::take(&mut line.spans), width)
.into_iter()
.map(Self::user_prompt_content_span)
.collect();
} else if line_width < width {
line.spans.push(ratatui::text::Span::styled(
" ".repeat(width - line_width),
content_style,
));
}
line
}
fn user_prompt_content_span(
mut span: ratatui::text::Span<'static>,
) -> ratatui::text::Span<'static> {
let content_style = Self::user_prompt_content_style();
if span.style.fg.is_none() {
span.style.fg = content_style.fg;
}
if span.style.bg.is_none() {
span.style.bg = content_style.bg;
}
span
}
fn user_prompt_prefix_style() -> Style {
Style::default()
.fg(style::palette::accent())
.bg(style::palette::surface())
.add_modifier(Modifier::BOLD)
}
fn user_prompt_content_style() -> Style {
Style::default()
.fg(style::palette::text())
.bg(style::palette::surface())
}
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;
}
Self::append_block_separator(lines, SessionOutputSeparator::AfterPreviousContent);
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 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 = text_util::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 std::borrow::Cow;
use ag_protocol::AgentResponseSummary;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use serde_json;
use super::*;
use crate::domain::session::PublishedBranchSyncStatus;
use crate::domain::theme::ColorTheme;
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::test_support::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
}
fn table_header_background(layout: &SessionOutputLayout) -> Option<Color> {
layout
.lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.as_ref().contains("Input"))
.and_then(|span| span.style.bg)
}
fn set_assistant_transcript(session: &mut Session, output: &str) {
let transcript = SessionTranscript::new(vec![SessionMessage::conversation(
0,
SessionMessageKind::AssistantAnswer,
output,
)]);
session.transcript = Some(transcript);
}
fn set_conversation_transcript(
session: &mut Session,
messages: Vec<(SessionMessageKind, &str)>,
) {
let transcript = SessionTranscript::new(
messages
.into_iter()
.enumerate()
.map(|(position, (kind, content))| {
let position = i64::try_from(position).unwrap_or(i64::MAX);
if kind.is_conversation_message() {
SessionMessage::conversation(position, kind, content)
} else {
SessionMessage::new(position, kind, content)
}
})
.collect(),
);
session.transcript = Some(transcript);
}
#[test]
fn test_rendered_line_count_counts_wrapped_content() {
let mut session = session_fixture();
set_assistant_transcript(&mut session, &"word ".repeat(40));
let raw_line_count = u16::try_from(
session
.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
.unwrap_or_default()
.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();
set_assistant_transcript(&mut session, "## Heading\n\ncached body");
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_active_theme() {
let mut session = session_fixture();
session.status = Status::Review;
set_conversation_transcript(
&mut session,
vec![(
SessionMessageKind::UserPrompt,
concat!(
"Use **bold** and `code`.\n\n",
"| Input | Meaning |\n",
"| --- | --- |\n",
"| User prompt | Markdown |",
),
)],
);
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let output_layout_cache = SessionOutputLayoutCache::default();
let context = line_context(None, None, None);
let current_layout = {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
)
};
let dark_horizon_layout = {
let _theme_scope = style::scoped_active_theme(ColorTheme::DarkHorizon);
output_layout_cache.layout(
&session,
Rect::new(0, 0, 80, 8),
context,
Some(&markdown_render_cache),
)
};
assert!(!Arc::ptr_eq(
¤t_layout.lines,
&dark_horizon_layout.lines
));
assert_eq!(
table_header_background(&dark_horizon_layout),
Some(Color::Rgb(33, 36, 48))
);
}
#[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;
set_assistant_transcript(&mut session, " › running prompt");
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();
set_assistant_transcript(&mut session, "implemented the feature");
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");
let transcript_text = session
.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
.unwrap_or_default();
assert!(!transcript_text.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();
set_assistant_transcript(&mut session, "active output");
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}"));
set_assistant_transcript(&mut session, &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();
set_assistant_transcript(
&mut session,
&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_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 = text_util::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();
set_assistant_transcript(&mut session, "streamed output");
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_draft_preview_with_status_lines() {
let mut session = session_fixture();
session.is_draft = true;
set_assistant_transcript(
&mut session,
"[Paste Image Error] Clipboard is unavailable.",
);
session.prompt = "First 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("First draft"));
assert!(text.contains("Paste Image Error"));
assert!(text.contains("Clipboard is unavailable"));
}
#[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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::AssistantAnswer, "streamed output"),
(
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
],
);
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::AssistantAnswer, "streamed output"),
(
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
(
SessionMessageKind::WorkflowNotice,
"\n[Sync Assist] Attempt 1/3. Resolving conflicts in:\n- \
crates/agentty/src/runtime/worker.rs\n",
),
],
);
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_typed_assistant_notice_prefix_stays_before_summary() {
let transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "summarize merge"),
SessionMessage::conversation(
1,
SessionMessageKind::AssistantAnswer,
"Assistant output.\n[Merge] this is literal assistant text.",
),
]);
let mut session = session_fixture();
session.summary = Some(summary_fixture());
session.transcript = Some(transcript);
session.status = Status::Review;
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 assistant_notice_index = text
.find("[Merge] this is literal assistant text.")
.expect("assistant notice-looking line should be rendered");
let summary_index = text
.find("Change Summary")
.expect("structured summary should be rendered");
assert!(assistant_notice_index < summary_index);
}
#[test]
fn test_typed_transcript_sections_ignore_assistant_prompt_markers() {
let transcript = SessionTranscript::new(vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "previous prompt"),
SessionMessage::conversation(
1,
SessionMessageKind::AssistantAnswer,
"previous answer\n › quoted assistant marker",
),
SessionMessage::new(
2,
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
SessionMessage::conversation(3, SessionMessageKind::UserPrompt, "actual prompt"),
SessionMessage::conversation(
4,
SessionMessageKind::AssistantAnswer,
"streaming answer\n › quoted active output",
),
]);
let sections = SessionOutput::typed_transcript_sections(Status::InProgress, &transcript);
let completed_turn = match sections.completed_turn {
SessionOutputTranscriptSection::Messages(messages) => {
SessionTranscript::display_text_for_messages(messages)
}
_ => String::new(),
};
let trailing_notice = match sections.trailing_notice {
SessionOutputTranscriptSection::Messages(messages) => {
SessionTranscript::display_text_for_messages(messages)
}
_ => String::new(),
};
let active_turn = match sections.active_turn {
SessionOutputTranscriptSection::Messages(messages) => {
SessionTranscript::display_text_for_messages(messages)
}
_ => String::new(),
};
assert!(completed_turn.contains(" › quoted assistant marker"));
assert!(trailing_notice.contains("[Commit] No changes to commit."));
assert!(active_turn.starts_with(" › actual prompt"));
}
#[test]
fn test_output_lines_places_review_before_trailing_workflow_notices() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::AssistantAnswer, "implemented fix"),
(
SessionMessageKind::WorkflowNotice,
"\n[Merge Error] Cannot merge branch\n",
),
],
);
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();
set_assistant_transcript(&mut session, "interrupted transcript");
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_uses_transcript_for_completed_published_branch_push() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![(
SessionMessageKind::WorkflowNotice,
"\n[Branch Push] Auto-pushed published branch after completed turn.\n",
)],
);
session.published_branch_sync_status = PublishedBranchSyncStatus::Succeeded;
session.published_upstream_ref = Some("origin/wt/session-id".to_string());
session.status = Status::Review;
let lines = SessionOutput::output_lines_with_metadata(
&session,
Rect::new(0, 0, 80, 8),
line_context(None, None, None),
None,
);
let text = lines
.lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert_eq!(lines.published_loader_line_index, None);
assert!(text.contains("[Branch Push]"));
assert_eq!(
text.matches("Auto-pushed published branch after completed turn.")
.count(),
1
);
}
#[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();
set_assistant_transcript(&mut session, "implemented the feature");
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::UserPrompt, "hi"),
(
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
(SessionMessageKind::UserPrompt, "add hello world"),
],
);
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::UserPrompt, "hi"),
(
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
(SessionMessageKind::UserPrompt, "add hello world"),
(SessionMessageKind::AssistantAnswer, "working"),
],
);
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::UserPrompt, "add hello world"),
(
SessionMessageKind::AssistantAnswer,
"I added the README change.",
),
],
);
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::UserPrompt, "hi"),
(SessionMessageKind::AssistantAnswer, "Hello!"),
(
SessionMessageKind::WorkflowNotice,
"\n[Commit] No changes to commit.\n",
),
(SessionMessageKind::UserPrompt, "review project"),
],
);
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();
set_conversation_transcript(
&mut session,
vec![
(SessionMessageKind::UserPrompt, "hi"),
(SessionMessageKind::AssistantAnswer, "previous answer"),
(SessionMessageKind::UserPrompt, "actual prompt"),
(
SessionMessageKind::AssistantAnswer,
"streaming answer\n › quoted output",
),
],
);
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();
set_assistant_transcript(&mut session, "implemented the feature");
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_render_user_prompt_markdown() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![
(
SessionMessageKind::UserPrompt,
concat!(
"Use **bold** and `code`.\n\n",
"| Input | Meaning |\n",
"| --- | --- |\n",
"| User prompt | Markdown |",
),
),
(SessionMessageKind::AssistantAnswer, "assistant response"),
],
);
session.status = Status::Review;
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");
let inline_line = lines
.iter()
.find(|line| line.to_string().contains("Use bold and code."))
.expect("inline markdown line should render");
let table_header_line = lines
.iter()
.find(|line| line.to_string().contains("Input"))
.expect("table header line should render");
assert!(text.contains(" › Use bold and code."));
assert!(text.contains("┌"));
assert!(text.contains("User prompt"));
assert!(!text.contains("**bold**"));
assert!(!text.contains("`code`"));
assert!(!text.contains("| --- | --- |"));
assert!(inline_line.spans.iter().any(|span| {
span.content.as_ref() == "bold"
&& span
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
}));
assert!(table_header_line.spans.iter().any(|span| {
span.content.as_ref().contains("Input")
&& span.style.bg == Some(style::palette::surface_elevated())
}));
}
#[test]
fn test_output_lines_render_user_prompt_markdown_with_minimum_content_width() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![(SessionMessageKind::UserPrompt, "alpha beta")],
);
session.status = Status::Review;
let lines = output_lines(
&session,
Rect::new(0, 0, 3, 8),
line_context(None, None, None),
None,
);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("..."));
assert!(lines.iter().all(|line| line.width() <= 3));
}
#[test]
fn test_output_lines_render_user_prompt_mermaid_with_uniform_background() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![(
SessionMessageKind::UserPrompt,
concat!(
"```mermaid {theme=default}\n",
"flowchart TD\n",
" A[Start] --> B[Finish]\n",
"```",
),
)],
);
session.status = Status::Review;
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");
let start_line = lines
.iter()
.find(|line| line.to_string().contains("Start"))
.expect("Mermaid diagram label should render");
let border_line = lines
.iter()
.find(|line| line.to_string().contains('┌'))
.expect("Mermaid diagram border should render");
assert!(text.contains("Start"));
assert!(text.contains("Finish"));
assert!(text.contains("â–¼"));
assert!(!text.contains("flowchart TD"));
assert!(!text.contains("```"));
assert_eq!(start_line.width(), 80);
assert_eq!(
start_line.spans[0].style.bg,
Some(style::palette::surface())
);
assert!(start_line.spans.iter().any(|span| {
span.content.as_ref().trim().is_empty()
&& span.style.bg == Some(style::palette::surface())
}));
assert!(
start_line
.spans
.iter()
.all(|span| span.style.bg == Some(style::palette::surface()))
);
assert!(border_line.spans.iter().any(|span| {
span.content.as_ref().contains('┌')
&& span.style.fg == Some(style::palette::text())
&& span.style.bg == Some(style::palette::surface())
}));
assert!(
border_line
.spans
.iter()
.all(|span| span.style.bg == Some(style::palette::surface()))
);
}
#[test]
fn test_output_lines_keep_prompt_shading_for_mermaid_prefix_language() {
let mut session = session_fixture();
set_conversation_transcript(
&mut session,
vec![(
SessionMessageKind::UserPrompt,
concat!(
"```mermaids\n",
"flowchart TD\n",
" A[Start] --> B[Finish]\n",
"```",
),
)],
);
session.status = Status::Review;
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");
let source_line = lines
.iter()
.find(|line| line.to_string().contains("flowchart TD"))
.expect("non-Mermaid source should remain visible");
assert!(text.contains("flowchart TD"));
assert!(text.contains("A[Start] --> B[Finish]"));
assert!(!text.contains("â–¼"));
assert_eq!(source_line.width(), 80);
assert!(
source_line
.spans
.iter()
.any(|span| span.style.bg == Some(style::palette::surface()))
);
}
#[test]
fn test_output_lines_render_markdown_tables() {
let mut session = session_fixture();
set_assistant_transcript(
&mut session,
concat!(
"| Message kind | Storage |\n",
"| --- | --- |\n",
"| User prompt | Session.output |",
),
);
session.status = Status::Review;
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("Message kind"));
assert!(text.contains("Storage"));
assert!(text.contains("User prompt"));
assert!(text.contains("Session.output"));
assert!(text.contains("┌"));
assert!(!text.contains("| --- | --- |"));
}
#[test]
fn test_output_lines_render_mermaid_diagrams() {
let mut session = session_fixture();
set_assistant_transcript(
&mut session,
concat!(
"```mermaid\n",
"graph TD\n",
" A[Start] --> B[Finish]\n",
"```",
),
);
session.status = Status::Review;
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("Start"));
assert!(text.contains("Finish"));
assert!(text.contains("┌"));
assert!(text.contains("â–¼"));
assert!(!text.contains("graph TD"));
}
#[test]
fn test_output_lines_done_summary_transition_preserves_transcript() {
let mut session = session_fixture();
set_assistant_transcript(&mut session, "streamed output");
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_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!(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();
set_assistant_transcript(&mut session, "streamed output");
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();
set_assistant_transcript(&mut session, "some output");
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()));
}
}