返回 CodeWhale
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::collections::HashSet;
20 use std::sync::Arc;
21
22 use ratatui::{
23 style::Style,
24 text::{Line, Span},
25 };
26
27 use crate::tui::app::TranscriptSpacing;
28 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
29 use crate::tui::scrolling::TranscriptLineMeta;
30 use crate::tui::ui_text::CopyLineSeparator;
31
32 /// Per-cell cached render output. Reused across `ensure` calls when the
33 /// upstream cell's revision counter hasn't changed.
34 ///
35 /// Lines are stored behind an `Arc` so that cloning a `CachedCell` during
36 /// cache-ensure (which touches every cell every frame) is O(1) rather than
37 /// O(rendered_line_count). Without this, scrolling on a long transcript
38 /// pays the cost of deep-cloning every cell's `Vec<Line>` per frame, which
39 /// is the surface-level symptom of issue #78. The flatten step uses
40 /// `Arc::make_mut` to produce an owned `Vec` for the final `lines`
41 /// assembly, so the only deep-clone occurs on the flattened output — once
42 /// per frame instead of once per cell.
43 #[derive(Debug)]
44 struct CachedCell {
45 /// Revision the cell was at when the lines/meta were rendered.
46 revision: u64,
47 /// Rendered lines for this cell (without trailing inter-cell spacers),
48 /// shared via `Arc` so cache enumeration is O(N) not O(N*lines).
49 lines: Arc<Vec<Line<'static>>>,
50 /// Hyperlinks aligned with `lines`, in display columns relative to each
51 /// line. Targets never enter the ratatui cell buffer.
52 links: Arc<Vec<Vec<crate::tui::osc8::LineLink>>>,
53 /// Copy separators aligned with `lines`. These preserve source hard
54 /// newlines while allowing copy to remove visual soft-wrap breaks.
55 copy_separators: Arc<Vec<CopyLineSeparator>>,
56 /// Display-column widths of visual prefixes that should be omitted from
57 /// clipboard text, aligned with `lines`.
58 copy_prefix_widths: Arc<Vec<usize>>,
59 /// Whether this cell's rendered output was empty (e.g. Thinking hidden).
60 /// Cached so we can skip empty cells without re-rendering.
61 is_empty: bool,
62 /// Whether the cell's last rendered line is blank. A cell that already
63 /// ends on a blank row must not also receive a separator row after it —
64 /// two stacked blanks look worse than none.
65 ends_blank: bool,
66 /// Semantic role used by the transcript's explicit boundary matrix.
67 /// Keeping the role in the cache makes spacing independent of rendered
68 /// strings, theme colors, terminal depth, and animation state.
69 kind: TranscriptBlockKind,
70 /// Whether this cell participates in the compact tool-card rail group.
71 is_tool_groupable: bool,
72 /// Persistent parser/highlighter carry for the one changing Assistant
73 /// cell. Stable rendered lines remain in the vectors above and are
74 /// truncated only from the cache's replaceable-tail index.
75 incremental_markdown: Option<Box<crate::tui::markdown_render::IncrementalMarkdownRenderCache>>,
76 /// The hot-tail treatment mutates the last line for animation. Preserve
77 /// its settled form so the next append can restore it without re-rendering
78 /// the stable prefix.
79 hot_tail_original: Option<(usize, Line<'static>)>,
80 }
81
82 /// Provenance that one live Assistant cell's source stayed unchanged or only
83 /// gained appended bytes. Visual-only revision bumps can therefore reuse it.
84 /// Revisions use the same transformed keys passed to `ensure_*`.
85 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
86 pub(crate) struct StreamingSourceReceipt {
87 pub cell_index: usize,
88 pub from_revision: u64,
89 pub to_revision: u64,
90 pub content_len: usize,
91 }
92
93 /// Visual role of one transcript cell.
94 ///
95 /// Approval, question, Work-panel, and composer surfaces live outside the
96 /// transcript cache and already own bounded panels/edges. This enum covers
97 /// every in-transcript seam, including durable Work receipts emitted by plan,
98 /// checklist, and workflow tools.
99 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
100 enum TranscriptBlockKind {
101 User,
102 Reasoning,
103 Answer,
104 ToolAction,
105 DurableWork,
106 Notice,
107 }
108
109 impl TranscriptBlockKind {
110 fn for_cell(cell: &HistoryCell) -> Self {
111 match cell {
112 HistoryCell::User { .. } => Self::User,
113 HistoryCell::Thinking { .. } => Self::Reasoning,
114 HistoryCell::Assistant { .. } => Self::Answer,
115 HistoryCell::Tool(tool) if tool.is_durable_work_receipt() => Self::DurableWork,
116 HistoryCell::Tool(_) | HistoryCell::SubAgent(_) => Self::ToolAction,
117 HistoryCell::System { .. }
118 | HistoryCell::Error { .. }
119 | HistoryCell::ArchivedContext { .. } => Self::Notice,
120 }
121 }
122 }
123
124 /// Rows a single visible block separation is worth.
125 ///
126 /// One blank row — never two. The transcript scrolls inside a terminal
127 /// viewport, so every separator row is a row of content the reader loses.
128 /// One row is enough to read two blocks as two paragraphs; two rows halve
129 /// the visible transcript for no extra legibility. `Turn` at `Spacious` is
130 /// the sole deliberate exception, and it is opt-in.
131 const BLOCK_SEPARATOR_ROWS: usize = 1;
132
133 /// Strength of a visible boundary. These four levels are the complete
134 /// transcript spacing vocabulary: no blanket per-cell padding is added.
135 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
136 enum TranscriptBoundary {
137 /// Two cells are literally continuation of one another — successive
138 /// reasoning segments, or successive prose blocks of one answer.
139 Joined,
140 /// Two cells sit inside one tool-card rail group. Separated by a rail
141 /// spacer (`│`) rather than a bare blank row so the card box survives.
142 GroupedTool,
143 /// Transition between response phases, or into/out of tools, Work, or
144 /// notices.
145 Activity,
146 /// A human turn boundary; always visible, even at compact density.
147 Turn,
148 }
149
150 /// Cache of rendered transcript lines for the current viewport.
151 #[derive(Debug)]
152 pub struct TranscriptViewCache {
153 width: u16,
154 options: TranscriptRenderOptions,
155 /// Snapshot of folded_thinking indices from the last `ensure` call.
156 /// When this changes, all cells must be re-rendered because the fold
157 /// state affects the rendered output but not the cell revision.
158 folded_cells: HashSet<usize>,
159 /// Per-cell rendered output, indexed by current cell position.
160 /// Length always equals the cell count seen on the last `ensure` call.
161 per_cell: Vec<CachedCell>,
162 /// Flattened lines reassembled from `per_cell` plus spacers.
163 lines: Vec<Line<'static>>,
164 /// Per-line hyperlink metadata aligned with `lines`.
165 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
166 /// Per-line metadata aligned with `lines`.
167 line_meta: Vec<TranscriptLineMeta>,
168 /// Per-line rail-prefix display-column count (`0` or `2`), aligned with
169 /// `lines`. Populated during flatten so that selection-to-text can shift
170 /// columns past visual-only decoration glyphs without guessing which
171 /// spans are decorative (#1163).
172 rail_prefix_widths: Vec<usize>,
173 streaming_source_receipt: Option<StreamingSourceReceipt>,
174 /// Deterministic receipt for actual flattened-line reconstruction work.
175 /// Kept in production state so tests measure the real path without hooks.
176 streaming_lines_reflattened: u64,
177 streaming_meta_rows_scanned: u64,
178 }
179
180 impl TranscriptViewCache {
181 /// Create an empty cache.
182 #[must_use]
183 pub fn new() -> Self {
184 Self {
185 width: 0,
186 options: TranscriptRenderOptions::default(),
187 folded_cells: HashSet::new(),
188 per_cell: Vec::new(),
189 lines: Vec::new(),
190 line_links: Vec::new(),
191 line_meta: Vec::new(),
192 rail_prefix_widths: Vec::new(),
193 streaming_source_receipt: None,
194 streaming_lines_reflattened: 0,
195 streaming_meta_rows_scanned: 0,
196 }
197 }
198
199 pub(crate) fn set_streaming_source_receipt(&mut self, receipt: Option<StreamingSourceReceipt>) {
200 self.streaming_source_receipt = receipt;
201 }
202
203 #[cfg(test)]
204 #[must_use]
205 fn streaming_lines_reflattened(&self) -> u64 {
206 self.streaming_lines_reflattened
207 }
208
209 #[cfg(test)]
210 #[must_use]
211 fn streaming_meta_rows_scanned(&self) -> u64 {
212 self.streaming_meta_rows_scanned
213 }
214
215 /// Ensure cached lines match the provided cells/widths/per-cell revisions.
216 ///
217 /// Reuses rendered lines for cells whose `cell_revisions[i]` matches the
218 /// previously cached revision (when the cell shape — empty/spacer flags —
219 /// also matches). Width or option changes bust the entire cache.
220 ///
221 /// `cell_revisions.len()` is expected to equal `cells.len()`. If they
222 /// disagree (shouldn't happen in normal use) the cache treats every cell
223 /// as dirty.
224 ///
225 /// Retained for tests and external use; the live render path uses the
226 /// `ensure_split` variant to avoid concatenating history + active-cell
227 /// entries every frame.
228 #[allow(dead_code)]
229 pub fn ensure(
230 &mut self,
231 cells: &[HistoryCell],
232 cell_revisions: &[u64],
233 width: u16,
234 options: TranscriptRenderOptions,
235 ) {
236 self.ensure_split(
237 &[cells],
238 cell_revisions,
239 width,
240 options,
241 &HashSet::new(),
242 None,
243 );
244 }
245
246 /// Ensure cached lines match the provided cell shards (logically
247 /// concatenated) plus per-cell revisions. Avoids the
248 /// `concat-into-Vec<HistoryCell>` clone the caller would otherwise pay
249 /// every frame on long transcripts.
250 ///
251 /// `folded_cells` contains original virtual indices of thinking cells
252 /// that should render in their folded (summary) form.
253 ///
254 /// `original_index_map` maps filtered (positional) indices to original
255 /// virtual indices. Required when `collapsed_cells` filtering is active
256 /// so that `folded_cells` lookups resolve to the correct original index.
257 pub fn ensure_split(
258 &mut self,
259 cell_shards: &[&[HistoryCell]],
260 cell_revisions: &[u64],
261 width: u16,
262 options: TranscriptRenderOptions,
263 folded_cells: &HashSet<usize>,
264 original_index_map: Option<&[usize]>,
265 ) {
266 let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
267 self.ensure_iter(
268 total_cells,
269 cell_shards.iter().flat_map(|shard| shard.iter()),
270 cell_revisions,
271 width,
272 options,
273 folded_cells,
274 original_index_map,
275 );
276 }
277
278 /// `ensure_split` over an already-filtered list of borrowed cells.
279 ///
280 /// The collapse path substitutes synthetic tool-run summary cells and
281 /// skips collapsed cells, so it cannot hand over contiguous shard
282 /// slices. Accepting `&[&HistoryCell]` lets it pass borrows instead of
283 /// deep-cloning every visible cell into a fresh `Vec<HistoryCell>` each
284 /// frame (#3896).
285 #[allow(clippy::too_many_arguments)]
286 pub fn ensure_filtered(
287 &mut self,
288 cells: &[&HistoryCell],
289 cell_revisions: &[u64],
290 width: u16,
291 options: TranscriptRenderOptions,
292 folded_cells: &HashSet<usize>,
293 original_index_map: Option<&[usize]>,
294 ) {
295 self.ensure_iter(
296 cells.len(),
297 cells.iter().copied(),
298 cell_revisions,
299 width,
300 options,
301 folded_cells,
302 original_index_map,
303 );
304 }
305
306 #[allow(clippy::too_many_arguments)]
307 fn ensure_iter<'a>(
308 &mut self,
309 total_cells: usize,
310 cells: impl Iterator<Item = &'a HistoryCell>,
311 cell_revisions: &[u64],
312 width: u16,
313 options: TranscriptRenderOptions,
314 folded_cells: &HashSet<usize>,
315 original_index_map: Option<&[usize]>,
316 ) {
317 let layout_changed = self.width != width || self.options != options;
318 let folded_changed = self.folded_cells != *folded_cells;
319 if layout_changed || folded_changed {
320 self.per_cell.clear();
321 }
322 self.width = width;
323 self.options = options;
324 self.folded_cells = folded_cells.clone();
325
326 // Track whether anything actually changed; if all cells are reused at
327 // the same indices, we can skip the reflatten.
328 let old_len = self.per_cell.len();
329 let mut any_dirty = layout_changed || folded_changed || old_len != total_cells;
330 let mut first_dirty: Option<usize> = if old_len != total_cells {
331 Some(old_len.min(total_cells))
332 } else {
333 None
334 };
335
336 let mut old_per_cell: Vec<Option<CachedCell>> = std::mem::take(&mut self.per_cell)
337 .into_iter()
338 .map(Some)
339 .collect();
340 let mut new_per_cell: Vec<CachedCell> = Vec::with_capacity(total_cells);
341 let revisions_match = cell_revisions.len() == total_cells;
342 let mut dirty_cells = 0usize;
343 let mut streaming_tail_update = None;
344
345 let mut idx: usize = 0;
346 for cell in cells {
347 let current_rev = if revisions_match {
348 cell_revisions[idx]
349 } else {
350 // No matching revisions — force a re-render this cycle.
351 u64::MAX
352 };
353
354 // Reuse cached entry if the revision matches AND it's at the
355 // same index (cells can shift on insert/remove, so we only
356 // reuse when the index is identical — a stricter invariant
357 // codex also uses for its active-cell tail).
358 if !layout_changed
359 && revisions_match
360 && old_per_cell
361 .get(idx)
362 .and_then(Option::as_ref)
363 .is_some_and(|prev| prev.revision == current_rev)
364 {
365 new_per_cell.push(
366 old_per_cell[idx]
367 .take()
368 .expect("cached cell checked as present"),
369 );
370 idx += 1;
371 continue;
372 }
373
374 any_dirty = true;
375 dirty_cells = dirty_cells.saturating_add(1);
376 first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx)));
377 let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
378 let render_width = if is_tool_groupable {
379 width.saturating_sub(2).max(1)
380 } else {
381 width
382 };
383 let original_idx = original_index_map
384 .map(|m| *m.get(idx).unwrap_or(&idx))
385 .unwrap_or(idx);
386 let folded = folded_cells.contains(&original_idx);
387
388 if matches!(
389 cell,
390 HistoryCell::Assistant {
391 streaming: true,
392 ..
393 }
394 ) {
395 let mut cached = old_per_cell
396 .get_mut(idx)
397 .and_then(Option::take)
398 .unwrap_or_else(|| CachedCell {
399 revision: current_rev,
400 lines: Arc::new(Vec::new()),
401 links: Arc::new(Vec::new()),
402 copy_separators: Arc::new(Vec::new()),
403 copy_prefix_widths: Arc::new(Vec::new()),
404 is_empty: true,
405 ends_blank: false,
406 kind: TranscriptBlockKind::Answer,
407 is_tool_groupable: false,
408 incremental_markdown: Some(Box::default()),
409 hot_tail_original: None,
410 });
411 if let Some((line_index, original)) = cached.hot_tail_original.take()
412 && let Some(line) = Arc::make_mut(&mut cached.lines).get_mut(line_index)
413 {
414 *line = original;
415 }
416 let content_len = match cell {
417 HistoryCell::Assistant { content, .. } => content.len(),
418 _ => 0,
419 };
420 let verified_append = self.streaming_source_receipt.is_some_and(|receipt| {
421 receipt.cell_index == original_idx
422 && receipt.from_revision == cached.revision
423 && receipt.to_revision == current_rev
424 && receipt.content_len == content_len
425 });
426 let incremental = cached.incremental_markdown.get_or_insert_with(Box::default);
427 let replace_from = cell
428 .update_incremental_streaming_render(
429 render_width,
430 options,
431 verified_append,
432 incremental,
433 Arc::make_mut(&mut cached.lines),
434 Arc::make_mut(&mut cached.links),
435 Arc::make_mut(&mut cached.copy_separators),
436 Arc::make_mut(&mut cached.copy_prefix_widths),
437 )
438 .expect("streaming Assistant matched above");
439 let cached_lines = Arc::make_mut(&mut cached.lines);
440 let last_index = cached_lines.len().checked_sub(1);
441 if let Some((index, last)) = last_index
442 .and_then(|index| cached_lines.get_mut(index).map(|line| (index, line)))
443 {
444 cached.hot_tail_original = Some((index, last.clone()));
445 crate::tui::history::apply_hot_tail_to_line(last, options.low_motion);
446 }
447 cached.revision = current_rev;
448 cached.is_empty = cached.lines.is_empty();
449 cached.ends_blank = last_line_is_blank(&cached.lines);
450 cached.kind = TranscriptBlockKind::Answer;
451 cached.is_tool_groupable = false;
452 // The hot-tail style also changes on the preceding settled
453 // line, so reflatten one line before the Markdown tail.
454 streaming_tail_update = Some((idx, replace_from.saturating_sub(1)));
455 new_per_cell.push(cached);
456 idx += 1;
457 continue;
458 }
459
460 let rendered = cell.lines_with_copy_metadata_folded(render_width, options, folded);
461 let mut lines = Vec::with_capacity(rendered.len());
462 let mut links = Vec::with_capacity(rendered.len());
463 let mut copy_separators = Vec::with_capacity(rendered.len());
464 let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
465 for rendered_line in rendered {
466 let mut line = rendered_line.line;
467 if is_tool_groupable {
468 strip_cell_local_tool_rail(&mut line);
469 }
470 lines.push(line);
471 links.push(rendered_line.links);
472 copy_prefix_widths.push(rendered_line.copy_prefix_width);
473 copy_separators.push(rendered_line.copy_separator_after);
474 }
475 let is_empty = lines.is_empty();
476 let ends_blank = last_line_is_blank(&lines);
477 new_per_cell.push(CachedCell {
478 revision: current_rev,
479 lines: Arc::new(lines),
480 links: Arc::new(links),
481 copy_separators: Arc::new(copy_separators),
482 copy_prefix_widths: Arc::new(copy_prefix_widths),
483 is_empty,
484 ends_blank,
485 kind: TranscriptBlockKind::for_cell(cell),
486 is_tool_groupable,
487 incremental_markdown: None,
488 hot_tail_original: None,
489 });
490 idx += 1;
491 }
492
493 self.per_cell = new_per_cell;
494
495 if !any_dirty {
496 // All cells reused at the same indices: nothing to reflatten.
497 // (Width didn't change either, since that bumps `layout_changed`.)
498 return;
499 }
500
501 if !layout_changed
502 && !folded_changed
503 && old_len == total_cells
504 && dirty_cells == 1
505 && let Some((cell_index, line_from)) = streaming_tail_update
506 && cell_index + 1 == total_cells
507 && self.flatten_streaming_tail(cell_index, line_from)
508 {
509 return;
510 }
511
512 let mut rebuild_from = if layout_changed {
513 0
514 } else {
515 first_dirty.unwrap_or(0).saturating_sub(1)
516 };
517 // A hidden cell has no line at which `flatten_from` can truncate.
518 // Walk back to the nearest visible predecessor so a cell appearing,
519 // disappearing, or changing kind cannot leave a stale spacer behind.
520 while rebuild_from > 0
521 && self
522 .per_cell
523 .get(rebuild_from)
524 .is_some_and(|cell| cell.is_empty)
525 {
526 rebuild_from -= 1;
527 }
528 self.flatten_from(options.spacing, rebuild_from);
529 }
530
531 /// Reassemble flat `lines` / `line_meta` from `per_cell` plus spacers.
532 fn flatten(&mut self, spacing: TranscriptSpacing) {
533 self.lines.clear();
534 self.line_links.clear();
535 self.line_meta.clear();
536 self.rail_prefix_widths.clear();
537 self.append_flattened_cells(spacing, 0);
538 }
539
540 /// Reassemble only the suffix starting at `first_cell`.
541 ///
542 /// Streaming usually mutates the active tail cell. Rebuilding from the
543 /// previous cell preserves spacer correctness while avoiding a full
544 /// O(total transcript lines) flatten on every token chunk.
545 fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) {
546 if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() {
547 self.flatten(spacing);
548 return;
549 }
550
551 let truncate_at = self
552 .line_meta
553 .iter()
554 .position(|meta| match meta {
555 TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell,
556 TranscriptLineMeta::Spacer { .. } => false,
557 })
558 .unwrap_or(self.lines.len());
559 self.lines.truncate(truncate_at);
560 self.line_links.truncate(truncate_at);
561 self.line_meta.truncate(truncate_at);
562 self.rail_prefix_widths.truncate(truncate_at);
563 self.append_flattened_cells(spacing, first_cell);
564 }
565
566 /// Replace only the changing tail of the final streaming cell in the
567 /// flattened viewport. Returns false when the prior cell had no visible
568 /// line at the requested boundary, in which case the caller performs the
569 /// canonical suffix rebuild.
570 fn flatten_streaming_tail(&mut self, cell_index: usize, line_from: usize) -> bool {
571 // Search backward: for append-only updates `line_from` is at the old
572 // hot tail, so this examines only the replaceable suffix rather than
573 // the full transcript prefix.
574 let mut truncate_at = None;
575 for (index, meta) in self.line_meta.iter().enumerate().rev() {
576 self.streaming_meta_rows_scanned = self.streaming_meta_rows_scanned.saturating_add(1);
577 if matches!(
578 meta,
579 TranscriptLineMeta::CellLine {
580 cell_index: candidate,
581 line_in_cell,
582 ..
583 } if *candidate == cell_index && *line_in_cell == line_from
584 ) {
585 truncate_at = Some(index);
586 break;
587 }
588 }
589 let Some(truncate_at) = truncate_at else {
590 return false;
591 };
592 self.lines.truncate(truncate_at);
593 self.line_links.truncate(truncate_at);
594 self.line_meta.truncate(truncate_at);
595 self.rail_prefix_widths.truncate(truncate_at);
596
597 let Some(cached) = self.per_cell.get(cell_index) else {
598 return false;
599 };
600 let rendered_line_count = cached.lines.len();
601 for line_in_cell in line_from..rendered_line_count {
602 let line = &cached.lines[line_in_cell];
603 let rail = tool_group_rail(
604 self.per_cell.as_slice(),
605 cell_index,
606 line_in_cell,
607 rendered_line_count,
608 );
609 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
610 let final_links = links_with_group_rail(
611 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
612 rail,
613 usize::from(self.width),
614 );
615 self.rail_prefix_widths
616 .push(compute_rail_prefix_width(&final_line));
617 self.lines.push(final_line);
618 self.line_links.push(final_links);
619 self.line_meta.push(TranscriptLineMeta::CellLine {
620 cell_index,
621 line_in_cell,
622 copy_prefix_width: cached
623 .copy_prefix_widths
624 .get(line_in_cell)
625 .copied()
626 .unwrap_or(0),
627 copy_separator_after: cached
628 .copy_separators
629 .get(line_in_cell)
630 .copied()
631 .unwrap_or(CopyLineSeparator::Newline),
632 });
633 self.streaming_lines_reflattened = self.streaming_lines_reflattened.saturating_add(1);
634 }
635 true
636 }
637
638 fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) {
639 for (cell_index, cached) in self.per_cell.iter().enumerate().skip(start_cell) {
640 if cached.is_empty {
641 continue;
642 }
643 // Arc::make_mut would deep-clone only on write; since we just
644 // rebuilt `lines` from scratch we always need the owned data.
645 // Deref is zero-cost and gives us &[Line].
646 let rendered_line_count = cached.lines.len();
647 for (line_in_cell, line) in cached.lines.iter().enumerate() {
648 let rail = tool_group_rail(
649 self.per_cell.as_slice(),
650 cell_index,
651 line_in_cell,
652 rendered_line_count,
653 );
654 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
655 let final_links = links_with_group_rail(
656 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
657 rail,
658 usize::from(self.width),
659 );
660 self.rail_prefix_widths
661 .push(compute_rail_prefix_width(&final_line));
662 self.lines.push(final_line);
663 self.line_links.push(final_links);
664 self.line_meta.push(TranscriptLineMeta::CellLine {
665 cell_index,
666 line_in_cell,
667 copy_prefix_width: cached
668 .copy_prefix_widths
669 .get(line_in_cell)
670 .copied()
671 .unwrap_or(0),
672 copy_separator_after: cached
673 .copy_separators
674 .get(line_in_cell)
675 .copied()
676 .unwrap_or(CopyLineSeparator::Newline),
677 });
678 self.streaming_lines_reflattened =
679 self.streaming_lines_reflattened.saturating_add(1);
680 }
681
682 if let Some(next) = next_visible_cell(&self.per_cell, cell_index) {
683 let separator = separator_between(cached, next, spacing);
684 let rail = separator
685 .railed
686 .then_some(crate::tui::widgets::tool_card::CardRail::Middle);
687 for _ in 0..separator.rows {
688 let line = line_with_group_rail(&Line::from(""), rail, usize::from(self.width));
689 let copy_prefix_width = compute_rail_prefix_width(&line);
690 self.rail_prefix_widths.push(copy_prefix_width);
691 self.lines.push(line);
692 self.line_links.push(Vec::new());
693 self.line_meta
694 .push(TranscriptLineMeta::Spacer { copy_prefix_width });
695 }
696 }
697 }
698 }
699
700 /// Return cached lines.
701 #[must_use]
702 pub fn lines(&self) -> &[Line<'static>] {
703 &self.lines
704 }
705
706 /// Return hyperlinks aligned with [`Self::lines`].
707 #[must_use]
708 pub fn line_links(&self) -> &[Vec<crate::tui::osc8::LineLink>] {
709 &self.line_links
710 }
711
712 /// Return cached line metadata.
713 #[must_use]
714 pub fn line_meta(&self) -> &[TranscriptLineMeta] {
715 &self.line_meta
716 }
717
718 /// Return total cached lines.
719 #[must_use]
720 pub fn total_lines(&self) -> usize {
721 self.lines.len()
722 }
723
724 /// Return the rail-prefix display-column count for the line at
725 /// `line_index`. Callers use this to shift selection coordinates past
726 /// visual-only decoration glyphs without guessing which spans are
727 /// decorative (#1163).
728 #[must_use]
729 pub fn rail_prefix_width(&self, line_index: usize) -> usize {
730 self.rail_prefix_widths
731 .get(line_index)
732 .copied()
733 .unwrap_or(0)
734 }
735 }
736
737 /// Tool cells still render their own rail when used outside the transcript
738 /// cache (pager, clipboard, focused detail). Inside the live transcript this
739 /// cache owns grouping across adjacent cells, so retaining both rails produces
740 /// doubled prefixes such as `╭ ╭`. Replace the cell-local decoration with the
741 /// group rail added by `line_with_group_rail` during flattening.
742 fn strip_cell_local_tool_rail(line: &mut Line<'static>) {
743 if line
744 .spans
745 .first()
746 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
747 {
748 line.spans.remove(0);
749 }
750 }
751
752 /// Whether a cell's own render already ends on a visually blank row.
753 fn last_line_is_blank(lines: &[Line<'static>]) -> bool {
754 lines
755 .last()
756 .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
757 }
758
759 /// One block separation: how many rows, and whether those rows carry the
760 /// tool-card rail. Kept as one value so the flatten loop cannot emit the row
761 /// count from one rule and the decoration from another.
762 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
763 struct BlockSeparator {
764 rows: usize,
765 railed: bool,
766 }
767
768 fn separator_between(
769 current: &CachedCell,
770 next: &CachedCell,
771 spacing: TranscriptSpacing,
772 ) -> BlockSeparator {
773 let boundary = transcript_boundary(
774 current.kind,
775 next.kind,
776 same_tool_activity_group(current, next),
777 );
778 let mut rows = spacer_rows_for_boundary(boundary, spacing);
779 // Never stack two blank rows. A cell whose own render already ends on a
780 // blank line has paid for the separation; adding another on top reads as
781 // a hole, and at Spacious it would be a three-row gap.
782 if !current.ends_blank {
783 return BlockSeparator {
784 rows,
785 railed: boundary == TranscriptBoundary::GroupedTool,
786 };
787 }
788 if boundary == TranscriptBoundary::GroupedTool {
789 // A railed spacer is not a blank row — the rail must continue.
790 return BlockSeparator { rows, railed: true };
791 }
792 rows = rows.saturating_sub(1);
793 BlockSeparator {
794 rows,
795 railed: false,
796 }
797 }
798
799 /// Adjacent tool cells share one rail only when they represent the same kind
800 /// of activity. Durable Work receipts are persisted state, not another
801 /// transient action, so crossing that semantic seam closes the current rail
802 /// even at compact density where no blank row is available.
803 fn same_tool_activity_group(current: &CachedCell, next: &CachedCell) -> bool {
804 current.is_tool_groupable && next.is_tool_groupable && current.kind == next.kind
805 }
806
807 fn transcript_boundary(
808 current: TranscriptBlockKind,
809 next: TranscriptBlockKind,
810 same_tool_group: bool,
811 ) -> TranscriptBoundary {
812 if same_tool_group {
813 debug_assert_eq!(current, next);
814 // Two distinct tool calls that happen to share a rail are still two
815 // things the reader has to tell apart. Give them a rail spacer.
816 return TranscriptBoundary::GroupedTool;
817 }
818
819 // A user block is the only unambiguous turn delimiter available to the
820 // renderer. Keep it distinct from direct tool execution too: models may
821 // legitimately move from a prompt straight into a tool without first
822 // emitting answer prose.
823 if current == TranscriptBlockKind::User || next == TranscriptBlockKind::User {
824 return TranscriptBoundary::Turn;
825 }
826
827 // Successive cells of the *same* model phase are one block split across
828 // cells — consecutive reasoning segments, or an answer whose settled and
829 // streaming halves live in separate cells. Blank rows appearing between
830 // those mid-stream would jitter the row budget, so keep them joined.
831 if current == next
832 && matches!(
833 current,
834 TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer
835 )
836 {
837 return TranscriptBoundary::Joined;
838 }
839
840 // Everything else — including reasoning handing off to answer prose — is
841 // a boundary the reader needs to see. Reasoning running straight into the
842 // answer with no blank row was the specific density complaint this matrix
843 // exists to answer.
844 TranscriptBoundary::Activity
845 }
846
847 const fn spacer_rows_for_boundary(
848 boundary: TranscriptBoundary,
849 spacing: TranscriptSpacing,
850 ) -> usize {
851 match (boundary, spacing) {
852 (TranscriptBoundary::Joined, _) => 0,
853 (
854 TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity,
855 TranscriptSpacing::Compact,
856 ) => 0,
857 (TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
858 (TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
859 BLOCK_SEPARATOR_ROWS
860 }
861 (TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => BLOCK_SEPARATOR_ROWS + 1,
862 }
863 }
864
865 fn previous_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
866 cells[..cell_index].iter().rev().find(|cell| !cell.is_empty)
867 }
868
869 fn next_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
870 cells
871 .get(cell_index + 1..)?
872 .iter()
873 .find(|cell| !cell.is_empty)
874 }
875
876 fn tool_group_rail(
877 cells: &[CachedCell],
878 cell_index: usize,
879 line_in_cell: usize,
880 rendered_line_count: usize,
881 ) -> Option<crate::tui::widgets::tool_card::CardRail> {
882 let cached = cells.get(cell_index)?;
883 if !cached.is_tool_groupable || rendered_line_count == 0 {
884 return None;
885 }
886
887 let previous_shares_group = previous_visible_cell(cells, cell_index)
888 .is_some_and(|previous| same_tool_activity_group(previous, cached));
889 let next_shares_group = next_visible_cell(cells, cell_index)
890 .is_some_and(|next| same_tool_activity_group(cached, next));
891 let first_line_in_group = !previous_shares_group && line_in_cell == 0;
892 let last_line_in_group = !next_shares_group && line_in_cell + 1 == rendered_line_count;
893
894 let rail = match (first_line_in_group, last_line_in_group) {
895 (true, true) if rendered_line_count == 1 => {
896 crate::tui::widgets::tool_card::CardRail::Single
897 }
898 (true, _) => crate::tui::widgets::tool_card::CardRail::Top,
899 (_, true) => crate::tui::widgets::tool_card::CardRail::Bottom,
900 _ => crate::tui::widgets::tool_card::CardRail::Middle,
901 };
902 Some(rail)
903 }
904
905 fn line_with_group_rail(
906 line: &Line<'static>,
907 rail: Option<crate::tui::widgets::tool_card::CardRail>,
908 max_width: usize,
909 ) -> Line<'static> {
910 let Some(rail) = rail else {
911 return line.clone();
912 };
913 let glyph = crate::tui::widgets::tool_card::rail_glyph(rail);
914 if glyph.is_empty() {
915 let mut rendered = line.clone();
916 rendered.spans = truncate_spans_to_width(rendered.spans, max_width);
917 return rendered;
918 }
919
920 let mut rendered = line.clone();
921 let mut spans = Vec::with_capacity(rendered.spans.len() + 1);
922 spans.push(Span::styled(
923 format!("{glyph} "),
924 Style::default().fg(crate::palette::TEXT_DIM),
925 ));
926 spans.extend(rendered.spans);
927 rendered.spans = truncate_spans_to_width(spans, max_width);
928 rendered
929 }
930
931 fn links_with_group_rail(
932 links: &[crate::tui::osc8::LineLink],
933 rail: Option<crate::tui::widgets::tool_card::CardRail>,
934 max_width: usize,
935 ) -> Vec<crate::tui::osc8::LineLink> {
936 let shift = rail
937 .map(crate::tui::widgets::tool_card::rail_glyph)
938 .filter(|glyph| !glyph.is_empty())
939 .map_or(0, |glyph| unicode_width::UnicodeWidthStr::width(glyph) + 1);
940 links
941 .iter()
942 .map(|link| link.shifted(shift))
943 .filter(|link| link.col_start < max_width)
944 .map(|mut link| {
945 link.col_end = link.col_end.min(max_width.saturating_sub(1));
946 link
947 })
948 .collect()
949 }
950
951 /// Return the display-column count of consecutive visual-only decorative
952 /// spans at the start of a rendered transcript line. Iterates through
953 /// leading spans matching either of two patterns:
954 ///
955 /// * Pattern A — span is `"<glyph>[<glyph>…]<space>"` where every character
956 /// except the trailing space is a rail-drawing character (e.g. `▏ `,
957 /// `▶ `, `⋮⋮ `). The entire span width is accumulated.
958 /// * Pattern B — span is `"<glyph>"` (1 drawing char) followed by a lone
959 /// space span `" "` (e.g. `●` then ` `, `▎` then ` `).
960 ///
961 /// Stops at the first non-matching span. Every decorated glyph used by the
962 /// TUI is a single display-column character, so char-count = display width.
963 ///
964 /// Returns `0` for lines whose first span is not a decorative prefix.
965 fn compute_rail_prefix_width(line: &Line<'static>) -> usize {
966 let spans = line.spans.as_slice();
967 let mut total = 0;
968 let mut i = 0;
969
970 while i < spans.len() {
971 let content = spans[i].content.as_ref();
972 let n_chars = content.chars().count();
973
974 // Pattern A — span "<glyph>[<glyph>…]<space>" (≥ 2 chars, trailing
975 // space, all preceding chars are drawing chars).
976 if n_chars >= 2
977 && content.ends_with(' ')
978 && content
979 .chars()
980 .take(n_chars.saturating_sub(1))
981 .all(is_rail_drawing_char)
982 {
983 total += n_chars;
984 i += 1;
985 continue;
986 }
987
988 // Pattern B — span "<glyph>" (1 drawing char) + next span " ".
989 if n_chars == 1
990 && content.chars().next().is_some_and(is_rail_drawing_char)
991 && spans.get(i + 1).is_some_and(|s| s.content.as_ref() == " ")
992 {
993 total += 2;
994 i += 2;
995 continue;
996 }
997
998 break;
999 }
1000
1001 total
1002 }
1003
1004 /// Characters that serve as decoration glyphs in the TUI left-rail and
1005 /// tool-header prefix system. All are single display-column characters.
1006 fn is_rail_drawing_char(ch: char) -> bool {
1007 matches!(
1008 ch,
1009 '\u{2500}'..='\u{257F}' // Box Drawing (╭ ╮ ╰ ╯ │ ╎ …)
1010 | '\u{2580}'..='\u{259F}' // Block Elements (▏ ▎ ▍ ▌ …)
1011 | '\u{25A0}'..='\u{25FF}' // Geometric Shapes (● ▶ ▷ ◆ ◐ …)
1012 | '\u{2022}' // • bullet (tool status / generic tool)
1013 | '\u{2026}' // … ellipsis (reasoning opener)
1014 | '\u{00B7}' // · middle dot (tool running symbol)
1015 | '\u{2315}' // ⌕ telephone recorder (find/search tool)
1016 | '\u{22EE}' // ⋮ vertical ellipsis (fanout/rlm tool)
1017 )
1018 }
1019
1020 fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
1021 if max_width == 0 || spans.is_empty() {
1022 return Vec::new();
1023 }
1024 let current_width: usize = spans
1025 .iter()
1026 .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
1027 .sum();
1028 if current_width <= max_width {
1029 return spans;
1030 }
1031
1032 let ellipsis = if max_width > 3 { "..." } else { "" };
1033 let content_budget = max_width.saturating_sub(ellipsis.len());
1034 let mut used = 0usize;
1035 let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty()));
1036 let mut last_style = Style::default();
1037
1038 'outer: for span in spans {
1039 last_style = span.style;
1040 let mut content = String::new();
1041 for ch in span.content.chars() {
1042 let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1043 if used + width > content_budget {
1044 break 'outer;
1045 }
1046 content.push(ch);
1047 used += width;
1048 }
1049 if !content.is_empty() {
1050 truncated.push(Span::styled(content, span.style));
1051 }
1052 }
1053
1054 if !ellipsis.is_empty() {
1055 truncated.push(Span::styled(ellipsis.to_string(), last_style));
1056 }
1057 truncated
1058 }
1059
1060 #[cfg(test)]
1061 #[path = "transcript/tests.rs"]
1062 mod tests;
1063
1063 lines RUST