| 1 | //! Cached transcript rendering for the TUI. |
| 2 | //! |
| 3 | //! ## Per-cell revision caching |
| 4 | //! |
| 5 | //! Naive caching invalidates the whole transcript whenever ANY cell mutates. |
| 6 | //! During streaming the assistant content cell mutates on every delta — that |
| 7 | //! would force a re-wrap of every cell on every chunk. Codex avoids this by |
| 8 | //! tracking a per-cell revision counter; we mirror that pattern here. |
| 9 | //! |
| 10 | //! Each cell index has a paired `revision: u64`. The cache stores |
| 11 | //! `Vec<CachedCell>` with `(cell_index, revision, lines, line_meta)`. On |
| 12 | //! `ensure`, walk the cells; if a cell's current `revision` matches the cached |
| 13 | //! one (and width/options haven't changed), reuse the rendered lines. |
| 14 | //! Otherwise re-render that cell only and reassemble. |
| 15 | //! |
| 16 | //! Width or render-option changes still bust the entire cache (correct: wrap |
| 17 | //! layout depends on width and which cells are visible at all). |
| 18 | |
| 19 | use std::sync::Arc; |
| 20 | |
| 21 | use ratatui::{ |
| 22 | style::Style, |
| 23 | text::{Line, Span}, |
| 24 | }; |
| 25 | |
| 26 | use crate::tui::app::TranscriptSpacing; |
| 27 | use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; |
| 28 | use crate::tui::scrolling::TranscriptLineMeta; |
| 29 | |
| 30 | /// Per-cell cached render output. Reused across `ensure` calls when the |
| 31 | /// upstream cell's revision counter hasn't changed. |
| 32 | /// |
| 33 | /// Lines are stored behind an `Arc` so that cloning a `CachedCell` during |
| 34 | /// cache-ensure (which touches every cell every frame) is O(1) rather than |
| 35 | /// O(rendered_line_count). Without this, scrolling on a long transcript |
| 36 | /// pays the cost of deep-cloning every cell's `Vec<Line>` per frame, which |
| 37 | /// is the surface-level symptom of issue #78. The flatten step uses |
| 38 | /// `Arc::make_mut` to produce an owned `Vec` for the final `lines` |
| 39 | /// assembly, so the only deep-clone occurs on the flattened output — once |
| 40 | /// per frame instead of once per cell. |
| 41 | #[derive(Debug, Clone)] |
| 42 | struct CachedCell { |
| 43 | /// Revision the cell was at when the lines/meta were rendered. |
| 44 | revision: u64, |
| 45 | /// Rendered lines for this cell (without trailing inter-cell spacers), |
| 46 | /// shared via `Arc` so cache enumeration is O(N) not O(N*lines). |
| 47 | lines: Arc<Vec<Line<'static>>>, |
| 48 | /// Whether this cell's rendered output was empty (e.g. Thinking hidden). |
| 49 | /// Cached so we can skip empty cells without re-rendering. |
| 50 | is_empty: bool, |
| 51 | /// Whether this cell is a stream continuation. Determines spacer rules. |
| 52 | /// Cached because `is_stream_continuation` is cheap but reading via the |
| 53 | /// cache lets us decide spacers without touching the cell. |
| 54 | is_stream_continuation: bool, |
| 55 | /// Whether this cell is conversational (User/Assistant/Thinking). Used |
| 56 | /// for spacer calculations. |
| 57 | is_conversational: bool, |
| 58 | /// Whether this cell is a System or Tool cell (affects spacer rules). |
| 59 | is_system_or_tool: bool, |
| 60 | /// Whether this cell participates in the compact tool-card rail group. |
| 61 | is_tool_groupable: bool, |
| 62 | } |
| 63 | |
| 64 | /// Cache of rendered transcript lines for the current viewport. |
| 65 | #[derive(Debug)] |
| 66 | pub struct TranscriptViewCache { |
| 67 | width: u16, |
| 68 | options: TranscriptRenderOptions, |
| 69 | /// Per-cell rendered output, indexed by current cell position. |
| 70 | /// Length always equals the cell count seen on the last `ensure` call. |
| 71 | per_cell: Vec<CachedCell>, |
| 72 | /// Flattened lines reassembled from `per_cell` plus spacers. |
| 73 | lines: Vec<Line<'static>>, |
| 74 | /// Per-line metadata aligned with `lines`. |
| 75 | line_meta: Vec<TranscriptLineMeta>, |
| 76 | } |
| 77 | |
| 78 | impl TranscriptViewCache { |
| 79 | /// Create an empty cache. |
| 80 | #[must_use] |
| 81 | pub fn new() -> Self { |
| 82 | Self { |
| 83 | width: 0, |
| 84 | options: TranscriptRenderOptions::default(), |
| 85 | per_cell: Vec::new(), |
| 86 | lines: Vec::new(), |
| 87 | line_meta: Vec::new(), |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// Ensure cached lines match the provided cells/widths/per-cell revisions. |
| 92 | /// |
| 93 | /// Reuses rendered lines for cells whose `cell_revisions[i]` matches the |
| 94 | /// previously cached revision (when the cell shape — empty/spacer flags — |
| 95 | /// also matches). Width or option changes bust the entire cache. |
| 96 | /// |
| 97 | /// `cell_revisions.len()` is expected to equal `cells.len()`. If they |
| 98 | /// disagree (shouldn't happen in normal use) the cache treats every cell |
| 99 | /// as dirty. |
| 100 | /// |
| 101 | /// Retained for tests and external use; the live render path uses the |
| 102 | /// `ensure_split` variant to avoid concatenating history + active-cell |
| 103 | /// entries every frame. |
| 104 | #[allow(dead_code)] |
| 105 | pub fn ensure( |
| 106 | &mut self, |
| 107 | cells: &[HistoryCell], |
| 108 | cell_revisions: &[u64], |
| 109 | width: u16, |
| 110 | options: TranscriptRenderOptions, |
| 111 | ) { |
| 112 | self.ensure_split(&[cells], cell_revisions, width, options); |
| 113 | } |
| 114 | |
| 115 | /// Ensure cached lines match the provided cell shards (logically |
| 116 | /// concatenated) plus per-cell revisions. Avoids the |
| 117 | /// `concat-into-Vec<HistoryCell>` clone the caller would otherwise pay |
| 118 | /// every frame on long transcripts. |
| 119 | pub fn ensure_split( |
| 120 | &mut self, |
| 121 | cell_shards: &[&[HistoryCell]], |
| 122 | cell_revisions: &[u64], |
| 123 | width: u16, |
| 124 | options: TranscriptRenderOptions, |
| 125 | ) { |
| 126 | let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum(); |
| 127 | |
| 128 | let layout_changed = self.width != width || self.options != options; |
| 129 | if layout_changed { |
| 130 | self.per_cell.clear(); |
| 131 | } |
| 132 | self.width = width; |
| 133 | self.options = options; |
| 134 | |
| 135 | // Track whether anything actually changed; if all cells are reused at |
| 136 | // the same indices, we can skip the reflatten. |
| 137 | let old_len = self.per_cell.len(); |
| 138 | let mut any_dirty = layout_changed || old_len != total_cells; |
| 139 | let mut first_dirty: Option<usize> = if old_len != total_cells { |
| 140 | Some(old_len.min(total_cells)) |
| 141 | } else { |
| 142 | None |
| 143 | }; |
| 144 | |
| 145 | let mut new_per_cell: Vec<CachedCell> = Vec::with_capacity(total_cells); |
| 146 | let revisions_match = cell_revisions.len() == total_cells; |
| 147 | |
| 148 | let mut idx: usize = 0; |
| 149 | for shard in cell_shards { |
| 150 | for cell in *shard { |
| 151 | let current_rev = if revisions_match { |
| 152 | cell_revisions[idx] |
| 153 | } else { |
| 154 | // No matching revisions — force a re-render this cycle. |
| 155 | u64::MAX |
| 156 | }; |
| 157 | |
| 158 | // Reuse cached entry if the revision matches AND it's at the |
| 159 | // same index (cells can shift on insert/remove, so we only |
| 160 | // reuse when the index is identical — a stricter invariant |
| 161 | // codex also uses for its active-cell tail). |
| 162 | if let Some(prev) = self.per_cell.get(idx) |
| 163 | && !layout_changed |
| 164 | && prev.revision == current_rev |
| 165 | && revisions_match |
| 166 | { |
| 167 | new_per_cell.push(prev.clone()); |
| 168 | idx += 1; |
| 169 | continue; |
| 170 | } |
| 171 | |
| 172 | any_dirty = true; |
| 173 | first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx))); |
| 174 | let is_tool_groupable = matches!(cell, HistoryCell::Tool(_)); |
| 175 | let render_width = if is_tool_groupable { |
| 176 | width.saturating_sub(2).max(1) |
| 177 | } else { |
| 178 | width |
| 179 | }; |
| 180 | let rendered = cell.lines_with_options(render_width, options); |
| 181 | let is_empty = rendered.is_empty(); |
| 182 | new_per_cell.push(CachedCell { |
| 183 | revision: current_rev, |
| 184 | lines: Arc::new(rendered), |
| 185 | is_empty, |
| 186 | is_stream_continuation: cell.is_stream_continuation(), |
| 187 | is_conversational: cell.is_conversational(), |
| 188 | is_system_or_tool: matches!( |
| 189 | cell, |
| 190 | HistoryCell::System { .. } |
| 191 | | HistoryCell::Error { .. } |
| 192 | | HistoryCell::Tool(_) |
| 193 | | HistoryCell::SubAgent(_) |
| 194 | | HistoryCell::ArchivedContext { .. } |
| 195 | ), |
| 196 | is_tool_groupable, |
| 197 | }); |
| 198 | idx += 1; |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | self.per_cell = new_per_cell; |
| 203 | |
| 204 | if !any_dirty { |
| 205 | // All cells reused at the same indices: nothing to reflatten. |
| 206 | // (Width didn't change either, since that bumps `layout_changed`.) |
| 207 | return; |
| 208 | } |
| 209 | |
| 210 | let rebuild_from = if layout_changed { |
| 211 | 0 |
| 212 | } else { |
| 213 | first_dirty.unwrap_or(0).saturating_sub(1) |
| 214 | }; |
| 215 | self.flatten_from(options.spacing, rebuild_from); |
| 216 | } |
| 217 | |
| 218 | /// Reassemble flat `lines` / `line_meta` from `per_cell` plus spacers. |
| 219 | fn flatten(&mut self, spacing: TranscriptSpacing) { |
| 220 | self.lines.clear(); |
| 221 | self.line_meta.clear(); |
| 222 | self.append_flattened_cells(spacing, 0); |
| 223 | } |
| 224 | |
| 225 | /// Reassemble only the suffix starting at `first_cell`. |
| 226 | /// |
| 227 | /// Streaming usually mutates the active tail cell. Rebuilding from the |
| 228 | /// previous cell preserves spacer correctness while avoiding a full |
| 229 | /// O(total transcript lines) flatten on every token chunk. |
| 230 | fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) { |
| 231 | if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() { |
| 232 | self.flatten(spacing); |
| 233 | return; |
| 234 | } |
| 235 | |
| 236 | let truncate_at = self |
| 237 | .line_meta |
| 238 | .iter() |
| 239 | .position(|meta| match meta { |
| 240 | TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell, |
| 241 | TranscriptLineMeta::Spacer => false, |
| 242 | }) |
| 243 | .unwrap_or(self.lines.len()); |
| 244 | self.lines.truncate(truncate_at); |
| 245 | self.line_meta.truncate(truncate_at); |
| 246 | self.append_flattened_cells(spacing, first_cell); |
| 247 | } |
| 248 | |
| 249 | fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) { |
| 250 | for (cell_index, cached) in self.per_cell.iter().enumerate().skip(start_cell) { |
| 251 | if cached.is_empty { |
| 252 | continue; |
| 253 | } |
| 254 | // Arc::make_mut would deep-clone only on write; since we just |
| 255 | // rebuilt `lines` from scratch we always need the owned data. |
| 256 | // Deref is zero-cost and gives us &[Line]. |
| 257 | let rendered_line_count = cached.lines.len(); |
| 258 | for (line_in_cell, line) in cached.lines.iter().enumerate() { |
| 259 | self.lines.push(line_with_group_rail( |
| 260 | line, |
| 261 | tool_group_rail( |
| 262 | self.per_cell.as_slice(), |
| 263 | cell_index, |
| 264 | line_in_cell, |
| 265 | rendered_line_count, |
| 266 | ), |
| 267 | usize::from(self.width), |
| 268 | )); |
| 269 | self.line_meta.push(TranscriptLineMeta::CellLine { |
| 270 | cell_index, |
| 271 | line_in_cell, |
| 272 | }); |
| 273 | } |
| 274 | |
| 275 | if let Some(next) = self.per_cell.get(cell_index + 1) { |
| 276 | let spacer_rows = spacer_rows_between(cached, next, spacing); |
| 277 | for _ in 0..spacer_rows { |
| 278 | self.lines.push(Line::from("")); |
| 279 | self.line_meta.push(TranscriptLineMeta::Spacer); |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /// Return cached lines. |
| 286 | #[must_use] |
| 287 | pub fn lines(&self) -> &[Line<'static>] { |
| 288 | &self.lines |
| 289 | } |
| 290 | |
| 291 | /// Return cached line metadata. |
| 292 | #[must_use] |
| 293 | pub fn line_meta(&self) -> &[TranscriptLineMeta] { |
| 294 | &self.line_meta |
| 295 | } |
| 296 | |
| 297 | /// Return total cached lines. |
| 298 | #[must_use] |
| 299 | pub fn total_lines(&self) -> usize { |
| 300 | self.lines.len() |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | fn spacer_rows_between( |
| 305 | current: &CachedCell, |
| 306 | next: &CachedCell, |
| 307 | spacing: TranscriptSpacing, |
| 308 | ) -> usize { |
| 309 | if current.is_stream_continuation { |
| 310 | return 0; |
| 311 | } |
| 312 | |
| 313 | if current.is_tool_groupable && next.is_tool_groupable { |
| 314 | return 0; |
| 315 | } |
| 316 | |
| 317 | let conversational_gap = match spacing { |
| 318 | TranscriptSpacing::Compact => 0, |
| 319 | TranscriptSpacing::Comfortable => 1, |
| 320 | TranscriptSpacing::Spacious => 2, |
| 321 | }; |
| 322 | let secondary_gap = match spacing { |
| 323 | TranscriptSpacing::Compact => 0, |
| 324 | TranscriptSpacing::Comfortable | TranscriptSpacing::Spacious => 1, |
| 325 | }; |
| 326 | |
| 327 | if current.is_conversational && next.is_conversational { |
| 328 | conversational_gap |
| 329 | } else if current.is_system_or_tool || next.is_system_or_tool { |
| 330 | secondary_gap |
| 331 | } else { |
| 332 | 0 |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | fn tool_group_rail( |
| 337 | cells: &[CachedCell], |
| 338 | cell_index: usize, |
| 339 | line_in_cell: usize, |
| 340 | rendered_line_count: usize, |
| 341 | ) -> Option<crate::tui::widgets::tool_card::CardRail> { |
| 342 | let cached = cells.get(cell_index)?; |
| 343 | if !cached.is_tool_groupable || rendered_line_count == 0 { |
| 344 | return None; |
| 345 | } |
| 346 | |
| 347 | let previous_is_tool = cell_index |
| 348 | .checked_sub(1) |
| 349 | .and_then(|idx| cells.get(idx)) |
| 350 | .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); |
| 351 | let next_is_tool = cells |
| 352 | .get(cell_index + 1) |
| 353 | .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); |
| 354 | let first_line_in_group = !previous_is_tool && line_in_cell == 0; |
| 355 | let last_line_in_group = !next_is_tool && line_in_cell + 1 == rendered_line_count; |
| 356 | |
| 357 | let rail = match (first_line_in_group, last_line_in_group) { |
| 358 | (true, true) if rendered_line_count == 1 => { |
| 359 | crate::tui::widgets::tool_card::CardRail::Single |
| 360 | } |
| 361 | (true, _) => crate::tui::widgets::tool_card::CardRail::Top, |
| 362 | (_, true) => crate::tui::widgets::tool_card::CardRail::Bottom, |
| 363 | _ => crate::tui::widgets::tool_card::CardRail::Middle, |
| 364 | }; |
| 365 | Some(rail) |
| 366 | } |
| 367 | |
| 368 | fn line_with_group_rail( |
| 369 | line: &Line<'static>, |
| 370 | rail: Option<crate::tui::widgets::tool_card::CardRail>, |
| 371 | max_width: usize, |
| 372 | ) -> Line<'static> { |
| 373 | let Some(rail) = rail else { |
| 374 | return line.clone(); |
| 375 | }; |
| 376 | let glyph = crate::tui::widgets::tool_card::rail_glyph(rail); |
| 377 | if glyph.is_empty() { |
| 378 | let mut rendered = line.clone(); |
| 379 | rendered.spans = truncate_spans_to_width(rendered.spans, max_width); |
| 380 | return rendered; |
| 381 | } |
| 382 | |
| 383 | let mut rendered = line.clone(); |
| 384 | let mut spans = Vec::with_capacity(rendered.spans.len() + 1); |
| 385 | spans.push(Span::styled( |
| 386 | format!("{glyph} "), |
| 387 | Style::default().fg(crate::palette::TEXT_DIM), |
| 388 | )); |
| 389 | spans.extend(rendered.spans); |
| 390 | rendered.spans = truncate_spans_to_width(spans, max_width); |
| 391 | rendered |
| 392 | } |
| 393 | |
| 394 | fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> { |
| 395 | if max_width == 0 || spans.is_empty() { |
| 396 | return Vec::new(); |
| 397 | } |
| 398 | let current_width: usize = spans |
| 399 | .iter() |
| 400 | .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref())) |
| 401 | .sum(); |
| 402 | if current_width <= max_width { |
| 403 | return spans; |
| 404 | } |
| 405 | |
| 406 | let ellipsis = if max_width > 3 { "..." } else { "" }; |
| 407 | let content_budget = max_width.saturating_sub(ellipsis.len()); |
| 408 | let mut used = 0usize; |
| 409 | let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty())); |
| 410 | let mut last_style = Style::default(); |
| 411 | |
| 412 | 'outer: for span in spans { |
| 413 | last_style = span.style; |
| 414 | let mut content = String::new(); |
| 415 | for ch in span.content.chars() { |
| 416 | let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); |
| 417 | if used + width > content_budget { |
| 418 | break 'outer; |
| 419 | } |
| 420 | content.push(ch); |
| 421 | used += width; |
| 422 | } |
| 423 | if !content.is_empty() { |
| 424 | truncated.push(Span::styled(content, span.style)); |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | if !ellipsis.is_empty() { |
| 429 | truncated.push(Span::styled(ellipsis.to_string(), last_style)); |
| 430 | } |
| 431 | truncated |
| 432 | } |
| 433 | |
| 434 | #[cfg(test)] |
| 435 | mod tests { |
| 436 | use super::*; |
| 437 | use crate::tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus}; |
| 438 | |
| 439 | fn plain_lines(cache: &TranscriptViewCache) -> Vec<String> { |
| 440 | cache |
| 441 | .lines() |
| 442 | .iter() |
| 443 | .map(|line| { |
| 444 | line.spans |
| 445 | .iter() |
| 446 | .map(|span| span.content.as_ref()) |
| 447 | .collect::<String>() |
| 448 | }) |
| 449 | .collect() |
| 450 | } |
| 451 | |
| 452 | fn user_cell(content: &str) -> HistoryCell { |
| 453 | HistoryCell::User { |
| 454 | content: content.to_string(), |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | fn assistant_cell(content: &str, streaming: bool) -> HistoryCell { |
| 459 | HistoryCell::Assistant { |
| 460 | content: content.to_string(), |
| 461 | streaming, |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | fn exec_tool_cell(command: &str) -> HistoryCell { |
| 466 | HistoryCell::Tool(ToolCell::Exec(ExecCell { |
| 467 | command: command.to_string(), |
| 468 | status: ToolStatus::Running, |
| 469 | output: None, |
| 470 | started_at: None, |
| 471 | duration_ms: None, |
| 472 | source: ExecSource::Assistant, |
| 473 | interaction: None, |
| 474 | })) |
| 475 | } |
| 476 | |
| 477 | #[test] |
| 478 | fn cache_reuses_cells_when_revision_unchanged() { |
| 479 | let cells = vec![ |
| 480 | user_cell("hello"), |
| 481 | assistant_cell("world", false), |
| 482 | user_cell("again"), |
| 483 | ]; |
| 484 | let revisions = vec![1u64, 1, 1]; |
| 485 | |
| 486 | let mut cache = TranscriptViewCache::new(); |
| 487 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 488 | let first_lines: Vec<String> = cache |
| 489 | .lines() |
| 490 | .iter() |
| 491 | .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) |
| 492 | .collect(); |
| 493 | let first_total = cache.total_lines(); |
| 494 | assert!(first_total > 0, "expected non-empty render"); |
| 495 | |
| 496 | // Capture per-cell lines snapshot to verify reuse. |
| 497 | let snapshot_per_cell: Vec<Vec<String>> = cache |
| 498 | .per_cell |
| 499 | .iter() |
| 500 | .map(|c| { |
| 501 | c.lines |
| 502 | .iter() |
| 503 | .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) |
| 504 | .collect() |
| 505 | }) |
| 506 | .collect(); |
| 507 | |
| 508 | // Same revisions => everything reused, output identical. |
| 509 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 510 | let second_lines: Vec<String> = cache |
| 511 | .lines() |
| 512 | .iter() |
| 513 | .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) |
| 514 | .collect(); |
| 515 | assert_eq!(first_lines, second_lines); |
| 516 | assert_eq!(cache.total_lines(), first_total); |
| 517 | |
| 518 | let snapshot_per_cell_2: Vec<Vec<String>> = cache |
| 519 | .per_cell |
| 520 | .iter() |
| 521 | .map(|c| { |
| 522 | c.lines |
| 523 | .iter() |
| 524 | .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) |
| 525 | .collect() |
| 526 | }) |
| 527 | .collect(); |
| 528 | assert_eq!(snapshot_per_cell, snapshot_per_cell_2); |
| 529 | } |
| 530 | |
| 531 | #[test] |
| 532 | fn bumping_one_cell_revision_only_rerenders_that_cell() { |
| 533 | // Track render counts per cell using a custom HistoryCell wrapper |
| 534 | // would require trait changes; instead, we detect reuse by inspecting |
| 535 | // CachedCell instances. After a bump, only the bumped cell's stored |
| 536 | // revision should differ from before; others remain identical. |
| 537 | |
| 538 | let cells_v1 = vec![ |
| 539 | user_cell("hello"), |
| 540 | assistant_cell("hi", true), |
| 541 | user_cell("again"), |
| 542 | ]; |
| 543 | let revs_v1 = vec![1u64, 1, 1]; |
| 544 | |
| 545 | let mut cache = TranscriptViewCache::new(); |
| 546 | cache.ensure(&cells_v1, &revs_v1, 80, TranscriptRenderOptions::default()); |
| 547 | |
| 548 | // Snapshot the cached lines for cells 0 and 2 (unchanged across the |
| 549 | // delta). |
| 550 | let cell0_lines_before = cache.per_cell[0] |
| 551 | .lines |
| 552 | .iter() |
| 553 | .map(|l| { |
| 554 | l.spans |
| 555 | .iter() |
| 556 | .map(|s| s.content.to_string()) |
| 557 | .collect::<String>() |
| 558 | }) |
| 559 | .collect::<Vec<_>>(); |
| 560 | let cell2_lines_before = cache.per_cell[2] |
| 561 | .lines |
| 562 | .iter() |
| 563 | .map(|l| { |
| 564 | l.spans |
| 565 | .iter() |
| 566 | .map(|s| s.content.to_string()) |
| 567 | .collect::<String>() |
| 568 | }) |
| 569 | .collect::<Vec<_>>(); |
| 570 | |
| 571 | // Mutate cell 1 (assistant streaming delta) and bump only its rev. |
| 572 | let cells_v2 = vec![ |
| 573 | user_cell("hello"), |
| 574 | assistant_cell("hi world", true), |
| 575 | user_cell("again"), |
| 576 | ]; |
| 577 | let revs_v2 = vec![1u64, 2, 1]; |
| 578 | |
| 579 | cache.ensure(&cells_v2, &revs_v2, 80, TranscriptRenderOptions::default()); |
| 580 | |
| 581 | // Cells 0 and 2 are byte-identical (proving reuse path didn't corrupt). |
| 582 | let cell0_lines_after = cache.per_cell[0] |
| 583 | .lines |
| 584 | .iter() |
| 585 | .map(|l| { |
| 586 | l.spans |
| 587 | .iter() |
| 588 | .map(|s| s.content.to_string()) |
| 589 | .collect::<String>() |
| 590 | }) |
| 591 | .collect::<Vec<_>>(); |
| 592 | let cell2_lines_after = cache.per_cell[2] |
| 593 | .lines |
| 594 | .iter() |
| 595 | .map(|l| { |
| 596 | l.spans |
| 597 | .iter() |
| 598 | .map(|s| s.content.to_string()) |
| 599 | .collect::<String>() |
| 600 | }) |
| 601 | .collect::<Vec<_>>(); |
| 602 | assert_eq!(cell0_lines_before, cell0_lines_after); |
| 603 | assert_eq!(cell2_lines_before, cell2_lines_after); |
| 604 | |
| 605 | // Cell 1 reflects the new content. |
| 606 | // The renderer interleaves role/whitespace spans, so the joined |
| 607 | // content has internal padding (e.g. "Assistant hi world"). |
| 608 | // Check for the new tokens individually rather than a literal |
| 609 | // "hi world" substring. |
| 610 | let cell1_after: String = cache.per_cell[1] |
| 611 | .lines |
| 612 | .iter() |
| 613 | .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) |
| 614 | .collect::<Vec<_>>() |
| 615 | .join(" "); |
| 616 | assert!( |
| 617 | cell1_after.contains("hi") && cell1_after.contains("world"), |
| 618 | "cell1 should re-render with new content; got: {cell1_after}" |
| 619 | ); |
| 620 | |
| 621 | // Revisions in cache reflect the bump. |
| 622 | assert_eq!(cache.per_cell[0].revision, 1); |
| 623 | assert_eq!(cache.per_cell[1].revision, 2); |
| 624 | assert_eq!(cache.per_cell[2].revision, 1); |
| 625 | } |
| 626 | |
| 627 | #[test] |
| 628 | fn tail_update_suffix_rebuild_matches_fresh_flatten() { |
| 629 | let mut cells = vec![ |
| 630 | user_cell("first message"), |
| 631 | assistant_cell("stable answer", false), |
| 632 | user_cell("tail prompt"), |
| 633 | ]; |
| 634 | let mut revisions = vec![1u64, 1, 1]; |
| 635 | let mut cache = TranscriptViewCache::new(); |
| 636 | cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); |
| 637 | |
| 638 | cells.push(assistant_cell("streaming tail", true)); |
| 639 | revisions.push(1); |
| 640 | cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); |
| 641 | |
| 642 | if let HistoryCell::Assistant { content, .. } = cells.last_mut().unwrap() { |
| 643 | content.push_str(" plus delta"); |
| 644 | } |
| 645 | *revisions.last_mut().unwrap() += 1; |
| 646 | cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); |
| 647 | let incremental = plain_lines(&cache); |
| 648 | |
| 649 | let mut fresh = TranscriptViewCache::new(); |
| 650 | fresh.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); |
| 651 | assert_eq!(incremental, plain_lines(&fresh)); |
| 652 | } |
| 653 | |
| 654 | #[test] |
| 655 | fn width_change_rerenders_all_cells() { |
| 656 | let cells = vec![ |
| 657 | user_cell("a fairly long message that may wrap at narrow widths"), |
| 658 | assistant_cell("another long message body content", false), |
| 659 | ]; |
| 660 | let revisions = vec![5u64, 7]; |
| 661 | |
| 662 | let mut cache = TranscriptViewCache::new(); |
| 663 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 664 | let wide_total = cache.total_lines(); |
| 665 | |
| 666 | // Narrow width should change layout — everything re-renders. |
| 667 | cache.ensure(&cells, &revisions, 20, TranscriptRenderOptions::default()); |
| 668 | let narrow_total = cache.total_lines(); |
| 669 | |
| 670 | assert_ne!( |
| 671 | wide_total, narrow_total, |
| 672 | "narrow width should produce a different number of lines" |
| 673 | ); |
| 674 | |
| 675 | // Restoring the original width re-renders again. |
| 676 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 677 | assert_eq!(cache.total_lines(), wide_total); |
| 678 | } |
| 679 | |
| 680 | #[test] |
| 681 | fn streaming_assistant_only_rebuilds_one_cell_render_count() { |
| 682 | // Verify behavior 6: when one Assistant cell streams a delta, only |
| 683 | // that one cell is re-rendered. We use a counting wrapper hooked into |
| 684 | // a custom History setup. Since `lines_with_options` is on `HistoryCell` |
| 685 | // (concrete enum), we can't mock it directly. Instead we verify the |
| 686 | // cache's invariant: cells with unchanged revisions retain their |
| 687 | // previous CachedCell entries (clone-equal), proving no re-render |
| 688 | // happened for them. |
| 689 | // |
| 690 | // We do this by storing revisions as monotonic u64 and verifying that |
| 691 | // a `Vec<u64>` snapshot of `per_cell.revision` only differs at the |
| 692 | // index that was bumped. |
| 693 | |
| 694 | let mut cells: Vec<HistoryCell> = |
| 695 | (0..50).map(|i| user_cell(&format!("cell {i}"))).collect(); |
| 696 | cells.push(assistant_cell("streaming", true)); |
| 697 | let mut revisions: Vec<u64> = vec![1; 51]; |
| 698 | |
| 699 | let mut cache = TranscriptViewCache::new(); |
| 700 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 701 | |
| 702 | // Snapshot total bytes rendered for cells 0..50 (unchanged). |
| 703 | let stable_snapshot: Vec<String> = cache.per_cell[..50] |
| 704 | .iter() |
| 705 | .map(|c| { |
| 706 | c.lines |
| 707 | .iter() |
| 708 | .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) |
| 709 | .collect::<Vec<_>>() |
| 710 | .join("|") |
| 711 | }) |
| 712 | .collect(); |
| 713 | |
| 714 | // Stream 10 deltas to the assistant cell, bumping only its revision. |
| 715 | for i in 0..10 { |
| 716 | if let HistoryCell::Assistant { content, .. } = &mut cells[50] { |
| 717 | content.push_str(&format!(" delta-{i}")); |
| 718 | } |
| 719 | revisions[50] += 1; |
| 720 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 721 | |
| 722 | // After every delta, cells 0..50 must be byte-identical to the |
| 723 | // initial render. If we re-rendered them we'd observe identical |
| 724 | // bytes anyway (deterministic), but the test ALSO checks the |
| 725 | // CachedCell.revision values stayed at 1 — meaning the cache |
| 726 | // never replaced them, only reused them. |
| 727 | let stable_now: Vec<String> = cache.per_cell[..50] |
| 728 | .iter() |
| 729 | .map(|c| { |
| 730 | c.lines |
| 731 | .iter() |
| 732 | .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) |
| 733 | .collect::<Vec<_>>() |
| 734 | .join("|") |
| 735 | }) |
| 736 | .collect(); |
| 737 | assert_eq!( |
| 738 | stable_now, stable_snapshot, |
| 739 | "stable cells diverged at delta {i}" |
| 740 | ); |
| 741 | |
| 742 | for (idx, c) in cache.per_cell[..50].iter().enumerate() { |
| 743 | assert_eq!( |
| 744 | c.revision, 1, |
| 745 | "cell {idx} revision changed during streaming delta" |
| 746 | ); |
| 747 | } |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | #[test] |
| 752 | fn missing_revisions_falls_back_to_full_render() { |
| 753 | // If callers pass a `cell_revisions` slice with the wrong length |
| 754 | // (shouldn't happen, but be defensive), the cache should still |
| 755 | // produce correct output rather than panic or skip cells. |
| 756 | let cells = vec![user_cell("a"), assistant_cell("b", false)]; |
| 757 | let bogus_revisions = vec![1u64]; // wrong length |
| 758 | |
| 759 | let mut cache = TranscriptViewCache::new(); |
| 760 | cache.ensure( |
| 761 | &cells, |
| 762 | &bogus_revisions, |
| 763 | 80, |
| 764 | TranscriptRenderOptions::default(), |
| 765 | ); |
| 766 | |
| 767 | // Both cells were rendered (no panic, output non-empty). |
| 768 | assert_eq!(cache.per_cell.len(), 2); |
| 769 | assert!(!cache.lines().is_empty()); |
| 770 | } |
| 771 | |
| 772 | #[test] |
| 773 | fn adjacent_tool_cells_render_as_one_railed_group() { |
| 774 | let cells = vec![exec_tool_cell("cargo test"), exec_tool_cell("cargo clippy")]; |
| 775 | let revisions = vec![1u64, 1]; |
| 776 | let mut cache = TranscriptViewCache::new(); |
| 777 | |
| 778 | cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); |
| 779 | let lines = plain_lines(&cache); |
| 780 | |
| 781 | assert!( |
| 782 | lines |
| 783 | .first() |
| 784 | .is_some_and(|line| line.starts_with("\u{256D} ")), |
| 785 | "first tool line should open the shared rail: {lines:?}" |
| 786 | ); |
| 787 | assert!( |
| 788 | lines.iter().any(|line| line.starts_with("\u{2502} ")), |
| 789 | "middle tool lines should continue the shared rail: {lines:?}" |
| 790 | ); |
| 791 | assert!( |
| 792 | lines |
| 793 | .last() |
| 794 | .is_some_and(|line| line.starts_with("\u{2570} ")), |
| 795 | "last tool line should close the shared rail: {lines:?}" |
| 796 | ); |
| 797 | assert!( |
| 798 | !lines.iter().any(String::is_empty), |
| 799 | "adjacent tool cells should not be separated by blank spacer rows: {lines:?}" |
| 800 | ); |
| 801 | } |
| 802 | |
| 803 | #[test] |
| 804 | fn tool_rails_preserve_rendered_width_budget() { |
| 805 | let cells = vec![exec_tool_cell( |
| 806 | "printf 'this is a command with enough text to wrap in narrow terminals'", |
| 807 | )]; |
| 808 | let revisions = vec![1u64]; |
| 809 | let mut cache = TranscriptViewCache::new(); |
| 810 | |
| 811 | cache.ensure(&cells, &revisions, 24, TranscriptRenderOptions::default()); |
| 812 | |
| 813 | for line in plain_lines(&cache) { |
| 814 | assert!( |
| 815 | unicode_width::UnicodeWidthStr::width(line.as_str()) <= 24, |
| 816 | "tool rail line exceeded narrow width: {line:?}" |
| 817 | ); |
| 818 | } |
| 819 | } |
| 820 | } |
| 821 |