use std::collections::HashSet;
use std::sync::Arc;
use ratatui::{
style::Style,
text::{Line, Span},
};
use crate::tui::app::TranscriptSpacing;
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
use crate::tui::scrolling::TranscriptLineMeta;
use crate::tui::ui_text::CopyLineSeparator;
#[derive(Debug)]
struct CachedCell {
revision: u64,
lines: Arc<Vec<Line<'static>>>,
links: Arc<Vec<Vec<crate::tui::osc8::LineLink>>>,
copy_separators: Arc<Vec<CopyLineSeparator>>,
copy_prefix_widths: Arc<Vec<usize>>,
is_empty: bool,
kind: TranscriptBlockKind,
is_tool_groupable: bool,
incremental_markdown: Option<Box<crate::tui::markdown_render::IncrementalMarkdownRenderCache>>,
hot_tail_original: Option<(usize, Line<'static>)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StreamingSourceReceipt {
pub cell_index: usize,
pub from_revision: u64,
pub to_revision: u64,
pub content_len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TranscriptBlockKind {
User,
Reasoning,
Answer,
ToolAction,
DurableWork,
Notice,
}
impl TranscriptBlockKind {
fn for_cell(cell: &HistoryCell) -> Self {
match cell {
HistoryCell::User { .. } => Self::User,
HistoryCell::Thinking { .. } => Self::Reasoning,
HistoryCell::Assistant { .. } => Self::Answer,
HistoryCell::Tool(tool) if tool.is_durable_work_receipt() => Self::DurableWork,
HistoryCell::Tool(_) | HistoryCell::SubAgent(_) => Self::ToolAction,
HistoryCell::System { .. }
| HistoryCell::Error { .. }
| HistoryCell::ArchivedContext { .. } => Self::Notice,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TranscriptBoundary {
Joined,
Activity,
Turn,
}
#[derive(Debug)]
pub struct TranscriptViewCache {
width: u16,
options: TranscriptRenderOptions,
folded_cells: HashSet<usize>,
per_cell: Vec<CachedCell>,
lines: Vec<Line<'static>>,
line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
line_meta: Vec<TranscriptLineMeta>,
rail_prefix_widths: Vec<usize>,
streaming_source_receipt: Option<StreamingSourceReceipt>,
streaming_lines_reflattened: u64,
streaming_meta_rows_scanned: u64,
}
impl TranscriptViewCache {
#[must_use]
pub fn new() -> Self {
Self {
width: 0,
options: TranscriptRenderOptions::default(),
folded_cells: HashSet::new(),
per_cell: Vec::new(),
lines: Vec::new(),
line_links: Vec::new(),
line_meta: Vec::new(),
rail_prefix_widths: Vec::new(),
streaming_source_receipt: None,
streaming_lines_reflattened: 0,
streaming_meta_rows_scanned: 0,
}
}
pub(crate) fn set_streaming_source_receipt(&mut self, receipt: Option<StreamingSourceReceipt>) {
self.streaming_source_receipt = receipt;
}
#[cfg(test)]
#[must_use]
fn streaming_lines_reflattened(&self) -> u64 {
self.streaming_lines_reflattened
}
#[cfg(test)]
#[must_use]
fn streaming_meta_rows_scanned(&self) -> u64 {
self.streaming_meta_rows_scanned
}
#[allow(dead_code)]
pub fn ensure(
&mut self,
cells: &[HistoryCell],
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
) {
self.ensure_split(
&[cells],
cell_revisions,
width,
options,
&HashSet::new(),
None,
);
}
pub fn ensure_split(
&mut self,
cell_shards: &[&[HistoryCell]],
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
original_index_map: Option<&[usize]>,
) {
let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
self.ensure_iter(
total_cells,
cell_shards.iter().flat_map(|shard| shard.iter()),
cell_revisions,
width,
options,
folded_cells,
original_index_map,
);
}
#[allow(clippy::too_many_arguments)]
pub fn ensure_filtered(
&mut self,
cells: &[&HistoryCell],
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
original_index_map: Option<&[usize]>,
) {
self.ensure_iter(
cells.len(),
cells.iter().copied(),
cell_revisions,
width,
options,
folded_cells,
original_index_map,
);
}
#[allow(clippy::too_many_arguments)]
fn ensure_iter<'a>(
&mut self,
total_cells: usize,
cells: impl Iterator<Item = &'a HistoryCell>,
cell_revisions: &[u64],
width: u16,
options: TranscriptRenderOptions,
folded_cells: &HashSet<usize>,
original_index_map: Option<&[usize]>,
) {
let layout_changed = self.width != width || self.options != options;
let folded_changed = self.folded_cells != *folded_cells;
if layout_changed || folded_changed {
self.per_cell.clear();
}
self.width = width;
self.options = options;
self.folded_cells = folded_cells.clone();
let old_len = self.per_cell.len();
let mut any_dirty = layout_changed || folded_changed || old_len != total_cells;
let mut first_dirty: Option<usize> = if old_len != total_cells {
Some(old_len.min(total_cells))
} else {
None
};
let mut old_per_cell: Vec<Option<CachedCell>> = std::mem::take(&mut self.per_cell)
.into_iter()
.map(Some)
.collect();
let mut new_per_cell: Vec<CachedCell> = Vec::with_capacity(total_cells);
let revisions_match = cell_revisions.len() == total_cells;
let mut dirty_cells = 0usize;
let mut streaming_tail_update = None;
let mut idx: usize = 0;
for cell in cells {
let current_rev = if revisions_match {
cell_revisions[idx]
} else {
u64::MAX
};
if !layout_changed
&& revisions_match
&& old_per_cell
.get(idx)
.and_then(Option::as_ref)
.is_some_and(|prev| prev.revision == current_rev)
{
new_per_cell.push(
old_per_cell[idx]
.take()
.expect("cached cell checked as present"),
);
idx += 1;
continue;
}
any_dirty = true;
dirty_cells = dirty_cells.saturating_add(1);
first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx)));
let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
let render_width = if is_tool_groupable {
width.saturating_sub(2).max(1)
} else {
width
};
let original_idx = original_index_map
.map(|m| *m.get(idx).unwrap_or(&idx))
.unwrap_or(idx);
let folded = folded_cells.contains(&original_idx);
if matches!(
cell,
HistoryCell::Assistant {
streaming: true,
..
}
) {
let mut cached = old_per_cell
.get_mut(idx)
.and_then(Option::take)
.unwrap_or_else(|| CachedCell {
revision: current_rev,
lines: Arc::new(Vec::new()),
links: Arc::new(Vec::new()),
copy_separators: Arc::new(Vec::new()),
copy_prefix_widths: Arc::new(Vec::new()),
is_empty: true,
kind: TranscriptBlockKind::Answer,
is_tool_groupable: false,
incremental_markdown: Some(Box::default()),
hot_tail_original: None,
});
if let Some((line_index, original)) = cached.hot_tail_original.take()
&& let Some(line) = Arc::make_mut(&mut cached.lines).get_mut(line_index)
{
*line = original;
}
let content_len = match cell {
HistoryCell::Assistant { content, .. } => content.len(),
_ => 0,
};
let verified_append = self.streaming_source_receipt.is_some_and(|receipt| {
receipt.cell_index == original_idx
&& receipt.from_revision == cached.revision
&& receipt.to_revision == current_rev
&& receipt.content_len == content_len
});
let incremental = cached.incremental_markdown.get_or_insert_with(Box::default);
let replace_from = cell
.update_incremental_streaming_render(
render_width,
options,
verified_append,
incremental,
Arc::make_mut(&mut cached.lines),
Arc::make_mut(&mut cached.links),
Arc::make_mut(&mut cached.copy_separators),
Arc::make_mut(&mut cached.copy_prefix_widths),
)
.expect("streaming Assistant matched above");
let cached_lines = Arc::make_mut(&mut cached.lines);
let last_index = cached_lines.len().checked_sub(1);
if let Some((index, last)) = last_index
.and_then(|index| cached_lines.get_mut(index).map(|line| (index, line)))
{
cached.hot_tail_original = Some((index, last.clone()));
crate::tui::history::apply_hot_tail_to_line(last, options.low_motion);
}
cached.revision = current_rev;
cached.is_empty = cached.lines.is_empty();
cached.kind = TranscriptBlockKind::Answer;
cached.is_tool_groupable = false;
streaming_tail_update = Some((idx, replace_from.saturating_sub(1)));
new_per_cell.push(cached);
idx += 1;
continue;
}
let rendered = cell.lines_with_copy_metadata_folded(render_width, options, folded);
let mut lines = Vec::with_capacity(rendered.len());
let mut links = Vec::with_capacity(rendered.len());
let mut copy_separators = Vec::with_capacity(rendered.len());
let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
for rendered_line in rendered {
let mut line = rendered_line.line;
if is_tool_groupable {
strip_cell_local_tool_rail(&mut line);
}
lines.push(line);
links.push(rendered_line.links);
copy_prefix_widths.push(rendered_line.copy_prefix_width);
copy_separators.push(rendered_line.copy_separator_after);
}
let is_empty = lines.is_empty();
new_per_cell.push(CachedCell {
revision: current_rev,
lines: Arc::new(lines),
links: Arc::new(links),
copy_separators: Arc::new(copy_separators),
copy_prefix_widths: Arc::new(copy_prefix_widths),
is_empty,
kind: TranscriptBlockKind::for_cell(cell),
is_tool_groupable,
incremental_markdown: None,
hot_tail_original: None,
});
idx += 1;
}
self.per_cell = new_per_cell;
if !any_dirty {
return;
}
if !layout_changed
&& !folded_changed
&& old_len == total_cells
&& dirty_cells == 1
&& let Some((cell_index, line_from)) = streaming_tail_update
&& cell_index + 1 == total_cells
&& self.flatten_streaming_tail(cell_index, line_from)
{
return;
}
let mut rebuild_from = if layout_changed {
0
} else {
first_dirty.unwrap_or(0).saturating_sub(1)
};
while rebuild_from > 0
&& self
.per_cell
.get(rebuild_from)
.is_some_and(|cell| cell.is_empty)
{
rebuild_from -= 1;
}
self.flatten_from(options.spacing, rebuild_from);
}
fn flatten(&mut self, spacing: TranscriptSpacing) {
self.lines.clear();
self.line_links.clear();
self.line_meta.clear();
self.rail_prefix_widths.clear();
self.append_flattened_cells(spacing, 0);
}
fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) {
if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() {
self.flatten(spacing);
return;
}
let truncate_at = self
.line_meta
.iter()
.position(|meta| match meta {
TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell,
TranscriptLineMeta::Spacer => false,
})
.unwrap_or(self.lines.len());
self.lines.truncate(truncate_at);
self.line_links.truncate(truncate_at);
self.line_meta.truncate(truncate_at);
self.rail_prefix_widths.truncate(truncate_at);
self.append_flattened_cells(spacing, first_cell);
}
fn flatten_streaming_tail(&mut self, cell_index: usize, line_from: usize) -> bool {
let mut truncate_at = None;
for (index, meta) in self.line_meta.iter().enumerate().rev() {
self.streaming_meta_rows_scanned = self.streaming_meta_rows_scanned.saturating_add(1);
if matches!(
meta,
TranscriptLineMeta::CellLine {
cell_index: candidate,
line_in_cell,
..
} if *candidate == cell_index && *line_in_cell == line_from
) {
truncate_at = Some(index);
break;
}
}
let Some(truncate_at) = truncate_at else {
return false;
};
self.lines.truncate(truncate_at);
self.line_links.truncate(truncate_at);
self.line_meta.truncate(truncate_at);
self.rail_prefix_widths.truncate(truncate_at);
let Some(cached) = self.per_cell.get(cell_index) else {
return false;
};
let rendered_line_count = cached.lines.len();
for line_in_cell in line_from..rendered_line_count {
let line = &cached.lines[line_in_cell];
let rail = tool_group_rail(
self.per_cell.as_slice(),
cell_index,
line_in_cell,
rendered_line_count,
);
let final_line = line_with_group_rail(line, rail, usize::from(self.width));
let final_links = links_with_group_rail(
cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
rail,
usize::from(self.width),
);
self.rail_prefix_widths
.push(compute_rail_prefix_width(&final_line));
self.lines.push(final_line);
self.line_links.push(final_links);
self.line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
copy_prefix_width: cached
.copy_prefix_widths
.get(line_in_cell)
.copied()
.unwrap_or(0),
copy_separator_after: cached
.copy_separators
.get(line_in_cell)
.copied()
.unwrap_or(CopyLineSeparator::Newline),
});
self.streaming_lines_reflattened = self.streaming_lines_reflattened.saturating_add(1);
}
true
}
fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) {
for (cell_index, cached) in self.per_cell.iter().enumerate().skip(start_cell) {
if cached.is_empty {
continue;
}
let rendered_line_count = cached.lines.len();
for (line_in_cell, line) in cached.lines.iter().enumerate() {
let rail = tool_group_rail(
self.per_cell.as_slice(),
cell_index,
line_in_cell,
rendered_line_count,
);
let final_line = line_with_group_rail(line, rail, usize::from(self.width));
let final_links = links_with_group_rail(
cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
rail,
usize::from(self.width),
);
self.rail_prefix_widths
.push(compute_rail_prefix_width(&final_line));
self.lines.push(final_line);
self.line_links.push(final_links);
self.line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
copy_prefix_width: cached
.copy_prefix_widths
.get(line_in_cell)
.copied()
.unwrap_or(0),
copy_separator_after: cached
.copy_separators
.get(line_in_cell)
.copied()
.unwrap_or(CopyLineSeparator::Newline),
});
self.streaming_lines_reflattened =
self.streaming_lines_reflattened.saturating_add(1);
}
if let Some(next) = next_visible_cell(&self.per_cell, cell_index) {
let spacer_rows = spacer_rows_between(cached, next, spacing);
for _ in 0..spacer_rows {
self.lines.push(Line::from(""));
self.line_links.push(Vec::new());
self.line_meta.push(TranscriptLineMeta::Spacer);
self.rail_prefix_widths.push(0);
}
}
}
}
#[must_use]
pub fn lines(&self) -> &[Line<'static>] {
&self.lines
}
#[must_use]
pub fn line_links(&self) -> &[Vec<crate::tui::osc8::LineLink>] {
&self.line_links
}
#[must_use]
pub fn line_meta(&self) -> &[TranscriptLineMeta] {
&self.line_meta
}
#[must_use]
pub fn total_lines(&self) -> usize {
self.lines.len()
}
#[must_use]
pub fn rail_prefix_width(&self, line_index: usize) -> usize {
self.rail_prefix_widths
.get(line_index)
.copied()
.unwrap_or(0)
}
}
fn strip_cell_local_tool_rail(line: &mut Line<'static>) {
if line
.spans
.first()
.is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
{
line.spans.remove(0);
}
}
fn spacer_rows_between(
current: &CachedCell,
next: &CachedCell,
spacing: TranscriptSpacing,
) -> usize {
spacer_rows_for_boundary(
transcript_boundary(
current.kind,
next.kind,
same_tool_activity_group(current, next),
),
spacing,
)
}
fn same_tool_activity_group(current: &CachedCell, next: &CachedCell) -> bool {
current.is_tool_groupable && next.is_tool_groupable && current.kind == next.kind
}
fn transcript_boundary(
current: TranscriptBlockKind,
next: TranscriptBlockKind,
same_tool_group: bool,
) -> TranscriptBoundary {
if same_tool_group {
debug_assert_eq!(current, next);
return TranscriptBoundary::Joined;
}
if current == TranscriptBlockKind::User || next == TranscriptBlockKind::User {
return TranscriptBoundary::Turn;
}
if matches!(
(current, next),
(
TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer,
TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer
)
) {
return TranscriptBoundary::Joined;
}
TranscriptBoundary::Activity
}
const fn spacer_rows_for_boundary(
boundary: TranscriptBoundary,
spacing: TranscriptSpacing,
) -> usize {
match (boundary, spacing) {
(TranscriptBoundary::Joined, _) => 0,
(TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0,
(TranscriptBoundary::Activity, _) => 1,
(TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
1
}
(TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => 2,
}
}
fn previous_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
cells[..cell_index].iter().rev().find(|cell| !cell.is_empty)
}
fn next_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
cells
.get(cell_index + 1..)?
.iter()
.find(|cell| !cell.is_empty)
}
fn tool_group_rail(
cells: &[CachedCell],
cell_index: usize,
line_in_cell: usize,
rendered_line_count: usize,
) -> Option<crate::tui::widgets::tool_card::CardRail> {
let cached = cells.get(cell_index)?;
if !cached.is_tool_groupable || rendered_line_count == 0 {
return None;
}
let previous_shares_group = previous_visible_cell(cells, cell_index)
.is_some_and(|previous| same_tool_activity_group(previous, cached));
let next_shares_group = next_visible_cell(cells, cell_index)
.is_some_and(|next| same_tool_activity_group(cached, next));
let first_line_in_group = !previous_shares_group && line_in_cell == 0;
let last_line_in_group = !next_shares_group && line_in_cell + 1 == rendered_line_count;
let rail = match (first_line_in_group, last_line_in_group) {
(true, true) if rendered_line_count == 1 => {
crate::tui::widgets::tool_card::CardRail::Single
}
(true, _) => crate::tui::widgets::tool_card::CardRail::Top,
(_, true) => crate::tui::widgets::tool_card::CardRail::Bottom,
_ => crate::tui::widgets::tool_card::CardRail::Middle,
};
Some(rail)
}
fn line_with_group_rail(
line: &Line<'static>,
rail: Option<crate::tui::widgets::tool_card::CardRail>,
max_width: usize,
) -> Line<'static> {
let Some(rail) = rail else {
return line.clone();
};
let glyph = crate::tui::widgets::tool_card::rail_glyph(rail);
if glyph.is_empty() {
let mut rendered = line.clone();
rendered.spans = truncate_spans_to_width(rendered.spans, max_width);
return rendered;
}
let mut rendered = line.clone();
let mut spans = Vec::with_capacity(rendered.spans.len() + 1);
spans.push(Span::styled(
format!("{glyph} "),
Style::default().fg(crate::palette::TEXT_DIM),
));
spans.extend(rendered.spans);
rendered.spans = truncate_spans_to_width(spans, max_width);
rendered
}
fn links_with_group_rail(
links: &[crate::tui::osc8::LineLink],
rail: Option<crate::tui::widgets::tool_card::CardRail>,
max_width: usize,
) -> Vec<crate::tui::osc8::LineLink> {
let shift = rail
.map(crate::tui::widgets::tool_card::rail_glyph)
.filter(|glyph| !glyph.is_empty())
.map_or(0, |glyph| unicode_width::UnicodeWidthStr::width(glyph) + 1);
links
.iter()
.map(|link| link.shifted(shift))
.filter(|link| link.col_start < max_width)
.map(|mut link| {
link.col_end = link.col_end.min(max_width.saturating_sub(1));
link
})
.collect()
}
fn compute_rail_prefix_width(line: &Line<'static>) -> usize {
let spans = line.spans.as_slice();
let mut total = 0;
let mut i = 0;
while i < spans.len() {
let content = spans[i].content.as_ref();
let n_chars = content.chars().count();
if n_chars >= 2
&& content.ends_with(' ')
&& content
.chars()
.take(n_chars.saturating_sub(1))
.all(is_rail_drawing_char)
{
total += n_chars;
i += 1;
continue;
}
if n_chars == 1
&& content.chars().next().is_some_and(is_rail_drawing_char)
&& spans.get(i + 1).is_some_and(|s| s.content.as_ref() == " ")
{
total += 2;
i += 2;
continue;
}
break;
}
total
}
fn is_rail_drawing_char(ch: char) -> bool {
matches!(
ch,
'\u{2500}'..='\u{257F}' | '\u{2580}'..='\u{259F}' | '\u{25A0}'..='\u{25FF}' | '\u{2022}' | '\u{2026}' | '\u{00B7}' | '\u{2315}' | '\u{22EE}' )
}
fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
if max_width == 0 || spans.is_empty() {
return Vec::new();
}
let current_width: usize = spans
.iter()
.map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
.sum();
if current_width <= max_width {
return spans;
}
let ellipsis = if max_width > 3 { "..." } else { "" };
let content_budget = max_width.saturating_sub(ellipsis.len());
let mut used = 0usize;
let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty()));
let mut last_style = Style::default();
'outer: for span in spans {
last_style = span.style;
let mut content = String::new();
for ch in span.content.chars() {
let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + width > content_budget {
break 'outer;
}
content.push(ch);
used += width;
}
if !content.is_empty() {
truncated.push(Span::styled(content, span.style));
}
}
if !ellipsis.is_empty() {
truncated.push(Span::styled(ellipsis.to_string(), last_style));
}
truncated
}
#[cfg(test)]
mod tests {
use super::*;
use crate::palette;
use crate::tools::plan::PlanSnapshot;
use crate::tui::history::{
ExecCell, ExecSource, HistoryCell, PlanUpdateCell, ToolCell, ToolStatus,
};
fn plain_lines(cache: &TranscriptViewCache) -> Vec<String> {
cache
.lines()
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn user_cell(content: &str) -> HistoryCell {
HistoryCell::User {
content: content.to_string(),
}
}
fn assistant_cell(content: &str, streaming: bool) -> HistoryCell {
HistoryCell::Assistant {
content: content.to_string(),
streaming,
}
}
fn exec_tool_cell(command: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: command.to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
}))
}
fn durable_work_cell() -> HistoryCell {
HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
snapshot: PlanSnapshot::default(),
status: ToolStatus::Running,
}))
}
fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> usize {
let mut saw_target = false;
let mut spacer_rows = 0;
for meta in cache.line_meta() {
match meta {
TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
saw_target = true;
spacer_rows = 0;
}
TranscriptLineMeta::Spacer if saw_target => spacer_rows += 1,
TranscriptLineMeta::CellLine { .. } if saw_target => break,
TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => {}
}
}
spacer_rows
}
#[test]
fn cache_renders_user_cells_with_highlight_background() {
let cells = vec![user_cell("# literal user prompt")];
let revisions = vec![1u64];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
let lines = cache.lines();
assert_eq!(lines[0].style.bg, Some(palette::SURFACE_ELEVATED));
assert_eq!(lines[0].width(), 40);
assert_eq!(plain_lines(&cache)[0].trim_end(), "▎ # literal user prompt");
}
#[test]
fn cache_reuses_cells_when_revision_unchanged() {
let cells = vec![
user_cell("hello"),
assistant_cell("world", false),
user_cell("again"),
];
let revisions = vec![1u64, 1, 1];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let first_lines: Vec<String> = cache
.lines()
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let first_total = cache.total_lines();
assert!(first_total > 0, "expected non-empty render");
let snapshot_per_cell: Vec<Vec<String>> = cache
.per_cell
.iter()
.map(|c| {
c.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
})
.collect();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let second_lines: Vec<String> = cache
.lines()
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(first_lines, second_lines);
assert_eq!(cache.total_lines(), first_total);
let snapshot_per_cell_2: Vec<Vec<String>> = cache
.per_cell
.iter()
.map(|c| {
c.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
})
.collect();
assert_eq!(snapshot_per_cell, snapshot_per_cell_2);
}
#[test]
fn bumping_one_cell_revision_only_rerenders_that_cell() {
let cells_v1 = vec![
user_cell("hello"),
assistant_cell("hi", true),
user_cell("again"),
];
let revs_v1 = vec![1u64, 1, 1];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells_v1, &revs_v1, 80, TranscriptRenderOptions::default());
let cell0_lines_before = cache.per_cell[0]
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>();
let cell2_lines_before = cache.per_cell[2]
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>();
let cells_v2 = vec![
user_cell("hello"),
assistant_cell("hi world", true),
user_cell("again"),
];
let revs_v2 = vec![1u64, 2, 1];
cache.ensure(&cells_v2, &revs_v2, 80, TranscriptRenderOptions::default());
let cell0_lines_after = cache.per_cell[0]
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>();
let cell2_lines_after = cache.per_cell[2]
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(cell0_lines_before, cell0_lines_after);
assert_eq!(cell2_lines_before, cell2_lines_after);
let cell1_after: String = cache.per_cell[1]
.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect::<Vec<_>>()
.join(" ");
assert!(
cell1_after.contains("hi") && cell1_after.contains("world"),
"cell1 should re-render with new content; got: {cell1_after}"
);
assert_eq!(cache.per_cell[0].revision, 1);
assert_eq!(cache.per_cell[1].revision, 2);
assert_eq!(cache.per_cell[2].revision, 1);
}
#[test]
fn streaming_assistant_keeps_a_persistent_linear_render_prefix() {
let mut content = String::new();
let mut revision = 1u64;
let mut cache = TranscriptViewCache::new();
let options = TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
};
content.push_str("start\n```rust\nlet value_0 = 0;\n```\n\n");
let mut cells = vec![assistant_cell(&content, true)];
cache.ensure(&cells, &[revision], 96, options);
let lines_arc = Arc::as_ptr(&cache.per_cell[0].lines);
for index in 1..120usize {
let previous = revision;
revision += 1;
content.push_str(&format!(
"段落 {index} e\u{301} 🚀\n```rust\nlet value_{index} = {index};\n```\n\n"
));
cells[0] = assistant_cell(&content, true);
cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
cell_index: 0,
from_revision: previous,
to_revision: revision,
content_len: content.len(),
}));
cache.ensure(&cells, &[revision], 96, options);
}
let previous = revision;
revision += 1;
cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
cell_index: 0,
from_revision: previous,
to_revision: revision,
content_len: content.len(),
}));
cache.ensure(&cells, &[revision], 96, options);
assert_eq!(Arc::as_ptr(&cache.per_cell[0].lines), lines_arc);
let work = cache.per_cell[0]
.incremental_markdown
.as_ref()
.expect("streaming markdown cache")
.work();
assert_eq!(work.invalidations, 1);
assert_eq!(work.tail_blocks_rendered, 0);
assert_eq!(work.classified_lines as usize, content.lines().count());
assert!(
cache.streaming_lines_reflattened() <= (cache.total_lines() + 121) as u64,
"flatten work must be final output plus at most one hot-tail line per update: work={}, final={}",
cache.streaming_lines_reflattened(),
cache.total_lines()
);
assert!(
cache.streaming_meta_rows_scanned() <= 121,
"reverse lookup must inspect only the replaceable tail: {}",
cache.streaming_meta_rows_scanned()
);
let mut cold = TranscriptViewCache::new();
cold.ensure(&cells, &[revision], 96, options);
assert_eq!(plain_lines(&cache), plain_lines(&cold));
}
#[test]
fn tail_update_suffix_rebuild_matches_fresh_flatten() {
let mut cells = vec![
user_cell("first message"),
assistant_cell("stable answer", false),
user_cell("tail prompt"),
];
let mut revisions = vec![1u64, 1, 1];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
cells.push(assistant_cell("streaming tail", true));
revisions.push(1);
cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
if let HistoryCell::Assistant { content, .. } = cells.last_mut().unwrap() {
content.push_str(" plus delta");
}
*revisions.last_mut().unwrap() += 1;
cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
let incremental = plain_lines(&cache);
let mut fresh = TranscriptViewCache::new();
fresh.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
assert_eq!(incremental, plain_lines(&fresh));
}
#[test]
fn width_change_rerenders_all_cells() {
let cells = vec![
user_cell("a fairly long message that may wrap at narrow widths"),
assistant_cell("another long message body content", false),
];
let revisions = vec![5u64, 7];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let wide_total = cache.total_lines();
cache.ensure(&cells, &revisions, 20, TranscriptRenderOptions::default());
let narrow_total = cache.total_lines();
assert_ne!(
wide_total, narrow_total,
"narrow width should produce a different number of lines"
);
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
assert_eq!(cache.total_lines(), wide_total);
}
#[test]
fn streaming_assistant_only_rebuilds_one_cell_render_count() {
let mut cells: Vec<HistoryCell> =
(0..50).map(|i| user_cell(&format!("cell {i}"))).collect();
cells.push(assistant_cell("streaming", true));
let mut revisions: Vec<u64> = vec![1; 51];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let stable_snapshot: Vec<String> = cache.per_cell[..50]
.iter()
.map(|c| {
c.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect::<Vec<_>>()
.join("|")
})
.collect();
for i in 0..10 {
if let HistoryCell::Assistant { content, .. } = &mut cells[50] {
content.push_str(&format!(" delta-{i}"));
}
revisions[50] += 1;
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let stable_now: Vec<String> = cache.per_cell[..50]
.iter()
.map(|c| {
c.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect::<Vec<_>>()
.join("|")
})
.collect();
assert_eq!(
stable_now, stable_snapshot,
"stable cells diverged at delta {i}"
);
for (idx, c) in cache.per_cell[..50].iter().enumerate() {
assert_eq!(
c.revision, 1,
"cell {idx} revision changed during streaming delta"
);
}
}
}
#[test]
fn missing_revisions_falls_back_to_full_render() {
let cells = vec![user_cell("a"), assistant_cell("b", false)];
let bogus_revisions = vec![1u64];
let mut cache = TranscriptViewCache::new();
cache.ensure(
&cells,
&bogus_revisions,
80,
TranscriptRenderOptions::default(),
);
assert_eq!(cache.per_cell.len(), 2);
assert!(!cache.lines().is_empty());
}
#[test]
fn adjacent_tool_cells_render_as_one_railed_group() {
let cells = vec![
exec_tool_cell("cargo test"),
exec_tool_cell("cargo clippy"),
exec_tool_cell("cargo fmt"),
];
let revisions = vec![1u64, 1, 1];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let lines = plain_lines(&cache);
assert!(
lines
.first()
.is_some_and(|line| line.starts_with("\u{256D} ")),
"first tool line should open the shared rail: {lines:?}"
);
assert!(
lines.iter().any(|line| line.starts_with("\u{2502} ")),
"middle tool lines should continue the shared rail: {lines:?}"
);
assert!(
lines
.last()
.is_some_and(|line| line.starts_with("\u{2570} ")),
"last tool line should close the shared rail: {lines:?}"
);
assert!(
!lines.iter().any(String::is_empty),
"adjacent tool cells should not be separated by blank spacer rows: {lines:?}"
);
}
#[test]
fn semantic_boundary_matrix_has_three_deliberate_rhythm_levels() {
use TranscriptBlockKind::{Answer, DurableWork, Notice, Reasoning, ToolAction, User};
use TranscriptBoundary::{Activity, Joined, Turn};
let cases = [
(User, Answer, false, Turn),
(User, ToolAction, false, Turn),
(DurableWork, User, false, Turn),
(Reasoning, Answer, false, Joined),
(Answer, Reasoning, false, Joined),
(Answer, Answer, false, Joined),
(Answer, ToolAction, false, Activity),
(ToolAction, Reasoning, false, Activity),
(Notice, DurableWork, false, Activity),
(ToolAction, ToolAction, true, Joined),
(DurableWork, DurableWork, true, Joined),
(ToolAction, DurableWork, false, Activity),
];
for (current, next, grouped_tools, expected) in cases {
assert_eq!(
transcript_boundary(current, next, grouped_tools),
expected,
"{current:?} -> {next:?}"
);
}
assert_eq!(
spacer_rows_for_boundary(Turn, TranscriptSpacing::Compact),
1
);
assert_eq!(
spacer_rows_for_boundary(Turn, TranscriptSpacing::Comfortable),
1
);
assert_eq!(
spacer_rows_for_boundary(Turn, TranscriptSpacing::Spacious),
2
);
assert_eq!(
spacer_rows_for_boundary(Activity, TranscriptSpacing::Compact),
0
);
assert_eq!(
spacer_rows_for_boundary(Activity, TranscriptSpacing::Comfortable),
1
);
assert_eq!(
spacer_rows_for_boundary(Activity, TranscriptSpacing::Spacious),
1
);
}
#[test]
fn durable_work_tools_have_an_explicit_semantic_role() {
let plan = durable_work_cell();
let tool = exec_tool_cell("cargo test --locked");
assert_eq!(
TranscriptBlockKind::for_cell(&plan),
TranscriptBlockKind::DurableWork
);
assert_eq!(
TranscriptBlockKind::for_cell(&tool),
TranscriptBlockKind::ToolAction
);
}
#[test]
fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
let durable = HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
snapshot: PlanSnapshot {
objective: Some("Keep the release receipt durable".to_string()),
..PlanSnapshot::default()
},
status: ToolStatus::Running,
}));
let cells = vec![
exec_tool_cell("cargo test --locked"),
exec_tool_cell("cargo clippy --locked"),
durable,
];
let revisions = vec![1u64; cells.len()];
let mut compact = TranscriptViewCache::new();
compact.ensure(
&cells,
&revisions,
80,
TranscriptRenderOptions {
spacing: TranscriptSpacing::Compact,
low_motion: true,
..TranscriptRenderOptions::default()
},
);
assert_eq!(spacer_rows_after_cell(&compact, 0), 0);
assert_eq!(spacer_rows_after_cell(&compact, 1), 0);
let compact_lines = plain_lines(&compact);
assert!(
!compact_lines.iter().any(String::is_empty),
"compact activity seams must not spend a blank row: {compact_lines:?}"
);
let lines_for_cell = |target| {
compact
.lines()
.iter()
.zip(compact.line_meta())
.filter_map(|(line, meta)| match meta {
TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target => {
Some(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
)
}
TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => None,
})
.collect::<Vec<_>>()
};
let second_action = lines_for_cell(1);
let durable_work = lines_for_cell(2);
assert!(
second_action
.last()
.is_some_and(|line| line.starts_with("\u{2570} ")),
"ordinary action rail should close before durable Work: {second_action:?}"
);
assert!(
durable_work
.first()
.is_some_and(|line| line.starts_with("\u{256D} ")),
"durable Work should open its own rail: {durable_work:?}"
);
let mut comfortable = TranscriptViewCache::new();
comfortable.ensure(
&cells,
&revisions,
80,
TranscriptRenderOptions {
spacing: TranscriptSpacing::Comfortable,
low_motion: true,
..TranscriptRenderOptions::default()
},
);
assert_eq!(spacer_rows_after_cell(&comfortable, 0), 0);
assert_eq!(
spacer_rows_after_cell(&comfortable, 1),
1,
"durable Work needs a semantic activity row outside compact density"
);
}
#[test]
fn compact_spacing_keeps_conversation_blocks_separate() {
let cells = vec![
user_cell("Please verify the release."),
assistant_cell("I will check the receipts.", false),
];
let revisions = vec![1u64, 1];
let mut cache = TranscriptViewCache::new();
let options = TranscriptRenderOptions {
spacing: TranscriptSpacing::Compact,
..TranscriptRenderOptions::default()
};
cache.ensure(&cells, &revisions, 89, options);
let lines = plain_lines(&cache);
assert!(
lines.iter().any(String::is_empty),
"compact density still needs one user/assistant boundary: {lines:?}"
);
}
#[test]
fn compact_spacing_keeps_direct_user_tool_turns_separate() {
let cells = vec![
user_cell("Inspect the repository."),
exec_tool_cell("git status --short"),
user_cell("Now summarize the result."),
];
let revisions = vec![1u64, 1, 1];
let options = TranscriptRenderOptions {
spacing: TranscriptSpacing::Compact,
low_motion: true,
..TranscriptRenderOptions::default()
};
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, options);
assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
assert_eq!(spacer_rows_after_cell(&cache, 1), 1);
}
#[test]
fn compact_spacing_keeps_reasoning_and_answer_in_one_response_block() {
let cells = vec![
HistoryCell::Thinking {
content: "I should verify the release receipts first.".to_string(),
streaming: false,
duration_secs: Some(0.4),
},
assistant_cell("The release receipts are green.", false),
];
let revisions = vec![1u64, 1];
let mut cache = TranscriptViewCache::new();
let options = TranscriptRenderOptions {
spacing: TranscriptSpacing::Compact,
..TranscriptRenderOptions::default()
};
cache.ensure(&cells, &revisions, 89, options);
let lines = plain_lines(&cache);
assert!(
!lines.iter().any(String::is_empty),
"reasoning and its answer should read as one response block: {lines:?}"
);
}
#[test]
fn hidden_reasoning_keeps_visible_rhythm_without_phantom_tail_rows() {
let cells = vec![
user_cell("Verify the release."),
HistoryCell::Thinking {
content: "Check the exact receipts.".to_string(),
streaming: false,
duration_secs: Some(0.4),
},
assistant_cell("The receipts are green.", false),
];
let revisions = vec![1u64, 1, 1];
let hidden = TranscriptRenderOptions {
show_thinking: false,
low_motion: true,
..TranscriptRenderOptions::default()
};
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, hidden);
let hidden_lines = plain_lines(&cache);
assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
assert!(
hidden_lines.last().is_some_and(|line| !line.is_empty()),
"hidden cells must not leave a trailing blank row: {hidden_lines:?}"
);
let visible = TranscriptRenderOptions {
show_thinking: true,
..hidden
};
cache.ensure(&cells, &revisions, 80, visible);
cache.ensure(&cells, &revisions, 80, hidden);
assert_eq!(plain_lines(&cache), hidden_lines);
let trailing_hidden = &cells[..2];
let mut tail_cache = TranscriptViewCache::new();
tail_cache.ensure(trailing_hidden, &revisions[..2], 80, hidden);
assert!(
plain_lines(&tail_cache)
.last()
.is_some_and(|line| !line.is_empty()),
"a hidden final cell must not reserve a phantom spacer"
);
}
#[test]
fn transcript_rhythm_is_width_and_reduced_motion_invariant() {
let cells = vec![
user_cell("Please inspect the release candidate and verify all receipts."),
HistoryCell::Thinking {
content: "I will inspect the source, run the checks, and compare the receipts."
.to_string(),
streaming: true,
duration_secs: Some(0.8),
},
assistant_cell("I will start with the locked test suite.", false),
exec_tool_cell("cargo test -p codewhale-tui --bins --locked"),
durable_work_cell(),
assistant_cell("The focused checks passed.", false),
user_cell("Proceed to the final verification."),
];
let revisions = vec![1u64; cells.len()];
let expected = [1, 0, 1, 1, 1, 1, 0];
for width in [40, 80, 100, 140] {
for low_motion in [false, true] {
let options = TranscriptRenderOptions {
low_motion,
spacing: TranscriptSpacing::Comfortable,
..TranscriptRenderOptions::default()
};
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, width, options);
let actual =
std::array::from_fn::<_, 7, _>(|index| spacer_rows_after_cell(&cache, index));
assert_eq!(actual, expected, "width={width} low_motion={low_motion}");
assert!(
cache
.lines()
.iter()
.all(|line| line.width() <= usize::from(width)),
"render exceeded width={width} low_motion={low_motion}"
);
}
}
}
#[test]
fn streaming_state_transitions_do_not_move_neighbor_boundaries() {
let mut cells = vec![
user_cell("Inspect the candidate."),
HistoryCell::Thinking {
content: "Inspecting the candidate now.".to_string(),
streaming: true,
duration_secs: None,
},
exec_tool_cell("git status --short"),
user_cell("Summarize the receipt."),
];
let mut revisions = vec![1u64; cells.len()];
let options = TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
};
let mut cache = TranscriptViewCache::new();
let boundary_rows = |cache: &TranscriptViewCache| {
[
spacer_rows_after_cell(cache, 0),
spacer_rows_after_cell(cache, 1),
spacer_rows_after_cell(cache, 2),
]
};
cache.ensure(&cells, &revisions, 80, options);
assert_eq!(boundary_rows(&cache), [1, 1, 1]);
cells[1] = assistant_cell("I inspected the candidate.", true);
revisions[1] += 1;
cache.ensure(&cells, &revisions, 80, options);
assert_eq!(boundary_rows(&cache), [1, 1, 1]);
cells[1] = assistant_cell("I inspected the candidate.", false);
revisions[1] += 1;
cache.ensure(&cells, &revisions, 80, options);
assert_eq!(boundary_rows(&cache), [1, 1, 1]);
let HistoryCell::Tool(ToolCell::Exec(exec)) = &mut cells[2] else {
unreachable!("fixture is an exec tool")
};
exec.status = ToolStatus::Success;
revisions[2] += 1;
cache.ensure(&cells, &revisions, 80, options);
assert_eq!(boundary_rows(&cache), [1, 1, 1]);
}
#[test]
fn resize_round_trip_rebuilds_the_same_semantic_rows() {
let cells = vec![
user_cell("A long prompt that wraps when the terminal narrows considerably."),
exec_tool_cell("printf 'a tool receipt with a deliberately long summary'"),
assistant_cell("A stable answer after the tool receipt.", false),
];
let revisions = vec![1u64; cells.len()];
let options = TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
};
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 140, options);
let wide = plain_lines(&cache);
cache.ensure(&cells, &revisions, 40, options);
cache.ensure(&cells, &revisions, 140, options);
assert_eq!(plain_lines(&cache), wide);
assert_eq!(cache.lines().len(), cache.line_meta().len());
assert_eq!(cache.lines().len(), cache.line_links().len());
}
#[test]
fn palette_mode_change_invalidates_cached_syntax_rendering() {
let cells = vec![assistant_cell(
"```rust\nfn main() { let answer = 42; }\n```",
false,
)];
let revisions = [1u64];
let mut cache = TranscriptViewCache::new();
let dark = TranscriptRenderOptions {
palette_mode: palette::PaletteMode::Dark,
..TranscriptRenderOptions::default()
};
cache.ensure(&cells, &revisions, 80, dark);
let dark_lines = Arc::clone(&cache.per_cell[0].lines);
cache.ensure(
&cells,
&revisions,
80,
TranscriptRenderOptions {
palette_mode: palette::PaletteMode::Light,
..dark
},
);
assert!(
!Arc::ptr_eq(&dark_lines, &cache.per_cell[0].lines),
"palette mode is part of TranscriptRenderOptions and must bust cached cells"
);
}
#[test]
fn tool_rails_preserve_rendered_width_budget() {
let cells = vec![exec_tool_cell(
"printf 'this is a command with enough text to wrap in narrow terminals'",
)];
let revisions = vec![1u64];
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 24, TranscriptRenderOptions::default());
for line in plain_lines(&cache) {
assert!(
unicode_width::UnicodeWidthStr::width(line.as_str()) <= 24,
"tool rail line exceeded narrow width: {line:?}"
);
}
}
#[allow(clippy::print_stderr)]
#[test]
fn rail_prefix_widths_memory_overhead_complex_session() {
let mut cells: Vec<HistoryCell> = Vec::new();
for i in 0..30 {
cells.push(user_cell(&format!("complex query {i} about system design")));
cells.push(HistoryCell::Thinking {
content:
"line A\nline B\nline C\nline D\nline E\nline F\nline G\nline H\nline I\nline J"
.to_string(),
streaming: false,
duration_secs: Some(3.5),
});
cells.push(assistant_cell(
&format!("response {i} with multi-line\ntext content spanning\nseveral lines"),
false,
));
cells.push(exec_tool_cell(
"cargo test --package my_crate -- --nocapture 2>&1 | head -40",
));
cells.push(exec_tool_cell(&format!("git diff --stat HEAD~{i}")));
}
let revisions: Vec<u64> = (0..cells.len()).map(|i| i as u64 + 1).collect();
let mut cache = TranscriptViewCache::new();
cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
let total_lines = cache.total_lines();
let pw_len = cache.rail_prefix_widths.len();
let pw_cap = cache.rail_prefix_widths.capacity();
assert_eq!(pw_len, total_lines);
assert!(pw_cap >= pw_len);
let memory_bytes = pw_cap * std::mem::size_of::<usize>();
let memory_kb = memory_bytes as f64 / 1024.0;
let kbytes_per_1k_lines = (memory_bytes as f64 / total_lines as f64) * 1000.0 / 1024.0;
eprintln!("=== rail_prefix_widths memory (complex session) ===");
eprintln!(" total_lines: {total_lines}");
eprintln!(" vec len: {pw_len}");
eprintln!(" vec capacity: {pw_cap}");
eprintln!(" memory (bytes): {memory_bytes}");
eprintln!(" memory (KB): {memory_kb:.2}");
eprintln!(" KB per 1k lines: {kbytes_per_1k_lines:.2}");
eprintln!(" lines × 8 bytes: {} KB", total_lines * 8 / 1024);
assert!(
memory_kb < 1024.0,
"rail_prefix_widths memory unexpectedly large: {memory_kb:.1} KB"
);
eprintln!(" ✓ well under 1 MB even for very long sessions");
}
#[test]
fn ensure_filtered_matches_ensure_split_output() {
let cells = vec![
user_cell("hello"),
assistant_cell("some **markdown** body", false),
exec_tool_cell("cargo test"),
user_cell("again"),
];
let revisions = vec![1u64, 2, 3, 4];
let index_map: Vec<usize> = vec![0, 1, 2, 3];
let options = TranscriptRenderOptions {
low_motion: true,
motion_mode: crate::tui::motion::MotionMode::Still,
..TranscriptRenderOptions::default()
};
let mut split_cache = TranscriptViewCache::new();
split_cache.ensure_split(
&[&cells],
&revisions,
40,
options,
&HashSet::new(),
Some(&index_map),
);
let refs: Vec<&HistoryCell> = cells.iter().collect();
let mut filtered_cache = TranscriptViewCache::new();
filtered_cache.ensure_filtered(
&refs,
&revisions,
40,
options,
&HashSet::new(),
Some(&index_map),
);
assert_eq!(plain_lines(&split_cache), plain_lines(&filtered_cache));
assert_eq!(
split_cache.line_meta().len(),
filtered_cache.line_meta().len()
);
}
#[test]
fn ensure_filtered_reuses_unchanged_cells() {
let cells = [
user_cell("hello"),
assistant_cell("streaming", true),
user_cell("again"),
];
let mut revisions = vec![1u64, 1, 1];
let refs: Vec<&HistoryCell> = cells.iter().collect();
let mut cache = TranscriptViewCache::new();
cache.ensure_filtered(
&refs,
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
None,
);
let first = plain_lines(&cache);
cache.ensure_filtered(
&refs,
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
None,
);
assert_eq!(first, plain_lines(&cache));
for (idx, cached) in cache.per_cell.iter().enumerate() {
assert_eq!(
cached.revision, 1,
"cell {idx} must be reused, not re-rendered"
);
}
revisions[1] = 2;
cache.ensure_filtered(
&refs,
&revisions,
80,
TranscriptRenderOptions::default(),
&HashSet::new(),
None,
);
assert_eq!(cache.per_cell[0].revision, 1);
assert_eq!(cache.per_cell[1].revision, 2);
assert_eq!(cache.per_cell[2].revision, 1);
}
#[test]
fn folded_thinking_cache_invalidation() {
let long_content = "reasoning line\n".repeat(50);
let cells = [HistoryCell::Thinking {
content: long_content.clone(),
streaming: false,
duration_secs: Some(1.5),
}];
let revisions = [1u64];
let options = TranscriptRenderOptions {
verbose: true, ..TranscriptRenderOptions::default()
};
let width = 80u16;
let mut cache = TranscriptViewCache::new();
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
let full_line_count = cache.total_lines();
let mut folded = HashSet::new();
folded.insert(0usize);
cache.ensure_split(&[&cells], &revisions, width, options, &folded, None);
let folded_line_count = cache.total_lines();
assert!(
folded_line_count < full_line_count,
"folded thinking should render fewer lines: folded={folded_line_count} full={full_line_count}"
);
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
let restored_line_count = cache.total_lines();
assert_eq!(
restored_line_count, full_line_count,
"unfolded thinking should restore full line count"
);
}
#[test]
fn folded_thinking_with_collapsed_cells_uses_original_indices() {
let cells = [
HistoryCell::Thinking {
content: "first thinking block\n".repeat(20),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Thinking {
content: "second thinking block\n".repeat(20),
streaming: false,
duration_secs: Some(2.0),
},
];
let revisions = [1u64, 2u64];
let options = TranscriptRenderOptions {
verbose: true,
..TranscriptRenderOptions::default()
};
let width = 80u16;
let mut cache = TranscriptViewCache::new();
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
let baseline = cache.total_lines();
assert!(baseline > 0, "baseline render should contain visible lines");
let filtered_cells = [cells[1].clone()];
let filtered_revs = [2u64];
let index_map: Vec<usize> = vec![1];
let mut folded = HashSet::new();
folded.insert(1usize);
let mut cache2 = TranscriptViewCache::new();
cache2.ensure_split(
&[&filtered_cells],
&filtered_revs,
width,
options,
&folded,
Some(&index_map),
);
let folded_filtered = cache2.total_lines();
let mut cache3 = TranscriptViewCache::new();
cache3.ensure_split(
&[&filtered_cells],
&filtered_revs,
width,
options,
&HashSet::new(),
Some(&index_map),
);
let expanded_filtered = cache3.total_lines();
assert!(
folded_filtered < expanded_filtered,
"folded cell via index map should render fewer lines: folded={folded_filtered} expanded={expanded_filtered}"
);
}
}