返回 DeepSeek-TUI-2026
transcript.rs
根目录 / crates / tui / src / tui / transcript.rs
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
821 lines RUST