返回 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 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 &HashSet::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 &HashSet::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 &HashSet::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 &HashSet::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 &HashSet::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 &HashSet::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, &HashSet::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, &HashSet::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 &HashSet::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 = HashSet::new();
1527 folded.insert(0usize);
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 &HashSet::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 &HashSet::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 = HashSet::new();
1598 folded.insert(1usize); // 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 &HashSet::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 &HashSet::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))| {
1689 meta.cell_line() == Some((1, cache.per_cell[1].lines.len().saturating_sub(1)))
1690 })
1691 .map(|(index, (line, meta))| (index, line, meta))
1692 .expect("untargeted neutral affordance");
1693 assert_eq!(
1694 neutral_meta.copy_prefix_width(),
1695 neutral_line
1696 .width()
1697 .saturating_sub(cache.rail_prefix_width(neutral_index))
1698 );
1699 assert!(cache.line_links[neutral_index].is_empty());
1700
1701 cache.retarget(Some(reasoning_owner(1)), None);
1702 assert_eq!(cache.total_lines(), total);
1703 assert_eq!(hint_cells(&cache), vec![1]);
1704 assert_eq!(
1705 cache
1706 .per_cell
1707 .iter()
1708 .map(|cell| Arc::as_ptr(&cell.lines))
1709 .collect::<Vec<_>>(),
1710 cached_lines,
1711 "target transfer must reuse neutral Markdown renders"
1712 );
1713
1714 cache.retarget(None, None);
1715 assert_eq!(cache.total_lines(), total);
1716 assert!(hint_cells(&cache).is_empty());
1717 }
1718
1719 #[test]
1720 fn layout_aware_reasoning_budget_applies_only_to_the_newest_cell() {
1721 let cells = vec![reasoning_cell(false), reasoning_cell(false)];
1722 let revisions = [1, 1];
1723 let mut cache = TranscriptViewCache::new();
1724 let constrained = TranscriptRenderOptions {
1725 reasoning_preview_viewport_lines: Some(18),
1726 ..TranscriptRenderOptions::default()
1727 };
1728 cache.ensure_split(
1729 &[&cells],
1730 &revisions,
1731 80,
1732 constrained,
1733 &HashSet::new(),
1734 None,
1735 Some(reasoning_owner(1)),
1736 );
1737
1738 let first = cache.per_cell[0]
1739 .lines
1740 .iter()
1741 .map(ToString::to_string)
1742 .collect::<Vec<_>>()
1743 .join("\n");
1744 let newest = cache.per_cell[1]
1745 .lines
1746 .iter()
1747 .map(ToString::to_string)
1748 .collect::<Vec<_>>()
1749 .join("\n");
1750 assert!(!first.contains("reasoning line 20"), "{first}");
1751 assert!(newest.contains("reasoning line 12"), "{newest}");
1752 assert!(!newest.contains("reasoning line 13"), "{newest}");
1753 assert_eq!(cache.total_lines(), 18);
1754 assert!(cache.per_cell[0].reasoning_action.is_some());
1755 assert!(cache.per_cell[1].reasoning_action.is_some());
1756
1757 let roomy = TranscriptRenderOptions {
1758 reasoning_preview_viewport_lines: Some(34),
1759 ..TranscriptRenderOptions::default()
1760 };
1761 cache.ensure_split(
1762 &[&cells],
1763 &revisions,
1764 80,
1765 roomy,
1766 &HashSet::new(),
1767 None,
1768 Some(reasoning_owner(1)),
1769 );
1770 let newest = cache.per_cell[1]
1771 .lines
1772 .iter()
1773 .map(ToString::to_string)
1774 .collect::<Vec<_>>()
1775 .join("\n");
1776 assert!(newest.contains("reasoning line 20"), "{newest}");
1777 assert!(cache.total_lines() <= 34);
1778 assert!(cache.per_cell[1].reasoning_action.is_none());
1779
1780 // Appending a non-reasoning cell removes the adaptive treatment from the
1781 // old tail even though its revision did not change.
1782 let extended = vec![
1783 reasoning_cell(false),
1784 reasoning_cell(false),
1785 assistant_cell("answer", false),
1786 ];
1787 cache.ensure_split(
1788 &[&extended],
1789 &[1, 1, 1],
1790 80,
1791 roomy,
1792 &HashSet::new(),
1793 None,
1794 Some(reasoning_owner(2)),
1795 );
1796 let former_tail = cache.per_cell[1]
1797 .lines
1798 .iter()
1799 .map(ToString::to_string)
1800 .collect::<Vec<_>>()
1801 .join("\n");
1802 assert!(!former_tail.contains("reasoning line 20"), "{former_tail}");
1803 assert!(cache.per_cell[1].reasoning_action.is_some());
1804 }
1805
1806 #[test]
1807 fn filtered_reasoning_owner_keeps_original_identity() {
1808 let cells = [reasoning_cell(false)];
1809 let revisions = [2];
1810 let original_map = [1];
1811 let mut cache = TranscriptViewCache::new();
1812 cache.ensure_split(
1813 &[&cells],
1814 &revisions,
1815 80,
1816 TranscriptRenderOptions::default(),
1817 &HashSet::new(),
1818 Some(&original_map),
1819 Some(reasoning_owner(1)),
1820 );
1821 assert!(plain_lines(&cache).join("\n").contains("Space:expand"));
1822 assert_eq!(
1823 cache.reasoning_action_target(),
1824 Some(ReasoningActionTarget {
1825 owner: reasoning_owner(1),
1826 action: ReasoningAction::Expand,
1827 })
1828 );
1829 assert!(
1830 cache
1831 .line_meta()
1832 .iter()
1833 .any(|meta| meta.cell_line().is_some_and(|(rendered, _)| rendered == 0))
1834 );
1835
1836 cache.ensure_split(
1837 &[&cells],
1838 &revisions,
1839 80,
1840 TranscriptRenderOptions::default(),
1841 &HashSet::new(),
1842 Some(&original_map),
1843 Some(reasoning_owner(0)),
1844 );
1845 assert!(cache.reasoning_action_target().is_none());
1846 assert!(!plain_lines(&cache).join("\n").contains("Space:expand"));
1847 }
1848
1849 #[test]
1850 fn streaming_tail_fast_path_cannot_skip_reasoning_retarget() {
1851 let cells = [reasoning_cell(false), assistant_cell("tail", true)];
1852 let mut cache = TranscriptViewCache::new();
1853 cache.ensure_split(
1854 &[&cells],
1855 &[1, 1],
1856 80,
1857 TranscriptRenderOptions::default(),
1858 &HashSet::new(),
1859 None,
1860 Some(reasoning_owner(0)),
1861 );
1862 assert!(plain_lines(&cache).join("\n").contains("Space:expand"));
1863
1864 let updated = [reasoning_cell(false), assistant_cell("tail extended", true)];
1865 cache.ensure_split(
1866 &[&updated],
1867 &[1, 2],
1868 80,
1869 TranscriptRenderOptions::default(),
1870 &HashSet::new(),
1871 None,
1872 None,
1873 );
1874 assert!(cache.reasoning_action_target().is_none());
1875 assert!(!plain_lines(&cache).join("\n").contains("Space:expand"));
1876 }
1877
1878 #[test]
1879 fn narrow_reasoning_hint_never_changes_cache_geometry() {
1880 let cells = [reasoning_cell(false)];
1881 for width in 1..=16 {
1882 let mut cache = TranscriptViewCache::new();
1883 cache.ensure_split(
1884 &[&cells],
1885 &[1],
1886 width,
1887 TranscriptRenderOptions::default(),
1888 &HashSet::new(),
1889 None,
1890 None,
1891 );
1892 let neutral_lines = cache.total_lines();
1893 cache.ensure_split(
1894 &[&cells],
1895 &[1],
1896 width,
1897 TranscriptRenderOptions::default(),
1898 &HashSet::new(),
1899 None,
1900 Some(reasoning_owner(0)),
1901 );
1902 assert_eq!(cache.total_lines(), neutral_lines, "width {width}");
1903 let affordance_line = cache
1904 .lines()
1905 .iter()
1906 .zip(cache.line_meta())
1907 .find(|(_, meta)| {
1908 meta.cell_line() == Some((0, cache.per_cell[0].lines.len().saturating_sub(1)))
1909 })
1910 .map(|(line, _)| line.to_string())
1911 .expect("reasoning affordance line");
1912 if width >= 14 {
1913 assert_eq!(affordance_line, "╎ Space:expand", "width {width}");
1914 } else {
1915 assert_eq!(affordance_line, "╎ …", "width {width}");
1916 assert!(!plain_lines(&cache).join("\n").contains("Space:"));
1917 }
1918 }
1919 }
1920
1921 #[test]
1922 fn reasoning_hint_uses_the_render_locale() {
1923 let cells = [reasoning_cell(false)];
1924 let options = TranscriptRenderOptions {
1925 locale: Locale::Ja,
1926 ..TranscriptRenderOptions::default()
1927 };
1928 let mut cache = TranscriptViewCache::new();
1929 cache.ensure_split(
1930 &[&cells],
1931 &[1],
1932 80,
1933 options,
1934 &HashSet::new(),
1935 None,
1936 Some(reasoning_owner(0)),
1937 );
1938 let text = plain_lines(&cache).join("\n");
1939 assert!(text.contains("Space:展開"), "{text}");
1940 assert!(!text.contains("Space:expand"), "{text}");
1941 }
1942
1942 lines RUST