返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / transcript / tests.rs
1 use super::*;
2 use crate::tools::plan::PlanSnapshot;
3 use crate::tui::history::{
4 ExecCell, ExecSource, HistoryCell, PlanUpdateCell, ReasoningAction, ReasoningActionTarget,
5 ThinkingFold, ToolCell, ToolStatus, TranscriptActionOwner,
6 };
7 use codewhale_localization::Locale;
8 use codewhale_palette as palette;
9
10 impl TranscriptViewCache {
11 pub(crate) fn reasoning_action_target(&self) -> Option<ReasoningActionTarget> {
12 self.reasoning_action_target
13 }
14
15 fn streaming_lines_reflattened(&self) -> u64 {
16 self.streaming_lines_reflattened
17 }
18
19 fn streaming_meta_rows_scanned(&self) -> u64 {
20 self.streaming_meta_rows_scanned
21 }
22 }
23
24 fn plain_lines(cache: &TranscriptViewCache) -> Vec<String> {
25 cache
26 .lines()
27 .iter()
28 .map(|line| {
29 line.spans
30 .iter()
31 .map(|span| span.content.as_ref())
32 .collect::<String>()
33 })
34 .collect()
35 }
36
37 fn user_cell(content: &str) -> HistoryCell {
38 HistoryCell::User {
39 content: content.to_string(),
40 }
41 }
42
43 fn assistant_cell(content: &str, streaming: bool) -> HistoryCell {
44 HistoryCell::Assistant {
45 content: content.to_string(),
46 streaming,
47 }
48 }
49
50 fn reasoning_cell(streaming: bool) -> HistoryCell {
51 HistoryCell::Thinking {
52 content: (1..=20)
53 .map(|line| format!("reasoning line {line:02}"))
54 .collect::<Vec<_>>()
55 .join("\n"),
56 streaming,
57 duration_secs: (!streaming).then_some(1.0),
58 }
59 }
60
61 fn reasoning_owner(cell_index: usize) -> TranscriptActionOwner {
62 TranscriptActionOwner {
63 cell_index,
64 identity_epoch: 7,
65 }
66 }
67
68 fn exec_tool_cell_with_output(command: &str, output: String) -> HistoryCell {
69 // A failed shell cell keeps its full output in the live render, so
70 // this fixture proves tool cells do not inherit the prose measure.
71 HistoryCell::Tool(ToolCell::Exec(ExecCell {
72 command: command.to_string(),
73 status: ToolStatus::Failed,
74 output: Some(output),
75 live_output: None,
76 shell_task_id: None,
77 owner_agent_id: None,
78 owner_agent_name: None,
79 started_at: None,
80 duration_ms: None,
81 stale_elapsed_since_output_ms: None,
82 source: ExecSource::Assistant,
83 interaction: None,
84 output_summary: None,
85 }))
86 }
87
88 fn exec_tool_cell(command: &str) -> HistoryCell {
89 HistoryCell::Tool(ToolCell::Exec(ExecCell {
90 command: command.to_string(),
91 status: ToolStatus::Running,
92 output: None,
93 live_output: None,
94 shell_task_id: None,
95 owner_agent_id: None,
96 owner_agent_name: None,
97 started_at: None,
98 duration_ms: None,
99 stale_elapsed_since_output_ms: None,
100 source: ExecSource::Assistant,
101 interaction: None,
102 output_summary: None,
103 }))
104 }
105
106 fn durable_work_cell() -> HistoryCell {
107 HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
108 snapshot: PlanSnapshot::default(),
109 status: ToolStatus::Running,
110 }))
111 }
112
113 fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> usize {
114 let mut saw_target = false;
115 let mut spacer_rows = 0;
116 for meta in cache.line_meta() {
117 match meta {
118 TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
119 saw_target = true;
120 spacer_rows = 0;
121 }
122 TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
123 TranscriptLineMeta::CellLine { .. } if saw_target => break,
124 TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
125 }
126 }
127 spacer_rows
128 }
129
130 #[test]
131 fn cache_highlights_only_the_newest_user_turn() {
132 let cells = vec![
133 user_cell("first prompt"),
134 assistant_cell("first answer", false),
135 user_cell("second prompt"),
136 ];
137 let revisions = vec![1u64, 1, 1];
138
139 let mut cache = TranscriptViewCache::new();
140 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
141
142 let texts = plain_lines(&cache);
143 let first = texts
144 .iter()
145 .position(|line| line.contains("first prompt"))
146 .expect("first prompt renders");
147 let second = texts
148 .iter()
149 .position(|line| line.contains("second prompt"))
150 .expect("second prompt renders");
151 let lines = cache.lines();
152 assert_eq!(
153 lines[first].style.bg, None,
154 "an older prompt renders on the bare ground"
155 );
156 assert!(
157 lines[first]
158 .spans
159 .iter()
160 .all(|span| span.style.bg.is_none()),
161 "an older prompt paints no background block"
162 );
163 assert_eq!(
164 lines[second].style.bg,
165 Some(palette::SURFACE_ELEVATED),
166 "only the newest prompt carries the background"
167 );
168 assert_eq!(lines[second].width(), 40);
169 }
170
171 #[test]
172 fn cache_unhighlights_the_previous_prompt_when_a_new_one_lands() {
173 let first = vec![user_cell("first prompt")];
174 let revisions = vec![1u64];
175
176 let mut cache = TranscriptViewCache::new();
177 cache.ensure(&first, &revisions, 40, TranscriptRenderOptions::default());
178 assert_eq!(
179 cache.lines()[0].style.bg,
180 Some(palette::SURFACE_ELEVATED),
181 "a lone prompt is the newest turn"
182 );
183
184 // The first cell's own revision never moves; supersession alone must
185 // re-render it without the block.
186 let both = vec![user_cell("first prompt"), user_cell("second prompt")];
187 let revisions = vec![1u64, 1];
188 cache.ensure(&both, &revisions, 40, TranscriptRenderOptions::default());
189 let lines = cache.lines();
190 assert_eq!(
191 lines[0].style.bg, None,
192 "the previous newest loses the background"
193 );
194 let texts = plain_lines(&cache);
195 let second = texts
196 .iter()
197 .position(|line| line.contains("second prompt"))
198 .expect("second prompt renders");
199 assert_eq!(lines[second].style.bg, Some(palette::SURFACE_ELEVATED));
200 }
201
202 #[test]
203 fn cache_reuses_cells_when_revision_unchanged() {
204 let cells = vec![
205 user_cell("hello"),
206 assistant_cell("world", false),
207 user_cell("again"),
208 ];
209 let revisions = vec![1u64, 1, 1];
210
211 let mut cache = TranscriptViewCache::new();
212 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
213 let first_lines: Vec<String> = cache
214 .lines()
215 .iter()
216 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
217 .collect();
218 let first_total = cache.total_lines();
219 assert!(first_total > 0, "expected non-empty render");
220
221 // Capture per-cell lines snapshot to verify reuse.
222 let snapshot_per_cell: Vec<Vec<String>> = cache
223 .per_cell
224 .iter()
225 .map(|c| {
226 c.lines
227 .iter()
228 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
229 .collect()
230 })
231 .collect();
232
233 // Same revisions => everything reused, output identical.
234 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
235 let second_lines: Vec<String> = cache
236 .lines()
237 .iter()
238 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
239 .collect();
240 assert_eq!(first_lines, second_lines);
241 assert_eq!(cache.total_lines(), first_total);
242
243 let snapshot_per_cell_2: Vec<Vec<String>> = cache
244 .per_cell
245 .iter()
246 .map(|c| {
247 c.lines
248 .iter()
249 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
250 .collect()
251 })
252 .collect();
253 assert_eq!(snapshot_per_cell, snapshot_per_cell_2);
254 }
255
256 #[test]
257 fn bumping_one_cell_revision_only_rerenders_that_cell() {
258 // Track render counts per cell using a custom HistoryCell wrapper
259 // would require trait changes; instead, we detect reuse by inspecting
260 // CachedCell instances. After a bump, only the bumped cell's stored
261 // revision should differ from before; others remain identical.
262
263 let cells_v1 = vec![
264 user_cell("hello"),
265 assistant_cell("hi", true),
266 user_cell("again"),
267 ];
268 let revs_v1 = vec![1u64, 1, 1];
269
270 let mut cache = TranscriptViewCache::new();
271 cache.ensure(&cells_v1, &revs_v1, 80, TranscriptRenderOptions::default());
272
273 // Snapshot the cached lines for cells 0 and 2 (unchanged across the
274 // delta).
275 let cell0_lines_before = cache.per_cell[0]
276 .lines
277 .iter()
278 .map(|l| {
279 l.spans
280 .iter()
281 .map(|s| s.content.to_string())
282 .collect::<String>()
283 })
284 .collect::<Vec<_>>();
285 let cell2_lines_before = cache.per_cell[2]
286 .lines
287 .iter()
288 .map(|l| {
289 l.spans
290 .iter()
291 .map(|s| s.content.to_string())
292 .collect::<String>()
293 })
294 .collect::<Vec<_>>();
295
296 // Mutate cell 1 (assistant streaming delta) and bump only its rev.
297 let cells_v2 = vec![
298 user_cell("hello"),
299 assistant_cell("hi world", true),
300 user_cell("again"),
301 ];
302 let revs_v2 = vec![1u64, 2, 1];
303
304 cache.ensure(&cells_v2, &revs_v2, 80, TranscriptRenderOptions::default());
305
306 // Cells 0 and 2 are byte-identical (proving reuse path didn't corrupt).
307 let cell0_lines_after = cache.per_cell[0]
308 .lines
309 .iter()
310 .map(|l| {
311 l.spans
312 .iter()
313 .map(|s| s.content.to_string())
314 .collect::<String>()
315 })
316 .collect::<Vec<_>>();
317 let cell2_lines_after = cache.per_cell[2]
318 .lines
319 .iter()
320 .map(|l| {
321 l.spans
322 .iter()
323 .map(|s| s.content.to_string())
324 .collect::<String>()
325 })
326 .collect::<Vec<_>>();
327 assert_eq!(cell0_lines_before, cell0_lines_after);
328 assert_eq!(cell2_lines_before, cell2_lines_after);
329
330 // Cell 1 reflects the new content.
331 // The renderer interleaves role/whitespace spans, so the joined
332 // content has internal padding (e.g. "Assistant hi world").
333 // Check for the new tokens individually rather than a literal
334 // "hi world" substring.
335 let cell1_after: String = cache.per_cell[1]
336 .lines
337 .iter()
338 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
339 .collect::<Vec<_>>()
340 .join(" ");
341 assert!(
342 cell1_after.contains("hi") && cell1_after.contains("world"),
343 "cell1 should re-render with new content; got: {cell1_after}"
344 );
345
346 // Revisions in cache reflect the bump.
347 assert_eq!(cache.per_cell[0].revision, 1);
348 assert_eq!(cache.per_cell[1].revision, 2);
349 assert_eq!(cache.per_cell[2].revision, 1);
350 }
351
352 #[test]
353 fn streaming_assistant_keeps_a_persistent_linear_render_prefix() {
354 let mut content = String::new();
355 let mut revision = 1u64;
356 let mut cache = TranscriptViewCache::new();
357 let options = TranscriptRenderOptions {
358 low_motion: true,
359 ..TranscriptRenderOptions::default()
360 };
361
362 content.push_str("start\n```rust\nlet value_0 = 0;\n```\n\n");
363 let mut cells = vec![assistant_cell(&content, true)];
364 cache.ensure(&cells, &[revision], 96, options);
365 let lines_arc = Arc::as_ptr(&cache.per_cell[0].lines);
366
367 for index in 1..120usize {
368 let previous = revision;
369 revision += 1;
370 content.push_str(&format!(
371 "段落 {index} e\u{301} 🚀\n```rust\nlet value_{index} = {index};\n```\n\n"
372 ));
373 cells[0] = assistant_cell(&content, true);
374 cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
375 cell_index: 0,
376 from_revision: previous,
377 to_revision: revision,
378 content_len: content.len(),
379 }));
380 cache.ensure(&cells, &[revision], 96, options);
381 }
382
383 let previous = revision;
384 revision += 1;
385 cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
386 cell_index: 0,
387 from_revision: previous,
388 to_revision: revision,
389 content_len: content.len(),
390 }));
391 cache.ensure(&cells, &[revision], 96, options);
392
393 assert_eq!(Arc::as_ptr(&cache.per_cell[0].lines), lines_arc);
394 let work = cache.per_cell[0]
395 .incremental_markdown
396 .as_ref()
397 .expect("streaming markdown cache")
398 .work();
399 assert_eq!(work.invalidations, 1);
400 assert_eq!(work.tail_blocks_rendered, 0);
401 assert_eq!(work.classified_lines as usize, content.lines().count());
402 assert!(
403 cache.streaming_lines_reflattened() <= (cache.total_lines() + 121) as u64,
404 "flatten work must be final output plus at most one hot-tail line per update: work={}, final={}",
405 cache.streaming_lines_reflattened(),
406 cache.total_lines()
407 );
408 assert!(
409 cache.streaming_meta_rows_scanned() <= 121,
410 "reverse lookup must inspect only the replaceable tail: {}",
411 cache.streaming_meta_rows_scanned()
412 );
413
414 let mut cold = TranscriptViewCache::new();
415 cold.ensure(&cells, &[revision], 96, options);
416 assert_eq!(plain_lines(&cache), plain_lines(&cold));
417 }
418
419 #[test]
420 fn tail_update_suffix_rebuild_matches_fresh_flatten() {
421 let mut cells = vec![
422 user_cell("first message"),
423 assistant_cell("stable answer", false),
424 user_cell("tail prompt"),
425 ];
426 let mut revisions = vec![1u64, 1, 1];
427 let mut cache = TranscriptViewCache::new();
428 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
429
430 cells.push(assistant_cell("streaming tail", true));
431 revisions.push(1);
432 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
433
434 if let HistoryCell::Assistant { content, .. } = cells.last_mut().unwrap() {
435 content.push_str(" plus delta");
436 }
437 *revisions.last_mut().unwrap() += 1;
438 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
439 let incremental = plain_lines(&cache);
440
441 let mut fresh = TranscriptViewCache::new();
442 fresh.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
443 assert_eq!(incremental, plain_lines(&fresh));
444 }
445
446 #[test]
447 fn width_change_rerenders_all_cells() {
448 let cells = vec![
449 user_cell("a fairly long message that may wrap at narrow widths"),
450 assistant_cell("another long message body content", false),
451 ];
452 let revisions = vec![5u64, 7];
453
454 let mut cache = TranscriptViewCache::new();
455 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
456 let wide_total = cache.total_lines();
457
458 // Narrow width should change layout — everything re-renders.
459 cache.ensure(&cells, &revisions, 20, TranscriptRenderOptions::default());
460 let narrow_total = cache.total_lines();
461
462 assert_ne!(
463 wide_total, narrow_total,
464 "narrow width should produce a different number of lines"
465 );
466
467 // Restoring the original width re-renders again.
468 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
469 assert_eq!(cache.total_lines(), wide_total);
470 }
471
472 #[test]
473 fn streaming_assistant_only_rebuilds_one_cell_render_count() {
474 // Verify behavior 6: when one Assistant cell streams a delta, only
475 // that one cell is re-rendered. We use a counting wrapper hooked into
476 // a custom History setup. Since `lines_with_options` is on `HistoryCell`
477 // (concrete enum), we can't mock it directly. Instead we verify the
478 // cache's invariant: cells with unchanged revisions retain their
479 // previous CachedCell entries (clone-equal), proving no re-render
480 // happened for them.
481 //
482 // We do this by storing revisions as monotonic u64 and verifying that
483 // a `Vec<u64>` snapshot of `per_cell.revision` only differs at the
484 // index that was bumped.
485
486 let mut cells: Vec<HistoryCell> = (0..50).map(|i| user_cell(&format!("cell {i}"))).collect();
487 cells.push(assistant_cell("streaming", true));
488 let mut revisions: Vec<u64> = vec![1; 51];
489
490 let mut cache = TranscriptViewCache::new();
491 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
492
493 // Snapshot total bytes rendered for cells 0..50 (unchanged).
494 let stable_snapshot: Vec<String> = cache.per_cell[..50]
495 .iter()
496 .map(|c| {
497 c.lines
498 .iter()
499 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
500 .collect::<Vec<_>>()
501 .join("|")
502 })
503 .collect();
504
505 // Stream 10 deltas to the assistant cell, bumping only its revision.
506 for i in 0..10 {
507 if let HistoryCell::Assistant { content, .. } = &mut cells[50] {
508 content.push_str(&format!(" delta-{i}"));
509 }
510 revisions[50] += 1;
511 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
512
513 // After every delta, cells 0..50 must be byte-identical to the
514 // initial render. If we re-rendered them we'd observe identical
515 // bytes anyway (deterministic), but the test ALSO checks the
516 // CachedCell.revision values stayed at 1 — meaning the cache
517 // never replaced them, only reused them.
518 let stable_now: Vec<String> = cache.per_cell[..50]
519 .iter()
520 .map(|c| {
521 c.lines
522 .iter()
523 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
524 .collect::<Vec<_>>()
525 .join("|")
526 })
527 .collect();
528 assert_eq!(
529 stable_now, stable_snapshot,
530 "stable cells diverged at delta {i}"
531 );
532
533 for (idx, c) in cache.per_cell[..50].iter().enumerate() {
534 assert_eq!(
535 c.revision, 1,
536 "cell {idx} revision changed during streaming delta"
537 );
538 }
539 }
540 }
541
542 #[test]
543 fn missing_revisions_falls_back_to_full_render() {
544 // If callers pass a `cell_revisions` slice with the wrong length
545 // (shouldn't happen, but be defensive), the cache should still
546 // produce correct output rather than panic or skip cells.
547 let cells = vec![user_cell("a"), assistant_cell("b", false)];
548 let bogus_revisions = vec![1u64]; // wrong length
549
550 let mut cache = TranscriptViewCache::new();
551 cache.ensure(
552 &cells,
553 &bogus_revisions,
554 80,
555 TranscriptRenderOptions::default(),
556 );
557
558 // Both cells were rendered (no panic, output non-empty).
559 assert_eq!(cache.per_cell.len(), 2);
560 assert!(!cache.lines().is_empty());
561 }
562
563 #[test]
564 fn adjacent_tool_cells_render_as_one_railed_group() {
565 // Live foreground exec cells collapse to a single header line (copy
566 // dedupe #17), so a third cell is needed for a rail-continuation row.
567 let cells = vec![
568 exec_tool_cell("cargo test"),
569 exec_tool_cell("cargo clippy"),
570 exec_tool_cell("cargo fmt"),
571 ];
572 let revisions = vec![1u64, 1, 1];
573 let mut cache = TranscriptViewCache::new();
574
575 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
576 let lines = plain_lines(&cache);
577
578 assert!(
579 lines
580 .first()
581 .is_some_and(|line| line.starts_with("\u{256D} ")),
582 "first tool line should open the shared rail: {lines:?}"
583 );
584 assert!(
585 lines.iter().any(|line| line.starts_with("\u{2502} ")),
586 "middle tool lines should continue the shared rail: {lines:?}"
587 );
588 assert!(
589 lines
590 .last()
591 .is_some_and(|line| line.starts_with("\u{2570} ")),
592 "last tool line should close the shared rail: {lines:?}"
593 );
594 assert!(
595 !lines.iter().any(String::is_empty),
596 "adjacent tool cells must never be separated by a bare blank row — that \
597 would tear the card box open: {lines:?}"
598 );
599 assert!(
600 !lines.iter().any(|line| line.trim_end() == "\u{2502}"),
601 "one tool group must stay compact instead of padding every call: {lines:?}"
602 );
603 assert_eq!(spacer_rows_after_cell(&cache, 0), 0);
604 assert_eq!(spacer_rows_after_cell(&cache, 1), 0);
605 }
606
607 #[test]
608 fn semantic_boundary_matrix_has_four_deliberate_rhythm_levels() {
609 use TranscriptBlockKind::{Answer, DurableWork, Notice, Reasoning, ToolAction, User};
610 use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
611
612 let cases = [
613 (User, Answer, false, Turn),
614 (User, ToolAction, false, Turn),
615 (DurableWork, User, false, Turn),
616 // Reasoning handing off to the answer is a phase change the reader
617 // has to see. Running the two together with no blank row is the
618 // density complaint this matrix exists to answer.
619 (Reasoning, Answer, false, Activity),
620 (Answer, Reasoning, false, Activity),
621 // Successive cells of the *same* phase are one block split across
622 // cells; a blank row there would jitter mid-stream.
623 (Answer, Answer, false, Joined),
624 (Reasoning, Reasoning, false, Joined),
625 (Answer, ToolAction, false, Activity),
626 (ToolAction, Reasoning, false, Activity),
627 (Notice, DurableWork, false, Activity),
628 (ToolAction, ToolAction, true, GroupedTool),
629 (DurableWork, DurableWork, true, GroupedTool),
630 (ToolAction, DurableWork, false, Activity),
631 ];
632
633 for (current, next, grouped_tools, expected) in cases {
634 assert_eq!(
635 transcript_boundary(current, next, grouped_tools),
636 expected,
637 "{current:?} -> {next:?}"
638 );
639 }
640
641 assert_eq!(
642 spacer_rows_for_boundary(Turn, TranscriptSpacing::Compact),
643 1
644 );
645 assert_eq!(
646 spacer_rows_for_boundary(Turn, TranscriptSpacing::Comfortable),
647 1
648 );
649 assert_eq!(
650 spacer_rows_for_boundary(Turn, TranscriptSpacing::Spacious),
651 2
652 );
653 assert_eq!(
654 spacer_rows_for_boundary(Activity, TranscriptSpacing::Compact),
655 0
656 );
657 assert_eq!(
658 spacer_rows_for_boundary(Activity, TranscriptSpacing::Comfortable),
659 1
660 );
661 assert_eq!(
662 spacer_rows_for_boundary(Activity, TranscriptSpacing::Spacious),
663 1
664 );
665 assert_eq!(
666 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Compact),
667 0,
668 "compact density buys its density by spending no separator rows"
669 );
670 assert_eq!(
671 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Comfortable),
672 0,
673 "the shared rail carries grouping without a row per tool call"
674 );
675 assert_eq!(
676 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Spacious),
677 0,
678 "even spacious mode breathes around the group, not inside it"
679 );
680 }
681
682 /// Separation is one row or none. Nothing in the matrix may produce a
683 /// double blank, because a scrolling terminal cannot afford it.
684 #[test]
685 fn no_boundary_ever_spends_more_than_one_row_below_spacious_turns() {
686 use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
687
688 for boundary in [Joined, GroupedTool, Activity, Turn] {
689 for spacing in [
690 TranscriptSpacing::Compact,
691 TranscriptSpacing::Comfortable,
692 TranscriptSpacing::Spacious,
693 ] {
694 let rows = spacer_rows_for_boundary(boundary, spacing);
695 let allowed = if boundary == Turn && spacing == TranscriptSpacing::Spacious {
696 2
697 } else {
698 BLOCK_SEPARATOR_ROWS
699 };
700 assert!(
701 rows <= allowed,
702 "{boundary:?} at {spacing:?} spent {rows} rows (max {allowed})"
703 );
704 }
705 }
706 }
707
708 #[test]
709 fn durable_work_tools_have_an_explicit_semantic_role() {
710 let plan = durable_work_cell();
711 let tool = exec_tool_cell("cargo test --locked");
712
713 assert_eq!(
714 TranscriptBlockKind::for_cell(&plan),
715 TranscriptBlockKind::DurableWork
716 );
717 assert_eq!(
718 TranscriptBlockKind::for_cell(&tool),
719 TranscriptBlockKind::ToolAction
720 );
721 }
722
723 #[test]
724 fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
725 let durable = HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
726 snapshot: PlanSnapshot {
727 objective: Some("Keep the release receipt durable".to_string()),
728 ..PlanSnapshot::default()
729 },
730 status: ToolStatus::Running,
731 }));
732 let cells = vec![
733 exec_tool_cell("cargo test --locked"),
734 exec_tool_cell("cargo clippy --locked"),
735 durable,
736 ];
737 let revisions = vec![1u64; cells.len()];
738
739 let mut compact = TranscriptViewCache::new();
740 compact.ensure(
741 &cells,
742 &revisions,
743 80,
744 TranscriptRenderOptions {
745 spacing: TranscriptSpacing::Compact,
746 low_motion: true,
747 ..TranscriptRenderOptions::default()
748 },
749 );
750
751 assert_eq!(spacer_rows_after_cell(&compact, 0), 0);
752 assert_eq!(spacer_rows_after_cell(&compact, 1), 0);
753 let compact_lines = plain_lines(&compact);
754 assert!(
755 !compact_lines.iter().any(String::is_empty),
756 "compact activity seams must not spend a blank row: {compact_lines:?}"
757 );
758 let lines_for_cell = |target| {
759 compact
760 .lines()
761 .iter()
762 .zip(compact.line_meta())
763 .filter_map(|(line, meta)| match meta {
764 TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target => Some(
765 line.spans
766 .iter()
767 .map(|span| span.content.as_ref())
768 .collect::<String>(),
769 ),
770 TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => None,
771 })
772 .collect::<Vec<_>>()
773 };
774 let second_action = lines_for_cell(1);
775 let durable_work = lines_for_cell(2);
776 assert!(
777 second_action
778 .last()
779 .is_some_and(|line| line.starts_with("\u{2570} ")),
780 "ordinary action rail should close before durable Work: {second_action:?}"
781 );
782 assert!(
783 durable_work
784 .first()
785 .is_some_and(|line| line.starts_with("\u{256D} ")),
786 "durable Work should open its own rail: {durable_work:?}"
787 );
788
789 let mut comfortable = TranscriptViewCache::new();
790 comfortable.ensure(
791 &cells,
792 &revisions,
793 80,
794 TranscriptRenderOptions {
795 spacing: TranscriptSpacing::Comfortable,
796 low_motion: true,
797 ..TranscriptRenderOptions::default()
798 },
799 );
800 assert_eq!(
801 spacer_rows_after_cell(&comfortable, 0),
802 0,
803 "two commands inside one activity rail must remain compact"
804 );
805 assert_eq!(
806 spacer_rows_after_cell(&comfortable, 1),
807 1,
808 "durable Work needs a semantic activity row outside compact density"
809 );
810 }
811
812 #[test]
813 fn compact_spacing_keeps_conversation_blocks_separate() {
814 let cells = vec![
815 user_cell("Please verify the release."),
816 assistant_cell("I will check the receipts.", false),
817 ];
818 let revisions = vec![1u64, 1];
819 let mut cache = TranscriptViewCache::new();
820 let options = TranscriptRenderOptions {
821 spacing: TranscriptSpacing::Compact,
822 ..TranscriptRenderOptions::default()
823 };
824
825 cache.ensure(&cells, &revisions, 89, options);
826 let lines = plain_lines(&cache);
827
828 assert!(
829 lines.iter().any(String::is_empty),
830 "compact density still needs one user/assistant boundary: {lines:?}"
831 );
832 }
833
834 #[test]
835 fn compact_spacing_keeps_direct_user_tool_turns_separate() {
836 let cells = vec![
837 user_cell("Inspect the repository."),
838 exec_tool_cell("git status --short"),
839 user_cell("Now summarize the result."),
840 ];
841 let revisions = vec![1u64, 1, 1];
842 let options = TranscriptRenderOptions {
843 spacing: TranscriptSpacing::Compact,
844 low_motion: true,
845 ..TranscriptRenderOptions::default()
846 };
847 let mut cache = TranscriptViewCache::new();
848
849 cache.ensure(&cells, &revisions, 80, options);
850
851 assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
852 assert_eq!(spacer_rows_after_cell(&cache, 1), 1);
853 }
854
855 #[test]
856 fn compact_spacing_keeps_reasoning_and_answer_in_one_response_block() {
857 let cells = vec![
858 HistoryCell::Thinking {
859 content: "I should verify the release receipts first.".to_string(),
860 streaming: false,
861 duration_secs: Some(0.4),
862 },
863 assistant_cell("The release receipts are green.", false),
864 ];
865 let revisions = vec![1u64, 1];
866 let mut cache = TranscriptViewCache::new();
867 let options = TranscriptRenderOptions {
868 spacing: TranscriptSpacing::Compact,
869 ..TranscriptRenderOptions::default()
870 };
871
872 cache.ensure(&cells, &revisions, 89, options);
873 let lines = plain_lines(&cache);
874
875 assert!(
876 !lines.iter().any(String::is_empty),
877 "reasoning and its answer should read as one response block: {lines:?}"
878 );
879 }
880
881 #[test]
882 fn hidden_reasoning_keeps_visible_rhythm_without_phantom_tail_rows() {
883 let cells = vec![
884 user_cell("Verify the release."),
885 HistoryCell::Thinking {
886 content: "Check the exact receipts.".to_string(),
887 streaming: false,
888 duration_secs: Some(0.4),
889 },
890 assistant_cell("The receipts are green.", false),
891 ];
892 let revisions = vec![1u64, 1, 1];
893 let hidden = TranscriptRenderOptions {
894 show_thinking: false,
895 low_motion: true,
896 ..TranscriptRenderOptions::default()
897 };
898 let mut cache = TranscriptViewCache::new();
899
900 cache.ensure(&cells, &revisions, 80, hidden);
901 let hidden_lines = plain_lines(&cache);
902 assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
903 assert!(
904 hidden_lines.last().is_some_and(|line| !line.is_empty()),
905 "hidden cells must not leave a trailing blank row: {hidden_lines:?}"
906 );
907
908 let visible = TranscriptRenderOptions {
909 show_thinking: true,
910 ..hidden
911 };
912 cache.ensure(&cells, &revisions, 80, visible);
913 cache.ensure(&cells, &revisions, 80, hidden);
914 assert_eq!(plain_lines(&cache), hidden_lines);
915
916 let trailing_hidden = &cells[..2];
917 let mut tail_cache = TranscriptViewCache::new();
918 tail_cache.ensure(trailing_hidden, &revisions[..2], 80, hidden);
919 assert!(
920 plain_lines(&tail_cache)
921 .last()
922 .is_some_and(|line| !line.is_empty()),
923 "a hidden final cell must not reserve a phantom spacer"
924 );
925 }
926
927 #[test]
928 fn hidden_reasoning_cache_never_advertises_or_leaks_content() {
929 for streaming in [false, true] {
930 let cells = [reasoning_cell(streaming)];
931 let mut cache = TranscriptViewCache::new();
932 cache.ensure_split(
933 &[&cells],
934 &[1],
935 80,
936 TranscriptRenderOptions {
937 show_thinking: false,
938 ..TranscriptRenderOptions::default()
939 },
940 &HashMap::new(),
941 None,
942 Some(reasoning_owner(0)),
943 );
944 let text = plain_lines(&cache).join("\n");
945 assert_eq!(cache.reasoning_action_target(), None);
946 assert!(
947 !text.contains("reasoning line"),
948 "hidden body leaked: {text}"
949 );
950 assert!(!text.contains("Space:"), "hidden hint leaked: {text}");
951 assert_eq!(text.contains("reasoning hidden"), streaming);
952 }
953 }
954
955 #[test]
956 fn transcript_rhythm_is_width_and_reduced_motion_invariant() {
957 let cells = vec![
958 user_cell("Please inspect the release candidate and verify all receipts."),
959 HistoryCell::Thinking {
960 content: "I will inspect the source, run the checks, and compare the receipts."
961 .to_string(),
962 streaming: true,
963 duration_secs: Some(0.8),
964 },
965 assistant_cell("I will start with the locked test suite.", false),
966 exec_tool_cell("cargo test -p codewhale-tui --bins --locked"),
967 durable_work_cell(),
968 assistant_cell("The focused checks passed.", false),
969 user_cell("Proceed to the final verification."),
970 ];
971 let revisions = vec![1u64; cells.len()];
972 // user | reasoning | answer | tool | work | answer | user.
973 // Every seam is one row: the reasoning→answer seam (index 1) used to be
974 // the one place the transcript ran two blocks together.
975 let expected = [1, 1, 1, 1, 1, 1, 0];
976
977 for width in [40, 80, 100, 140] {
978 for low_motion in [false, true] {
979 let options = TranscriptRenderOptions {
980 low_motion,
981 spacing: TranscriptSpacing::Comfortable,
982 ..TranscriptRenderOptions::default()
983 };
984 let mut cache = TranscriptViewCache::new();
985 cache.ensure(&cells, &revisions, width, options);
986
987 let actual =
988 std::array::from_fn::<_, 7, _>(|index| spacer_rows_after_cell(&cache, index));
989 assert_eq!(actual, expected, "width={width} low_motion={low_motion}");
990 assert!(
991 cache
992 .lines()
993 .iter()
994 .all(|line| line.width() <= usize::from(width)),
995 "render exceeded width={width} low_motion={low_motion}"
996 );
997 }
998 }
999 }
1000
1001 #[test]
1002 fn streaming_state_transitions_do_not_move_neighbor_boundaries() {
1003 let mut cells = vec![
1004 user_cell("Inspect the candidate."),
1005 HistoryCell::Thinking {
1006 content: "Inspecting the candidate now.".to_string(),
1007 streaming: true,
1008 duration_secs: None,
1009 },
1010 exec_tool_cell("git status --short"),
1011 user_cell("Summarize the receipt."),
1012 ];
1013 let mut revisions = vec![1u64; cells.len()];
1014 let options = TranscriptRenderOptions {
1015 low_motion: true,
1016 ..TranscriptRenderOptions::default()
1017 };
1018 let mut cache = TranscriptViewCache::new();
1019
1020 let boundary_rows = |cache: &TranscriptViewCache| {
1021 [
1022 spacer_rows_after_cell(cache, 0),
1023 spacer_rows_after_cell(cache, 1),
1024 spacer_rows_after_cell(cache, 2),
1025 ]
1026 };
1027
1028 cache.ensure(&cells, &revisions, 80, options);
1029 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
1030
1031 cells[1] = assistant_cell("I inspected the candidate.", true);
1032 revisions[1] += 1;
1033 cache.ensure(&cells, &revisions, 80, options);
1034 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
1035
1036 cells[1] = assistant_cell("I inspected the candidate.", false);
1037 revisions[1] += 1;
1038 cache.ensure(&cells, &revisions, 80, options);
1039 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
1040
1041 let HistoryCell::Tool(ToolCell::Exec(exec)) = &mut cells[2] else {
1042 unreachable!("fixture is an exec tool")
1043 };
1044 exec.status = ToolStatus::Success;
1045 revisions[2] += 1;
1046 cache.ensure(&cells, &revisions, 80, options);
1047 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
1048 }
1049
1050 #[test]
1051 fn resize_round_trip_rebuilds_the_same_semantic_rows() {
1052 let cells = vec![
1053 user_cell("A long prompt that wraps when the terminal narrows considerably."),
1054 exec_tool_cell("printf 'a tool receipt with a deliberately long summary'"),
1055 assistant_cell("A stable answer after the tool receipt.", false),
1056 ];
1057 let revisions = vec![1u64; cells.len()];
1058 let options = TranscriptRenderOptions {
1059 low_motion: true,
1060 ..TranscriptRenderOptions::default()
1061 };
1062 let mut cache = TranscriptViewCache::new();
1063
1064 cache.ensure(&cells, &revisions, 140, options);
1065 let wide = plain_lines(&cache);
1066 cache.ensure(&cells, &revisions, 40, options);
1067 cache.ensure(&cells, &revisions, 140, options);
1068
1069 assert_eq!(plain_lines(&cache), wide);
1070 assert_eq!(cache.lines().len(), cache.line_meta().len());
1071 assert_eq!(cache.lines().len(), cache.line_links().len());
1072 }
1073
1074 #[test]
1075 fn palette_mode_change_invalidates_cached_syntax_rendering() {
1076 let cells = vec![assistant_cell(
1077 "```rust\nfn main() { let answer = 42; }\n```",
1078 false,
1079 )];
1080 let revisions = [1u64];
1081 let mut cache = TranscriptViewCache::new();
1082 let dark = TranscriptRenderOptions {
1083 palette_mode: palette::PaletteMode::Dark,
1084 ..TranscriptRenderOptions::default()
1085 };
1086
1087 cache.ensure(&cells, &revisions, 80, dark);
1088 let dark_lines = Arc::clone(&cache.per_cell[0].lines);
1089
1090 cache.ensure(
1091 &cells,
1092 &revisions,
1093 80,
1094 TranscriptRenderOptions {
1095 palette_mode: palette::PaletteMode::Light,
1096 ..dark
1097 },
1098 );
1099
1100 assert!(
1101 !Arc::ptr_eq(&dark_lines, &cache.per_cell[0].lines),
1102 "palette mode is part of TranscriptRenderOptions and must bust cached cells"
1103 );
1104 }
1105
1106 #[test]
1107 fn tool_rails_preserve_rendered_width_budget() {
1108 let cells = vec![exec_tool_cell(
1109 "printf 'this is a command with enough text to wrap in narrow terminals'",
1110 )];
1111 let revisions = vec![1u64];
1112 let mut cache = TranscriptViewCache::new();
1113
1114 cache.ensure(&cells, &revisions, 24, TranscriptRenderOptions::default());
1115
1116 for line in plain_lines(&cache) {
1117 assert!(
1118 unicode_width::UnicodeWidthStr::width(line.as_str()) <= 24,
1119 "tool rail line exceeded narrow width: {line:?}"
1120 );
1121 }
1122 }
1123
1124 /// Simulate a long, complex conversation (thinking + multi-line tool output +
1125 /// tool headers with multiple decorative spans) and report the memory
1126 /// consumed by `rail_prefix_widths`. This is informational — the assertion
1127 /// only fails if the per-line overhead exceeds a generous bound.
1128 // Test prints memory-overhead diagnostics — runs in `cargo test`, never
1129 // inside the TUI alt-screen, so the module-level deny doesn't apply.
1130 #[allow(clippy::print_stderr)]
1131 #[test]
1132 fn rail_prefix_widths_memory_overhead_complex_session() {
1133 let mut cells: Vec<HistoryCell> = Vec::new();
1134 // Build ~60 turns covering the typical deep-reasoning workflow:
1135 // user → thinking (5-15 lines) → assistant → tool → tool output →
1136 // thinking → assistant → ... repeat.
1137 for i in 0..30 {
1138 cells.push(user_cell(&format!("complex query {i} about system design")));
1139 cells.push(HistoryCell::Thinking {
1140 content:
1141 "line A\nline B\nline C\nline D\nline E\nline F\nline G\nline H\nline I\nline J"
1142 .to_string(),
1143 streaming: false,
1144 duration_secs: Some(3.5),
1145 });
1146 cells.push(assistant_cell(
1147 &format!("response {i} with multi-line\ntext content spanning\nseveral lines"),
1148 false,
1149 ));
1150 cells.push(exec_tool_cell(
1151 "cargo test --package my_crate -- --nocapture 2>&1 | head -40",
1152 ));
1153 // Insert a second tool so adjacent tool cells merge into a railed group.
1154 cells.push(exec_tool_cell(&format!("git diff --stat HEAD~{i}")));
1155 }
1156 let revisions: Vec<u64> = (0..cells.len()).map(|i| i as u64 + 1).collect();
1157
1158 let mut cache = TranscriptViewCache::new();
1159 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
1160
1161 let total_lines = cache.total_lines();
1162 let pw_len = cache.rail_prefix_widths.len();
1163 let pw_cap = cache.rail_prefix_widths.capacity();
1164 // The Vec's inlined buffer on most platforms is small; capacity
1165 // should be >= len. Both must equal total_lines.
1166 assert_eq!(pw_len, total_lines);
1167 assert!(pw_cap >= pw_len);
1168
1169 let memory_bytes = pw_cap * std::mem::size_of::<usize>();
1170 let memory_kb = memory_bytes as f64 / 1024.0;
1171 // Each usize is 8 bytes on 64-bit. Even with 100k lines this stays
1172 // under 1 MB.
1173 let kbytes_per_1k_lines = (memory_bytes as f64 / total_lines as f64) * 1000.0 / 1024.0;
1174
1175 eprintln!("=== rail_prefix_widths memory (complex session) ===");
1176 eprintln!(" total_lines: {total_lines}");
1177 eprintln!(" vec len: {pw_len}");
1178 eprintln!(" vec capacity: {pw_cap}");
1179 eprintln!(" memory (bytes): {memory_bytes}");
1180 eprintln!(" memory (KB): {memory_kb:.2}");
1181 eprintln!(" KB per 1k lines: {kbytes_per_1k_lines:.2}");
1182 eprintln!(" lines × 8 bytes: {} KB", total_lines * 8 / 1024);
1183
1184 // Sanity: per-line overhead must be reasonable.
1185 assert!(
1186 memory_kb < 1024.0,
1187 "rail_prefix_widths memory unexpectedly large: {memory_kb:.1} KB"
1188 );
1189 eprintln!(" ✓ well under 1 MB even for very long sessions");
1190 }
1191
1192 #[test]
1193 fn ensure_filtered_matches_ensure_split_output() {
1194 let cells = vec![
1195 user_cell("hello"),
1196 assistant_cell("some **markdown** body", false),
1197 exec_tool_cell("cargo test"),
1198 user_cell("again"),
1199 ];
1200 let revisions = vec![1u64, 2, 3, 4];
1201 let index_map: Vec<usize> = vec![0, 1, 2, 3];
1202 // This test compares the two cache traversal paths, not animation.
1203 // Freeze live motion so a spinner tick between the two renders cannot
1204 // turn an equivalent layout into a timing-dependent failure.
1205 let options = TranscriptRenderOptions {
1206 low_motion: true,
1207 motion_mode: crate::tui::motion::MotionMode::Still,
1208 ..TranscriptRenderOptions::default()
1209 };
1210
1211 let mut split_cache = TranscriptViewCache::new();
1212 split_cache.ensure_split(
1213 &[&cells],
1214 &revisions,
1215 40,
1216 options,
1217 &HashMap::new(),
1218 Some(&index_map),
1219 None,
1220 );
1221
1222 let refs: Vec<&HistoryCell> = cells.iter().collect();
1223 let mut filtered_cache = TranscriptViewCache::new();
1224 filtered_cache.ensure_filtered(
1225 &refs,
1226 &revisions,
1227 40,
1228 options,
1229 &HashMap::new(),
1230 Some(&index_map),
1231 None,
1232 );
1233
1234 assert_eq!(plain_lines(&split_cache), plain_lines(&filtered_cache));
1235 assert_eq!(
1236 split_cache.line_meta().len(),
1237 filtered_cache.line_meta().len()
1238 );
1239 }
1240
1241 #[test]
1242 fn ensure_filtered_reuses_unchanged_cells() {
1243 let cells = [
1244 user_cell("hello"),
1245 assistant_cell("streaming", true),
1246 user_cell("again"),
1247 ];
1248 let mut revisions = vec![1u64, 1, 1];
1249 let refs: Vec<&HistoryCell> = cells.iter().collect();
1250
1251 let mut cache = TranscriptViewCache::new();
1252 cache.ensure_filtered(
1253 &refs,
1254 &revisions,
1255 80,
1256 TranscriptRenderOptions::default(),
1257 &HashMap::new(),
1258 None,
1259 None,
1260 );
1261 let first = plain_lines(&cache);
1262
1263 cache.ensure_filtered(
1264 &refs,
1265 &revisions,
1266 80,
1267 TranscriptRenderOptions::default(),
1268 &HashMap::new(),
1269 None,
1270 None,
1271 );
1272 assert_eq!(first, plain_lines(&cache));
1273 for (idx, cached) in cache.per_cell.iter().enumerate() {
1274 assert_eq!(
1275 cached.revision, 1,
1276 "cell {idx} must be reused, not re-rendered"
1277 );
1278 }
1279
1280 // Bump one revision: only that entry re-renders.
1281 revisions[1] = 2;
1282 cache.ensure_filtered(
1283 &refs,
1284 &revisions,
1285 80,
1286 TranscriptRenderOptions::default(),
1287 &HashMap::new(),
1288 None,
1289 None,
1290 );
1291 assert_eq!(cache.per_cell[0].revision, 1);
1292 assert_eq!(cache.per_cell[1].revision, 2);
1293 assert_eq!(cache.per_cell[2].revision, 1);
1294 }
1295
1296 #[test]
1297 fn prose_cells_fill_full_width_on_ultrawide_by_default() {
1298 // #5436: prose (user/assistant/thinking) spends the full content width
1299 // on wide terminals, consistent with tool cells and the #5322
1300 // wide-frame decision. The old 105-column rail is gone unless
1301 // `transcript.prose_measure` opts back into a bounded measure. The
1302 // cache key stays `(CellId, fed_width, revision)` so resize keeps its
1303 // single-feed cost model; the per-cell measure is applied inside the
1304 // render entry points.
1305 const RETIRED_RAIL_MEASURE: usize = 105;
1306 let long = "ultrawide prose paragraph that wraps its words across \
1307 the whole terminal canvas, repeated to guarantee \
1308 several wrapped rows at any column budget, "
1309 .repeat(6);
1310 let cells = [
1311 user_cell(&long),
1312 assistant_cell(&long, false),
1313 HistoryCell::Thinking {
1314 content: long.clone(),
1315 streaming: false,
1316 duration_secs: Some(2.0),
1317 },
1318 exec_tool_cell_with_output(
1319 "cargo test --all",
1320 "long tool output that itself wraps well past the prose measure ".repeat(6),
1321 ),
1322 ];
1323 let refs: Vec<&HistoryCell> = cells.iter().collect();
1324 let revisions = vec![1u64, 2, 3, 4];
1325 let options = TranscriptRenderOptions {
1326 low_motion: true,
1327 motion_mode: crate::tui::motion::MotionMode::Still,
1328 // Expanded thinking so the reasoning body also spends the width.
1329 verbose: true,
1330 thinking_default_expanded: true,
1331 ..TranscriptRenderOptions::default()
1332 };
1333
1334 let mut cache = TranscriptViewCache::new();
1335 cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
1336
1337 for idx in 0..3 {
1338 let width = max_line_width(&cache.per_cell[idx].lines);
1339 assert!(
1340 width > RETIRED_RAIL_MEASURE,
1341 "prose cell {idx} wrapped to {width} columns — still on the retired \
1342 {RETIRED_RAIL_MEASURE}-column rail",
1343 );
1344 assert!(
1345 width <= 220,
1346 "prose cell {idx} wrapped to {width} columns, past the 220-column canvas",
1347 );
1348 }
1349 let tool_width = max_line_width(&cache.per_cell[3].lines);
1350 assert!(
1351 tool_width > RETIRED_RAIL_MEASURE,
1352 "tool cell must keep the full width, got {tool_width}",
1353 );
1354 }
1355
1356 #[test]
1357 fn transcript_prose_measure_caps_prose_but_not_tools() {
1358 // A positive `transcript.prose_measure` restores a bounded reading
1359 // measure for prose only; tool/status cells keep the full content width
1360 // (#5436). The 120-column cap is deliberately above the retired
1361 // 105-column rail so a pass proves the configured cap — not the old
1362 // default — is in effect.
1363 let long = "ultrawide ".repeat(400);
1364 let cells = [
1365 user_cell(&long),
1366 assistant_cell(&long, false),
1367 HistoryCell::Thinking {
1368 content: long.clone(),
1369 streaming: false,
1370 duration_secs: Some(2.0),
1371 },
1372 exec_tool_cell_with_output(
1373 "cargo test --all",
1374 "long tool output that itself wraps well past the prose measure ".repeat(6),
1375 ),
1376 ];
1377 let refs: Vec<&HistoryCell> = cells.iter().collect();
1378 let revisions = vec![1u64, 2, 3, 4];
1379 let options = TranscriptRenderOptions {
1380 low_motion: true,
1381 motion_mode: crate::tui::motion::MotionMode::Still,
1382 verbose: true,
1383 thinking_default_expanded: true,
1384 prose_measure: Some(120),
1385 ..TranscriptRenderOptions::default()
1386 };
1387
1388 let mut cache = TranscriptViewCache::new();
1389 cache.ensure_filtered(&refs, &revisions, 220, options, &HashMap::new(), None, None);
1390
1391 for idx in 0..3 {
1392 let width = max_line_width(&cache.per_cell[idx].lines);
1393 assert!(
1394 width > 105,
1395 "prose cell {idx} wrapped to {width} columns — still on the retired \
1396 105-column rail, so the configured cap is not in effect",
1397 );
1398 assert!(
1399 width <= 120,
1400 "prose cell {idx} wrapped to {width} columns, over the 120-column measure",
1401 );
1402 }
1403 let tool_width = max_line_width(&cache.per_cell[3].lines);
1404 assert!(
1405 tool_width > 120,
1406 "tool cell must keep the full width past the prose measure, got {tool_width}",
1407 );
1408 }
1409
1410 #[test]
1411 fn overlay_and_streaming_entries_share_the_prose_measure() {
1412 // The main cache and the full-screen live-transcript overlay must agree
1413 // on the same effective prose width — the reason the measure rides on
1414 // `TranscriptRenderOptions` instead of being re-derived per cell
1415 // (#5436). Render one streaming assistant message through both
1416 // live-transcript entry points: the copy-metadata path (overlay) and
1417 // the incremental streaming path (active cell).
1418 let long = "ultrawide ".repeat(400);
1419 let cell = assistant_cell(&long, true);
1420 let options = TranscriptRenderOptions {
1421 low_motion: true,
1422 motion_mode: crate::tui::motion::MotionMode::Still,
1423 prose_measure: Some(120),
1424 ..TranscriptRenderOptions::default()
1425 };
1426
1427 let overlay_lines = cell.lines_with_copy_metadata(220, options);
1428 let overlay_width = overlay_lines
1429 .iter()
1430 .map(|line| max_line_width(std::slice::from_ref(&line.line)))
1431 .max()
1432 .unwrap_or(0);
1433
1434 let mut cache = crate::tui::markdown_render::IncrementalMarkdownRenderCache::default();
1435 let mut streaming_lines = Vec::new();
1436 let mut links = Vec::new();
1437 let mut separators = Vec::new();
1438 let mut prefix_widths = Vec::new();
1439 cell.update_incremental_streaming_render(
1440 220,
1441 options,
1442 false,
1443 &mut cache,
1444 &mut streaming_lines,
1445 &mut links,
1446 &mut separators,
1447 &mut prefix_widths,
1448 );
1449 let streaming_width = max_line_width(&streaming_lines);
1450
1451 for (name, width) in [("overlay", overlay_width), ("streaming", streaming_width)] {
1452 assert!(
1453 width > 105,
1454 "{name} entry stayed on the retired 105-column rail ({width} columns)",
1455 );
1456 assert!(
1457 width <= 120,
1458 "{name} entry wrapped to {width} columns, over the 120-column measure",
1459 );
1460 }
1461 }
1462
1463 #[test]
1464 fn prose_width_resolves_the_transcript_prose_measure_contract() {
1465 // Absent = full content width (floored at 1); a positive cap clamps
1466 // from above only, so narrow terminals keep their content width
1467 // (#5436: 0/absent means full width).
1468 let full = TranscriptRenderOptions::default();
1469 assert_eq!(full.prose_measure, None);
1470 assert_eq!(full.prose_width(220), 220);
1471 assert_eq!(full.prose_width(96), 96);
1472 assert_eq!(full.prose_width(0), 1);
1473
1474 let capped = TranscriptRenderOptions {
1475 prose_measure: Some(120),
1476 ..TranscriptRenderOptions::default()
1477 };
1478 assert_eq!(capped.prose_width(220), 120);
1479 assert_eq!(capped.prose_width(96), 96);
1480 assert_eq!(capped.prose_width(0), 1);
1481 }
1482
1483 fn max_line_width(lines: &[Line<'static>]) -> usize {
1484 lines
1485 .iter()
1486 .map(|line| {
1487 line.spans
1488 .iter()
1489 .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
1490 .sum()
1491 })
1492 .max()
1493 .unwrap_or(0)
1494 }
1495
1496 #[test]
1497 fn folded_thinking_cache_invalidation() {
1498 let long_content = "reasoning line\n".repeat(50);
1499 let cells = [HistoryCell::Thinking {
1500 content: long_content.clone(),
1501 streaming: false,
1502 duration_secs: Some(1.5),
1503 }];
1504 let revisions = [1u64];
1505 let options = TranscriptRenderOptions {
1506 verbose: true, // expanded by default
1507 ..TranscriptRenderOptions::default()
1508 };
1509 let width = 80u16;
1510
1511 // First render: no folding → full content.
1512 let mut cache = TranscriptViewCache::new();
1513 cache.ensure_split(
1514 &[&cells],
1515 &revisions,
1516 width,
1517 options,
1518 &HashMap::new(),
1519 None,
1520 None,
1521 );
1522 let full_line_count = cache.total_lines();
1523
1524 // Second render: fold the thinking cell → should invalidate and
1525 // produce fewer lines (collapsed summary).
1526 let mut folded = HashMap::new();
1527 folded.insert(0usize, ThinkingFold::Collapsed);
1528 cache.ensure_split(&[&cells], &revisions, width, options, &folded, None, None);
1529 let folded_line_count = cache.total_lines();
1530
1531 assert!(
1532 folded_line_count < full_line_count,
1533 "folded thinking should render fewer lines: folded={folded_line_count} full={full_line_count}"
1534 );
1535
1536 // Third render: unfold → should restore full content.
1537 cache.ensure_split(
1538 &[&cells],
1539 &revisions,
1540 width,
1541 options,
1542 &HashMap::new(),
1543 None,
1544 None,
1545 );
1546 let restored_line_count = cache.total_lines();
1547 assert_eq!(
1548 restored_line_count, full_line_count,
1549 "unfolded thinking should restore full line count"
1550 );
1551 }
1552
1553 #[test]
1554 fn folded_thinking_with_collapsed_cells_uses_original_indices() {
1555 // Two thinking cells: cell 0 and cell 1. Cell 0 is collapsed (hidden).
1556 // Fold cell 1 (original index 1). With the filtered index map,
1557 // the cache should still fold the correct cell.
1558 let cells = [
1559 HistoryCell::Thinking {
1560 content: "first thinking block\n".repeat(20),
1561 streaming: false,
1562 duration_secs: Some(1.0),
1563 },
1564 HistoryCell::Thinking {
1565 content: "second thinking block\n".repeat(20),
1566 streaming: false,
1567 duration_secs: Some(2.0),
1568 },
1569 ];
1570 let revisions = [1u64, 2u64];
1571 let options = TranscriptRenderOptions {
1572 verbose: true,
1573 ..TranscriptRenderOptions::default()
1574 };
1575 let width = 80u16;
1576
1577 // No collapsing, no folding — baseline.
1578 let mut cache = TranscriptViewCache::new();
1579 cache.ensure_split(
1580 &[&cells],
1581 &revisions,
1582 width,
1583 options,
1584 &HashMap::new(),
1585 None,
1586 None,
1587 );
1588 let baseline = cache.total_lines();
1589 assert!(baseline > 0, "baseline render should contain visible lines");
1590
1591 // Collapse cell 0, fold cell 1. The filtered list has only cell 1
1592 // at filtered index 0, but it maps to original index 1.
1593 let filtered_cells = [cells[1].clone()];
1594 let filtered_revs = [2u64];
1595 let index_map: Vec<usize> = vec![1]; // filtered 0 → original 1
1596
1597 let mut folded = HashMap::new();
1598 folded.insert(1usize, ThinkingFold::Collapsed); // fold original index 1
1599
1600 let mut cache2 = TranscriptViewCache::new();
1601 cache2.ensure_split(
1602 &[&filtered_cells],
1603 &filtered_revs,
1604 width,
1605 options,
1606 &folded,
1607 Some(&index_map),
1608 None,
1609 );
1610 let folded_filtered = cache2.total_lines();
1611
1612 // Cell 1 was expanded in baseline; now it should be folded.
1613 // We can't compare directly to baseline because baseline had both
1614 // cells, but folded_filtered should be less than if cell 1 were
1615 // expanded in the filtered view.
1616 let mut cache3 = TranscriptViewCache::new();
1617 cache3.ensure_split(
1618 &[&filtered_cells],
1619 &filtered_revs,
1620 width,
1621 options,
1622 &HashMap::new(),
1623 Some(&index_map),
1624 None,
1625 );
1626 let expanded_filtered = cache3.total_lines();
1627
1628 assert!(
1629 folded_filtered < expanded_filtered,
1630 "folded cell via index map should render fewer lines: folded={folded_filtered} expanded={expanded_filtered}"
1631 );
1632 }
1633
1634 #[test]
1635 fn reasoning_target_transfer_rewrites_same_revision_cells() {
1636 let cells = vec![reasoning_cell(false), reasoning_cell(false)];
1637 let revisions = [1, 2];
1638 let mut cache = TranscriptViewCache::new();
1639 let options = TranscriptRenderOptions::default();
1640 let hint_cells = |cache: &TranscriptViewCache| {
1641 cache
1642 .lines()
1643 .iter()
1644 .zip(cache.line_meta())
1645 .filter(|(line, _)| line.to_string().contains("Space:expand"))
1646 .filter_map(|(_, meta)| meta.cell_line().map(|(cell, _)| cell))
1647 .collect::<Vec<_>>()
1648 };
1649
1650 cache.ensure_split(
1651 &[&cells],
1652 &revisions,
1653 80,
1654 options,
1655 &HashMap::new(),
1656 None,
1657 Some(reasoning_owner(0)),
1658 );
1659 let total = cache.total_lines();
1660 assert_eq!(hint_cells(&cache), vec![0]);
1661 let cached_lines = cache
1662 .per_cell
1663 .iter()
1664 .map(|cell| Arc::as_ptr(&cell.lines))
1665 .collect::<Vec<_>>();
1666 let (hint_line, hint_meta) = cache
1667 .lines()
1668 .iter()
1669 .zip(cache.line_meta())
1670 .find(|(line, _)| line.to_string().contains("Space:expand"))
1671 .expect("hint line");
1672 let hint_index = cache
1673 .lines()
1674 .iter()
1675 .position(|line| line.to_string().contains("Space:expand"))
1676 .expect("hint index");
1677 assert_eq!(
1678 hint_meta.copy_prefix_width(),
1679 hint_line
1680 .width()
1681 .saturating_sub(cache.rail_prefix_width(hint_index))
1682 );
1683 let (neutral_index, neutral_line, neutral_meta) = cache
1684 .lines()
1685 .iter()
1686 .zip(cache.line_meta())
1687 .enumerate()
1688 .find(|(_, (_, meta))| meta.cell_line() == Some((1, 0)))
1689 .map(|(index, (line, meta))| (index, line, meta))
1690 .expect("untargeted neutral affordance");
1691 assert_eq!(
1692 neutral_meta.copy_prefix_width(),
1693 neutral_line
1694 .width()
1695 .saturating_sub(cache.rail_prefix_width(neutral_index))
1696 );
1697 assert!(cache.line_links[neutral_index].is_empty());
1698
1699 cache.retarget(Some(reasoning_owner(1)), None);
1700 assert_eq!(cache.total_lines(), total);
1701 assert_eq!(hint_cells(&cache), vec![1]);
1702 assert_eq!(
1703 cache
1704 .per_cell
1705 .iter()
1706 .map(|cell| Arc::as_ptr(&cell.lines))
1707 .collect::<Vec<_>>(),
1708 cached_lines,
1709 "target transfer must reuse neutral Markdown renders"
1710 );
1711
1712 cache.retarget(None, None);
1713 assert_eq!(cache.total_lines(), total);
1714 assert!(hint_cells(&cache).is_empty());
1715 }
1716
1717 /// Scrolling moves the Space owner to the newest visible cell. On a long
1718 /// transcript that must repaint the two affordance rows, not re-flatten the
1719 /// whole tail below the previous owner (#6652).
1720 fn long_reasoning_transcript(pairs: usize) -> (Vec<HistoryCell>, Vec<u64>) {
1721 let cells = (0..pairs)
1722 .flat_map(|turn| {
1723 [
1724 reasoning_cell(false),
1725 assistant_cell(&format!("answer {turn}\n\nmore prose for {turn}"), false),
1726 ]
1727 })
1728 .collect::<Vec<_>>();
1729 let revisions = vec![1; cells.len()];
1730 (cells, revisions)
1731 }
1732
1733 fn assert_same_flat_output(cache: &TranscriptViewCache, cold: &TranscriptViewCache) {
1734 assert_eq!(cache.lines(), cold.lines());
1735 assert_eq!(cache.line_links(), cold.line_links());
1736 assert_eq!(cache.line_meta(), cold.line_meta());
1737 assert_eq!(cache.rail_prefix_widths, cold.rail_prefix_widths);
1738 assert_eq!(cache.cell_line_starts, cold.cell_line_starts);
1739 }
1740
1741 #[test]
1742 fn retargeting_a_long_transcript_repaints_only_the_hint_rows() {
1743 let (cells, revisions) = long_reasoning_transcript(100);
1744 let options = TranscriptRenderOptions::default();
1745 let ensure = |cache: &mut TranscriptViewCache, owner: usize| {
1746 cache.ensure_split(
1747 &[&cells],
1748 &revisions,
1749 80,
1750 options,
1751 &HashMap::new(),
1752 None,
1753 Some(reasoning_owner(owner)),
1754 );
1755 };
1756 let mut cache = TranscriptViewCache::new();
1757 ensure(&mut cache, 0);
1758 assert!(cache.total_lines() > 600, "{}", cache.total_lines());
1759
1760 // Scroll-frame path: `retarget` after layout.
1761 for owner in [2, 100, 198, 0] {
1762 let before = cache.streaming_lines_reflattened();
1763 cache.retarget(Some(reasoning_owner(owner)), None);
1764 assert!(
1765 cache.streaming_lines_reflattened() - before <= 2,
1766 "retarget to {owner} re-flattened {} rows",
1767 cache.streaming_lines_reflattened() - before
1768 );
1769 let mut cold = TranscriptViewCache::new();
1770 ensure(&mut cold, owner);
1771 assert_same_flat_output(&cache, &cold);
1772 }
1773
1774 // Provisional-owner path: `ensure_split` with only the owner moved.
1775 for owner in [4, 150, 0] {
1776 let before = cache.streaming_lines_reflattened();
1777 ensure(&mut cache, owner);
1778 assert!(
1779 cache.streaming_lines_reflattened() - before <= 2,
1780 "ensure with owner {owner} re-flattened {} rows",
1781 cache.streaming_lines_reflattened() - before
1782 );
1783 let mut cold = TranscriptViewCache::new();
1784 ensure(&mut cold, owner);
1785 assert_same_flat_output(&cache, &cold);
1786 }
1787
1788 // An answer cell owns no hint: moving there only clears the old row.
1789 let before = cache.streaming_lines_reflattened();
1790 cache.retarget(Some(reasoning_owner(1)), None);
1791 assert_eq!(cache.streaming_lines_reflattened() - before, 1);
1792 assert!(!plain_lines(&cache).join("\n").contains("Space:expand"));
1793 }
1794
1795 /// While a reply streams its cell is dirty every frame. Scrolling in that
1796 /// state must still repaint only the hint rows plus the streamed tail, not
1797 /// re-flatten everything below the old owner (#6652).
1798 #[test]
1799 fn retargeting_while_streaming_repaints_only_hint_rows_and_tail() {
1800 let (mut cells, _) = long_reasoning_transcript(100);
1801 // A reasoning cell right above the streaming reply: moving the hint
1802 // there lands inside the rows this frame rebuilds anyway.
1803 cells.push(reasoning_cell(false));
1804 let mut content = String::from("streamed start\n\n");
1805 cells.push(assistant_cell(&content, true));
1806 let streaming = cells.len() - 1;
1807 let mut revisions = vec![1; cells.len()];
1808 let options = TranscriptRenderOptions {
1809 low_motion: true,
1810 ..TranscriptRenderOptions::default()
1811 };
1812 let ensure = |cache: &mut TranscriptViewCache, cells: &[HistoryCell], revs: &[u64], owner| {
1813 cache.ensure_split(
1814 &[cells],
1815 revs,
1816 80,
1817 options,
1818 &HashMap::new(),
1819 None,
1820 Some(reasoning_owner(owner)),
1821 );
1822 };
1823 let mut cache = TranscriptViewCache::new();
1824 ensure(&mut cache, &cells, &revisions, 0);
1825 assert!(cache.total_lines() > 600, "{}", cache.total_lines());
1826
1827 for (frame, owner) in [2, 100, 198, streaming - 1, 0, 4].into_iter().enumerate() {
1828 let previous = revisions[streaming];
1829 revisions[streaming] += 1;
1830 content.push_str(&format!("chunk {frame} of the streamed reply\n\n"));
1831 cells[streaming] = assistant_cell(&content, true);
1832 cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
1833 cell_index: streaming,
1834 from_revision: previous,
1835 to_revision: revisions[streaming],
1836 content_len: content.len(),
1837 }));
1838 let before = cache.streaming_lines_reflattened();
1839 ensure(&mut cache, &cells, &revisions, owner);
1840 let work = cache.streaming_lines_reflattened() - before;
1841 let tail_rows = (cache.per_cell[streaming - 1].lines.len()
1842 + cache.per_cell[streaming].lines.len()
1843 + 4) as u64;
1844 assert!(
1845 work <= tail_rows,
1846 "streaming frame with owner {owner} re-flattened {work} rows (tail {tail_rows})"
1847 );
1848 let mut cold = TranscriptViewCache::new();
1849 ensure(&mut cold, &cells, &revisions, owner);
1850 assert_same_flat_output(&cache, &cold);
1851 }
1852 }
1853
1854 /// Collapsed transcripts render through an original-index map, and hidden
1855 /// cells record their successor's line start. In-place hint repaint must
1856 /// land on the right rows in both cases (#6652).
1857 #[test]
1858 fn retargeting_a_filtered_transcript_with_hidden_cells_repaints_in_place() {
1859 let original = (0..80)
1860 .flat_map(|turn| {
1861 [
1862 reasoning_cell(false),
1863 // Renders no rows: a hidden cell between reasoning cells.
1864 assistant_cell("", false),
1865 reasoning_cell(false),
1866 assistant_cell(&format!("answer {turn}\n\nmore prose for {turn}"), false),
1867 // Dropped by the filter, so rendered and original indices diverge.
1868 assistant_cell(&format!("collapsed {turn}"), false),
1869 ]
1870 })
1871 .collect::<Vec<_>>();
1872 let map = (0..original.len())
1873 .filter(|index| index % 5 != 4)
1874 .collect::<Vec<_>>();
1875 let refs = map
1876 .iter()
1877 .map(|&index| &original[index])
1878 .collect::<Vec<_>>();
1879 let revisions = vec![1; refs.len()];
1880 let options = TranscriptRenderOptions::default();
1881 let ensure = |cache: &mut TranscriptViewCache, owner: usize| {
1882 cache.ensure_filtered(
1883 &refs,
1884 &revisions,
1885 80,
1886 options,
1887 &HashMap::new(),
1888 Some(&map),
1889 Some(reasoning_owner(owner)),
1890 );
1891 };
1892 let mut cache = TranscriptViewCache::new();
1893 ensure(&mut cache, 0);
1894 assert!(cache.per_cell.iter().any(|cell| cell.is_empty));
1895 assert!(cache.total_lines() > 600, "{}", cache.total_lines());
1896
1897 // Original indices: 2 and 7 sit after a hidden cell, 397 is the last
1898 // reasoning cell, and 1 is the hidden cell itself (no hint).
1899 for owner in [2, 150, 397, 7, 0, 1, 5] {
1900 let before = cache.streaming_lines_reflattened();
1901 cache.retarget(Some(reasoning_owner(owner)), Some(&map));
1902 assert!(
1903 cache.streaming_lines_reflattened() - before <= 2,
1904 "retarget to {owner} re-flattened {} rows",
1905 cache.streaming_lines_reflattened() - before
1906 );
1907 let mut cold = TranscriptViewCache::new();
1908 ensure(&mut cold, owner);
1909 assert_same_flat_output(&cache, &cold);
1910
1911 let next = if owner == 0 { 5 } else { 0 };
1912 let before = cache.streaming_lines_reflattened();
1913 ensure(&mut cache, next);
1914 assert!(
1915 cache.streaming_lines_reflattened() - before <= 2,
1916 "ensure with owner {next} re-flattened {} rows",
1917 cache.streaming_lines_reflattened() - before
1918 );
1919 let mut cold = TranscriptViewCache::new();
1920 ensure(&mut cold, next);
1921 assert_same_flat_output(&cache, &cold);
1922 }
1923 }
1924
1925 /// Synthetic scroll benchmark for #6652; run with `--ignored --nocapture`.
1926 #[test]
1927 #[ignore = "timing benchmark, not a correctness gate"]
1928 #[allow(clippy::print_stderr)]
1929 fn bench_retarget_scroll_over_long_transcript() {
1930 let (cells, revisions) = long_reasoning_transcript(2_000);
1931 let mut cache = TranscriptViewCache::new();
1932 cache.ensure_split(
1933 &[&cells],
1934 &revisions,
1935 120,
1936 TranscriptRenderOptions::default(),
1937 &HashMap::new(),
1938 None,
1939 Some(reasoning_owner(0)),
1940 );
1941 let before = cache.streaming_lines_reflattened();
1942 let started = std::time::Instant::now();
1943 let frames = 400;
1944 for frame in 0..frames {
1945 // Walk the owner between early reasoning cells as a scroll would.
1946 cache.retarget(Some(reasoning_owner((frame % 20) * 2)), None);
1947 }
1948 let elapsed = started.elapsed();
1949 eprintln!(
1950 "#6652 bench: {} lines, {frames} retargets, {:?} total, {:?}/frame, {} rows re-flattened",
1951 cache.total_lines(),
1952 elapsed,
1953 elapsed / frames as u32,
1954 cache.streaming_lines_reflattened() - before
1955 );
1956 }
1957
1958 #[test]
1959 fn filtered_reasoning_owner_keeps_original_identity() {
1960 let cells = [reasoning_cell(false)];
1961 let revisions = [2];
1962 let original_map = [1];
1963 let mut cache = TranscriptViewCache::new();
1964 cache.ensure_split(
1965 &[&cells],
1966 &revisions,
1967 80,
1968 TranscriptRenderOptions::default(),
1969 &HashMap::new(),
1970 Some(&original_map),
1971 Some(reasoning_owner(1)),
1972 );
1973 assert!(plain_lines(&cache).join("\n").contains("Space:expand"));
1974 assert_eq!(
1975 cache.reasoning_action_target(),
1976 Some(ReasoningActionTarget {
1977 owner: reasoning_owner(1),
1978 action: ReasoningAction::Expand,
1979 })
1980 );
1981 assert!(
1982 cache
1983 .line_meta()
1984 .iter()
1985 .any(|meta| meta.cell_line().is_some_and(|(rendered, _)| rendered == 0))
1986 );
1987
1988 cache.ensure_split(
1989 &[&cells],
1990 &revisions,
1991 80,
1992 TranscriptRenderOptions::default(),
1993 &HashMap::new(),
1994 Some(&original_map),
1995 Some(reasoning_owner(0)),
1996 );
1997 assert!(cache.reasoning_action_target().is_none());
1998 assert!(!plain_lines(&cache).join("\n").contains("Space:expand"));
1999 }
2000
2001 #[test]
2002 fn streaming_tail_fast_path_cannot_skip_reasoning_retarget() {
2003 let cells = [reasoning_cell(false), assistant_cell("tail", true)];
2004 let mut cache = TranscriptViewCache::new();
2005 cache.ensure_split(
2006 &[&cells],
2007 &[1, 1],
2008 80,
2009 TranscriptRenderOptions::default(),
2010 &HashMap::new(),
2011 None,
2012 Some(reasoning_owner(0)),
2013 );
2014 assert!(plain_lines(&cache).join("\n").contains("Space:expand"));
2015
2016 let updated = [reasoning_cell(false), assistant_cell("tail extended", true)];
2017 cache.ensure_split(
2018 &[&updated],
2019 &[1, 2],
2020 80,
2021 TranscriptRenderOptions::default(),
2022 &HashMap::new(),
2023 None,
2024 None,
2025 );
2026 assert!(cache.reasoning_action_target().is_none());
2027 assert!(!plain_lines(&cache).join("\n").contains("Space:expand"));
2028 }
2029
2030 #[test]
2031 fn narrow_reasoning_hint_never_changes_cache_geometry() {
2032 let cells = [reasoning_cell(false)];
2033 for width in 1..=40 {
2034 let mut cache = TranscriptViewCache::new();
2035 cache.ensure_split(
2036 &[&cells],
2037 &[1],
2038 width,
2039 TranscriptRenderOptions::default(),
2040 &HashMap::new(),
2041 None,
2042 None,
2043 );
2044 let neutral_lines = cache.total_lines();
2045 cache.ensure_split(
2046 &[&cells],
2047 &[1],
2048 width,
2049 TranscriptRenderOptions::default(),
2050 &HashMap::new(),
2051 None,
2052 Some(reasoning_owner(0)),
2053 );
2054 assert_eq!(cache.total_lines(), neutral_lines, "width {width}");
2055 // The terminal clips an overlong neutral header. Adding a hint must
2056 // never add a row or advertise a chord that cannot fit alongside it.
2057 if width == 40 {
2058 assert!(plain_lines(&cache).join("\n").contains("Space:expand"));
2059 }
2060 if width < 14 {
2061 assert!(!plain_lines(&cache).join("\n").contains("Space:"));
2062 }
2063 }
2064 }
2065
2066 #[test]
2067 fn reasoning_hint_uses_the_render_locale() {
2068 let cells = [reasoning_cell(false)];
2069 let options = TranscriptRenderOptions {
2070 locale: Locale::Ja,
2071 ..TranscriptRenderOptions::default()
2072 };
2073 let mut cache = TranscriptViewCache::new();
2074 cache.ensure_split(
2075 &[&cells],
2076 &[1],
2077 80,
2078 options,
2079 &HashMap::new(),
2080 None,
2081 Some(reasoning_owner(0)),
2082 );
2083 let text = plain_lines(&cache).join("\n");
2084 assert!(text.contains("Space:展開"), "{text}");
2085 assert!(!text.contains("Space:expand"), "{text}");
2086 }
2087
2088 /// Rows-per-element measurement for the calm-UI PR body. Run with
2089 /// `--ignored --nocapture`.
2090 #[test]
2091 #[ignore = "measurement, not a correctness gate"]
2092 #[allow(clippy::print_stderr)]
2093 fn measure_calm_rows_per_turn() {
2094 use crate::tui::history::{GenericToolCell, McpToolCell};
2095 let shipped = TranscriptRenderOptions {
2096 show_tool_details: false,
2097 calm_mode: true,
2098 low_motion: true,
2099 ..TranscriptRenderOptions::default()
2100 };
2101 let body = (1..=40)
2102 .map(|i| format!("reasoning line {i:02}"))
2103 .collect::<Vec<_>>()
2104 .join("\n");
2105 let noisy = (0..30)
2106 .map(|i| format!("row {i:02} output"))
2107 .collect::<Vec<_>>()
2108 .join("\n");
2109 let generic = |name: &str, status: ToolStatus, output: Option<String>| {
2110 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2111 name: name.to_string(),
2112 status,
2113 input_summary: Some("path: src/lib.rs".to_string()),
2114 output,
2115 prompts: None,
2116 spillover_path: None,
2117 output_summary: None,
2118 is_diff: false,
2119 }))
2120 };
2121 let mcp = |status: ToolStatus, content: String| {
2122 HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
2123 tool: "mcp_linear_get_issue".to_string(),
2124 status,
2125 content: Some(content),
2126 is_image: false,
2127 }))
2128 };
2129 let turn = vec![
2130 user_cell("fix the bug"),
2131 HistoryCell::Thinking {
2132 content: body.clone(),
2133 streaming: false,
2134 duration_secs: Some(12.0),
2135 },
2136 mcp(
2137 ToolStatus::Success,
2138 "CW-123\nTitle\nState: Todo".to_string(),
2139 ),
2140 mcp(ToolStatus::Failed, noisy.clone()),
2141 generic("read_file", ToolStatus::Failed, Some(noisy.clone())),
2142 exec_tool_cell_with_output("cargo test", noisy.clone()),
2143 assistant_cell("done", false),
2144 ];
2145 for cell in &turn {
2146 let rows = cell.lines_with_options(80, shipped).len();
2147 eprintln!("calm-rows element: {rows}");
2148 }
2149 let revisions = vec![1u64; turn.len()];
2150 let mut cache = TranscriptViewCache::new();
2151 cache.ensure_split(
2152 &[&turn],
2153 &revisions,
2154 80,
2155 shipped,
2156 &HashMap::new(),
2157 None,
2158 None,
2159 );
2160 eprintln!("calm-rows settled turn total: {}", cache.total_lines());
2161
2162 let live = vec![
2163 user_cell("fix the bug"),
2164 HistoryCell::Thinking {
2165 content: body,
2166 streaming: true,
2167 duration_secs: None,
2168 },
2169 ];
2170 let mut cache = TranscriptViewCache::new();
2171 let live_options = shipped;
2172 cache.ensure_split(
2173 &[&live],
2174 &[1, 1],
2175 80,
2176 live_options,
2177 &HashMap::new(),
2178 None,
2179 None,
2180 );
2181 eprintln!(
2182 "calm-rows streaming thinking cell (height-independent): {}",
2183 cache.per_cell[1].lines.len()
2184 );
2185 }
2186
2187 #[test]
2188 fn calm1_reasoning_header_hint_keeps_the_entire_live_tail_copyable() {
2189 let cells = [reasoning_cell(true)];
2190 let mut cache = TranscriptViewCache::new();
2191 let options = TranscriptRenderOptions {
2192 calm_mode: true,
2193 low_motion: true,
2194 ..Default::default()
2195 };
2196 cache.ensure_split(
2197 &[&cells],
2198 &[1],
2199 80,
2200 options,
2201 &HashMap::new(),
2202 None,
2203 Some(reasoning_owner(0)),
2204 );
2205 assert_eq!(cache.total_lines(), 4);
2206 assert!(cache.lines()[0].to_string().contains("Space:expand"));
2207 assert!(cache.lines()[3].to_string().contains("reasoning line 20"));
2208 for index in 1..4 {
2209 assert!(cache.line_meta()[index].copy_prefix_width() < cache.lines()[index].width());
2210 }
2211 }
2212
2213 // ── Steady-state frames do no transcript work (#6652) ───────────────────────
2214
2215 fn settled_transcript(turns: usize) -> (Vec<HistoryCell>, Vec<u64>) {
2216 let cells = (0..turns)
2217 .flat_map(|turn| {
2218 [
2219 user_cell(&format!("question {turn}")),
2220 reasoning_cell(false),
2221 assistant_cell(&format!("answer {turn}\n\n- one\n- two"), false),
2222 ]
2223 })
2224 .collect::<Vec<_>>();
2225 let revisions = (1..=cells.len() as u64).collect();
2226 (cells, revisions)
2227 }
2228
2229 #[test]
2230 fn an_unchanged_frame_renders_and_moves_nothing() {
2231 let (cells, revisions) = settled_transcript(400);
2232 let options = TranscriptRenderOptions::default();
2233 let ensure = |cache: &mut TranscriptViewCache| {
2234 cache.ensure_split(
2235 &[&cells],
2236 &revisions,
2237 80,
2238 options,
2239 &HashMap::new(),
2240 None,
2241 Some(reasoning_owner(1)),
2242 );
2243 };
2244 let mut cache = TranscriptViewCache::new();
2245 ensure(&mut cache);
2246 let rendered = cache.cells_rendered;
2247 let reflattened = cache.streaming_lines_reflattened();
2248 let storage = cache.per_cell.as_ptr();
2249 let line_arcs: Vec<_> = cache
2250 .per_cell
2251 .iter()
2252 .map(|cell| Arc::as_ptr(&cell.lines))
2253 .collect();
2254
2255 for _ in 0..5 {
2256 ensure(&mut cache);
2257 }
2258
2259 assert_eq!(cache.cells_rendered, rendered, "a settled frame rendered");
2260 assert_eq!(cache.streaming_lines_reflattened(), reflattened);
2261 assert_eq!(cache.per_cell.as_ptr(), storage, "cached cells were moved");
2262 for (index, cell) in cache.per_cell.iter().enumerate() {
2263 assert_eq!(Arc::as_ptr(&cell.lines), line_arcs[index], "cell {index}");
2264 }
2265 }
2266
2267 #[test]
2268 fn a_tail_only_change_renders_only_the_tail_in_place() {
2269 let (mut cells, mut revisions) = settled_transcript(400);
2270 cells.push(assistant_cell("streaming", true));
2271 revisions.push(10_000);
2272 let options = TranscriptRenderOptions {
2273 low_motion: true,
2274 ..TranscriptRenderOptions::default()
2275 };
2276 let ensure = |cache: &mut TranscriptViewCache, cells: &[HistoryCell], revisions: &[u64]| {
2277 cache.ensure_split(
2278 &[cells],
2279 revisions,
2280 80,
2281 options,
2282 &HashMap::new(),
2283 None,
2284 None,
2285 );
2286 };
2287 let mut cache = TranscriptViewCache::new();
2288 ensure(&mut cache, &cells, &revisions);
2289 let storage = cache.per_cell.as_ptr();
2290 let line_arcs: Vec<_> = cache
2291 .per_cell
2292 .iter()
2293 .map(|cell| Arc::as_ptr(&cell.lines))
2294 .collect();
2295
2296 for delta in 0..20u64 {
2297 let before = cache.cells_rendered;
2298 if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
2299 content.push_str(&format!(" delta {delta}"));
2300 }
2301 *revisions.last_mut().unwrap() += 1;
2302 ensure(&mut cache, &cells, &revisions);
2303 assert_eq!(cache.cells_rendered - before, 1, "delta {delta}");
2304 }
2305 assert_eq!(cache.per_cell.as_ptr(), storage, "cached cells were moved");
2306 let settled = line_arcs.len() - 1;
2307 for (index, cell) in cache.per_cell[..settled].iter().enumerate() {
2308 assert_eq!(Arc::as_ptr(&cell.lines), line_arcs[index], "cell {index}");
2309 }
2310 let mut cold = TranscriptViewCache::new();
2311 ensure(&mut cold, &cells, &revisions);
2312 assert_same_flat_output(&cache, &cold);
2313
2314 // A change in the middle renders that cell and nothing after it.
2315 let before = cache.cells_rendered;
2316 cells[600] = assistant_cell("rewritten in place", false);
2317 revisions[600] = 20_000;
2318 ensure(&mut cache, &cells, &revisions);
2319 assert_eq!(cache.cells_rendered - before, 1);
2320 let mut cold = TranscriptViewCache::new();
2321 ensure(&mut cold, &cells, &revisions);
2322 assert_same_flat_output(&cache, &cold);
2323 }
2324
2325 /// A filter can shift rows so that a tool cell's slot is taken by a streaming
2326 /// answer at an unchanged cell count. The answer takes the tail-only update,
2327 /// but the group rail and spacer between it and its neighbour belong to the
2328 /// neighbour's rows and must be rebuilt (found by the cold-render property
2329 /// test below).
2330 #[test]
2331 fn a_tool_slot_taken_by_a_streaming_answer_refreshes_its_neighbour() {
2332 let options = TranscriptRenderOptions {
2333 low_motion: true,
2334 ..TranscriptRenderOptions::default()
2335 };
2336 let mut cells = vec![
2337 user_cell("run it"),
2338 exec_tool_cell("cargo check"),
2339 exec_tool_cell("cargo test"),
2340 ];
2341 let mut revisions = vec![1u64, 2, 3];
2342 let ensure = |cache: &mut TranscriptViewCache, cells: &[HistoryCell], revisions: &[u64]| {
2343 cache.ensure_split(
2344 &[cells],
2345 revisions,
2346 60,
2347 options,
2348 &HashMap::new(),
2349 None,
2350 None,
2351 );
2352 };
2353 let mut cache = TranscriptViewCache::new();
2354 ensure(&mut cache, &cells, &revisions);
2355
2356 cells[2] = assistant_cell("opening", true);
2357 revisions[2] = 4;
2358 ensure(&mut cache, &cells, &revisions);
2359
2360 let mut cold = TranscriptViewCache::new();
2361 ensure(&mut cold, &cells, &revisions);
2362 assert_same_flat_output(&cache, &cold);
2363 assert!(
2364 !plain_lines(&cache)
2365 .iter()
2366 .any(|line| line.starts_with('╭') || line.starts_with('╰')),
2367 "{:?}",
2368 plain_lines(&cache)
2369 );
2370 }
2371
2372 /// xorshift64*: a failing case prints its seed and step, so it replays.
2373 struct Xorshift(u64);
2374
2375 impl Xorshift {
2376 fn next(&mut self) -> u64 {
2377 self.0 ^= self.0 >> 12;
2378 self.0 ^= self.0 << 25;
2379 self.0 ^= self.0 >> 27;
2380 self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
2381 }
2382
2383 fn below(&mut self, bound: usize) -> usize {
2384 (self.next() % bound as u64) as usize
2385 }
2386 }
2387
2388 /// What a live caller hands the cache, advanced the way `App` advances it:
2389 /// every mutation takes a fresh revision from one monotonic counter, active
2390 /// entries share the active revision, and a destructive edit moves the
2391 /// identity epoch.
2392 struct TranscriptModel {
2393 cells: Vec<HistoryCell>,
2394 revisions: Vec<u64>,
2395 next_revision: u64,
2396 active: Vec<HistoryCell>,
2397 active_revision: u64,
2398 folds: HashMap<usize, ThinkingFold>,
2399 hidden: std::collections::HashSet<usize>,
2400 width: u16,
2401 options: TranscriptRenderOptions,
2402 owner: Option<usize>,
2403 epoch: u64,
2404 serial: u64,
2405 }
2406
2407 impl TranscriptModel {
2408 fn new() -> Self {
2409 Self {
2410 cells: Vec::new(),
2411 revisions: Vec::new(),
2412 next_revision: 1,
2413 active: Vec::new(),
2414 active_revision: 0,
2415 folds: HashMap::new(),
2416 hidden: Default::default(),
2417 width: 80,
2418 // Motion is wall-clock driven; the comparison is between two
2419 // renders taken at different instants.
2420 options: TranscriptRenderOptions {
2421 low_motion: true,
2422 ..TranscriptRenderOptions::default()
2423 },
2424 owner: None,
2425 epoch: 0,
2426 serial: 0,
2427 }
2428 }
2429
2430 fn fresh_revision(&mut self) -> u64 {
2431 self.next_revision += 1;
2432 self.next_revision
2433 }
2434
2435 fn random_cell(&mut self, rng: &mut Xorshift) -> HistoryCell {
2436 self.serial += 1;
2437 let serial = self.serial;
2438 let prose = format!(
2439 "{serial} {}",
2440 "words that wrap at narrow widths and keep going ".repeat(1 + rng.below(4))
2441 );
2442 match rng.below(9) {
2443 0 | 1 => user_cell(&prose),
2444 2 => assistant_cell(&format!("{prose}\n\n- item {serial}\n- item"), false),
2445 3 => assistant_cell(&prose, true),
2446 4 => reasoning_cell(false),
2447 5 => reasoning_cell(true),
2448 6 => exec_tool_cell(&format!("cargo test {serial}")),
2449 7 => exec_tool_cell_with_output(&format!("ls {serial}"), prose),
2450 _ => durable_work_cell(),
2451 }
2452 }
2453
2454 fn mutate(&mut self, rng: &mut Xorshift) -> &'static str {
2455 match rng.below(16) {
2456 0..=3 => {
2457 let cell = self.random_cell(rng);
2458 let revision = self.fresh_revision();
2459 self.cells.push(cell);
2460 self.revisions.push(revision);
2461 "append"
2462 }
2463 4 | 5 if !self.cells.is_empty() => {
2464 let index = rng.below(self.cells.len());
2465 self.cells[index] = self.random_cell(rng);
2466 self.revisions[index] = self.fresh_revision();
2467 "replace"
2468 }
2469 6 => {
2470 // Stream into the newest cell the way the reply path does.
2471 let revision = self.fresh_revision();
2472 let streaming_tail = matches!(
2473 self.cells.last(),
2474 Some(HistoryCell::Assistant {
2475 streaming: true,
2476 ..
2477 })
2478 );
2479 if streaming_tail {
2480 if let Some(HistoryCell::Assistant { content, .. }) = self.cells.last_mut() {
2481 content.push_str(" more streamed words\n");
2482 }
2483 *self.revisions.last_mut().unwrap() = revision;
2484 } else {
2485 self.cells.push(assistant_cell("opening ", true));
2486 self.revisions.push(revision);
2487 }
2488 "stream"
2489 }
2490 7 if !self.cells.is_empty() => {
2491 let index = self.cells.len() - 1;
2492 if let HistoryCell::Assistant { streaming, .. } = &mut self.cells[index] {
2493 *streaming = false;
2494 self.revisions[index] = self.fresh_revision();
2495 }
2496 "finish stream"
2497 }
2498 8 => {
2499 self.active = (0..rng.below(4)).map(|_| self.random_cell(rng)).collect();
2500 self.active_revision += 1;
2501 "replace active"
2502 }
2503 9 if !self.cells.is_empty() => {
2504 let index = rng.below(self.cells.len() + self.active.len());
2505 match rng.below(3) {
2506 0 => self.folds.insert(index, ThinkingFold::Expanded),
2507 1 => self.folds.insert(index, ThinkingFold::Collapsed),
2508 _ => self.folds.remove(&index),
2509 };
2510 "fold"
2511 }
2512 10 => {
2513 self.width = [24, 40, 80, 120][rng.below(4)];
2514 "width"
2515 }
2516 11 => {
2517 self.options.spacing = [
2518 TranscriptSpacing::Compact,
2519 TranscriptSpacing::Comfortable,
2520 TranscriptSpacing::Spacious,
2521 ][rng.below(3)];
2522 self.options.show_thinking = rng.below(4) != 0;
2523 "options"
2524 }
2525 12 if rng.below(4) == 0 => {
2526 self.cells.clear();
2527 self.revisions.clear();
2528 self.folds.clear();
2529 self.hidden.clear();
2530 self.epoch += 1;
2531 "clear"
2532 }
2533 13 if !self.cells.is_empty() => {
2534 // Edit/undo: roll back to an earlier prefix.
2535 let keep = rng.below(self.cells.len());
2536 self.cells.truncate(keep);
2537 self.revisions.truncate(keep);
2538 self.folds.retain(|index, _| *index < keep);
2539 self.hidden.retain(|index| *index < keep);
2540 self.epoch += 1;
2541 "truncate"
2542 }
2543 14 => {
2544 let total = self.cells.len() + self.active.len();
2545 if total > 0 {
2546 let index = rng.below(total);
2547 if !self.hidden.remove(&index) {
2548 self.hidden.insert(index);
2549 }
2550 }
2551 "hide"
2552 }
2553 15 => {
2554 let total = self.cells.len() + self.active.len();
2555 self.owner = (total > 0 && rng.below(3) != 0).then(|| rng.below(total));
2556 "owner"
2557 }
2558 _ => "no-op",
2559 }
2560 }
2561
2562 /// One live frame: committed history plus the active tail, filtered when
2563 /// the user has hidden cells, exactly as the chat widget assembles it.
2564 fn frame(&self, cache: &mut TranscriptViewCache, owner: Option<usize>) {
2565 let total = self.cells.len() + self.active.len();
2566 let owner = owner
2567 .filter(|index| *index < total)
2568 .map(|cell_index| TranscriptActionOwner {
2569 cell_index,
2570 identity_epoch: self.epoch,
2571 });
2572 let active_revisions = (0..self.active.len()).map(|i| {
2573 crate::tui::widgets::active_entry_revision(self.active_revision, i as u64 + 1)
2574 });
2575 if self.hidden.is_empty() {
2576 let revisions: Vec<u64> = self
2577 .revisions
2578 .iter()
2579 .copied()
2580 .chain(active_revisions)
2581 .collect();
2582 cache.ensure_split(
2583 &[&self.cells, &self.active],
2584 &revisions,
2585 self.width,
2586 self.options,
2587 &self.folds,
2588 None,
2589 owner,
2590 );
2591 return;
2592 }
2593 let revisions: Vec<u64> = self
2594 .revisions
2595 .iter()
2596 .copied()
2597 .chain(active_revisions)
2598 .collect();
2599 let mut kept = Vec::new();
2600 let mut kept_revisions = Vec::new();
2601 let mut map = Vec::new();
2602 for (index, cell) in self.cells.iter().chain(&self.active).enumerate() {
2603 if !self.hidden.contains(&index) {
2604 kept.push(cell);
2605 kept_revisions.push(revisions[index]);
2606 map.push(index);
2607 }
2608 }
2609 cache.ensure_filtered(
2610 &kept,
2611 &kept_revisions,
2612 self.width,
2613 self.options,
2614 &self.folds,
2615 Some(&map),
2616 owner,
2617 );
2618 }
2619 }
2620
2621 /// After every mutation, the incrementally maintained cache must equal one
2622 /// built from scratch from the same state: the early-out and in-place update
2623 /// may change how much work a frame does, never what it shows.
2624 #[test]
2625 fn cached_transcript_matches_a_cold_render_after_every_mutation() {
2626 for seed in 1..=8u64 {
2627 let mut rng = Xorshift(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
2628 let mut model = TranscriptModel::new();
2629 let mut warm = TranscriptViewCache::new();
2630 let mut log = Vec::new();
2631 for step in 0..150 {
2632 let op = model.mutate(&mut rng);
2633 log.push(op);
2634 let context = || format!("seed {seed} step {step}: {}", log.join(", "));
2635
2636 let owner = model.owner;
2637 model.frame(&mut warm, owner);
2638 let mut cold = TranscriptViewCache::new();
2639 model.frame(&mut cold, owner);
2640 assert_eq!(warm.lines(), cold.lines(), "{}", context());
2641 assert_eq!(warm.line_links(), cold.line_links(), "{}", context());
2642 assert_eq!(warm.line_meta(), cold.line_meta(), "{}", context());
2643 assert_eq!(
2644 warm.rail_prefix_widths,
2645 cold.rail_prefix_widths,
2646 "{}",
2647 context()
2648 );
2649 assert_eq!(
2650 warm.cell_line_starts,
2651 cold.cell_line_starts,
2652 "{}",
2653 context()
2654 );
2655 assert_eq!(
2656 warm.reasoning_action_target(),
2657 cold.reasoning_action_target(),
2658 "{}",
2659 context()
2660 );
2661
2662 // The same inputs again are a settled frame: no render, no rows
2663 // re-flattened, identical output.
2664 let rendered = warm.cells_rendered;
2665 let reflattened = warm.streaming_lines_reflattened();
2666 model.frame(&mut warm, owner);
2667 assert_eq!(warm.cells_rendered, rendered, "{}", context());
2668 assert_eq!(
2669 warm.streaming_lines_reflattened(),
2670 reflattened,
2671 "{}",
2672 context()
2673 );
2674 assert_eq!(warm.lines(), cold.lines(), "{}", context());
2675
2676 // A scroll frame retargets the hint after layout.
2677 if rng.below(3) == 0 {
2678 let total = model.cells.len() + model.active.len();
2679 let retarget = (total > 0).then(|| rng.below(total));
2680 let map: Vec<usize> = (0..total).filter(|i| !model.hidden.contains(i)).collect();
2681 let index_map = (!model.hidden.is_empty()).then_some(map.as_slice());
2682 let epoch = model.epoch;
2683 warm.retarget(
2684 retarget.map(|cell_index| TranscriptActionOwner {
2685 cell_index,
2686 identity_epoch: epoch,
2687 }),
2688 index_map,
2689 );
2690 let mut cold = TranscriptViewCache::new();
2691 model.frame(&mut cold, retarget);
2692 assert_eq!(warm.lines(), cold.lines(), "retarget; {}", context());
2693 assert_eq!(
2694 warm.line_meta(),
2695 cold.line_meta(),
2696 "retarget; {}",
2697 context()
2698 );
2699 }
2700 }
2701 }
2702 }
2703
2703 lines RUST