返回 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::HashMap;
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, ThinkingFold, 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 /// Explicit per-cell fold intent affects rendering without changing cell
121 /// revisions. Keyed by original virtual cell index.
122 thinking_folds: HashMap<usize, ThinkingFold>,
123 /// Index of the newest durable Work receipt (checklist / plan snapshot)
124 /// in the last pass. When a new one lands the previous newest must
125 /// re-render collapsed, and its revision alone would not say so.
126 newest_work_receipt: Option<usize>,
127 /// Index of the newest user turn in the last pass. Only it carries the
128 /// elevated-surface background; when a new prompt lands the previous
129 /// newest must re-render on the bare ground, and its revision alone
130 /// would not say so.
131 newest_user_turn: Option<usize>,
132 reasoning_action_target: Option<ReasoningActionTarget>,
133 transcript_action_owner: Option<TranscriptActionOwner>,
134 identity_epoch: Option<u64>,
135 reasoning_action_rendered_cell: Option<usize>,
136 /// Per-cell renders plus flattened lines and index-aligned link/selection
137 /// metadata. Rail prefix widths strip decoration without glyph guessing
138 /// (#1163); deterministic counters measure the production cache path.
139 per_cell: Vec<CachedCell>,
140 /// `per_cell[i].revision`, kept contiguous. The steady-state check and the
141 /// first-changed-cell search compare this against the caller's revisions
142 /// without touching a single cached render (#6652).
143 seen_revisions: Vec<u64>,
144 lines: Vec<Line<'static>>,
145 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
146 line_meta: Vec<TranscriptLineMeta>,
147 /// Visual-only prefix widths let selection copy strip rails without glyph guesses.
148 rail_prefix_widths: Vec<usize>,
149 /// Flat line index at which each cell's rendered lines begin (hidden
150 /// cells record where their successor starts). Lets a suffix rebuild
151 /// truncate and an action-hint move repaint without scanning every row.
152 cell_line_starts: Vec<usize>,
153 streaming_source_receipt: Option<StreamingSourceReceipt>,
154 streaming_lines_reflattened: u64,
155 streaming_meta_rows_scanned: u64,
156 /// Cells (re)rendered over the cache's lifetime; proves a settled frame
157 /// renders nothing.
158 #[cfg(test)]
159 cells_rendered: u64,
160 }
161
162 impl TranscriptViewCache {
163 #[must_use]
164 pub fn new() -> Self {
165 Self {
166 width: 0,
167 options: TranscriptRenderOptions::default(),
168 thinking_folds: HashMap::new(),
169 newest_work_receipt: None,
170 newest_user_turn: None,
171 reasoning_action_target: None,
172 transcript_action_owner: None,
173 identity_epoch: None,
174 reasoning_action_rendered_cell: None,
175 per_cell: Vec::new(),
176 seen_revisions: Vec::new(),
177 lines: Vec::new(),
178 line_links: Vec::new(),
179 line_meta: Vec::new(),
180 rail_prefix_widths: Vec::new(),
181 cell_line_starts: Vec::new(),
182 streaming_source_receipt: None,
183 streaming_lines_reflattened: 0,
184 streaming_meta_rows_scanned: 0,
185 #[cfg(test)]
186 cells_rendered: 0,
187 }
188 }
189
190 pub(crate) fn set_streaming_source_receipt(&mut self, receipt: Option<StreamingSourceReceipt>) {
191 self.streaming_source_receipt = receipt;
192 }
193
194 pub(crate) fn take_transcript_action(
195 &mut self,
196 ) -> Option<(TranscriptActionOwner, Option<ReasoningActionTarget>)> {
197 Some((
198 self.transcript_action_owner.take()?,
199 self.reasoning_action_target.take(),
200 ))
201 }
202
203 pub(crate) fn retarget(
204 &mut self,
205 owner: Option<TranscriptActionOwner>,
206 original_index_map: Option<&[usize]>,
207 ) {
208 // Scrolling moves the owner to the newest visible cell, so this runs
209 // on scroll frames. The hint only rewrites one line of the previous
210 // and next owner, so repaint those rows in place instead of
211 // re-flattening the transcript tail below them (#6652).
212 if let Some(change) = self.set_action_owner(owner, original_index_map)
213 && !self.repaint_action_hints(change)
214 {
215 self.flatten_from(
216 self.options.spacing,
217 first_hint_cell(change).saturating_sub(1),
218 );
219 }
220 }
221
222 /// Convenience entry point; the live path uses shards to avoid cloning.
223 #[cfg_attr(not(test), expect(dead_code))]
224 pub fn ensure(
225 &mut self,
226 cells: &[HistoryCell],
227 cell_revisions: &[u64],
228 width: u16,
229 options: TranscriptRenderOptions,
230 ) {
231 self.ensure_split(
232 &[cells],
233 cell_revisions,
234 width,
235 options,
236 &HashMap::new(),
237 None,
238 None,
239 );
240 }
241
242 /// Ensure logically concatenated shards without cloning history plus the
243 /// active tail each frame. Explicit cache inputs keep identity/fold/map indices
244 /// attached to original virtual cells even when filtering changes positions.
245 #[allow(clippy::too_many_arguments)]
246 pub fn ensure_split(
247 &mut self,
248 cell_shards: &[&[HistoryCell]],
249 cell_revisions: &[u64],
250 width: u16,
251 options: TranscriptRenderOptions,
252 thinking_folds: &HashMap<usize, ThinkingFold>,
253 original_index_map: Option<&[usize]>,
254 action_owner: Option<TranscriptActionOwner>,
255 ) {
256 let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
257 self.ensure_iter(
258 total_cells,
259 |index| {
260 let mut remaining = index;
261 for shard in cell_shards {
262 if let Some(cell) = shard.get(remaining) {
263 return cell;
264 }
265 remaining -= shard.len();
266 }
267 unreachable!("cell index {index} is below total_cells")
268 },
269 cell_revisions,
270 width,
271 options,
272 thinking_folds,
273 original_index_map,
274 action_owner,
275 );
276 }
277
278 /// Ensure an already-filtered list plus its original-index map. Collapse
279 /// may skip cells or substitute tool summaries, so the map is the contract
280 /// that keeps fold/action state attached to original virtual indices
281 /// rather than positional rendered indices (#3896).
282 #[allow(clippy::too_many_arguments)]
283 pub fn ensure_filtered(
284 &mut self,
285 cells: &[&HistoryCell],
286 cell_revisions: &[u64],
287 width: u16,
288 options: TranscriptRenderOptions,
289 thinking_folds: &HashMap<usize, ThinkingFold>,
290 original_index_map: Option<&[usize]>,
291 action_owner: Option<TranscriptActionOwner>,
292 ) {
293 self.ensure_iter(
294 cells.len(),
295 |index| cells[index],
296 cell_revisions,
297 width,
298 options,
299 thinking_folds,
300 original_index_map,
301 action_owner,
302 );
303 }
304
305 #[allow(clippy::too_many_arguments)]
306 fn ensure_iter<'a>(
307 &mut self,
308 total_cells: usize,
309 cell_at: impl Fn(usize) -> &'a HistoryCell,
310 cell_revisions: &[u64],
311 width: u16,
312 options: TranscriptRenderOptions,
313 thinking_folds: &HashMap<usize, ThinkingFold>,
314 original_index_map: Option<&[usize]>,
315 action_owner: Option<TranscriptActionOwner>,
316 ) {
317 let identity_changed = action_owner.is_some_and(|owner| {
318 self.identity_epoch
319 .is_some_and(|epoch| epoch != owner.identity_epoch)
320 });
321 if let Some(owner) = action_owner {
322 self.identity_epoch = Some(owner.identity_epoch);
323 }
324 self.transcript_action_owner = action_owner;
325 // Collapsed reasoning has a fixed preview budget; viewport height
326 // does not participate in wrapping or cached cell identity.
327 let layout_changed = self.width != width || self.options != options || identity_changed;
328 let folded_changed = self.thinking_folds != *thinking_folds;
329 let revisions_match = cell_revisions.len() == total_cells;
330 // Cells `0..unchanged` were rendered at exactly these revisions, so
331 // their cached output (and what they are: kind, supersession) is
332 // current. Layout, fold, or a mismatched revision vector (never
333 // trusted) leaves no such prefix. In steady state this is all of
334 // them, and the frame ends below without rendering, moving, or even
335 // looking at a cell (#6652).
336 let mut unchanged = if layout_changed || folded_changed || !revisions_match {
337 0
338 } else {
339 self.seen_revisions
340 .iter()
341 .zip(cell_revisions)
342 .position(|(seen, current)| seen != current)
343 .unwrap_or_else(|| self.seen_revisions.len().min(total_cells))
344 };
345 // `todo_write` replaces the whole list on every call, so only the
346 // newest snapshot is worth a full card (#5871). When a new one lands
347 // the previous newest must re-render collapsed; its own revision has
348 // not changed, so supersession has to invalidate the cache itself.
349 let newest_work_receipt = newest_matching_cell(
350 total_cells,
351 unchanged,
352 self.newest_work_receipt,
353 &cell_at,
354 |cell| matches!(cell, HistoryCell::Tool(tool) if tool.is_durable_work_receipt()),
355 );
356 let work_receipt_changed = self.newest_work_receipt != newest_work_receipt;
357 self.newest_work_receipt = newest_work_receipt;
358 // Same supersession shape as the work receipt: the highlight lives on
359 // the newest user turn only, so a newly sent prompt must un-highlight
360 // its predecessor even though that cell's own revision never moved.
361 let newest_user_turn = newest_matching_cell(
362 total_cells,
363 unchanged,
364 self.newest_user_turn,
365 &cell_at,
366 |cell| matches!(cell, HistoryCell::User { .. }),
367 );
368 let user_turn_changed = self.newest_user_turn != newest_user_turn;
369 self.newest_user_turn = newest_user_turn;
370 if layout_changed || folded_changed || work_receipt_changed || user_turn_changed {
371 self.per_cell.clear();
372 self.seen_revisions.clear();
373 unchanged = 0;
374 }
375 self.width = width;
376 self.options = options;
377 // Cloning the map every frame is wasted work for the usual unchanged
378 // case (#6652); `folded_changed` already compared the two.
379 if folded_changed {
380 self.thinking_folds.clone_from(thinking_folds);
381 }
382 let previous_rendered_target = self.reasoning_action_rendered_cell;
383
384 // Same-index revision reuse is intentional: insert/remove shifts must
385 // cold-render rather than attach cached lines to another cell. The
386 // destructive identity epoch also prevents revision reuse after an
387 // index is removed and later filled by a different cell.
388 let old_len = self.per_cell.len();
389 let mut any_dirty = layout_changed
390 || folded_changed
391 || work_receipt_changed
392 || user_turn_changed
393 || old_len != total_cells;
394 let mut first_dirty: Option<usize> = if old_len != total_cells {
395 Some(old_len.min(total_cells))
396 } else {
397 None
398 };
399
400 // Update the cache where it stands: a frame that changes only the tail
401 // renders only the tail instead of moving every cached cell out and
402 // back (#6652).
403 self.per_cell.truncate(total_cells);
404 self.seen_revisions.truncate(total_cells);
405 let mut dirty_cells = 0usize;
406 let mut streaming_tail_update = None;
407
408 for idx in unchanged..total_cells {
409 // A mismatched revision vector is never trusted.
410 let current_rev = cell_revisions
411 .get(idx)
412 .copied()
413 .filter(|_| revisions_match)
414 .unwrap_or(u64::MAX);
415 if revisions_match && self.seen_revisions.get(idx) == Some(&current_rev) {
416 continue;
417 }
418 let cell = cell_at(idx);
419 let original_idx = original_index_map
420 .map(|m| *m.get(idx).unwrap_or(&idx))
421 .unwrap_or(idx);
422 let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
423 let render_width = if is_tool_groupable {
424 width.saturating_sub(2).max(1)
425 } else {
426 width
427 };
428 let fold = thinking_folds.get(&original_idx).copied();
429
430 any_dirty = true;
431 dirty_cells = dirty_cells.saturating_add(1);
432 #[cfg(test)]
433 {
434 self.cells_rendered += 1;
435 }
436 first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx)));
437
438 if matches!(
439 cell,
440 HistoryCell::Assistant {
441 streaming: true,
442 ..
443 }
444 ) {
445 if idx >= self.per_cell.len() {
446 self.per_cell.push(CachedCell {
447 revision: current_rev,
448 lines: Arc::new(Vec::new()),
449 links: Arc::new(Vec::new()),
450 copy_separators: Arc::new(Vec::new()),
451 copy_prefix_widths: Arc::new(Vec::new()),
452 is_empty: true,
453 ends_blank: false,
454 kind: TranscriptBlockKind::Answer,
455 is_tool_groupable: false,
456 reasoning_action: None,
457 incremental_markdown: Some(Box::default()),
458 hot_tail_original: None,
459 });
460 self.seen_revisions.push(current_rev);
461 }
462 let receipt = self.streaming_source_receipt;
463 let cached = &mut self.per_cell[idx];
464 let shape_before = (cached.kind, cached.is_tool_groupable, cached.is_empty);
465 if let Some((line_index, original)) = cached.hot_tail_original.take()
466 && let Some(line) = Arc::make_mut(&mut cached.lines).get_mut(line_index)
467 {
468 *line = original;
469 }
470 let content_len = match cell {
471 HistoryCell::Assistant { content, .. } => content.len(),
472 _ => 0,
473 };
474 let verified_append = receipt.is_some_and(|receipt| {
475 receipt.cell_index == original_idx
476 && receipt.from_revision == cached.revision
477 && receipt.to_revision == current_rev
478 && receipt.content_len == content_len
479 });
480 let incremental = cached.incremental_markdown.get_or_insert_with(Box::default);
481 let replace_from = cell
482 .update_incremental_streaming_render(
483 render_width,
484 options,
485 verified_append,
486 incremental,
487 Arc::make_mut(&mut cached.lines),
488 Arc::make_mut(&mut cached.links),
489 Arc::make_mut(&mut cached.copy_separators),
490 Arc::make_mut(&mut cached.copy_prefix_widths),
491 )
492 .expect("streaming Assistant matched above");
493 let cached_lines = Arc::make_mut(&mut cached.lines);
494 let last_index = cached_lines.len().checked_sub(1);
495 if let Some((index, last)) = last_index
496 .and_then(|index| cached_lines.get_mut(index).map(|line| (index, line)))
497 {
498 cached.hot_tail_original = Some((index, last.clone()));
499 crate::tui::history::apply_hot_tail_to_line(last, options.low_motion);
500 }
501 cached.revision = current_rev;
502 cached.is_empty = cached.lines.is_empty();
503 cached.ends_blank = last_line_is_blank(&cached.lines);
504 cached.kind = TranscriptBlockKind::Answer;
505 cached.is_tool_groupable = false;
506 cached.reasoning_action = None;
507 // The spacer and group rail between this cell and its
508 // predecessor depend on what this cell is. A tool, hidden, or
509 // other cell becoming a streaming answer in place (a filter
510 // shifting rows under it) changes them, so only an answer that
511 // stays an answer may take the tail-only shortcut.
512 let same_shape =
513 shape_before == (cached.kind, cached.is_tool_groupable, cached.is_empty);
514 self.seen_revisions[idx] = current_rev;
515 // Hot-tail styling can affect the preceding settled line.
516 streaming_tail_update = same_shape.then_some((idx, replace_from.saturating_sub(1)));
517 continue;
518 }
519
520 let mut cell_options = options;
521 cell_options.superseded_work_receipt = matches!(
522 cell,
523 HistoryCell::Tool(tool) if tool.is_durable_work_receipt()
524 ) && newest_work_receipt
525 .is_some_and(|newest| newest != idx);
526 cell_options.newest_user_turn = matches!(cell, HistoryCell::User { .. })
527 && newest_user_turn.is_some_and(|newest| newest == idx);
528 let rendered = render_cached_cell(cell, current_rev, width, cell_options, fold);
529 if let Some(slot) = self.per_cell.get_mut(idx) {
530 *slot = rendered;
531 self.seen_revisions[idx] = current_rev;
532 } else {
533 self.per_cell.push(rendered);
534 self.seen_revisions.push(current_rev);
535 }
536 }
537 debug_assert_eq!(self.per_cell.len(), total_cells);
538 debug_assert_eq!(self.seen_revisions.len(), total_cells);
539 // Rows from here down are rebuilt this frame; rows above it are
540 // untouched and still sit at their recorded `cell_line_starts`.
541 let mut rebuild_from = if !any_dirty {
542 usize::MAX
543 } else if layout_changed {
544 0
545 } else {
546 self.visible_rebuild_start(first_dirty.unwrap_or(0).saturating_sub(1))
547 };
548 let mut hint_settled = true;
549 if let Some(change) = self.set_action_owner(action_owner, original_index_map) {
550 // Scrolling moves the owner to the newest visible cell, including
551 // while a reply streams and its cell is dirty every frame. A
552 // hinted cell inside the rebuilt suffix is re-flattened with the
553 // new hint anyway; one above it keeps its geometry, so repaint its
554 // single hint row instead of rebuilding the tail below it (#6652).
555 let above = |cell: Option<usize>| cell.filter(|&cell| cell < rebuild_from);
556 let in_place = HintChange {
557 previous: above(change.previous),
558 next: above(change.next),
559 };
560 let repainted = self.repaint_action_hints(in_place);
561 if !repainted {
562 any_dirty = true;
563 rebuild_from = self.visible_rebuild_start(
564 rebuild_from.min(first_hint_cell(change).saturating_sub(1)),
565 );
566 }
567 hint_settled = repainted && in_place == change;
568 }
569
570 if !any_dirty {
571 return;
572 }
573
574 if !layout_changed
575 && !folded_changed
576 && (hint_settled || previous_rendered_target == self.reasoning_action_rendered_cell)
577 && old_len == total_cells
578 && dirty_cells == 1
579 && let Some((cell_index, line_from)) = streaming_tail_update
580 && cell_index + 1 == total_cells
581 && self.flatten_streaming_tail(cell_index, line_from)
582 {
583 return;
584 }
585
586 self.flatten_from(options.spacing, rebuild_from);
587 }
588
589 /// A hidden cell has no line boundary at which to truncate. Rebuild from
590 /// a visible predecessor so appearance/disappearance cannot leave its old
591 /// spacer or the following cell's boundary behind.
592 fn visible_rebuild_start(&self, mut from: usize) -> usize {
593 while from > 0 && self.per_cell.get(from).is_some_and(|cell| cell.is_empty) {
594 from -= 1;
595 }
596 from
597 }
598
599 /// Returns the previous and next hinted cells when the hint moved.
600 fn set_action_owner(
601 &mut self,
602 owner: Option<TranscriptActionOwner>,
603 original_index_map: Option<&[usize]>,
604 ) -> Option<HintChange> {
605 self.transcript_action_owner = owner;
606 let rendered = owner.and_then(|owner| match original_index_map {
607 Some(map) => map.iter().position(|&index| index == owner.cell_index),
608 None => (owner.cell_index < self.per_cell.len()).then_some(owner.cell_index),
609 });
610 self.reasoning_action_target = owner.and_then(|owner| {
611 Some(ReasoningActionTarget {
612 owner,
613 action: self.per_cell.get(rendered?)?.reasoning_action?,
614 })
615 });
616 let next = self
617 .reasoning_action_target
618 .filter(|target| target.action == ReasoningAction::Expand)
619 .and(rendered);
620 let previous = self.reasoning_action_rendered_cell;
621 self.reasoning_action_rendered_cell = next;
622 (previous != next).then_some(HintChange { previous, next })
623 }
624
625 /// Rewrite the affordance row of the cells whose hint changed. The hint
626 /// only swaps the final span of a cell's header, so geometry, spacers,
627 /// and every other row are unchanged. Returns false when the flat index
628 /// cannot vouch for a cell, and the caller falls back to a rebuild.
629 fn repaint_action_hints(&mut self, change: HintChange) -> bool {
630 let hint = self.action_hint();
631 for cell_index in [change.previous, change.next].into_iter().flatten() {
632 let Some(cached) = self.per_cell.get(cell_index) else {
633 return false;
634 };
635 if cached.lines.is_empty() {
636 continue;
637 }
638 let line_in_cell = 0;
639 let Some(at) = self
640 .cell_line_starts
641 .get(cell_index)
642 .map(|start| start + line_in_cell)
643 else {
644 return false;
645 };
646 if self
647 .line_meta
648 .get(at)
649 .and_then(TranscriptLineMeta::cell_line)
650 != Some((cell_index, line_in_cell))
651 {
652 return false;
653 }
654 let (line, links, rail_prefix_width, meta) =
655 self.flat_cell_line(hint.as_deref(), cell_index, line_in_cell);
656 self.lines[at] = line;
657 self.line_links[at] = links;
658 self.rail_prefix_widths[at] = rail_prefix_width;
659 self.line_meta[at] = meta;
660 self.streaming_lines_reflattened = self.streaming_lines_reflattened.saturating_add(1);
661 }
662 true
663 }
664
665 /// Localized Space affordance, or None when it cannot fit the width.
666 fn action_hint(&self) -> Option<String> {
667 let hint = format!(
668 "Space:{}",
669 tr(self.options.locale, MessageId::TranscriptReasoningExpand)
670 );
671 (unicode_width::UnicodeWidthStr::width(hint.as_str())
672 <= usize::from(self.width).saturating_sub(2))
673 .then_some(hint)
674 }
675
676 /// Final flat form of one cached cell line: hint, group rail, links, and
677 /// copy metadata. Shared by flattening and in-place hint repaint.
678 fn flat_cell_line(
679 &self,
680 hint: Option<&str>,
681 cell_index: usize,
682 line_in_cell: usize,
683 ) -> (
684 Line<'static>,
685 Vec<crate::tui::osc8::LineLink>,
686 usize,
687 TranscriptLineMeta,
688 ) {
689 let cached = &self.per_cell[cell_index];
690 let rendered_line_count = cached.lines.len();
691 let line = &cached.lines[line_in_cell];
692 let hint = hint.filter(|hint| {
693 self.reasoning_action_rendered_cell == Some(cell_index)
694 && line_in_cell == 0
695 && line
696 .width()
697 .saturating_sub(line.spans.last().map_or(0, Span::width))
698 + unicode_width::UnicodeWidthStr::width(*hint)
699 < usize::from(self.width)
700 });
701 let is_hint = hint.is_some();
702 let hinted = hint.map(|hint| {
703 let mut hinted = line.clone();
704 if let Some(span) = hinted.spans.last_mut() {
705 span.content = format!(" {hint}").into();
706 }
707 hinted
708 });
709 let line = hinted.as_ref().unwrap_or(line);
710 let rail = tool_group_rail(
711 self.per_cell.as_slice(),
712 cell_index,
713 line_in_cell,
714 rendered_line_count,
715 );
716 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
717 let final_links = if is_hint {
718 Vec::new()
719 } else {
720 links_with_group_rail(
721 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
722 rail,
723 usize::from(self.width),
724 )
725 };
726 let rail_prefix_width = compute_rail_prefix_width(&final_line);
727 let copy_prefix_width = if is_hint {
728 final_line.width().saturating_sub(rail_prefix_width)
729 } else {
730 cached
731 .copy_prefix_widths
732 .get(line_in_cell)
733 .copied()
734 .unwrap_or(0)
735 };
736 let meta = TranscriptLineMeta::CellLine {
737 cell_index,
738 line_in_cell,
739 copy_prefix_width,
740 copy_separator_after: cached
741 .copy_separators
742 .get(line_in_cell)
743 .copied()
744 .unwrap_or(CopyLineSeparator::Newline),
745 };
746 (final_line, final_links, rail_prefix_width, meta)
747 }
748
749 fn flatten(&mut self, spacing: TranscriptSpacing) {
750 self.lines.clear();
751 self.line_links.clear();
752 self.line_meta.clear();
753 self.rail_prefix_widths.clear();
754 self.cell_line_starts.clear();
755 self.append_flattened_cells(spacing, 0);
756 }
757
758 /// Rebuild only a suffix while preserving its predecessor spacer.
759 /// Streaming normally changes only the active tail; rebuilding from the
760 /// previous cell preserves boundary correctness without flattening all
761 /// transcript lines on every token chunk.
762 fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) {
763 if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() {
764 self.flatten(spacing);
765 return;
766 }
767
768 let truncate_at = match self.cell_line_starts.get(first_cell) {
769 Some(&start) => start,
770 None => self
771 .line_meta
772 .iter()
773 .position(|meta| match meta {
774 TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell,
775 TranscriptLineMeta::Spacer { .. } => false,
776 })
777 .unwrap_or(self.lines.len()),
778 };
779 self.cell_line_starts
780 .truncate(first_cell.min(self.cell_line_starts.len()));
781 self.lines.truncate(truncate_at);
782 self.line_links.truncate(truncate_at);
783 self.line_meta.truncate(truncate_at);
784 self.rail_prefix_widths.truncate(truncate_at);
785 self.append_flattened_cells(spacing, first_cell);
786 }
787
788 /// Replace only the final streaming cell's changing Markdown tail. Search
789 /// backward from the old hot tail, so append-only updates scan the small
790 /// replaceable suffix; return false when no canonical boundary exists.
791 fn flatten_streaming_tail(&mut self, cell_index: usize, line_from: usize) -> bool {
792 let mut truncate_at = None;
793 for (index, meta) in self.line_meta.iter().enumerate().rev() {
794 self.streaming_meta_rows_scanned = self.streaming_meta_rows_scanned.saturating_add(1);
795 if matches!(
796 meta,
797 TranscriptLineMeta::CellLine {
798 cell_index: candidate,
799 line_in_cell,
800 ..
801 } if *candidate == cell_index && *line_in_cell == line_from
802 ) {
803 truncate_at = Some(index);
804 break;
805 }
806 }
807 let Some(truncate_at) = truncate_at else {
808 return false;
809 };
810 self.lines.truncate(truncate_at);
811 self.line_links.truncate(truncate_at);
812 self.line_meta.truncate(truncate_at);
813 self.rail_prefix_widths.truncate(truncate_at);
814
815 let Some(cached) = self.per_cell.get(cell_index) else {
816 return false;
817 };
818 let rendered_line_count = cached.lines.len();
819 for line_in_cell in line_from..rendered_line_count {
820 let line = &cached.lines[line_in_cell];
821 let rail = tool_group_rail(
822 self.per_cell.as_slice(),
823 cell_index,
824 line_in_cell,
825 rendered_line_count,
826 );
827 let final_line = line_with_group_rail(line, rail, usize::from(self.width));
828 let final_links = links_with_group_rail(
829 cached.links.get(line_in_cell).map_or(&[], Vec::as_slice),
830 rail,
831 usize::from(self.width),
832 );
833 self.rail_prefix_widths
834 .push(compute_rail_prefix_width(&final_line));
835 self.lines.push(final_line);
836 self.line_links.push(final_links);
837 self.line_meta.push(TranscriptLineMeta::CellLine {
838 cell_index,
839 line_in_cell,
840 copy_prefix_width: cached
841 .copy_prefix_widths
842 .get(line_in_cell)
843 .copied()
844 .unwrap_or(0),
845 copy_separator_after: cached
846 .copy_separators
847 .get(line_in_cell)
848 .copied()
849 .unwrap_or(CopyLineSeparator::Newline),
850 });
851 self.streaming_lines_reflattened = self.streaming_lines_reflattened.saturating_add(1);
852 }
853 true
854 }
855
856 fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) {
857 let hint = self.action_hint();
858 // A start beyond the recorded prefix is only reachable through the
859 // fallback scan; pad so indices stay aligned with cells.
860 let pad_to = start_cell.min(self.per_cell.len());
861 while self.cell_line_starts.len() < pad_to {
862 self.cell_line_starts.push(self.lines.len());
863 }
864 for cell_index in start_cell..self.per_cell.len() {
865 self.cell_line_starts.push(self.lines.len());
866 let cached = &self.per_cell[cell_index];
867 if cached.is_empty {
868 continue;
869 }
870 for line_in_cell in 0..cached.lines.len() {
871 let (final_line, final_links, rail_prefix_width, meta) =
872 self.flat_cell_line(hint.as_deref(), cell_index, line_in_cell);
873 self.rail_prefix_widths.push(rail_prefix_width);
874 self.lines.push(final_line);
875 self.line_links.push(final_links);
876 self.line_meta.push(meta);
877 self.streaming_lines_reflattened =
878 self.streaming_lines_reflattened.saturating_add(1);
879 }
880
881 let cached = &self.per_cell[cell_index];
882 if let Some(next) = next_visible_cell(&self.per_cell, cell_index) {
883 let separator = separator_between(cached, next, spacing);
884 let rail = separator
885 .railed
886 .then_some(crate::tui::widgets::tool_card::CardRail::Middle);
887 for _ in 0..separator.rows {
888 let line = line_with_group_rail(&Line::from(""), rail, usize::from(self.width));
889 let copy_prefix_width = compute_rail_prefix_width(&line);
890 self.rail_prefix_widths.push(copy_prefix_width);
891 self.lines.push(line);
892 self.line_links.push(Vec::new());
893 self.line_meta
894 .push(TranscriptLineMeta::Spacer { copy_prefix_width });
895 }
896 }
897 }
898 }
899
900 #[must_use]
901 pub fn lines(&self) -> &[Line<'static>] {
902 &self.lines
903 }
904
905 #[must_use]
906 pub fn line_links(&self) -> &[Vec<crate::tui::osc8::LineLink>] {
907 &self.line_links
908 }
909
910 #[must_use]
911 pub fn line_meta(&self) -> &[TranscriptLineMeta] {
912 &self.line_meta
913 }
914
915 #[must_use]
916 pub fn total_lines(&self) -> usize {
917 self.lines.len()
918 }
919
920 #[must_use]
921 pub fn rail_prefix_width(&self, line_index: usize) -> usize {
922 self.rail_prefix_widths
923 .get(line_index)
924 .copied()
925 .unwrap_or(0)
926 }
927 }
928
929 /// Index of the last cell satisfying `is_match`.
930 ///
931 /// The previous pass already classified cells `0..unchanged` (they are
932 /// rendered at the same revisions), so only the changed suffix is searched
933 /// and a settled transcript costs nothing. A prefix answer is reused only
934 /// while it still names a cell inside the prefix; otherwise the prefix is
935 /// searched too (#6652).
936 fn newest_matching_cell<'a>(
937 total_cells: usize,
938 unchanged: usize,
939 previous: Option<usize>,
940 cell_at: &impl Fn(usize) -> &'a HistoryCell,
941 is_match: impl Fn(&HistoryCell) -> bool,
942 ) -> Option<usize> {
943 let found = (unchanged..total_cells)
944 .rev()
945 .find(|&index| is_match(cell_at(index)))
946 .or_else(|| match previous {
947 None => None,
948 Some(index) if index < unchanged => Some(index),
949 Some(_) => (0..unchanged).rev().find(|&index| is_match(cell_at(index))),
950 });
951 // The shortcut above is only as good as the revision contract. Tests
952 // prove it against the full scan; production never pays for it.
953 #[cfg(test)]
954 assert_eq!(
955 found,
956 (0..total_cells)
957 .rev()
958 .find(|&index| is_match(cell_at(index))),
959 "a cell changed without its revision changing"
960 );
961 found
962 }
963
964 /// Previous and next cells carrying the Space affordance.
965 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
966 struct HintChange {
967 previous: Option<usize>,
968 next: Option<usize>,
969 }
970
971 /// Earliest cell whose hint changed; rebuilds start one cell earlier.
972 fn first_hint_cell(change: HintChange) -> usize {
973 [change.previous, change.next]
974 .into_iter()
975 .flatten()
976 .min()
977 .unwrap_or(0)
978 }
979
980 fn render_cached_cell(
981 cell: &HistoryCell,
982 revision: u64,
983 width: u16,
984 options: TranscriptRenderOptions,
985 fold: Option<ThinkingFold>,
986 ) -> CachedCell {
987 let is_tool_groupable = matches!(cell, HistoryCell::Tool(_));
988 let render_width = if is_tool_groupable {
989 width.saturating_sub(2).max(1)
990 } else {
991 width
992 };
993 let (rendered, reasoning_action) =
994 cell.lines_with_copy_metadata_folded(render_width, options, fold);
995 let mut lines = Vec::with_capacity(rendered.len());
996 let mut links = Vec::with_capacity(rendered.len());
997 let mut copy_separators = Vec::with_capacity(rendered.len());
998 let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
999 for rendered_line in rendered {
1000 let mut line = rendered_line.line;
1001 if is_tool_groupable {
1002 strip_cell_local_tool_rail(&mut line);
1003 }
1004 lines.push(line);
1005 links.push(rendered_line.links);
1006 copy_prefix_widths.push(rendered_line.copy_prefix_width);
1007 copy_separators.push(rendered_line.copy_separator_after);
1008 }
1009 if reasoning_action == Some(ReasoningAction::Expand)
1010 && let Some(line) = lines.first()
1011 {
1012 let prefix = line.width().saturating_sub(compute_rail_prefix_width(line));
1013 *copy_prefix_widths
1014 .first_mut()
1015 .expect("reasoning affordance header") = prefix;
1016 links
1017 .first_mut()
1018 .expect("reasoning affordance header")
1019 .clear();
1020 }
1021 let is_empty = lines.is_empty();
1022 let ends_blank = last_line_is_blank(&lines);
1023 CachedCell {
1024 revision,
1025 lines: Arc::new(lines),
1026 links: Arc::new(links),
1027 copy_separators: Arc::new(copy_separators),
1028 copy_prefix_widths: Arc::new(copy_prefix_widths),
1029 is_empty,
1030 ends_blank,
1031 kind: TranscriptBlockKind::for_cell(cell),
1032 is_tool_groupable,
1033 reasoning_action,
1034 incremental_markdown: None,
1035 hot_tail_original: None,
1036 }
1037 }
1038
1039 /// Strip the cell-local rail because the flat cache owns cross-cell grouping;
1040 /// retaining both produces doubled prefixes such as `╭ ╭`.
1041 fn strip_cell_local_tool_rail(line: &mut Line<'static>) {
1042 if line
1043 .spans
1044 .first()
1045 .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ "))
1046 {
1047 line.spans.remove(0);
1048 }
1049 }
1050
1051 fn last_line_is_blank(lines: &[Line<'static>]) -> bool {
1052 lines
1053 .last()
1054 .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
1055 }
1056
1057 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1058 struct BlockSeparator {
1059 rows: usize,
1060 railed: bool,
1061 }
1062
1063 fn separator_between(
1064 current: &CachedCell,
1065 next: &CachedCell,
1066 spacing: TranscriptSpacing,
1067 ) -> BlockSeparator {
1068 let boundary = transcript_boundary(
1069 current.kind,
1070 next.kind,
1071 same_tool_activity_group(current, next),
1072 );
1073 let mut rows = spacer_rows_for_boundary(boundary, spacing);
1074 // A cell ending blank already paid for separation; a grouped tool rail is
1075 // not blank and must remain continuous.
1076 if !current.ends_blank {
1077 return BlockSeparator {
1078 rows,
1079 railed: boundary == TranscriptBoundary::GroupedTool,
1080 };
1081 }
1082 if boundary == TranscriptBoundary::GroupedTool {
1083 return BlockSeparator { rows, railed: true };
1084 }
1085 rows = rows.saturating_sub(1);
1086 BlockSeparator {
1087 rows,
1088 railed: false,
1089 }
1090 }
1091
1092 fn same_tool_activity_group(current: &CachedCell, next: &CachedCell) -> bool {
1093 // Durable Work receipts are persisted state, not another transient tool
1094 // action; crossing that semantic seam closes the rail even at compact.
1095 current.is_tool_groupable && next.is_tool_groupable && current.kind == next.kind
1096 }
1097
1098 fn transcript_boundary(
1099 current: TranscriptBlockKind,
1100 next: TranscriptBlockKind,
1101 same_tool_group: bool,
1102 ) -> TranscriptBoundary {
1103 if same_tool_group {
1104 debug_assert_eq!(current, next);
1105 // Distinct calls sharing a rail are one compact activity group. The
1106 // rail itself carries the grouping; padding every low-level call would
1107 // recreate the density problem this boundary matrix exists to solve.
1108 return TranscriptBoundary::GroupedTool;
1109 }
1110
1111 // User cells are the only unambiguous turn delimiter. Keep prompt→tool
1112 // distinct too: a model need not emit answer prose before acting.
1113 if current == TranscriptBlockKind::User || next == TranscriptBlockKind::User {
1114 return TranscriptBoundary::Turn;
1115 }
1116
1117 // Successive reasoning or answer cells are one phase split across cells;
1118 // a blank row there would jitter the row budget during streaming.
1119 if current == next
1120 && matches!(
1121 current,
1122 TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer
1123 )
1124 {
1125 return TranscriptBoundary::Joined;
1126 }
1127
1128 // Every other phase transition is reader-visible. In particular,
1129 // reasoning running into final prose was the density bug this matrix
1130 // exists to prevent.
1131 TranscriptBoundary::Activity
1132 }
1133
1134 const fn spacer_rows_for_boundary(
1135 boundary: TranscriptBoundary,
1136 spacing: TranscriptSpacing,
1137 ) -> usize {
1138 match (boundary, spacing) {
1139 (TranscriptBoundary::Joined | TranscriptBoundary::GroupedTool, _) => 0,
1140 (TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0,
1141 (TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
1142 (TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
1143 BLOCK_SEPARATOR_ROWS
1144 }
1145 (TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => BLOCK_SEPARATOR_ROWS + 1,
1146 }
1147 }
1148
1149 fn previous_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
1150 cells[..cell_index].iter().rev().find(|cell| !cell.is_empty)
1151 }
1152
1153 fn next_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> {
1154 cells
1155 .get(cell_index + 1..)?
1156 .iter()
1157 .find(|cell| !cell.is_empty)
1158 }
1159
1160 fn tool_group_rail(
1161 cells: &[CachedCell],
1162 cell_index: usize,
1163 line_in_cell: usize,
1164 rendered_line_count: usize,
1165 ) -> Option<crate::tui::widgets::tool_card::CardRail> {
1166 let cached = cells.get(cell_index)?;
1167 if !cached.is_tool_groupable || rendered_line_count == 0 {
1168 return None;
1169 }
1170
1171 let previous_shares_group = previous_visible_cell(cells, cell_index)
1172 .is_some_and(|previous| same_tool_activity_group(previous, cached));
1173 let next_shares_group = next_visible_cell(cells, cell_index)
1174 .is_some_and(|next| same_tool_activity_group(cached, next));
1175 let first_line_in_group = !previous_shares_group && line_in_cell == 0;
1176 let last_line_in_group = !next_shares_group && line_in_cell + 1 == rendered_line_count;
1177
1178 let rail = match (first_line_in_group, last_line_in_group) {
1179 (true, true) if rendered_line_count == 1 => {
1180 crate::tui::widgets::tool_card::CardRail::Single
1181 }
1182 (true, _) => crate::tui::widgets::tool_card::CardRail::Top,
1183 (_, true) => crate::tui::widgets::tool_card::CardRail::Bottom,
1184 _ => crate::tui::widgets::tool_card::CardRail::Middle,
1185 };
1186 Some(rail)
1187 }
1188
1189 fn line_with_group_rail(
1190 line: &Line<'static>,
1191 rail: Option<crate::tui::widgets::tool_card::CardRail>,
1192 max_width: usize,
1193 ) -> Line<'static> {
1194 let Some(rail) = rail else {
1195 return line.clone();
1196 };
1197 let glyph = crate::tui::widgets::tool_card::rail_glyph(rail);
1198 if glyph.is_empty() {
1199 let mut rendered = line.clone();
1200 rendered.spans = truncate_spans_to_width(rendered.spans, max_width);
1201 return rendered;
1202 }
1203
1204 let mut rendered = line.clone();
1205 let mut spans = Vec::with_capacity(rendered.spans.len() + 1);
1206 spans.push(Span::styled(
1207 format!("{glyph} "),
1208 Style::default().fg(codewhale_palette::TEXT_DIM),
1209 ));
1210 spans.extend(rendered.spans);
1211 rendered.spans = truncate_spans_to_width(spans, max_width);
1212 rendered
1213 }
1214
1215 fn links_with_group_rail(
1216 links: &[crate::tui::osc8::LineLink],
1217 rail: Option<crate::tui::widgets::tool_card::CardRail>,
1218 max_width: usize,
1219 ) -> Vec<crate::tui::osc8::LineLink> {
1220 let shift = rail
1221 .map(crate::tui::widgets::tool_card::rail_glyph)
1222 .filter(|glyph| !glyph.is_empty())
1223 .map_or(0, |glyph| unicode_width::UnicodeWidthStr::width(glyph) + 1);
1224 links
1225 .iter()
1226 .map(|link| link.shifted(shift))
1227 .filter(|link| link.col_start < max_width)
1228 .map(|mut link| {
1229 link.col_end = link.col_end.min(max_width.saturating_sub(1));
1230 link
1231 })
1232 .collect()
1233 }
1234
1235 /// Return the display-column count of consecutive visual-only decorative
1236 /// spans at the start of a rendered transcript line. Iterates through
1237 /// leading spans matching either of two patterns:
1238 ///
1239 /// * Pattern A — span is `"<glyph>[<glyph>…]<space>"` where every character
1240 /// except the trailing space is a rail-drawing character (e.g. `▏ `,
1241 /// `▶ `, `⋮⋮ `). The entire span width is accumulated.
1242 /// * Pattern B — span is `"<glyph>"` (1 drawing char) followed by a lone
1243 /// space span `" "` (e.g. `●` then ` `, `▎` then ` `).
1244 ///
1245 /// Stops at the first non-matching span. Every decorated glyph used by the
1246 /// TUI is a single display-column character, so char-count = display width.
1247 ///
1248 /// Returns `0` for lines whose first span is not a decorative prefix.
1249 fn compute_rail_prefix_width(line: &Line<'static>) -> usize {
1250 let spans = line.spans.as_slice();
1251 let mut total = 0;
1252 let mut i = 0;
1253
1254 while i < spans.len() {
1255 let content = spans[i].content.as_ref();
1256 let n_chars = content.chars().count();
1257
1258 // Pattern A — span "<glyph>[<glyph>…]<space>" (≥ 2 chars, trailing
1259 // space, all preceding chars are drawing chars).
1260 if n_chars >= 2
1261 && content.ends_with(' ')
1262 && content
1263 .chars()
1264 .take(n_chars.saturating_sub(1))
1265 .all(is_rail_drawing_char)
1266 {
1267 total += n_chars;
1268 i += 1;
1269 continue;
1270 }
1271
1272 // Pattern B — span "<glyph>" (1 drawing char) + next span " ".
1273 if n_chars == 1
1274 && content.chars().next().is_some_and(is_rail_drawing_char)
1275 && spans.get(i + 1).is_some_and(|s| s.content.as_ref() == " ")
1276 {
1277 total += 2;
1278 i += 2;
1279 continue;
1280 }
1281
1282 break;
1283 }
1284
1285 total
1286 }
1287
1288 /// Characters that serve as decoration glyphs in the TUI left-rail and
1289 /// tool-header prefix system. All are single display-column characters.
1290 fn is_rail_drawing_char(ch: char) -> bool {
1291 matches!(
1292 ch,
1293 '\u{2500}'..='\u{257F}' // Box Drawing (╭ ╮ ╰ ╯ │ ╎ …)
1294 | '\u{2580}'..='\u{259F}' // Block Elements (▏ ▎ ▍ ▌ …)
1295 | '\u{25A0}'..='\u{25FF}' // Geometric Shapes (● ▶ ▷ ◆ ◐ …)
1296 | '\u{2022}' // • bullet (tool status / generic tool)
1297 | '\u{2026}' // … ellipsis (reasoning opener)
1298 | '\u{00B7}' // · middle dot (tool running symbol)
1299 | '\u{2315}' // ⌕ telephone recorder (find/search tool)
1300 | '\u{22EE}' // ⋮ vertical ellipsis (fanout/rlm tool)
1301 )
1302 }
1303
1304 fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
1305 if max_width == 0 || spans.is_empty() {
1306 return Vec::new();
1307 }
1308 let current_width: usize = spans
1309 .iter()
1310 .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
1311 .sum();
1312 if current_width <= max_width {
1313 return spans;
1314 }
1315
1316 let ellipsis = if max_width > 3 { "..." } else { "" };
1317 let content_budget = max_width.saturating_sub(ellipsis.len());
1318 let mut used = 0usize;
1319 let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty()));
1320 let mut last_style = Style::default();
1321
1322 'outer: for span in spans {
1323 last_style = span.style;
1324 let mut content = String::new();
1325 for ch in span.content.chars() {
1326 let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1327 if used + width > content_budget {
1328 break 'outer;
1329 }
1330 content.push(ch);
1331 used += width;
1332 }
1333 if !content.is_empty() {
1334 truncated.push(Span::styled(content, span.style));
1335 }
1336 }
1337
1338 if !ellipsis.is_empty() {
1339 truncated.push(Span::styled(ellipsis.to_string(), last_style));
1340 }
1341 truncated
1342 }
1343
1344 #[cfg(test)]
1345 #[path = "transcript/tests.rs"]
1346 mod tests;
1347
1347 lines RUST