返回 CodeWhale
transcript.rs
根目录 / crates / tui / src / tui / transcript.rs
1 //! Cached transcript rendering for the TUI.
2 //!
3 //! ## Per-cell revision caching
4 //!
5 //! A whole-transcript cache would re-wrap every cell whenever the streaming
6 //! Assistant mutates. Instead each index has a paired revision and unchanged
7 //! cells reuse their wrapped lines. Width, options, fold state, or destructive
8 //! identity changes bust the affected cache. Streaming therefore scales with
9 //! changed cells rather than history; width/option changes still bust all
10 //! cells because wrapping and visibility depend on them.
11
12 use std::collections::HashSet;
13 use std::sync::Arc;
14
15 use ratatui::{
16 style::Style,
17 text::{Line, Span},
18 };
19
20 use crate::tui::app::TranscriptSpacing;
21 use crate::tui::history::{
22 HistoryCell, ReasoningAction, ReasoningActionTarget, TranscriptActionOwner,
23 TranscriptRenderOptions,
24 };
25 use crate::tui::scrolling::TranscriptLineMeta;
26 use crate::tui::ui_text::CopyLineSeparator;
27 use codewhale_localization::{MessageId, tr};
28
29 /// Revision-bound render output. Arcs keep cache enumeration O(cells) instead
30 /// of deep-cloning every rendered line on ambient frames (issue #78); the
31 /// flattened output owns the only per-frame line copy.
32 #[derive(Debug)]
33 struct CachedCell {
34 /// Revision at which lines and metadata were rendered.
35 revision: u64,
36 /// Lines and aligned metadata; no inter-cell spacers. OSC 8 targets never
37 /// enter the ratatui cell buffer. Copy separators preserve source hard
38 /// newlines while allowing copy to remove visual soft-wrap breaks; prefix
39 /// widths strip visual rails. All four vectors remain index-aligned.
40 lines: Arc<Vec<Line<'static>>>,
41 links: Arc<Vec<Vec<crate::tui::osc8::LineLink>>>,
42 copy_separators: Arc<Vec<CopyLineSeparator>>,
43 copy_prefix_widths: Arc<Vec<usize>>,
44 /// Empty/blank facts keep spacing decisions independent of rendered text.
45 /// A block ending blank has paid for separation and must not get another.
46 is_empty: bool,
47 ends_blank: bool,
48 /// Semantic role and tool grouping feed the explicit boundary matrix, so
49 /// spacing never depends on strings, palette, terminal depth, or motion.
50 kind: TranscriptBlockKind,
51 is_tool_groupable: bool,
52 reasoning_action: Option<ReasoningAction>,
53 /// Only the changing Assistant cell carries incremental parser state;
54 /// stable lines stay above its replaceable-tail index.
55 incremental_markdown: Option<Box<crate::tui::markdown_render::IncrementalMarkdownRenderCache>>,
56 /// Settled form of the animation-mutated hot tail, restored on append so
57 /// the stable prefix is not reparsed.
58 hot_tail_original: Option<(usize, Line<'static>)>,
59 }
60
61 /// Proof that a live Assistant source only gained appended bytes. Visual-only
62 /// revision bumps can therefore reuse it; revisions are transformed exactly
63 /// as they are for `ensure_*`.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 pub(crate) struct StreamingSourceReceipt {
66 pub cell_index: usize,
67 pub from_revision: u64,
68 pub to_revision: u64,
69 pub content_len: usize,
70 }
71
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 enum TranscriptBlockKind {
74 User,
75 Reasoning,
76 Answer,
77 ToolAction,
78 DurableWork,
79 Notice,
80 }
81
82 impl TranscriptBlockKind {
83 fn for_cell(cell: &HistoryCell) -> Self {
84 match cell {
85 HistoryCell::User { .. } => Self::User,
86 HistoryCell::Thinking { .. } => Self::Reasoning,
87 HistoryCell::Assistant { .. } => Self::Answer,
88 HistoryCell::Tool(tool) if tool.is_durable_work_receipt() => Self::DurableWork,
89 HistoryCell::Tool(_) | HistoryCell::SubAgent(_) => Self::ToolAction,
90 HistoryCell::System { .. }
91 | HistoryCell::Error { .. }
92 | HistoryCell::Automation(_)
93 | HistoryCell::ArchivedContext { .. } => Self::Notice,
94 }
95 }
96 }
97
98 /// A visible boundary costs one row, because terminal separator rows displace
99 /// content and two rows add no extra legibility. Only an opt-in spacious turn
100 /// costs two.
101 const BLOCK_SEPARATOR_ROWS: usize = 1;
102
103 /// Complete transcript spacing vocabulary; no blanket per-cell padding.
104 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
105 enum TranscriptBoundary {
106 /// Successive cells are literally one reasoning/answer phase.
107 Joined,
108 /// Adjacent tool cells share one compact rail with no per-call padding.
109 GroupedTool,
110 /// Transition between response phases, tools, Work, or notices.
111 Activity,
112 /// Human turn boundary; visible even at compact density.
113 Turn,
114 }
115
116 #[derive(Debug)]
117 pub struct TranscriptViewCache {
118 width: u16,
119 options: TranscriptRenderOptions,
120 /// Fold state affects rendering without changing cell revisions.
121 folded_cells: HashSet<usize>,
122 /// Index of the newest durable Work receipt (checklist / plan snapshot)
123 /// in the last pass. When a new one lands the previous newest must
124 /// re-render collapsed, and its revision alone would not say so.
125 newest_work_receipt: Option<usize>,
126 /// Index of the newest user turn in the last pass. Only it carries the
127 /// elevated-surface background; when a new prompt lands the previous
128 /// newest must re-render on the bare ground, and its revision alone
129 /// would not say so.
130 newest_user_turn: Option<usize>,
131 reasoning_action_target: Option<ReasoningActionTarget>,
132 transcript_action_owner: Option<TranscriptActionOwner>,
133 identity_epoch: Option<u64>,
134 reasoning_action_rendered_cell: Option<usize>,
135 /// Per-cell renders plus flattened lines and index-aligned link/selection
136 /// metadata. Rail prefix widths strip decoration without glyph guessing
137 /// (#1163); deterministic counters measure the production cache path.
138 per_cell: Vec<CachedCell>,
139 lines: Vec<Line<'static>>,
140 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
141 line_meta: Vec<TranscriptLineMeta>,
142 /// Visual-only prefix widths let selection copy strip rails without glyph guesses.
143 rail_prefix_widths: Vec<usize>,
144 streaming_source_receipt: Option<StreamingSourceReceipt>,
145 streaming_lines_reflattened: u64,
146 streaming_meta_rows_scanned: u64,
147 }
148
149 impl TranscriptViewCache {
150 #[must_use]
151 pub fn new() -> Self {
152 Self {
153 width: 0,
154 options: TranscriptRenderOptions::default(),
155 folded_cells: HashSet::new(),
156 newest_work_receipt: None,
157 newest_user_turn: None,
158 reasoning_action_target: None,
159 transcript_action_owner: None,
160 identity_epoch: None,
161 reasoning_action_rendered_cell: None,
162 per_cell: Vec::new(),
163 lines: Vec::new(),
164 line_links: Vec::new(),
165 line_meta: Vec::new(),
166 rail_prefix_widths: Vec::new(),
167 streaming_source_receipt: None,
168 streaming_lines_reflattened: 0,
169 streaming_meta_rows_scanned: 0,
170 }
171 }
172
173 pub(crate) fn set_streaming_source_receipt(&mut self, receipt: Option<StreamingSourceReceipt>) {
174 self.streaming_source_receipt = receipt;
175 }
176
177 pub(crate) fn take_transcript_action(
178 &mut self,
179 ) -> Option<(TranscriptActionOwner, Option<ReasoningActionTarget>)> {
180 Some((
181 self.transcript_action_owner.take()?,
182 self.reasoning_action_target.take(),
183 ))
184 }
185
186 pub(crate) fn retarget(
187 &mut self,
188 owner: Option<TranscriptActionOwner>,
189 original_index_map: Option<&[usize]>,
190 ) {
191 if let Some(first) = self.set_action_owner(owner, original_index_map) {
192 self.flatten_from(self.options.spacing, first.saturating_sub(1));
193 }
194 }
195
196 /// Convenience entry point; the live path uses shards to avoid cloning.
197 #[cfg_attr(not(test), expect(dead_code))]
198 pub fn ensure(
199 &mut self,
200 cells: &[HistoryCell],
201 cell_revisions: &[u64],
202 width: u16,
203 options: TranscriptRenderOptions,
204 ) {
205 self.ensure_split(
206 &[cells],
207 cell_revisions,
208 width,
209 options,
210 &HashSet::new(),
211 None,
212 None,
213 );
214 }
215
216 /// Ensure logically concatenated shards without cloning history plus the
217 /// active tail each frame. Explicit cache inputs keep identity/fold/map indices
218 /// attached to original virtual cells even when filtering changes positions.
219 #[allow(clippy::too_many_arguments)]
220 pub fn ensure_split(
221 &mut self,
222 cell_shards: &[&[HistoryCell]],
223 cell_revisions: &[u64],
224 width: u16,
225 options: TranscriptRenderOptions,
226 folded_cells: &HashSet<usize>,
227 original_index_map: Option<&[usize]>,
228 action_owner: Option<TranscriptActionOwner>,
229 ) {
230 let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
231 self.ensure_iter(
232 total_cells,
233 cell_shards.iter().flat_map(|shard| shard.iter()),
234 cell_revisions,
235 width,
236 options,
237 folded_cells,
238 original_index_map,
239 action_owner,
240 );
241 }
242
243 /// Ensure an already-filtered list plus its original-index map. Collapse
244 /// may skip cells or substitute tool summaries, so the map is the contract
245 /// that keeps fold/action state attached to original virtual indices
246 /// rather than positional rendered indices (#3896).
247 #[allow(clippy::too_many_arguments)]
248 pub fn ensure_filtered(
249 &mut self,
250 cells: &[&HistoryCell],
251 cell_revisions: &[u64],
252 width: u16,
253 options: TranscriptRenderOptions,
254 folded_cells: &HashSet<usize>,
255 original_index_map: Option<&[usize]>,
256 action_owner: Option<TranscriptActionOwner>,
257 ) {
258 self.ensure_iter(
259 cells.len(),
260 cells.iter().copied(),
261 cell_revisions,
262 width,
263 options,
264 folded_cells,
265 original_index_map,
266 action_owner,
267 );
268 }
269
270 #[allow(clippy::too_many_arguments)]
271 fn ensure_iter<'a>(
272 &mut self,
273 total_cells: usize,
274 cells: impl Iterator<Item = &'a HistoryCell>,
275 cell_revisions: &[u64],
276 width: u16,
277 options: TranscriptRenderOptions,
278 folded_cells: &HashSet<usize>,
279 original_index_map: Option<&[usize]>,
280 action_owner: Option<TranscriptActionOwner>,
281 ) {
282 let identity_changed = action_owner.is_some_and(|owner| {
283 self.identity_epoch
284 .is_some_and(|epoch| epoch != owner.identity_epoch)
285 });
286 if let Some(owner) = action_owner {
287 self.identity_epoch = Some(owner.identity_epoch);
288 }
289 self.transcript_action_owner = action_owner;
290 let layout_changed = self.width != width || self.options != options || identity_changed;
291 let folded_changed = self.folded_cells != *folded_cells;
292 // `todo_write` replaces the whole list on every call, so only the
293 // newest snapshot is worth a full card (#5871). When a new one lands
294 // the previous newest must re-render collapsed; its own revision has
295 // not changed, so supersession has to invalidate the cache itself.
296 let cells: Vec<&'a HistoryCell> = cells.collect();
297 let newest_work_receipt = cells
298 .iter()
299 .copied()
300 .enumerate()
301 .filter(|(_, cell)| {
302 matches!(cell, HistoryCell::Tool(tool) if tool.is_durable_work_receipt())
303 })
304 .map(|(idx, _)| idx)
305 .next_back();
306 let work_receipt_changed = self.newest_work_receipt != newest_work_receipt;
307 self.newest_work_receipt = newest_work_receipt;
308 // Same supersession shape as the work receipt: the highlight lives on
309 // the newest user turn only, so a newly sent prompt must un-highlight
310 // its predecessor even though that cell's own revision never moved.
311 let newest_user_turn = cells
312 .iter()
313 .copied()
314 .enumerate()
315 .filter(|(_, cell)| matches!(cell, HistoryCell::User { .. }))
316 .map(|(idx, _)| idx)
317 .next_back();
318 let user_turn_changed = self.newest_user_turn != newest_user_turn;
319 self.newest_user_turn = newest_user_turn;
320 if layout_changed || folded_changed || work_receipt_changed || user_turn_changed {
321 self.per_cell.clear();
322 }
323 self.width = width;
324 self.options = options;
325 self.folded_cells = folded_cells.clone();
326 let previous_rendered_target = self.reasoning_action_rendered_cell;
327
328 // Same-index revision reuse is intentional: insert/remove shifts must
329 // cold-render rather than attach cached lines to another cell. The
330 // destructive identity epoch also prevents revision reuse after an
331 // index is removed and later filled by a different cell.
332 let old_len = self.per_cell.len();
333 let mut any_dirty = layout_changed
334 || folded_changed
335 || work_receipt_changed
336 || user_turn_changed
337 || old_len != total_cells;
338 let mut first_dirty: Option<usize> = if old_len != total_cells {
339 Some(old_len.min(total_cells))
340 } else {
341 None
342 };
343
344 let mut old_per_cell: Vec<Option<CachedCell>> = std::mem::take(&mut self.per_cell)
345 .into_iter()
346 .map(Some)
347 .collect();
348 let mut new_per_cell: Vec<CachedCell> = Vec::with_capacity(total_cells);
349 let revisions_match = cell_revisions.len() == total_cells;
350 let mut dirty_cells = 0usize;
351 let mut streaming_tail_update = None;
352 let mut newest_reasoning = None;
353
354 let mut idx: usize = 0;
355 for cell in cells {
356 let current_rev = if revisions_match {
357 cell_revisions[idx]
358 } else {
359 // A mismatched revision vector is never trusted.
360 u64::MAX
361 };
362 let original_idx = original_index_map
363 .map(|m| *m.get(idx).unwrap_or(&idx))
364 .unwrap_or(idx);
365 let is_layout_aware_preview = idx + 1 == total_cells;
366 let was_layout_aware_preview = idx + 1 == old_len;
367 let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
368 let render_width = if is_tool_groupable {
369 width.saturating_sub(2).max(1)
370 } else {
371 width
372 };
373 let folded = folded_cells.contains(&original_idx);
374 if is_layout_aware_preview && matches!(cell, HistoryCell::Thinking { .. }) {
375 newest_reasoning = Some((idx, cell, current_rev, folded));
376 }
377 if !layout_changed
378 && is_layout_aware_preview == was_layout_aware_preview
379 && !(is_layout_aware_preview && any_dirty)
380 && revisions_match
381 && old_per_cell
382 .get(idx)
383 .and_then(Option::as_ref)
384 .is_some_and(|prev| prev.revision == current_rev)
385 {
386 new_per_cell.push(
387 old_per_cell[idx]
388 .take()
389 .expect("cached cell checked as present"),
390 );
391 idx += 1;
392 continue;
393 }
394
395 any_dirty = true;
396 dirty_cells = dirty_cells.saturating_add(1);
397 first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx)));
398
399 if matches!(
400 cell,
401 HistoryCell::Assistant {
402 streaming: true,
403 ..
404 }
405 ) {
406 let mut cached = old_per_cell
407 .get_mut(idx)
408 .and_then(Option::take)
409 .unwrap_or_else(|| CachedCell {
410 revision: current_rev,
411 lines: Arc::new(Vec::new()),
412 links: Arc::new(Vec::new()),
413 copy_separators: Arc::new(Vec::new()),
414 copy_prefix_widths: Arc::new(Vec::new()),
415 is_empty: true,
416 ends_blank: false,
417 kind: TranscriptBlockKind::Answer,
418 is_tool_groupable: false,
419 reasoning_action: None,
420 incremental_markdown: Some(Box::default()),
421 hot_tail_original: None,
422 });
423 if let Some((line_index, original)) = cached.hot_tail_original.take()
424 && let Some(line) = Arc::make_mut(&mut cached.lines).get_mut(line_index)
425 {
426 *line = original;
427 }
428 let content_len = match cell {
429 HistoryCell::Assistant { content, .. } => content.len(),
430 _ => 0,
431 };
432 let verified_append = self.streaming_source_receipt.is_some_and(|receipt| {
433 receipt.cell_index == original_idx
434 && receipt.from_revision == cached.revision
435 && receipt.to_revision == current_rev
436 && receipt.content_len == content_len
437 });
438 let incremental = cached.incremental_markdown.get_or_insert_with(Box::default);
439 let replace_from = cell
440 .update_incremental_streaming_render(
441 render_width,
442 options,
443 verified_append,
444 incremental,
445 Arc::make_mut(&mut cached.lines),
446 Arc::make_mut(&mut cached.links),
447 Arc::make_mut(&mut cached.copy_separators),
448 Arc::make_mut(&mut cached.copy_prefix_widths),
449 )
450 .expect("streaming Assistant matched above");
451 let cached_lines = Arc::make_mut(&mut cached.lines);
452 let last_index = cached_lines.len().checked_sub(1);
453 if let Some((index, last)) = last_index
454 .and_then(|index| cached_lines.get_mut(index).map(|line| (index, line)))
455 {
456 cached.hot_tail_original = Some((index, last.clone()));
457 crate::tui::history::apply_hot_tail_to_line(last, options.low_motion);
458 }
459 cached.revision = current_rev;
460 cached.is_empty = cached.lines.is_empty();
461 cached.ends_blank = last_line_is_blank(&cached.lines);
462 cached.kind = TranscriptBlockKind::Answer;
463 cached.is_tool_groupable = false;
464 cached.reasoning_action = None;
465 // Hot-tail styling can affect the preceding settled line.
466 streaming_tail_update = Some((idx, replace_from.saturating_sub(1)));
467 new_per_cell.push(cached);
468 idx += 1;
469 continue;
470 }
471
472 let mut cell_options = options;
473 cell_options.reasoning_preview_extra_lines = 0;
474 cell_options.superseded_work_receipt = matches!(
475 cell,
476 HistoryCell::Tool(tool) if tool.is_durable_work_receipt()
477 ) && newest_work_receipt
478 .is_some_and(|newest| newest != idx);
479 cell_options.newest_user_turn = matches!(cell, HistoryCell::User { .. })
480 && newest_user_turn.is_some_and(|newest| newest == idx);
481 new_per_cell.push(render_cached_cell(
482 cell,
483 current_rev,
484 width,
485 cell_options,
486 folded,
487 ));
488 idx += 1;
489 }
490
491 self.per_cell = new_per_cell;
492 if let Some(target_first) = self.set_action_owner(action_owner, original_index_map) {
493 any_dirty = true;
494 first_dirty = Some(first_dirty.map_or(target_first, |dirty| dirty.min(target_first)));
495 }
496
497 if !any_dirty {
498 return;
499 }
500
501 if !layout_changed
502 && !folded_changed
503 && previous_rendered_target == self.reasoning_action_rendered_cell
504 && old_len == total_cells
505 && dirty_cells == 1
506 && let Some((cell_index, line_from)) = streaming_tail_update
507 && cell_index + 1 == total_cells
508 && self.flatten_streaming_tail(cell_index, line_from)
509 {
510 return;
511 }
512
513 let mut rebuild_from = if layout_changed {
514 0
515 } else {
516 first_dirty.unwrap_or(0).saturating_sub(1)
517 };
518 // A hidden cell has no line boundary at which to truncate. Rebuild from
519 // a visible predecessor so appearance/disappearance cannot leave its
520 // old spacer or the following cell's boundary behind.
521 while rebuild_from > 0
522 && self
523 .per_cell
524 .get(rebuild_from)
525 .is_some_and(|cell| cell.is_empty)
526 {
527 rebuild_from -= 1;
528 }
529 self.flatten_from(options.spacing, rebuild_from);
530
531 let Some(viewport_lines) = options.reasoning_preview_viewport_lines else {
532 return;
533 };
534 let free_rows = viewport_lines.saturating_sub(self.total_lines());
535 let Some((idx, cell, current_rev, folded)) = newest_reasoning.filter(|_| free_rows > 0)
536 else {
537 return;
538 };
539 let mut expanded_options = options;
540 expanded_options.reasoning_preview_extra_lines = free_rows;
541 let expanded = render_cached_cell(cell, current_rev, width, expanded_options, folded);
542 if expanded.lines == self.per_cell[idx].lines {
543 return;
544 }
545 self.per_cell[idx] = expanded;
546 self.set_action_owner(action_owner, original_index_map);
547 self.flatten_from(options.spacing, idx.saturating_sub(1));
548 }
549
550 fn set_action_owner(
551 &mut self,
552 owner: Option<TranscriptActionOwner>,
553 original_index_map: Option<&[usize]>,
554 ) -> Option<usize> {
555 self.transcript_action_owner = owner;
556 let rendered = owner.and_then(|owner| match original_index_map {
557 Some(map) => map.iter().position(|&index| index == owner.cell_index),
558 None => (owner.cell_index < self.per_cell.len()).then_some(owner.cell_index),
559 });
560 self.reasoning_action_target = owner.and_then(|owner| {
561 Some(ReasoningActionTarget {
562 owner,
563 action: self.per_cell.get(rendered?)?.reasoning_action?,
564 })
565 });
566 let next = self
567 .reasoning_action_target
568 .filter(|target| target.action == ReasoningAction::Expand)
569 .and(rendered);
570 let previous = self.reasoning_action_rendered_cell;
571 self.reasoning_action_rendered_cell = next;
572 (previous != next).then(|| previous.into_iter().chain(next).min().unwrap_or(0))
573 }
574
575 fn flatten(&mut self, spacing: TranscriptSpacing) {
576 self.lines.clear();
577 self.line_links.clear();
578 self.line_meta.clear();
579 self.rail_prefix_widths.clear();
580 self.append_flattened_cells(spacing, 0);
581 }
582
583 /// Rebuild only a suffix while preserving its predecessor spacer.
584 /// Streaming normally changes only the active tail; rebuilding from the
585 /// previous cell preserves boundary correctness without flattening all
586 /// transcript lines on every token chunk.
587 fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) {
588 if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() {
589 self.flatten(spacing);
590 return;
591 }
592
593 let truncate_at = self
594 .line_meta
595 .iter()
596 .position(|meta| match meta {
597 TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell,
598 TranscriptLineMeta::Spacer { .. } => false,
599 })
600 .unwrap_or(self.lines.len());
601 self.lines.truncate(truncate_at);
602 self.line_links.truncate(truncate_at);
603 self.line_meta.truncate(truncate_at);
604 self.rail_prefix_widths.truncate(truncate_at);
605 self.append_flattened_cells(spacing, first_cell);
606 }
607
608 /// Replace only the final streaming cell's changing Markdown tail. Search
609 /// backward from the old hot tail, so append-only updates scan the small
610 /// replaceable suffix; return false when no canonical boundary exists.
611 fn flatten_streaming_tail(&mut self, cell_index: usize, line_from: usize) -> bool {
612 let mut truncate_at = None;
613 for (index, meta) in self.line_meta.iter().enumerate().rev() {
614 self.streaming_meta_rows_scanned = self.streaming_meta_rows_scanned.saturating_add(1);
615 if matches!(
616 meta,
617 TranscriptLineMeta::CellLine {
618 cell_index: candidate,
619 line_in_cell,
620 ..
621 } if *candidate == cell_index && *line_in_cell == line_from
622 ) {
623 truncate_at = Some(index);
624 break;
625 }
626 }
627 let Some(truncate_at) = truncate_at else {
628 return false;
629 };
630 self.lines.truncate(truncate_at);
631 self.line_links.truncate(truncate_at);
632 self.line_meta.truncate(truncate_at);
633 self.rail_prefix_widths.truncate(truncate_at);
634
635 let Some(cached) = self.per_cell.get(cell_index) else {
636 return false;
637 };
638 let rendered_line_count = cached.lines.len();
639 for line_in_cell in line_from..rendered_line_count {
640 let line = &cached.lines[line_in_cell];
641 let rail = tool_group_rail(
642 self.per_cell.as_slice(),
643 cell_index,
644 line_in_cell,
645 rendered_line_count,
646 );
647 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
648 let final_links = links_with_group_rail(
649 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
650 rail,
651 usize::from(self.width),
652 );
653 self.rail_prefix_widths
654 .push(compute_rail_prefix_width(&final_line));
655 self.lines.push(final_line);
656 self.line_links.push(final_links);
657 self.line_meta.push(TranscriptLineMeta::CellLine {
658 cell_index,
659 line_in_cell,
660 copy_prefix_width: cached
661 .copy_prefix_widths
662 .get(line_in_cell)
663 .copied()
664 .unwrap_or(0),
665 copy_separator_after: cached
666 .copy_separators
667 .get(line_in_cell)
668 .copied()
669 .unwrap_or(CopyLineSeparator::Newline),
670 });
671 self.streaming_lines_reflattened = self.streaming_lines_reflattened.saturating_add(1);
672 }
673 true
674 }
675
676 fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) {
677 let hint = format!(
678 "Space:{}",
679 tr(self.options.locale, MessageId::TranscriptReasoningExpand)
680 );
681 let hint_fits = unicode_width::UnicodeWidthStr::width(hint.as_str())
682 <= usize::from(self.width).saturating_sub(2);
683 for (cell_index, cached) in self.per_cell.iter().enumerate().skip(start_cell) {
684 if cached.is_empty {
685 continue;
686 }
687 let rendered_line_count = cached.lines.len();
688 for (line_in_cell, line) in cached.lines.iter().enumerate() {
689 let is_hint = self.reasoning_action_rendered_cell == Some(cell_index)
690 && line_in_cell + 1 == rendered_line_count
691 && hint_fits;
692 let hinted = is_hint.then(|| {
693 let mut hinted = line.clone();
694 if let Some(span) = hinted.spans.last_mut() {
695 span.content = hint.clone().into();
696 }
697 hinted
698 });
699 let line = hinted.as_ref().unwrap_or(line);
700 let rail = tool_group_rail(
701 self.per_cell.as_slice(),
702 cell_index,
703 line_in_cell,
704 rendered_line_count,
705 );
706 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
707 let final_links = if is_hint {
708 Vec::new()
709 } else {
710 links_with_group_rail(
711 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
712 rail,
713 usize::from(self.width),
714 )
715 };
716 let rail_prefix_width = compute_rail_prefix_width(&final_line);
717 let copy_prefix_width = if is_hint {
718 final_line.width().saturating_sub(rail_prefix_width)
719 } else {
720 cached
721 .copy_prefix_widths
722 .get(line_in_cell)
723 .copied()
724 .unwrap_or(0)
725 };
726 self.rail_prefix_widths.push(rail_prefix_width);
727 self.lines.push(final_line);
728 self.line_links.push(final_links);
729 self.line_meta.push(TranscriptLineMeta::CellLine {
730 cell_index,
731 line_in_cell,
732 copy_prefix_width,
733 copy_separator_after: cached
734 .copy_separators
735 .get(line_in_cell)
736 .copied()
737 .unwrap_or(CopyLineSeparator::Newline),
738 });
739 self.streaming_lines_reflattened =
740 self.streaming_lines_reflattened.saturating_add(1);
741 }
742
743 if let Some(next) = next_visible_cell(&self.per_cell, cell_index) {
744 let separator = separator_between(cached, next, spacing);
745 let rail = separator
746 .railed
747 .then_some(crate::tui::widgets::tool_card::CardRail::Middle);
748 for _ in 0..separator.rows {
749 let line = line_with_group_rail(&Line::from(""), rail, usize::from(self.width));
750 let copy_prefix_width = compute_rail_prefix_width(&line);
751 self.rail_prefix_widths.push(copy_prefix_width);
752 self.lines.push(line);
753 self.line_links.push(Vec::new());
754 self.line_meta
755 .push(TranscriptLineMeta::Spacer { copy_prefix_width });
756 }
757 }
758 }
759 }
760
761 #[must_use]
762 pub fn lines(&self) -> &[Line<'static>] {
763 &self.lines
764 }
765
766 #[must_use]
767 pub fn line_links(&self) -> &[Vec<crate::tui::osc8::LineLink>] {
768 &self.line_links
769 }
770
771 #[must_use]
772 pub fn line_meta(&self) -> &[TranscriptLineMeta] {
773 &self.line_meta
774 }
775
776 #[must_use]
777 pub fn total_lines(&self) -> usize {
778 self.lines.len()
779 }
780
781 #[must_use]
782 pub fn rail_prefix_width(&self, line_index: usize) -> usize {
783 self.rail_prefix_widths
784 .get(line_index)
785 .copied()
786 .unwrap_or(0)
787 }
788 }
789
790 fn render_cached_cell(
791 cell: &HistoryCell,
792 revision: u64,
793 width: u16,
794 options: TranscriptRenderOptions,
795 folded: bool,
796 ) -> CachedCell {
797 let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
798 let render_width = if is_tool_groupable {
799 width.saturating_sub(2).max(1)
800 } else {
801 width
802 };
803 let (rendered, reasoning_action) =
804 cell.lines_with_copy_metadata_folded(render_width, options, folded);
805 let mut lines = Vec::with_capacity(rendered.len());
806 let mut links = Vec::with_capacity(rendered.len());
807 let mut copy_separators = Vec::with_capacity(rendered.len());
808 let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
809 for rendered_line in rendered {
810 let mut line = rendered_line.line;
811 if is_tool_groupable {
812 strip_cell_local_tool_rail(&mut line);
813 }
814 lines.push(line);
815 links.push(rendered_line.links);
816 copy_prefix_widths.push(rendered_line.copy_prefix_width);
817 copy_separators.push(rendered_line.copy_separator_after);
818 }
819 if reasoning_action == Some(ReasoningAction::Expand)
820 && let Some(line) = lines.last()
821 {
822 let prefix = line.width().saturating_sub(compute_rail_prefix_width(line));
823 *copy_prefix_widths
824 .last_mut()
825 .expect("reasoning affordance line") = prefix;
826 links.last_mut().expect("reasoning affordance line").clear();
827 }
828 let is_empty = lines.is_empty();
829 let ends_blank = last_line_is_blank(&lines);
830 CachedCell {
831 revision,
832 lines: Arc::new(lines),
833 links: Arc::new(links),
834 copy_separators: Arc::new(copy_separators),
835 copy_prefix_widths: Arc::new(copy_prefix_widths),
836 is_empty,
837 ends_blank,
838 kind: TranscriptBlockKind::for_cell(cell),
839 is_tool_groupable,
840 reasoning_action,
841 incremental_markdown: None,
842 hot_tail_original: None,
843 }
844 }
845
846 /// Strip the cell-local rail because the flat cache owns cross-cell grouping;
847 /// retaining both produces doubled prefixes such as `╭ ╭`.
848 fn strip_cell_local_tool_rail(line: &mut Line<'static>) {
849 if line
850 .spans
851 .first()
852 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
853 {
854 line.spans.remove(0);
855 }
856 }
857
858 fn last_line_is_blank(lines: &[Line<'static>]) -> bool {
859 lines
860 .last()
861 .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
862 }
863
864 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
865 struct BlockSeparator {
866 rows: usize,
867 railed: bool,
868 }
869
870 fn separator_between(
871 current: &CachedCell,
872 next: &CachedCell,
873 spacing: TranscriptSpacing,
874 ) -> BlockSeparator {
875 let boundary = transcript_boundary(
876 current.kind,
877 next.kind,
878 same_tool_activity_group(current, next),
879 );
880 let mut rows = spacer_rows_for_boundary(boundary, spacing);
881 // A cell ending blank already paid for separation; a grouped tool rail is
882 // not blank and must remain continuous.
883 if !current.ends_blank {
884 return BlockSeparator {
885 rows,
886 railed: boundary == TranscriptBoundary::GroupedTool,
887 };
888 }
889 if boundary == TranscriptBoundary::GroupedTool {
890 return BlockSeparator { rows, railed: true };
891 }
892 rows = rows.saturating_sub(1);
893 BlockSeparator {
894 rows,
895 railed: false,
896 }
897 }
898
899 fn same_tool_activity_group(current: &CachedCell, next: &CachedCell) -> bool {
900 // Durable Work receipts are persisted state, not another transient tool
901 // action; crossing that semantic seam closes the rail even at compact.
902 current.is_tool_groupable && next.is_tool_groupable && current.kind == next.kind
903 }
904
905 fn transcript_boundary(
906 current: TranscriptBlockKind,
907 next: TranscriptBlockKind,
908 same_tool_group: bool,
909 ) -> TranscriptBoundary {
910 if same_tool_group {
911 debug_assert_eq!(current, next);
912 // Distinct calls sharing a rail are one compact activity group. The
913 // rail itself carries the grouping; padding every low-level call would
914 // recreate the density problem this boundary matrix exists to solve.
915 return TranscriptBoundary::GroupedTool;
916 }
917
918 // User cells are the only unambiguous turn delimiter. Keep prompt→tool
919 // distinct too: a model need not emit answer prose before acting.
920 if current == TranscriptBlockKind::User || next == TranscriptBlockKind::User {
921 return TranscriptBoundary::Turn;
922 }
923
924 // Successive reasoning or answer cells are one phase split across cells;
925 // a blank row there would jitter the row budget during streaming.
926 if current == next
927 && matches!(
928 current,
929 TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer
930 )
931 {
932 return TranscriptBoundary::Joined;
933 }
934
935 // Every other phase transition is reader-visible. In particular,
936 // reasoning running into final prose was the density bug this matrix
937 // exists to prevent.
938 TranscriptBoundary::Activity
939 }
940
941 const fn spacer_rows_for_boundary(
942 boundary: TranscriptBoundary,
943 spacing: TranscriptSpacing,
944 ) -> usize {
945 match (boundary, spacing) {
946 (TranscriptBoundary::Joined | TranscriptBoundary::GroupedTool, _) => 0,
947 (TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0,
948 (TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
949 (TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
950 BLOCK_SEPARATOR_ROWS
951 }
952 (TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => BLOCK_SEPARATOR_ROWS + 1,
953 }
954 }
955
956 fn previous_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
957 cells[..cell_index].iter().rev().find(|cell| !cell.is_empty)
958 }
959
960 fn next_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
961 cells
962 .get(cell_index + 1..)?
963 .iter()
964 .find(|cell| !cell.is_empty)
965 }
966
967 fn tool_group_rail(
968 cells: &[CachedCell],
969 cell_index: usize,
970 line_in_cell: usize,
971 rendered_line_count: usize,
972 ) -> Option<crate::tui::widgets::tool_card::CardRail> {
973 let cached = cells.get(cell_index)?;
974 if !cached.is_tool_groupable || rendered_line_count == 0 {
975 return None;
976 }
977
978 let previous_shares_group = previous_visible_cell(cells, cell_index)
979 .is_some_and(|previous| same_tool_activity_group(previous, cached));
980 let next_shares_group = next_visible_cell(cells, cell_index)
981 .is_some_and(|next| same_tool_activity_group(cached, next));
982 let first_line_in_group = !previous_shares_group && line_in_cell == 0;
983 let last_line_in_group = !next_shares_group && line_in_cell + 1 == rendered_line_count;
984
985 let rail = match (first_line_in_group, last_line_in_group) {
986 (true, true) if rendered_line_count == 1 => {
987 crate::tui::widgets::tool_card::CardRail::Single
988 }
989 (true, _) => crate::tui::widgets::tool_card::CardRail::Top,
990 (_, true) => crate::tui::widgets::tool_card::CardRail::Bottom,
991 _ => crate::tui::widgets::tool_card::CardRail::Middle,
992 };
993 Some(rail)
994 }
995
996 fn line_with_group_rail(
997 line: &Line<'static>,
998 rail: Option<crate::tui::widgets::tool_card::CardRail>,
999 max_width: usize,
1000 ) -> Line<'static> {
1001 let Some(rail) = rail else {
1002 return line.clone();
1003 };
1004 let glyph = crate::tui::widgets::tool_card::rail_glyph(rail);
1005 if glyph.is_empty() {
1006 let mut rendered = line.clone();
1007 rendered.spans = truncate_spans_to_width(rendered.spans, max_width);
1008 return rendered;
1009 }
1010
1011 let mut rendered = line.clone();
1012 let mut spans = Vec::with_capacity(rendered.spans.len() + 1);
1013 spans.push(Span::styled(
1014 format!("{glyph} "),
1015 Style::default().fg(codewhale_palette::TEXT_DIM),
1016 ));
1017 spans.extend(rendered.spans);
1018 rendered.spans = truncate_spans_to_width(spans, max_width);
1019 rendered
1020 }
1021
1022 fn links_with_group_rail(
1023 links: &[crate::tui::osc8::LineLink],
1024 rail: Option<crate::tui::widgets::tool_card::CardRail>,
1025 max_width: usize,
1026 ) -> Vec<crate::tui::osc8::LineLink> {
1027 let shift = rail
1028 .map(crate::tui::widgets::tool_card::rail_glyph)
1029 .filter(|glyph| !glyph.is_empty())
1030 .map_or(0, |glyph| unicode_width::UnicodeWidthStr::width(glyph) + 1);
1031 links
1032 .iter()
1033 .map(|link| link.shifted(shift))
1034 .filter(|link| link.col_start < max_width)
1035 .map(|mut link| {
1036 link.col_end = link.col_end.min(max_width.saturating_sub(1));
1037 link
1038 })
1039 .collect()
1040 }
1041
1042 /// Return the display-column count of consecutive visual-only decorative
1043 /// spans at the start of a rendered transcript line. Iterates through
1044 /// leading spans matching either of two patterns:
1045 ///
1046 /// * Pattern A — span is `"<glyph>[<glyph>…]<space>"` where every character
1047 /// except the trailing space is a rail-drawing character (e.g. `▏ `,
1048 /// `▶ `, `⋮⋮ `). The entire span width is accumulated.
1049 /// * Pattern B — span is `"<glyph>"` (1 drawing char) followed by a lone
1050 /// space span `" "` (e.g. `●` then ` `, `▎` then ` `).
1051 ///
1052 /// Stops at the first non-matching span. Every decorated glyph used by the
1053 /// TUI is a single display-column character, so char-count = display width.
1054 ///
1055 /// Returns `0` for lines whose first span is not a decorative prefix.
1056 fn compute_rail_prefix_width(line: &Line<'static>) -> usize {
1057 let spans = line.spans.as_slice();
1058 let mut total = 0;
1059 let mut i = 0;
1060
1061 while i < spans.len() {
1062 let content = spans[i].content.as_ref();
1063 let n_chars = content.chars().count();
1064
1065 // Pattern A — span "<glyph>[<glyph>…]<space>" (≥ 2 chars, trailing
1066 // space, all preceding chars are drawing chars).
1067 if n_chars >= 2
1068 && content.ends_with(' ')
1069 && content
1070 .chars()
1071 .take(n_chars.saturating_sub(1))
1072 .all(is_rail_drawing_char)
1073 {
1074 total += n_chars;
1075 i += 1;
1076 continue;
1077 }
1078
1079 // Pattern B — span "<glyph>" (1 drawing char) + next span " ".
1080 if n_chars == 1
1081 && content.chars().next().is_some_and(is_rail_drawing_char)
1082 && spans.get(i + 1).is_some_and(|s| s.content.as_ref() == " ")
1083 {
1084 total += 2;
1085 i += 2;
1086 continue;
1087 }
1088
1089 break;
1090 }
1091
1092 total
1093 }
1094
1095 /// Characters that serve as decoration glyphs in the TUI left-rail and
1096 /// tool-header prefix system. All are single display-column characters.
1097 fn is_rail_drawing_char(ch: char) -> bool {
1098 matches!(
1099 ch,
1100 '\u{2500}'..='\u{257F}' // Box Drawing (╭ ╮ ╰ ╯ │ ╎ …)
1101 | '\u{2580}'..='\u{259F}' // Block Elements (▏ ▎ ▍ ▌ …)
1102 | '\u{25A0}'..='\u{25FF}' // Geometric Shapes (● ▶ ▷ ◆ ◐ …)
1103 | '\u{2022}' // • bullet (tool status / generic tool)
1104 | '\u{2026}' // … ellipsis (reasoning opener)
1105 | '\u{00B7}' // · middle dot (tool running symbol)
1106 | '\u{2315}' // ⌕ telephone recorder (find/search tool)
1107 | '\u{22EE}' // ⋮ vertical ellipsis (fanout/rlm tool)
1108 )
1109 }
1110
1111 fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
1112 if max_width == 0 || spans.is_empty() {
1113 return Vec::new();
1114 }
1115 let current_width: usize = spans
1116 .iter()
1117 .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
1118 .sum();
1119 if current_width <= max_width {
1120 return spans;
1121 }
1122
1123 let ellipsis = if max_width > 3 { "..." } else { "" };
1124 let content_budget = max_width.saturating_sub(ellipsis.len());
1125 let mut used = 0usize;
1126 let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty()));
1127 let mut last_style = Style::default();
1128
1129 'outer: for span in spans {
1130 last_style = span.style;
1131 let mut content = String::new();
1132 for ch in span.content.chars() {
1133 let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1134 if used + width > content_budget {
1135 break 'outer;
1136 }
1137 content.push(ch);
1138 used += width;
1139 }
1140 if !content.is_empty() {
1141 truncated.push(Span::styled(content, span.style));
1142 }
1143 }
1144
1145 if !ellipsis.is_empty() {
1146 truncated.push(Span::styled(ellipsis.to_string(), last_style));
1147 }
1148 truncated
1149 }
1150
1151 #[cfg(test)]
1152 #[path = "transcript/tests.rs"]
1153 mod tests;
1154
1154 lines RUST