返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / transcript / tests.rs
1 use super::*;
2 use crate::palette;
3 use crate::tools::plan::PlanSnapshot;
4 use crate::tui::history::{
5 ExecCell, ExecSource, HistoryCell, PlanUpdateCell, ToolCell, ToolStatus,
6 };
7
8 fn plain_lines(cache: &TranscriptViewCache) -> Vec<String> {
9 cache
10 .lines()
11 .iter()
12 .map(|line| {
13 line.spans
14 .iter()
15 .map(|span| span.content.as_ref())
16 .collect::<String>()
17 })
18 .collect()
19 }
20
21 fn user_cell(content: &str) -> HistoryCell {
22 HistoryCell::User {
23 content: content.to_string(),
24 }
25 }
26
27 fn assistant_cell(content: &str, streaming: bool) -> HistoryCell {
28 HistoryCell::Assistant {
29 content: content.to_string(),
30 streaming,
31 }
32 }
33
34 fn exec_tool_cell_with_output(command: &str, output: String) -> HistoryCell {
35 // A failed shell cell keeps its full output in the live render, so
36 // this fixture proves tool cells do not inherit the prose measure.
37 HistoryCell::Tool(ToolCell::Exec(ExecCell {
38 command: command.to_string(),
39 status: ToolStatus::Failed,
40 output: Some(output),
41 live_output: None,
42 shell_task_id: None,
43 owner_agent_id: None,
44 owner_agent_name: None,
45 started_at: None,
46 duration_ms: None,
47 stale_elapsed_since_output_ms: None,
48 source: ExecSource::Assistant,
49 interaction: None,
50 output_summary: None,
51 }))
52 }
53
54 fn exec_tool_cell(command: &str) -> HistoryCell {
55 HistoryCell::Tool(ToolCell::Exec(ExecCell {
56 command: command.to_string(),
57 status: ToolStatus::Running,
58 output: None,
59 live_output: None,
60 shell_task_id: None,
61 owner_agent_id: None,
62 owner_agent_name: None,
63 started_at: None,
64 duration_ms: None,
65 stale_elapsed_since_output_ms: None,
66 source: ExecSource::Assistant,
67 interaction: None,
68 output_summary: None,
69 }))
70 }
71
72 fn durable_work_cell() -> HistoryCell {
73 HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
74 snapshot: PlanSnapshot::default(),
75 status: ToolStatus::Running,
76 }))
77 }
78
79 fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> usize {
80 let mut saw_target = false;
81 let mut spacer_rows = 0;
82 for meta in cache.line_meta() {
83 match meta {
84 TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
85 saw_target = true;
86 spacer_rows = 0;
87 }
88 TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
89 TranscriptLineMeta::CellLine { .. } if saw_target => break,
90 TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
91 }
92 }
93 spacer_rows
94 }
95
96 #[test]
97 fn cache_renders_user_cells_with_highlight_background() {
98 let cells = vec![user_cell("# literal user prompt")];
99 let revisions = vec![1u64];
100
101 let mut cache = TranscriptViewCache::new();
102 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
103
104 let lines = cache.lines();
105 assert_eq!(lines[0].style.bg, Some(palette::SURFACE_ELEVATED));
106 assert_eq!(lines[0].width(), 40);
107 assert_eq!(plain_lines(&cache)[0].trim_end(), "▎ # literal user prompt");
108 }
109
110 #[test]
111 fn cache_reuses_cells_when_revision_unchanged() {
112 let cells = vec![
113 user_cell("hello"),
114 assistant_cell("world", false),
115 user_cell("again"),
116 ];
117 let revisions = vec![1u64, 1, 1];
118
119 let mut cache = TranscriptViewCache::new();
120 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
121 let first_lines: Vec<String> = cache
122 .lines()
123 .iter()
124 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
125 .collect();
126 let first_total = cache.total_lines();
127 assert!(first_total > 0, "expected non-empty render");
128
129 // Capture per-cell lines snapshot to verify reuse.
130 let snapshot_per_cell: Vec<Vec<String>> = cache
131 .per_cell
132 .iter()
133 .map(|c| {
134 c.lines
135 .iter()
136 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
137 .collect()
138 })
139 .collect();
140
141 // Same revisions => everything reused, output identical.
142 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
143 let second_lines: Vec<String> = cache
144 .lines()
145 .iter()
146 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
147 .collect();
148 assert_eq!(first_lines, second_lines);
149 assert_eq!(cache.total_lines(), first_total);
150
151 let snapshot_per_cell_2: Vec<Vec<String>> = cache
152 .per_cell
153 .iter()
154 .map(|c| {
155 c.lines
156 .iter()
157 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
158 .collect()
159 })
160 .collect();
161 assert_eq!(snapshot_per_cell, snapshot_per_cell_2);
162 }
163
164 #[test]
165 fn bumping_one_cell_revision_only_rerenders_that_cell() {
166 // Track render counts per cell using a custom HistoryCell wrapper
167 // would require trait changes; instead, we detect reuse by inspecting
168 // CachedCell instances. After a bump, only the bumped cell's stored
169 // revision should differ from before; others remain identical.
170
171 let cells_v1 = vec![
172 user_cell("hello"),
173 assistant_cell("hi", true),
174 user_cell("again"),
175 ];
176 let revs_v1 = vec![1u64, 1, 1];
177
178 let mut cache = TranscriptViewCache::new();
179 cache.ensure(&cells_v1, &revs_v1, 80, TranscriptRenderOptions::default());
180
181 // Snapshot the cached lines for cells 0 and 2 (unchanged across the
182 // delta).
183 let cell0_lines_before = cache.per_cell[0]
184 .lines
185 .iter()
186 .map(|l| {
187 l.spans
188 .iter()
189 .map(|s| s.content.to_string())
190 .collect::<String>()
191 })
192 .collect::<Vec<_>>();
193 let cell2_lines_before = cache.per_cell[2]
194 .lines
195 .iter()
196 .map(|l| {
197 l.spans
198 .iter()
199 .map(|s| s.content.to_string())
200 .collect::<String>()
201 })
202 .collect::<Vec<_>>();
203
204 // Mutate cell 1 (assistant streaming delta) and bump only its rev.
205 let cells_v2 = vec![
206 user_cell("hello"),
207 assistant_cell("hi world", true),
208 user_cell("again"),
209 ];
210 let revs_v2 = vec![1u64, 2, 1];
211
212 cache.ensure(&cells_v2, &revs_v2, 80, TranscriptRenderOptions::default());
213
214 // Cells 0 and 2 are byte-identical (proving reuse path didn't corrupt).
215 let cell0_lines_after = cache.per_cell[0]
216 .lines
217 .iter()
218 .map(|l| {
219 l.spans
220 .iter()
221 .map(|s| s.content.to_string())
222 .collect::<String>()
223 })
224 .collect::<Vec<_>>();
225 let cell2_lines_after = cache.per_cell[2]
226 .lines
227 .iter()
228 .map(|l| {
229 l.spans
230 .iter()
231 .map(|s| s.content.to_string())
232 .collect::<String>()
233 })
234 .collect::<Vec<_>>();
235 assert_eq!(cell0_lines_before, cell0_lines_after);
236 assert_eq!(cell2_lines_before, cell2_lines_after);
237
238 // Cell 1 reflects the new content.
239 // The renderer interleaves role/whitespace spans, so the joined
240 // content has internal padding (e.g. "Assistant hi world").
241 // Check for the new tokens individually rather than a literal
242 // "hi world" substring.
243 let cell1_after: String = cache.per_cell[1]
244 .lines
245 .iter()
246 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
247 .collect::<Vec<_>>()
248 .join(" ");
249 assert!(
250 cell1_after.contains("hi") && cell1_after.contains("world"),
251 "cell1 should re-render with new content; got: {cell1_after}"
252 );
253
254 // Revisions in cache reflect the bump.
255 assert_eq!(cache.per_cell[0].revision, 1);
256 assert_eq!(cache.per_cell[1].revision, 2);
257 assert_eq!(cache.per_cell[2].revision, 1);
258 }
259
260 #[test]
261 fn streaming_assistant_keeps_a_persistent_linear_render_prefix() {
262 let mut content = String::new();
263 let mut revision = 1u64;
264 let mut cache = TranscriptViewCache::new();
265 let options = TranscriptRenderOptions {
266 low_motion: true,
267 ..TranscriptRenderOptions::default()
268 };
269
270 content.push_str("start\n```rust\nlet value_0 = 0;\n```\n\n");
271 let mut cells = vec![assistant_cell(&content, true)];
272 cache.ensure(&cells, &[revision], 96, options);
273 let lines_arc = Arc::as_ptr(&cache.per_cell[0].lines);
274
275 for index in 1..120usize {
276 let previous = revision;
277 revision += 1;
278 content.push_str(&format!(
279 "段落 {index} e\u{301} 🚀\n```rust\nlet value_{index} = {index};\n```\n\n"
280 ));
281 cells[0] = assistant_cell(&content, true);
282 cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
283 cell_index: 0,
284 from_revision: previous,
285 to_revision: revision,
286 content_len: content.len(),
287 }));
288 cache.ensure(&cells, &[revision], 96, options);
289 }
290
291 let previous = revision;
292 revision += 1;
293 cache.set_streaming_source_receipt(Some(StreamingSourceReceipt {
294 cell_index: 0,
295 from_revision: previous,
296 to_revision: revision,
297 content_len: content.len(),
298 }));
299 cache.ensure(&cells, &[revision], 96, options);
300
301 assert_eq!(Arc::as_ptr(&cache.per_cell[0].lines), lines_arc);
302 let work = cache.per_cell[0]
303 .incremental_markdown
304 .as_ref()
305 .expect("streaming markdown cache")
306 .work();
307 assert_eq!(work.invalidations, 1);
308 assert_eq!(work.tail_blocks_rendered, 0);
309 assert_eq!(work.classified_lines as usize, content.lines().count());
310 assert!(
311 cache.streaming_lines_reflattened() <= (cache.total_lines() + 121) as u64,
312 "flatten work must be final output plus at most one hot-tail line per update: work={}, final={}",
313 cache.streaming_lines_reflattened(),
314 cache.total_lines()
315 );
316 assert!(
317 cache.streaming_meta_rows_scanned() <= 121,
318 "reverse lookup must inspect only the replaceable tail: {}",
319 cache.streaming_meta_rows_scanned()
320 );
321
322 let mut cold = TranscriptViewCache::new();
323 cold.ensure(&cells, &[revision], 96, options);
324 assert_eq!(plain_lines(&cache), plain_lines(&cold));
325 }
326
327 #[test]
328 fn tail_update_suffix_rebuild_matches_fresh_flatten() {
329 let mut cells = vec![
330 user_cell("first message"),
331 assistant_cell("stable answer", false),
332 user_cell("tail prompt"),
333 ];
334 let mut revisions = vec![1u64, 1, 1];
335 let mut cache = TranscriptViewCache::new();
336 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
337
338 cells.push(assistant_cell("streaming tail", true));
339 revisions.push(1);
340 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
341
342 if let HistoryCell::Assistant { content, .. } = cells.last_mut().unwrap() {
343 content.push_str(" plus delta");
344 }
345 *revisions.last_mut().unwrap() += 1;
346 cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
347 let incremental = plain_lines(&cache);
348
349 let mut fresh = TranscriptViewCache::new();
350 fresh.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
351 assert_eq!(incremental, plain_lines(&fresh));
352 }
353
354 #[test]
355 fn width_change_rerenders_all_cells() {
356 let cells = vec![
357 user_cell("a fairly long message that may wrap at narrow widths"),
358 assistant_cell("another long message body content", false),
359 ];
360 let revisions = vec![5u64, 7];
361
362 let mut cache = TranscriptViewCache::new();
363 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
364 let wide_total = cache.total_lines();
365
366 // Narrow width should change layout — everything re-renders.
367 cache.ensure(&cells, &revisions, 20, TranscriptRenderOptions::default());
368 let narrow_total = cache.total_lines();
369
370 assert_ne!(
371 wide_total, narrow_total,
372 "narrow width should produce a different number of lines"
373 );
374
375 // Restoring the original width re-renders again.
376 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
377 assert_eq!(cache.total_lines(), wide_total);
378 }
379
380 #[test]
381 fn streaming_assistant_only_rebuilds_one_cell_render_count() {
382 // Verify behavior 6: when one Assistant cell streams a delta, only
383 // that one cell is re-rendered. We use a counting wrapper hooked into
384 // a custom History setup. Since `lines_with_options` is on `HistoryCell`
385 // (concrete enum), we can't mock it directly. Instead we verify the
386 // cache's invariant: cells with unchanged revisions retain their
387 // previous CachedCell entries (clone-equal), proving no re-render
388 // happened for them.
389 //
390 // We do this by storing revisions as monotonic u64 and verifying that
391 // a `Vec<u64>` snapshot of `per_cell.revision` only differs at the
392 // index that was bumped.
393
394 let mut cells: Vec<HistoryCell> = (0..50).map(|i| user_cell(&format!("cell {i}"))).collect();
395 cells.push(assistant_cell("streaming", true));
396 let mut revisions: Vec<u64> = vec![1; 51];
397
398 let mut cache = TranscriptViewCache::new();
399 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
400
401 // Snapshot total bytes rendered for cells 0..50 (unchanged).
402 let stable_snapshot: Vec<String> = cache.per_cell[..50]
403 .iter()
404 .map(|c| {
405 c.lines
406 .iter()
407 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
408 .collect::<Vec<_>>()
409 .join("|")
410 })
411 .collect();
412
413 // Stream 10 deltas to the assistant cell, bumping only its revision.
414 for i in 0..10 {
415 if let HistoryCell::Assistant { content, .. } = &mut cells[50] {
416 content.push_str(&format!(" delta-{i}"));
417 }
418 revisions[50] += 1;
419 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
420
421 // After every delta, cells 0..50 must be byte-identical to the
422 // initial render. If we re-rendered them we'd observe identical
423 // bytes anyway (deterministic), but the test ALSO checks the
424 // CachedCell.revision values stayed at 1 — meaning the cache
425 // never replaced them, only reused them.
426 let stable_now: Vec<String> = cache.per_cell[..50]
427 .iter()
428 .map(|c| {
429 c.lines
430 .iter()
431 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
432 .collect::<Vec<_>>()
433 .join("|")
434 })
435 .collect();
436 assert_eq!(
437 stable_now, stable_snapshot,
438 "stable cells diverged at delta {i}"
439 );
440
441 for (idx, c) in cache.per_cell[..50].iter().enumerate() {
442 assert_eq!(
443 c.revision, 1,
444 "cell {idx} revision changed during streaming delta"
445 );
446 }
447 }
448 }
449
450 #[test]
451 fn missing_revisions_falls_back_to_full_render() {
452 // If callers pass a `cell_revisions` slice with the wrong length
453 // (shouldn't happen, but be defensive), the cache should still
454 // produce correct output rather than panic or skip cells.
455 let cells = vec![user_cell("a"), assistant_cell("b", false)];
456 let bogus_revisions = vec![1u64]; // wrong length
457
458 let mut cache = TranscriptViewCache::new();
459 cache.ensure(
460 &cells,
461 &bogus_revisions,
462 80,
463 TranscriptRenderOptions::default(),
464 );
465
466 // Both cells were rendered (no panic, output non-empty).
467 assert_eq!(cache.per_cell.len(), 2);
468 assert!(!cache.lines().is_empty());
469 }
470
471 #[test]
472 fn adjacent_tool_cells_render_as_one_railed_group() {
473 // Live foreground exec cells collapse to a single header line (copy
474 // dedupe #17), so a third cell is needed for a rail-continuation row.
475 let cells = vec![
476 exec_tool_cell("cargo test"),
477 exec_tool_cell("cargo clippy"),
478 exec_tool_cell("cargo fmt"),
479 ];
480 let revisions = vec![1u64, 1, 1];
481 let mut cache = TranscriptViewCache::new();
482
483 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
484 let lines = plain_lines(&cache);
485
486 assert!(
487 lines
488 .first()
489 .is_some_and(|line| line.starts_with("\u{256D} ")),
490 "first tool line should open the shared rail: {lines:?}"
491 );
492 assert!(
493 lines.iter().any(|line| line.starts_with("\u{2502} ")),
494 "middle tool lines should continue the shared rail: {lines:?}"
495 );
496 assert!(
497 lines
498 .last()
499 .is_some_and(|line| line.starts_with("\u{2570} ")),
500 "last tool line should close the shared rail: {lines:?}"
501 );
502 assert!(
503 !lines.iter().any(String::is_empty),
504 "adjacent tool cells must never be separated by a bare blank row — that \
505 would tear the card box open: {lines:?}"
506 );
507 // They are separated, though: by a rail-carrying spacer, so two distinct
508 // commands read as two blocks without the group losing its outline.
509 assert!(
510 lines.iter().any(|line| line.trim_end() == "\u{2502}"),
511 "distinct tool cells inside one rail group need a rail spacer between \
512 them: {lines:?}"
513 );
514 }
515
516 #[test]
517 fn semantic_boundary_matrix_has_four_deliberate_rhythm_levels() {
518 use TranscriptBlockKind::{Answer, DurableWork, Notice, Reasoning, ToolAction, User};
519 use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
520
521 let cases = [
522 (User, Answer, false, Turn),
523 (User, ToolAction, false, Turn),
524 (DurableWork, User, false, Turn),
525 // Reasoning handing off to the answer is a phase change the reader
526 // has to see. Running the two together with no blank row is the
527 // density complaint this matrix exists to answer.
528 (Reasoning, Answer, false, Activity),
529 (Answer, Reasoning, false, Activity),
530 // Successive cells of the *same* phase are one block split across
531 // cells; a blank row there would jitter mid-stream.
532 (Answer, Answer, false, Joined),
533 (Reasoning, Reasoning, false, Joined),
534 (Answer, ToolAction, false, Activity),
535 (ToolAction, Reasoning, false, Activity),
536 (Notice, DurableWork, false, Activity),
537 (ToolAction, ToolAction, true, GroupedTool),
538 (DurableWork, DurableWork, true, GroupedTool),
539 (ToolAction, DurableWork, false, Activity),
540 ];
541
542 for (current, next, grouped_tools, expected) in cases {
543 assert_eq!(
544 transcript_boundary(current, next, grouped_tools),
545 expected,
546 "{current:?} -> {next:?}"
547 );
548 }
549
550 assert_eq!(
551 spacer_rows_for_boundary(Turn, TranscriptSpacing::Compact),
552 1
553 );
554 assert_eq!(
555 spacer_rows_for_boundary(Turn, TranscriptSpacing::Comfortable),
556 1
557 );
558 assert_eq!(
559 spacer_rows_for_boundary(Turn, TranscriptSpacing::Spacious),
560 2
561 );
562 assert_eq!(
563 spacer_rows_for_boundary(Activity, TranscriptSpacing::Compact),
564 0
565 );
566 assert_eq!(
567 spacer_rows_for_boundary(Activity, TranscriptSpacing::Comfortable),
568 1
569 );
570 assert_eq!(
571 spacer_rows_for_boundary(Activity, TranscriptSpacing::Spacious),
572 1
573 );
574 assert_eq!(
575 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Compact),
576 0,
577 "compact density buys its density by spending no separator rows"
578 );
579 assert_eq!(
580 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Comfortable),
581 1
582 );
583 assert_eq!(
584 spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Spacious),
585 1,
586 "one row is the whole vocabulary above compact — never two"
587 );
588 }
589
590 /// Separation is one row or none. Nothing in the matrix may produce a
591 /// double blank, because a scrolling terminal cannot afford it.
592 #[test]
593 fn no_boundary_ever_spends_more_than_one_row_below_spacious_turns() {
594 use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
595
596 for boundary in [Joined, GroupedTool, Activity, Turn] {
597 for spacing in [
598 TranscriptSpacing::Compact,
599 TranscriptSpacing::Comfortable,
600 TranscriptSpacing::Spacious,
601 ] {
602 let rows = spacer_rows_for_boundary(boundary, spacing);
603 let allowed = if boundary == Turn && spacing == TranscriptSpacing::Spacious {
604 2
605 } else {
606 BLOCK_SEPARATOR_ROWS
607 };
608 assert!(
609 rows <= allowed,
610 "{boundary:?} at {spacing:?} spent {rows} rows (max {allowed})"
611 );
612 }
613 }
614 }
615
616 #[test]
617 fn durable_work_tools_have_an_explicit_semantic_role() {
618 let plan = durable_work_cell();
619 let tool = exec_tool_cell("cargo test --locked");
620
621 assert_eq!(
622 TranscriptBlockKind::for_cell(&plan),
623 TranscriptBlockKind::DurableWork
624 );
625 assert_eq!(
626 TranscriptBlockKind::for_cell(&tool),
627 TranscriptBlockKind::ToolAction
628 );
629 }
630
631 #[test]
632 fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
633 let durable = HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
634 snapshot: PlanSnapshot {
635 objective: Some("Keep the release receipt durable".to_string()),
636 ..PlanSnapshot::default()
637 },
638 status: ToolStatus::Running,
639 }));
640 let cells = vec![
641 exec_tool_cell("cargo test --locked"),
642 exec_tool_cell("cargo clippy --locked"),
643 durable,
644 ];
645 let revisions = vec![1u64; cells.len()];
646
647 let mut compact = TranscriptViewCache::new();
648 compact.ensure(
649 &cells,
650 &revisions,
651 80,
652 TranscriptRenderOptions {
653 spacing: TranscriptSpacing::Compact,
654 low_motion: true,
655 ..TranscriptRenderOptions::default()
656 },
657 );
658
659 assert_eq!(spacer_rows_after_cell(&compact, 0), 0);
660 assert_eq!(spacer_rows_after_cell(&compact, 1), 0);
661 let compact_lines = plain_lines(&compact);
662 assert!(
663 !compact_lines.iter().any(String::is_empty),
664 "compact activity seams must not spend a blank row: {compact_lines:?}"
665 );
666 let lines_for_cell = |target| {
667 compact
668 .lines()
669 .iter()
670 .zip(compact.line_meta())
671 .filter_map(|(line, meta)| match meta {
672 TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target => Some(
673 line.spans
674 .iter()
675 .map(|span| span.content.as_ref())
676 .collect::<String>(),
677 ),
678 TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => None,
679 })
680 .collect::<Vec<_>>()
681 };
682 let second_action = lines_for_cell(1);
683 let durable_work = lines_for_cell(2);
684 assert!(
685 second_action
686 .last()
687 .is_some_and(|line| line.starts_with("\u{2570} ")),
688 "ordinary action rail should close before durable Work: {second_action:?}"
689 );
690 assert!(
691 durable_work
692 .first()
693 .is_some_and(|line| line.starts_with("\u{256D} ")),
694 "durable Work should open its own rail: {durable_work:?}"
695 );
696
697 let mut comfortable = TranscriptViewCache::new();
698 comfortable.ensure(
699 &cells,
700 &revisions,
701 80,
702 TranscriptRenderOptions {
703 spacing: TranscriptSpacing::Comfortable,
704 low_motion: true,
705 ..TranscriptRenderOptions::default()
706 },
707 );
708 assert_eq!(
709 spacer_rows_after_cell(&comfortable, 0),
710 1,
711 "two distinct commands sharing a rail still need one row between them"
712 );
713 assert_eq!(
714 spacer_rows_after_cell(&comfortable, 1),
715 1,
716 "durable Work needs a semantic activity row outside compact density"
717 );
718 }
719
720 #[test]
721 fn compact_spacing_keeps_conversation_blocks_separate() {
722 let cells = vec![
723 user_cell("Please verify the release."),
724 assistant_cell("I will check the receipts.", false),
725 ];
726 let revisions = vec![1u64, 1];
727 let mut cache = TranscriptViewCache::new();
728 let options = TranscriptRenderOptions {
729 spacing: TranscriptSpacing::Compact,
730 ..TranscriptRenderOptions::default()
731 };
732
733 cache.ensure(&cells, &revisions, 89, options);
734 let lines = plain_lines(&cache);
735
736 assert!(
737 lines.iter().any(String::is_empty),
738 "compact density still needs one user/assistant boundary: {lines:?}"
739 );
740 }
741
742 #[test]
743 fn compact_spacing_keeps_direct_user_tool_turns_separate() {
744 let cells = vec![
745 user_cell("Inspect the repository."),
746 exec_tool_cell("git status --short"),
747 user_cell("Now summarize the result."),
748 ];
749 let revisions = vec![1u64, 1, 1];
750 let options = TranscriptRenderOptions {
751 spacing: TranscriptSpacing::Compact,
752 low_motion: true,
753 ..TranscriptRenderOptions::default()
754 };
755 let mut cache = TranscriptViewCache::new();
756
757 cache.ensure(&cells, &revisions, 80, options);
758
759 assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
760 assert_eq!(spacer_rows_after_cell(&cache, 1), 1);
761 }
762
763 #[test]
764 fn compact_spacing_keeps_reasoning_and_answer_in_one_response_block() {
765 let cells = vec![
766 HistoryCell::Thinking {
767 content: "I should verify the release receipts first.".to_string(),
768 streaming: false,
769 duration_secs: Some(0.4),
770 },
771 assistant_cell("The release receipts are green.", false),
772 ];
773 let revisions = vec![1u64, 1];
774 let mut cache = TranscriptViewCache::new();
775 let options = TranscriptRenderOptions {
776 spacing: TranscriptSpacing::Compact,
777 ..TranscriptRenderOptions::default()
778 };
779
780 cache.ensure(&cells, &revisions, 89, options);
781 let lines = plain_lines(&cache);
782
783 assert!(
784 !lines.iter().any(String::is_empty),
785 "reasoning and its answer should read as one response block: {lines:?}"
786 );
787 }
788
789 #[test]
790 fn hidden_reasoning_keeps_visible_rhythm_without_phantom_tail_rows() {
791 let cells = vec![
792 user_cell("Verify the release."),
793 HistoryCell::Thinking {
794 content: "Check the exact receipts.".to_string(),
795 streaming: false,
796 duration_secs: Some(0.4),
797 },
798 assistant_cell("The receipts are green.", false),
799 ];
800 let revisions = vec![1u64, 1, 1];
801 let hidden = TranscriptRenderOptions {
802 show_thinking: false,
803 low_motion: true,
804 ..TranscriptRenderOptions::default()
805 };
806 let mut cache = TranscriptViewCache::new();
807
808 cache.ensure(&cells, &revisions, 80, hidden);
809 let hidden_lines = plain_lines(&cache);
810 assert_eq!(spacer_rows_after_cell(&cache, 0), 1);
811 assert!(
812 hidden_lines.last().is_some_and(|line| !line.is_empty()),
813 "hidden cells must not leave a trailing blank row: {hidden_lines:?}"
814 );
815
816 let visible = TranscriptRenderOptions {
817 show_thinking: true,
818 ..hidden
819 };
820 cache.ensure(&cells, &revisions, 80, visible);
821 cache.ensure(&cells, &revisions, 80, hidden);
822 assert_eq!(plain_lines(&cache), hidden_lines);
823
824 let trailing_hidden = &cells[..2];
825 let mut tail_cache = TranscriptViewCache::new();
826 tail_cache.ensure(trailing_hidden, &revisions[..2], 80, hidden);
827 assert!(
828 plain_lines(&tail_cache)
829 .last()
830 .is_some_and(|line| !line.is_empty()),
831 "a hidden final cell must not reserve a phantom spacer"
832 );
833 }
834
835 #[test]
836 fn transcript_rhythm_is_width_and_reduced_motion_invariant() {
837 let cells = vec![
838 user_cell("Please inspect the release candidate and verify all receipts."),
839 HistoryCell::Thinking {
840 content: "I will inspect the source, run the checks, and compare the receipts."
841 .to_string(),
842 streaming: true,
843 duration_secs: Some(0.8),
844 },
845 assistant_cell("I will start with the locked test suite.", false),
846 exec_tool_cell("cargo test -p codewhale-tui --bins --locked"),
847 durable_work_cell(),
848 assistant_cell("The focused checks passed.", false),
849 user_cell("Proceed to the final verification."),
850 ];
851 let revisions = vec![1u64; cells.len()];
852 // user | reasoning | answer | tool | work | answer | user.
853 // Every seam is one row: the reasoning→answer seam (index 1) used to be
854 // the one place the transcript ran two blocks together.
855 let expected = [1, 1, 1, 1, 1, 1, 0];
856
857 for width in [40, 80, 100, 140] {
858 for low_motion in [false, true] {
859 let options = TranscriptRenderOptions {
860 low_motion,
861 spacing: TranscriptSpacing::Comfortable,
862 ..TranscriptRenderOptions::default()
863 };
864 let mut cache = TranscriptViewCache::new();
865 cache.ensure(&cells, &revisions, width, options);
866
867 let actual =
868 std::array::from_fn::<_, 7, _>(|index| spacer_rows_after_cell(&cache, index));
869 assert_eq!(actual, expected, "width={width} low_motion={low_motion}");
870 assert!(
871 cache
872 .lines()
873 .iter()
874 .all(|line| line.width() <= usize::from(width)),
875 "render exceeded width={width} low_motion={low_motion}"
876 );
877 }
878 }
879 }
880
881 #[test]
882 fn streaming_state_transitions_do_not_move_neighbor_boundaries() {
883 let mut cells = vec![
884 user_cell("Inspect the candidate."),
885 HistoryCell::Thinking {
886 content: "Inspecting the candidate now.".to_string(),
887 streaming: true,
888 duration_secs: None,
889 },
890 exec_tool_cell("git status --short"),
891 user_cell("Summarize the receipt."),
892 ];
893 let mut revisions = vec![1u64; cells.len()];
894 let options = TranscriptRenderOptions {
895 low_motion: true,
896 ..TranscriptRenderOptions::default()
897 };
898 let mut cache = TranscriptViewCache::new();
899
900 let boundary_rows = |cache: &TranscriptViewCache| {
901 [
902 spacer_rows_after_cell(cache, 0),
903 spacer_rows_after_cell(cache, 1),
904 spacer_rows_after_cell(cache, 2),
905 ]
906 };
907
908 cache.ensure(&cells, &revisions, 80, options);
909 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
910
911 cells[1] = assistant_cell("I inspected the candidate.", true);
912 revisions[1] += 1;
913 cache.ensure(&cells, &revisions, 80, options);
914 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
915
916 cells[1] = assistant_cell("I inspected the candidate.", false);
917 revisions[1] += 1;
918 cache.ensure(&cells, &revisions, 80, options);
919 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
920
921 let HistoryCell::Tool(ToolCell::Exec(exec)) = &mut cells[2] else {
922 unreachable!("fixture is an exec tool")
923 };
924 exec.status = ToolStatus::Success;
925 revisions[2] += 1;
926 cache.ensure(&cells, &revisions, 80, options);
927 assert_eq!(boundary_rows(&cache), [1, 1, 1]);
928 }
929
930 #[test]
931 fn resize_round_trip_rebuilds_the_same_semantic_rows() {
932 let cells = vec![
933 user_cell("A long prompt that wraps when the terminal narrows considerably."),
934 exec_tool_cell("printf 'a tool receipt with a deliberately long summary'"),
935 assistant_cell("A stable answer after the tool receipt.", false),
936 ];
937 let revisions = vec![1u64; cells.len()];
938 let options = TranscriptRenderOptions {
939 low_motion: true,
940 ..TranscriptRenderOptions::default()
941 };
942 let mut cache = TranscriptViewCache::new();
943
944 cache.ensure(&cells, &revisions, 140, options);
945 let wide = plain_lines(&cache);
946 cache.ensure(&cells, &revisions, 40, options);
947 cache.ensure(&cells, &revisions, 140, options);
948
949 assert_eq!(plain_lines(&cache), wide);
950 assert_eq!(cache.lines().len(), cache.line_meta().len());
951 assert_eq!(cache.lines().len(), cache.line_links().len());
952 }
953
954 #[test]
955 fn palette_mode_change_invalidates_cached_syntax_rendering() {
956 let cells = vec![assistant_cell(
957 "```rust\nfn main() { let answer = 42; }\n```",
958 false,
959 )];
960 let revisions = [1u64];
961 let mut cache = TranscriptViewCache::new();
962 let dark = TranscriptRenderOptions {
963 palette_mode: palette::PaletteMode::Dark,
964 ..TranscriptRenderOptions::default()
965 };
966
967 cache.ensure(&cells, &revisions, 80, dark);
968 let dark_lines = Arc::clone(&cache.per_cell[0].lines);
969
970 cache.ensure(
971 &cells,
972 &revisions,
973 80,
974 TranscriptRenderOptions {
975 palette_mode: palette::PaletteMode::Light,
976 ..dark
977 },
978 );
979
980 assert!(
981 !Arc::ptr_eq(&dark_lines, &cache.per_cell[0].lines),
982 "palette mode is part of TranscriptRenderOptions and must bust cached cells"
983 );
984 }
985
986 #[test]
987 fn tool_rails_preserve_rendered_width_budget() {
988 let cells = vec![exec_tool_cell(
989 "printf 'this is a command with enough text to wrap in narrow terminals'",
990 )];
991 let revisions = vec![1u64];
992 let mut cache = TranscriptViewCache::new();
993
994 cache.ensure(&cells, &revisions, 24, TranscriptRenderOptions::default());
995
996 for line in plain_lines(&cache) {
997 assert!(
998 unicode_width::UnicodeWidthStr::width(line.as_str()) <= 24,
999 "tool rail line exceeded narrow width: {line:?}"
1000 );
1001 }
1002 }
1003
1004 /// Simulate a long, complex conversation (thinking + multi-line tool output +
1005 /// tool headers with multiple decorative spans) and report the memory
1006 /// consumed by `rail_prefix_widths`. This is informational — the assertion
1007 /// only fails if the per-line overhead exceeds a generous bound.
1008 // Test prints memory-overhead diagnostics — runs in `cargo test`, never
1009 // inside the TUI alt-screen, so the module-level deny doesn't apply.
1010 #[allow(clippy::print_stderr)]
1011 #[test]
1012 fn rail_prefix_widths_memory_overhead_complex_session() {
1013 let mut cells: Vec<HistoryCell> = Vec::new();
1014 // Build ~60 turns covering the typical deep-reasoning workflow:
1015 // user → thinking (5-15 lines) → assistant → tool → tool output →
1016 // thinking → assistant → ... repeat.
1017 for i in 0..30 {
1018 cells.push(user_cell(&format!("complex query {i} about system design")));
1019 cells.push(HistoryCell::Thinking {
1020 content:
1021 "line A\nline B\nline C\nline D\nline E\nline F\nline G\nline H\nline I\nline J"
1022 .to_string(),
1023 streaming: false,
1024 duration_secs: Some(3.5),
1025 });
1026 cells.push(assistant_cell(
1027 &format!("response {i} with multi-line\ntext content spanning\nseveral lines"),
1028 false,
1029 ));
1030 cells.push(exec_tool_cell(
1031 "cargo test --package my_crate -- --nocapture 2>&1 | head -40",
1032 ));
1033 // Insert a second tool so adjacent tool cells merge into a railed group.
1034 cells.push(exec_tool_cell(&format!("git diff --stat HEAD~{i}")));
1035 }
1036 let revisions: Vec<u64> = (0..cells.len()).map(|i| i as u64 + 1).collect();
1037
1038 let mut cache = TranscriptViewCache::new();
1039 cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default());
1040
1041 let total_lines = cache.total_lines();
1042 let pw_len = cache.rail_prefix_widths.len();
1043 let pw_cap = cache.rail_prefix_widths.capacity();
1044 // The Vec's inlined buffer on most platforms is small; capacity
1045 // should be >= len. Both must equal total_lines.
1046 assert_eq!(pw_len, total_lines);
1047 assert!(pw_cap >= pw_len);
1048
1049 let memory_bytes = pw_cap * std::mem::size_of::<usize>();
1050 let memory_kb = memory_bytes as f64 / 1024.0;
1051 // Each usize is 8 bytes on 64-bit. Even with 100k lines this stays
1052 // under 1 MB.
1053 let kbytes_per_1k_lines = (memory_bytes as f64 / total_lines as f64) * 1000.0 / 1024.0;
1054
1055 eprintln!("=== rail_prefix_widths memory (complex session) ===");
1056 eprintln!(" total_lines: {total_lines}");
1057 eprintln!(" vec len: {pw_len}");
1058 eprintln!(" vec capacity: {pw_cap}");
1059 eprintln!(" memory (bytes): {memory_bytes}");
1060 eprintln!(" memory (KB): {memory_kb:.2}");
1061 eprintln!(" KB per 1k lines: {kbytes_per_1k_lines:.2}");
1062 eprintln!(" lines × 8 bytes: {} KB", total_lines * 8 / 1024);
1063
1064 // Sanity: per-line overhead must be reasonable.
1065 assert!(
1066 memory_kb < 1024.0,
1067 "rail_prefix_widths memory unexpectedly large: {memory_kb:.1} KB"
1068 );
1069 eprintln!(" ✓ well under 1 MB even for very long sessions");
1070 }
1071
1072 #[test]
1073 fn ensure_filtered_matches_ensure_split_output() {
1074 let cells = vec![
1075 user_cell("hello"),
1076 assistant_cell("some **markdown** body", false),
1077 exec_tool_cell("cargo test"),
1078 user_cell("again"),
1079 ];
1080 let revisions = vec![1u64, 2, 3, 4];
1081 let index_map: Vec<usize> = vec![0, 1, 2, 3];
1082 // This test compares the two cache traversal paths, not animation.
1083 // Freeze live motion so a spinner tick between the two renders cannot
1084 // turn an equivalent layout into a timing-dependent failure.
1085 let options = TranscriptRenderOptions {
1086 low_motion: true,
1087 motion_mode: crate::tui::motion::MotionMode::Still,
1088 ..TranscriptRenderOptions::default()
1089 };
1090
1091 let mut split_cache = TranscriptViewCache::new();
1092 split_cache.ensure_split(
1093 &[&cells],
1094 &revisions,
1095 40,
1096 options,
1097 &HashSet::new(),
1098 Some(&index_map),
1099 );
1100
1101 let refs: Vec<&HistoryCell> = cells.iter().collect();
1102 let mut filtered_cache = TranscriptViewCache::new();
1103 filtered_cache.ensure_filtered(
1104 &refs,
1105 &revisions,
1106 40,
1107 options,
1108 &HashSet::new(),
1109 Some(&index_map),
1110 );
1111
1112 assert_eq!(plain_lines(&split_cache), plain_lines(&filtered_cache));
1113 assert_eq!(
1114 split_cache.line_meta().len(),
1115 filtered_cache.line_meta().len()
1116 );
1117 }
1118
1119 #[test]
1120 fn ensure_filtered_reuses_unchanged_cells() {
1121 let cells = [
1122 user_cell("hello"),
1123 assistant_cell("streaming", true),
1124 user_cell("again"),
1125 ];
1126 let mut revisions = vec![1u64, 1, 1];
1127 let refs: Vec<&HistoryCell> = cells.iter().collect();
1128
1129 let mut cache = TranscriptViewCache::new();
1130 cache.ensure_filtered(
1131 &refs,
1132 &revisions,
1133 80,
1134 TranscriptRenderOptions::default(),
1135 &HashSet::new(),
1136 None,
1137 );
1138 let first = plain_lines(&cache);
1139
1140 cache.ensure_filtered(
1141 &refs,
1142 &revisions,
1143 80,
1144 TranscriptRenderOptions::default(),
1145 &HashSet::new(),
1146 None,
1147 );
1148 assert_eq!(first, plain_lines(&cache));
1149 for (idx, cached) in cache.per_cell.iter().enumerate() {
1150 assert_eq!(
1151 cached.revision, 1,
1152 "cell {idx} must be reused, not re-rendered"
1153 );
1154 }
1155
1156 // Bump one revision: only that entry re-renders.
1157 revisions[1] = 2;
1158 cache.ensure_filtered(
1159 &refs,
1160 &revisions,
1161 80,
1162 TranscriptRenderOptions::default(),
1163 &HashSet::new(),
1164 None,
1165 );
1166 assert_eq!(cache.per_cell[0].revision, 1);
1167 assert_eq!(cache.per_cell[1].revision, 2);
1168 assert_eq!(cache.per_cell[2].revision, 1);
1169 }
1170
1171 #[test]
1172 fn prose_cells_wrap_at_bounded_measure_on_ultrawide() {
1173 // v0.9.4: prose (user/assistant/thinking) must stop stretching
1174 // edge-to-edge at ultrawide widths while tool cells keep the full
1175 // content width. The cache key stays `(CellId, fed_width, revision)`
1176 // so resize keeps its single-feed cost model; the per-cell measure is
1177 // applied inside the render entry points.
1178 let measure = crate::tui::history::PROSE_MAX_MEASURE;
1179 let long = "prose line that is intentionally long enough to wrap \
1180 at one hundred and five columns but not at ordinary \
1181 widths, repeated to guarantee several wrapped rows "
1182 .repeat(4);
1183 let cells = [
1184 user_cell(&long),
1185 assistant_cell(&long, false),
1186 HistoryCell::Thinking {
1187 content: long.clone(),
1188 streaming: false,
1189 duration_secs: Some(2.0),
1190 },
1191 exec_tool_cell_with_output(
1192 "cargo test --all",
1193 "long tool output that itself wraps well past the prose measure ".repeat(6),
1194 ),
1195 ];
1196 let refs: Vec<&HistoryCell> = cells.iter().collect();
1197 let revisions = vec![1u64, 2, 3, 4];
1198 let options = TranscriptRenderOptions {
1199 low_motion: true,
1200 motion_mode: crate::tui::motion::MotionMode::Still,
1201 ..TranscriptRenderOptions::default()
1202 };
1203
1204 let mut cache = TranscriptViewCache::new();
1205 cache.ensure_filtered(&refs, &revisions, 220, options, &HashSet::new(), None);
1206
1207 fn max_line_width(cell: &CachedCell) -> usize {
1208 cell.lines
1209 .iter()
1210 .map(|line| {
1211 line.spans
1212 .iter()
1213 .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
1214 .sum()
1215 })
1216 .max()
1217 .unwrap_or(0)
1218 }
1219
1220 for idx in 0..3 {
1221 let width = max_line_width(&cache.per_cell[idx]);
1222 assert!(
1223 width <= usize::from(measure),
1224 "prose cell {idx} wrapped to {width} columns, over the \
1225 {measure}-column measure",
1226 );
1227 }
1228 let tool_width = max_line_width(&cache.per_cell[3]);
1229 assert!(
1230 tool_width > usize::from(measure),
1231 "tool cell must keep the full width, got {tool_width}",
1232 );
1233 }
1234
1235 #[test]
1236 fn folded_thinking_cache_invalidation() {
1237 let long_content = "reasoning line\n".repeat(50);
1238 let cells = [HistoryCell::Thinking {
1239 content: long_content.clone(),
1240 streaming: false,
1241 duration_secs: Some(1.5),
1242 }];
1243 let revisions = [1u64];
1244 let options = TranscriptRenderOptions {
1245 verbose: true, // expanded by default
1246 ..TranscriptRenderOptions::default()
1247 };
1248 let width = 80u16;
1249
1250 // First render: no folding → full content.
1251 let mut cache = TranscriptViewCache::new();
1252 cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
1253 let full_line_count = cache.total_lines();
1254
1255 // Second render: fold the thinking cell → should invalidate and
1256 // produce fewer lines (collapsed summary).
1257 let mut folded = HashSet::new();
1258 folded.insert(0usize);
1259 cache.ensure_split(&[&cells], &revisions, width, options, &folded, None);
1260 let folded_line_count = cache.total_lines();
1261
1262 assert!(
1263 folded_line_count < full_line_count,
1264 "folded thinking should render fewer lines: folded={folded_line_count} full={full_line_count}"
1265 );
1266
1267 // Third render: unfold → should restore full content.
1268 cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
1269 let restored_line_count = cache.total_lines();
1270 assert_eq!(
1271 restored_line_count, full_line_count,
1272 "unfolded thinking should restore full line count"
1273 );
1274 }
1275
1276 #[test]
1277 fn folded_thinking_with_collapsed_cells_uses_original_indices() {
1278 // Two thinking cells: cell 0 and cell 1. Cell 0 is collapsed (hidden).
1279 // Fold cell 1 (original index 1). With the filtered index map,
1280 // the cache should still fold the correct cell.
1281 let cells = [
1282 HistoryCell::Thinking {
1283 content: "first thinking block\n".repeat(20),
1284 streaming: false,
1285 duration_secs: Some(1.0),
1286 },
1287 HistoryCell::Thinking {
1288 content: "second thinking block\n".repeat(20),
1289 streaming: false,
1290 duration_secs: Some(2.0),
1291 },
1292 ];
1293 let revisions = [1u64, 2u64];
1294 let options = TranscriptRenderOptions {
1295 verbose: true,
1296 ..TranscriptRenderOptions::default()
1297 };
1298 let width = 80u16;
1299
1300 // No collapsing, no folding — baseline.
1301 let mut cache = TranscriptViewCache::new();
1302 cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
1303 let baseline = cache.total_lines();
1304 assert!(baseline > 0, "baseline render should contain visible lines");
1305
1306 // Collapse cell 0, fold cell 1. The filtered list has only cell 1
1307 // at filtered index 0, but it maps to original index 1.
1308 let filtered_cells = [cells[1].clone()];
1309 let filtered_revs = [2u64];
1310 let index_map: Vec<usize> = vec![1]; // filtered 0 → original 1
1311
1312 let mut folded = HashSet::new();
1313 folded.insert(1usize); // fold original index 1
1314
1315 let mut cache2 = TranscriptViewCache::new();
1316 cache2.ensure_split(
1317 &[&filtered_cells],
1318 &filtered_revs,
1319 width,
1320 options,
1321 &folded,
1322 Some(&index_map),
1323 );
1324 let folded_filtered = cache2.total_lines();
1325
1326 // Cell 1 was expanded in baseline; now it should be folded.
1327 // We can't compare directly to baseline because baseline had both
1328 // cells, but folded_filtered should be less than if cell 1 were
1329 // expanded in the filtered view.
1330 let mut cache3 = TranscriptViewCache::new();
1331 cache3.ensure_split(
1332 &[&filtered_cells],
1333 &filtered_revs,
1334 width,
1335 options,
1336 &HashSet::new(),
1337 Some(&index_map),
1338 );
1339 let expanded_filtered = cache3.total_lines();
1340
1341 assert!(
1342 folded_filtered < expanded_filtered,
1343 "folded cell via index map should render fewer lines: folded={folded_filtered} expanded={expanded_filtered}"
1344 );
1345 }
1346
1347 #[test]
1348 fn zz_dump_spacing() {
1349 let cells = vec![
1350 user_cell("add spacing to the transcript"),
1351 HistoryCell::Thinking {
1352 content: "The user wants vertical rhythm. I should look at the transcript cache first."
1353 .to_string(),
1354 streaming: false,
1355 duration_secs: Some(3.0),
1356 },
1357 assistant_cell("I'll start by reading the renderer.", false),
1358 exec_tool_cell_with_output(
1359 "rg -n spacer crates/tui".to_string().as_str(),
1360 "crates/tui/src/tui/transcript.rs:661\ncrates/tui/src/tui/transcript.rs:724"
1361 .to_string(),
1362 ),
1363 exec_tool_cell_with_output("cargo fmt --all", "".to_string()),
1364 assistant_cell(
1365 "Done — spacing is centralized in the transcript cache.",
1366 false,
1367 ),
1368 ];
1369 let revisions = vec![1u64; 6];
1370 let mut cache = TranscriptViewCache::new();
1371 let opts = TranscriptRenderOptions {
1372 low_motion: true,
1373 ..TranscriptRenderOptions::default()
1374 };
1375 cache.ensure(&cells, &revisions, 80, opts);
1376 // The dump helper is for manual inspection; the assertions in the
1377 // sibling tests carry the real contract, so nothing prints here.
1378 let _ = plain_lines(&cache);
1379 }
1380
1380 lines RUST