返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / history / tests.rs
1 //! Transcript history-cell tests.
2 //!
3 //! Rebuilt in v0.9.11 after declaring test bankruptcy on the previous suite
4 //! (123 tests / 3,964 lines). About a third of that file pinned glyph choices,
5 //! palette tokens, span indices and English label text — `spans[1] == "⣤"`,
6 //! `title_span.style.fg == theme.tool_title_color`, `visible[1] == "▏ done:
7 //! scan repo"`. Those assertions fail on every legitimate visual refactor and
8 //! catch nothing a user would notice, which is the liability `d64b9429b`
9 //! ("remove brittle visual test mass") named.
10 //!
11 //! What survives is named for the *property* it protects. Rules for additions:
12 //!
13 //! * Assert a property, not a token. `spans[1] == "⣤"` is a token; "the frame
14 //! does not change while motion is reduced" is the property, and it is
15 //! strictly stronger — it also catches an animation leak the constant missed.
16 //! * Where the value is a design choice (color, glyph, verb), assert the
17 //! *relationship* between cases instead: warning must not read as error.
18 //! * One test per property, with its cases in a table — not one test per case.
19 //! * Never assert `a || b` where `b` is trivially true of any English string.
20
21 use super::constants::{
22 TOOL_OUTPUT_HEAD_LINES, TOOL_OUTPUT_LINE_LIMIT, TOOL_OUTPUT_TAIL_LINES,
23 TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
24 };
25 use super::thinking::cached_color_depth;
26 use super::{
27 ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanUpdateCell,
28 REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, RenderMode, ToolCell, ToolStatus,
29 TranscriptRenderOptions, WebSearchCell, assistant_label_style_for, extract_reasoning_summary,
30 render_spillover_annotation, render_thinking, render_thinking_with_analysis,
31 running_status_label_with_elapsed,
32 };
33 use crate::tools::plan::{PlanSnapshot, StepStatus};
34 use crate::tui::motion::MotionMode;
35 use crate::tui::ui_text::{
36 line_to_plain, slice_visible_columns, text_display_width, text_visible_width,
37 };
38 use codewhale_models::{ContentBlock, Message, Role};
39 use std::path::PathBuf;
40 use std::time::{Duration, Instant};
41
42 // ---------------------------------------------------------------------------
43 // Helpers
44 // ---------------------------------------------------------------------------
45
46 fn line_text(line: &ratatui::text::Line<'static>) -> String {
47 line.spans
48 .iter()
49 .map(|span| span.content.as_ref())
50 .collect()
51 }
52
53 fn lines_text(lines: &[ratatui::text::Line<'static>]) -> String {
54 lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
55 }
56
57 fn generic_tool(name: &str, status: ToolStatus) -> GenericToolCell {
58 GenericToolCell {
59 name: name.to_string(),
60 status,
61 input_summary: None,
62 output: None,
63 prompts: None,
64 spillover_path: None,
65 output_summary: None,
66 is_diff: false,
67 }
68 }
69
70 fn exec_tool(command: &str, status: ToolStatus) -> ExecCell {
71 ExecCell {
72 command: command.to_string(),
73 status,
74 output: None,
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 numbered_output(count: usize) -> String {
89 (0..count)
90 .map(|i| format!("row {i:02} plain content"))
91 .collect::<Vec<_>>()
92 .join("\n")
93 }
94
95 fn calm_options() -> TranscriptRenderOptions {
96 TranscriptRenderOptions {
97 low_motion: true,
98 ..TranscriptRenderOptions::default()
99 }
100 }
101
102 // ---------------------------------------------------------------------------
103 // Leaks — a rendered cell never exposes something the user was not shown
104 // ---------------------------------------------------------------------------
105
106 /// Spilled tool output lives in a file under the session directory. The path is
107 /// an internal storage detail: it names the user's home, their session id, and
108 /// a content hash, and it is useless to them because the affordance opens the
109 /// pager, not the file. No width, no render mode, and no standalone annotation
110 /// may print it.
111 ///
112 /// Replaces five separate tests that each checked one width or one mode.
113 #[test]
114 fn no_width_or_render_mode_leaks_a_spillover_storage_path() {
115 let secret = "/Users/private/.codewhale/sessions/session-a/artifacts/hash.txt";
116
117 for width in [18_u16, 40, 80, 120] {
118 for mode in [RenderMode::Live, RenderMode::Transcript] {
119 let mut cell = generic_tool("read_file", ToolStatus::Success);
120 cell.input_summary = Some("cmd: cargo build --release".to_string());
121 cell.output = Some(numbered_output(40));
122 cell.spillover_path = Some(PathBuf::from(secret));
123
124 let rendered = lines_text(&cell.lines_with_mode(width, true, mode));
125 for fragment in ["/Users", ".codewhale", "sessions/", "hash.txt"] {
126 assert!(
127 !rendered.contains(fragment),
128 "storage path fragment {fragment:?} leaked at width {width} in {mode:?}: \
129 {rendered:?}"
130 );
131 }
132 }
133
134 // The standalone affordance carries no path either, and it fits the
135 // width it was given — an affordance that overflows is a wrap artifact
136 // in the transcript.
137 let annotation = line_to_plain(&render_spillover_annotation(width));
138 assert!(
139 text_display_width(&annotation) <= usize::from(width),
140 "affordance exceeds width {width}: {annotation:?}"
141 );
142 for fragment in ["/Users", ".codewhale", "hash.txt"] {
143 assert!(
144 !annotation.contains(fragment),
145 "affordance leaked {fragment:?}: {annotation:?}"
146 );
147 }
148 }
149
150 // The common case — a result that never spilled — spends no row on an
151 // affordance that would open an empty pager.
152 let mut plain = generic_tool("read_file", ToolStatus::Success);
153 plain.output = Some("contents".to_string());
154 let hint = crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output");
155 let rendered = lines_text(&plain.lines_with_mode(80, true, RenderMode::Live));
156 assert!(
157 !rendered.contains(&hint),
158 "a result that did not spill must not advertise the spillover pager: {rendered:?}"
159 );
160 }
161
162 /// With reasoning display off, the model's chain of thought must not reach the
163 /// screen in any lifecycle state — not while streaming, not once complete.
164 /// The live case still needs a progress signal, so it renders one compact row.
165 #[test]
166 fn hidden_reasoning_never_renders_its_content_in_any_state() {
167 let secret = "private chain of thought that must not be shown";
168 let hidden = TranscriptRenderOptions {
169 show_thinking: false,
170 low_motion: true,
171 ..TranscriptRenderOptions::default()
172 };
173
174 let streaming = HistoryCell::Thinking {
175 content: secret.to_string(),
176 streaming: true,
177 duration_secs: None,
178 };
179 let live = streaming.lines_with_options(80, hidden);
180 let live_text = lines_text(&live);
181 assert!(
182 !live_text.contains(secret),
183 "hidden live reasoning revealed its body: {live_text}"
184 );
185 assert_eq!(
186 live.len(),
187 1,
188 "hidden reasoning is one compact progress row, not a stack of state \
189 copy: {live_text}"
190 );
191
192 let complete = HistoryCell::Thinking {
193 content: secret.to_string(),
194 streaming: false,
195 duration_secs: Some(1.0),
196 };
197 assert!(
198 complete.lines_with_options(80, hidden).is_empty(),
199 "completed hidden reasoning must leave the live transcript entirely"
200 );
201 }
202
203 /// A live card is a summary. It must name the tool that ran (so the row is
204 /// attributable) and must not spend its one line echoing arguments the caller
205 /// never chose — `max_count: 15` is a schema default, not a user intent.
206 /// Transcript replay is the record, so it keeps the exact tool id.
207 ///
208 /// Replaces six tests, one of which (`unknown_generic_tool_keeps_raw_name_in_
209 /// live_mode`) asserted only `!text.is_empty()` and so could never fail.
210 #[test]
211 fn live_cards_name_their_tool_without_echoing_control_only_arguments() {
212 assert_eq!(
213 super::summarize_tool_args(&serde_json::json!({
214 "max_count": 15,
215 "timeout_ms": 30_000
216 })),
217 None,
218 "an argument set that is entirely control defaults summarizes to nothing"
219 );
220 assert_eq!(
221 super::summarize_tool_args(&serde_json::json!({
222 "max_count": 15,
223 "branch": "main"
224 }))
225 .as_deref(),
226 Some("branch: main"),
227 "the meaningful key is what the summary is for"
228 );
229
230 for name in ["git_log", "future_private_tool"] {
231 let mut cell = generic_tool(name, ToolStatus::Success);
232 cell.input_summary = Some("max_count: 15".to_string());
233 let lines = cell.lines_with_mode(120, true, RenderMode::Live);
234 let joined = lines_text(&lines);
235
236 assert_eq!(lines.len(), 1, "compact live row for {name}: {joined:?}");
237 assert!(
238 joined.contains(name),
239 "the row must be attributable to {name}: {joined:?}"
240 );
241 assert!(
242 !joined.contains("max_count"),
243 "control defaults must not become the visible summary for {name}: {joined:?}"
244 );
245 }
246
247 // A tool the UI has a family for is identified by that family live; the
248 // raw id would be a second, redundant name. Replay keeps it.
249 let mut known = generic_tool("run_verifiers", ToolStatus::Running);
250 known.input_summary = Some("profile: auto, level: quick".to_string());
251 let known = HistoryCell::Tool(ToolCell::Generic(known));
252 let live = lines_text(&known.lines(80));
253 let transcript = lines_text(&known.transcript_lines(80));
254 assert!(
255 !live.contains("run_verifiers"),
256 "a known tool id must not take a slot in the compact live card: {live}"
257 );
258 assert!(
259 transcript.contains("run_verifiers"),
260 "transcript replay preserves the exact tool id: {transcript}"
261 );
262 }
263
264 // ---------------------------------------------------------------------------
265 // Budget — the card never lies about how much it is showing
266 // ---------------------------------------------------------------------------
267
268 /// `selected_output_indices` fills head + tail and then tops up from lines that
269 /// look important (error / warning / path). Plain output — a list of names, a
270 /// clean build log — matches none of those, so the top-up found nothing and the
271 /// card silently forfeited the rest of its budget while still reporting the
272 /// remainder as omitted. A card that advertises N rows shows N rows.
273 #[test]
274 fn a_live_card_spends_the_whole_output_budget_it_advertises() {
275 let total = 40usize;
276 let cell = {
277 let mut exec = exec_tool("list_things", ToolStatus::Failed);
278 exec.output = Some(numbered_output(total));
279 exec.duration_ms = Some(120);
280 HistoryCell::Tool(ToolCell::Exec(exec))
281 };
282
283 let live_text = lines_text(&cell.lines_with_options(80, calm_options()));
284 let shown = (0..total)
285 .filter(|i| live_text.contains(&format!("row {i:02} plain content")))
286 .count();
287
288 assert_eq!(
289 shown, TOOL_OUTPUT_LINE_LIMIT,
290 "a card promising {TOOL_OUTPUT_LINE_LIMIT} rows must show \
291 {TOOL_OUTPUT_LINE_LIMIT}, not stop at head+tail: {live_text}"
292 );
293 for i in 0..TOOL_OUTPUT_HEAD_LINES {
294 assert!(
295 live_text.contains(&format!("row {i:02} plain content")),
296 "head row {i} missing: {live_text}"
297 );
298 }
299 for i in (total - TOOL_OUTPUT_TAIL_LINES)..total {
300 assert!(
301 live_text.contains(&format!("row {i:02} plain content")),
302 "tail row {i} missing: {live_text}"
303 );
304 }
305 }
306
307 /// Failure output is the one thing worth the vertical space. Whatever the
308 /// display settings say about density, a failed tool's body stays expanded and
309 /// is never traded for an omission marker or a "see details" affordance — the
310 /// user should not have to press a key to learn why something broke.
311 ///
312 /// Replaces four tests that differed only in which option flag they set.
313 #[test]
314 fn failed_tool_output_is_never_traded_for_an_affordance() {
315 let total = 30usize;
316 let last = format!("row {:02} plain content", total - 1);
317
318 for (label, options) in [
319 ("default", TranscriptRenderOptions::default()),
320 (
321 "tool details hidden",
322 TranscriptRenderOptions {
323 show_tool_details: false,
324 ..TranscriptRenderOptions::default()
325 },
326 ),
327 (
328 "calm mode",
329 TranscriptRenderOptions {
330 calm_mode: true,
331 ..TranscriptRenderOptions::default()
332 },
333 ),
334 ] {
335 let cell = {
336 let mut cell = generic_tool("read_file", ToolStatus::Failed);
337 cell.input_summary = Some("command: noisy".to_string());
338 cell.output = Some(numbered_output(total));
339 HistoryCell::Tool(ToolCell::Generic(cell))
340 };
341
342 let text = lines_text(&cell.lines_with_options(80, options));
343 assert!(
344 !text.contains("lines omitted"),
345 "[{label}] failed output must not be hidden behind an omission marker: {text}"
346 );
347 assert!(
348 text.contains(&last),
349 "[{label}] failed output must stay expanded to its last row: {text}"
350 );
351 assert!(
352 text.contains("command: noisy"),
353 "[{label}] the failing invocation must stay visible: {text}"
354 );
355 }
356 }
357
358 /// The live surface is a summary and the transcript is the record. The contract
359 /// is directional: anything the live view drops must still be in the
360 /// transcript, and the live view must say so when it drops something. A success
361 /// gets a bounded preview; a failure gets the full budget; neither may leave
362 /// the transcript short.
363 ///
364 /// Replaces four near-identical live/transcript comparison tests.
365 #[test]
366 fn whatever_live_truncates_the_transcript_still_holds() {
367 let total = 30usize;
368 let first = "row 00 plain content";
369 let last = format!("row {:02} plain content", total - 1);
370
371 // Failed exec: capped live with an honest marker, uncapped in transcript.
372 let failed = {
373 let mut exec = exec_tool("noisy_script.sh", ToolStatus::Failed);
374 exec.output = Some(numbered_output(total));
375 exec.duration_ms = Some(120);
376 HistoryCell::Tool(ToolCell::Exec(exec))
377 };
378 let live = failed.lines_with_options(80, calm_options());
379 let transcript = failed.transcript_lines(80);
380 let live_text = lines_text(&live);
381 let transcript_text = lines_text(&transcript);
382 assert!(
383 live.len() < transcript.len(),
384 "live must compress (live={}, transcript={})",
385 live.len(),
386 transcript.len()
387 );
388 assert!(
389 live_text.contains("lines omitted"),
390 "a live view that drops rows must say so: {live_text}"
391 );
392 assert!(
393 !transcript_text.contains("lines omitted"),
394 "the transcript drops nothing, so it claims nothing: {transcript_text}"
395 );
396 assert!(transcript_text.contains(first) && transcript_text.contains(&last));
397 assert!(
398 transcript_text.contains("row 15 plain content"),
399 "the transcript keeps the middle the live view skipped: {transcript_text}"
400 );
401
402 // Successful exec: a bounded head preview, never the whole body.
403 let success = {
404 let mut exec = exec_tool("noisy_script.sh", ToolStatus::Success);
405 exec.output = Some(numbered_output(total));
406 exec.duration_ms = Some(120);
407 HistoryCell::Tool(ToolCell::Exec(exec))
408 };
409 let live_text = lines_text(&success.lines_with_options(80, calm_options()));
410 let transcript_text = lines_text(&success.transcript_lines(80));
411 let previewed = (0..total)
412 .filter(|i| live_text.contains(&format!("row {i:02} plain content")))
413 .count();
414 assert_eq!(
415 previewed, TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
416 "a successful exec previews exactly {TOOL_SUCCESS_OUTPUT_PREVIEW_LINES} \
417 rows: {live_text}"
418 );
419 assert!(
420 live_text.contains(first) && !live_text.contains(&last),
421 "the preview reads from the top and stops: {live_text}"
422 );
423 assert!(transcript_text.contains(first) && transcript_text.contains(&last));
424
425 // Successful generic tool: output collapses entirely live, and does so
426 // without spending a row telling the user it collapsed.
427 let quiet = {
428 let mut cell = generic_tool("read_file", ToolStatus::Success);
429 cell.input_summary = Some("path: crates/tui/src/main.rs".to_string());
430 cell.output = Some(numbered_output(24));
431 HistoryCell::Tool(ToolCell::Generic(cell))
432 };
433 let live_text = lines_text(&quiet.lines_with_options(80, TranscriptRenderOptions::default()));
434 let transcript_text = lines_text(&quiet.transcript_lines(80));
435 assert!(
436 !live_text.contains(first) && !live_text.contains("lines omitted"),
437 "a quiet success collapses silently: {live_text}"
438 );
439 assert!(transcript_text.contains(first));
440 assert!(transcript_text.contains("row 23 plain content"));
441 }
442
443 /// Repro for #80: a `git diff --stat`-shaped result must keep its newlines on
444 /// the transcript surface — one file per row, not squashed into one line.
445 #[test]
446 fn multi_line_tool_output_keeps_one_row_per_source_line() {
447 let diff_stat = "Cargo.lock | 1 +\n\
448 crates/cli/Cargo.toml | 1 +\n\
449 crates/cli/src/main.rs | 47 ++++++\n\
450 crates/config/src/lib.rs | 27 ++++\n\
451 crates/tui/src/mcp.rs | 384 +++++";
452
453 let cell = {
454 let mut cell = generic_tool("read_file", ToolStatus::Success);
455 cell.input_summary = Some("command: git diff --stat".to_string());
456 cell.output = Some(diff_stat.to_string());
457 HistoryCell::Tool(ToolCell::Generic(cell))
458 };
459
460 let transcript_text = lines_text(&cell.transcript_lines(80));
461 for needle in [
462 "Cargo.lock",
463 "crates/cli/Cargo.toml",
464 "crates/cli/src/main.rs",
465 "crates/config/src/lib.rs",
466 "crates/tui/src/mcp.rs",
467 ] {
468 assert!(
469 transcript_text.contains(needle),
470 "transcript missing {needle:?}: {transcript_text}"
471 );
472 }
473 let cargo_lock_row = transcript_text
474 .lines()
475 .find(|line| line.contains("Cargo.lock"))
476 .expect("Cargo.lock row must exist");
477 assert!(
478 !cargo_lock_row.contains("crates/cli/Cargo.toml"),
479 "two files were joined onto one row: {cargo_lock_row}"
480 );
481 }
482
483 /// Reasoning folds in the live view and the fold is reversible: the collapsed
484 /// state must truncate a long body, the expanded state must restore every line
485 /// it dropped, and both must show the model's own identifiers verbatim (the
486 /// #4146/#4148 scrub rendered `refresh_catalog_cache` as `…` and protected
487 /// nothing, since the body was always one keypress away). The configured
488 /// default only inverts which state the toggle starts in.
489 ///
490 /// Replaces four separate fold tests.
491 #[test]
492 fn reasoning_folds_in_live_and_the_fold_is_reversible() {
493 let body = (1..=20)
494 .map(|i| format!("step {i:02}: refresh_catalog_cache iteration"))
495 .collect::<Vec<_>>()
496 .join("\n");
497 let cell = HistoryCell::Thinking {
498 content: body,
499 streaming: false,
500 duration_secs: Some(1.0),
501 };
502
503 for default_expanded in [false, true] {
504 let options = TranscriptRenderOptions {
505 thinking_default_expanded: default_expanded,
506 low_motion: true,
507 ..TranscriptRenderOptions::default()
508 };
509 // `folded` is the Space toggle *relative to* the configured default,
510 // so the expanded state is whichever call disagrees with it. Running
511 // both defaults proves the toggle survives the inversion.
512 let expanded = lines_text(
513 &cell
514 .lines_with_options_folded(80, options, !default_expanded)
515 .0,
516 );
517 let collapsed = lines_text(
518 &cell
519 .lines_with_options_folded(80, options, default_expanded)
520 .0,
521 );
522
523 for i in 1..=20 {
524 assert!(
525 expanded.contains(&format!("step {i:02}: refresh_catalog_cache iteration")),
526 "[default_expanded={default_expanded}] expanded reasoning dropped line {i}: \
527 {expanded}"
528 );
529 }
530 assert!(
531 !collapsed.contains("step 20:"),
532 "[default_expanded={default_expanded}] the collapsed fold must truncate: {collapsed}"
533 );
534 assert!(
535 collapsed.contains("refresh_catalog_cache"),
536 "[default_expanded={default_expanded}] the shown head keeps identifiers \
537 verbatim: {collapsed}"
538 );
539 assert!(
540 !collapsed.contains("Space:") && !collapsed.contains("Ctrl+O"),
541 "[default_expanded={default_expanded}] the per-cell renderer stays \
542 target-neutral; the chord belongs to whoever owns focus: {collapsed}"
543 );
544 }
545 }
546
547 /// The fold toggle is relative to the expanded baseline (verbose session or
548 /// expanded default): Space inverts the baseline, never the other flag.
549 /// In particular verbose plus an expanded default renders expanded — the
550 /// old triple-XOR collapsed exactly that cell.
551 #[test]
552 fn thinking_fold_toggle_is_relative_to_the_expanded_baseline() {
553 let body = (1..=20)
554 .map(|i| format!("step {i:02}: baseline check"))
555 .collect::<Vec<_>>()
556 .join("\n");
557 let cell = HistoryCell::Thinking {
558 content: body,
559 streaming: false,
560 duration_secs: Some(1.0),
561 };
562 // (folded, verbose, default_expanded, expect_expanded)
563 for (folded, verbose, default_expanded, expect_expanded) in [
564 (false, false, false, false),
565 (false, false, true, true),
566 (false, true, false, true),
567 (false, true, true, true),
568 (true, false, false, true),
569 (true, false, true, false),
570 (true, true, false, false),
571 (true, true, true, false),
572 ] {
573 let options = TranscriptRenderOptions {
574 verbose,
575 thinking_default_expanded: default_expanded,
576 low_motion: true,
577 ..TranscriptRenderOptions::default()
578 };
579 let text = lines_text(&cell.lines_with_options_folded(80, options, folded).0);
580 let expanded = text.contains("step 20: baseline check");
581 assert_eq!(
582 expanded, expect_expanded,
583 "[folded={folded} verbose={verbose} default_expanded={default_expanded}]"
584 );
585 }
586 }
587
588 /// A completed reasoning cell short enough to fit needs no expand affordance,
589 /// and the live view must still show it — the alternative was a dead card that
590 /// said reasoning happened and nothing about what it was.
591 #[test]
592 fn short_completed_reasoning_is_shown_live_without_an_affordance() {
593 let cell = HistoryCell::Thinking {
594 content: "One brief reasoning step.".to_string(),
595 streaming: false,
596 duration_secs: Some(0.4),
597 };
598
599 let live_text = lines_text(&cell.lines_with_options(80, calm_options()));
600 let transcript_text = lines_text(&cell.transcript_lines(80));
601
602 assert!(
603 live_text.contains("One brief reasoning step."),
604 "short completed reasoning belongs inline: {live_text}"
605 );
606 assert!(transcript_text.contains("One brief reasoning step."));
607 assert!(
608 !live_text.contains("Ctrl+O") && !live_text.contains("Space:"),
609 "a body that fits needs no affordance: {live_text}"
610 );
611 }
612
613 /// A live reasoning block must show what the model is thinking right now — the
614 /// old behavior stalled on a `thinking...` placeholder until the block closed,
615 /// and a long body must keep the newest line rather than the oldest.
616 #[test]
617 fn streaming_reasoning_shows_its_newest_line_not_a_placeholder() {
618 let short = render_thinking(
619 "Step 1: read the code\nStep 2: trace the call\nStep 3: form a hypothesis",
620 80,
621 true,
622 None,
623 true,
624 true,
625 );
626 let short_text = lines_text(&short);
627 assert!(
628 short_text.contains("Step 3: form a hypothesis"),
629 "the newest reasoning line must be visible while streaming: {short_text}"
630 );
631 assert!(
632 !short_text.contains("thinking..."),
633 "real content means the placeholder must not be drawn: {short_text}"
634 );
635
636 let long = (1..=16)
637 .map(|i| format!("Reasoning line {i}"))
638 .collect::<Vec<_>>()
639 .join("\n");
640 let long_text = lines_text(&render_thinking(&long, 80, true, None, true, true));
641 assert!(
642 long_text.contains("Reasoning line 16"),
643 "the tail is what is live: {long_text}"
644 );
645 assert!(
646 !long_text.contains("Reasoning line 1\n"),
647 "the head is what gets clipped: {long_text}"
648 );
649 }
650
651 /// A foreground shell wait blocks the turn. The card's job is to tell the user
652 /// how to take the terminal back, not to re-print the command they just watched
653 /// the model type, and not to duplicate the sidebar's live tail in the
654 /// transcript. Once the command finishes, the final output supersedes any stale
655 /// live tail.
656 ///
657 /// Replaces three tests.
658 #[test]
659 fn a_foreground_shell_wait_offers_the_escape_hatch_not_the_command_echo() {
660 let command = "cargo test --workspace --all-features";
661 let running = {
662 let mut exec = exec_tool(command, ToolStatus::Running);
663 exec.live_output = Some("running line 1\nrunning line 2".to_string());
664 exec.shell_task_id = Some("shell_live".to_string());
665 exec
666 };
667
668 for (label, text) in [
669 ("live", lines_text(&running.lines_with_motion(80, true))),
670 (
671 "transcript",
672 lines_text(&HistoryCell::Tool(ToolCell::Exec(running.clone())).transcript_lines(80)),
673 ),
674 ] {
675 assert!(
676 text.contains("Ctrl+B"),
677 "[{label}] the backgrounding chord is the point of the card: {text}"
678 );
679 assert!(
680 !text.contains("running line 1"),
681 "[{label}] the live tail belongs to the sidebar and /jobs: {text}"
682 );
683 assert!(
684 !text.contains(command),
685 "[{label}] the header already carries the summary; do not echo the \
686 command target: {text}"
687 );
688 assert!(!text.contains("command:"), "[{label}] {text}");
689 }
690
691 let mut finished = exec_tool(command, ToolStatus::Success);
692 finished.output = Some("final output".to_string());
693 finished.live_output = Some("stale live tail".to_string());
694 finished.shell_task_id = Some("shell_live".to_string());
695 let text = lines_text(&finished.lines_with_motion(80, true));
696 assert!(
697 !text.contains("stale live tail"),
698 "a finished command must not show the tail it already superseded: {text}"
699 );
700 }
701
702 // ---------------------------------------------------------------------------
703 // Clipboard — what you copy is what was authored
704 // ---------------------------------------------------------------------------
705
706 /// Every rendered line carries a `copy_prefix_width`: the display columns of
707 /// decoration the clipboard must skip. The property is that slicing a line at
708 /// that width yields the payload and nothing decorative — for role markers,
709 /// status chrome, and the two-column continuation prefix on wrapped fenced code
710 /// (which must be counted in display columns, not bytes, or CJK shifts it).
711 ///
712 /// Replaces three tests that each covered one cell kind.
713 #[test]
714 fn the_copy_prefix_skips_every_decoration_and_keeps_the_payload() {
715 let decorations = ['╎', '▎', '●', '│', '┃', '✓', '▏'];
716
717 let copied_line = |cell: &HistoryCell, width: u16, needle: &str| -> (String, usize) {
718 let rendered = cell.lines_with_copy_metadata(width, TranscriptRenderOptions::default());
719 let target = rendered
720 .iter()
721 .find(|entry| {
722 entry
723 .line
724 .spans
725 .iter()
726 .any(|span| span.content.contains(needle))
727 })
728 .unwrap_or_else(|| panic!("no rendered line contains {needle:?}"));
729 let text = line_to_plain(&target.line);
730 (
731 slice_visible_columns(&text, target.copy_prefix_width, text_visible_width(&text)),
732 target.copy_prefix_width,
733 )
734 };
735
736 // Fenced code: indentation survives, decoration does not.
737 let rust_fence = HistoryCell::Assistant {
738 content: "```rust\n let answer = 42;\n```".to_string(),
739 streaming: false,
740 };
741 let (copied, _) = copied_line(&rust_fence, 40, "answer");
742 assert!(
743 copied.contains(" let answer = 42;"),
744 "code indentation was not preserved: {copied:?}"
745 );
746 for glyph in decorations {
747 assert!(
748 !copied.contains(glyph),
749 "decorative glyph {glyph:?} leaked into copied code: {copied:?}"
750 );
751 }
752
753 // Wrapped CJK code: the prefix is two *display* columns, not two bytes.
754 let cjk_fence = HistoryCell::Assistant {
755 content: "```text\n 中文 = 1\n```".to_string(),
756 streaming: false,
757 };
758 let (copied, prefix) = copied_line(&cjk_fence, 24, "中文");
759 assert_eq!(
760 prefix, 2,
761 "the continuation prefix is the role marker's two display columns"
762 );
763 assert!(
764 copied.starts_with(" 中文"),
765 "wide-character indentation was mis-sliced: {copied:?}"
766 );
767
768 // Tool receipt: status and family chrome are prefix, the receipt text is
769 // payload.
770 let receipt = {
771 let mut exec = exec_tool("printf 'receipt'", ToolStatus::Success);
772 exec.output = Some("receipt".to_string());
773 HistoryCell::Tool(ToolCell::Exec(exec))
774 };
775 let rendered = receipt.lines_with_copy_metadata(80, TranscriptRenderOptions::default());
776 let header = rendered.first().expect("tool receipt header");
777 assert!(
778 header.copy_prefix_width >= 4,
779 "status and family chrome should be measured as prefix, got {}",
780 header.copy_prefix_width
781 );
782 let body = line_to_plain(&ratatui::text::Line::from(
783 header
784 .line
785 .spans
786 .iter()
787 .skip(1)
788 .cloned()
789 .collect::<Vec<_>>(),
790 ));
791 let copied = slice_visible_columns(&body, header.copy_prefix_width, text_visible_width(&body));
792 assert!(
793 copied.contains("run Done"),
794 "receipt text was clipped away: {copied:?}"
795 );
796 for glyph in decorations {
797 assert!(
798 !copied.contains(glyph),
799 "decorative glyph {glyph:?} leaked into the copied receipt: {copied:?}"
800 );
801 }
802 }
803
804 /// Issue #1212: the transcript rail (`▏`) marks prose continuation. Inside a
805 /// fence it corrupts anything the user copies, so no line of a code block may
806 /// carry it — not the first, not a blank line in the middle, not a wrapped
807 /// continuation of an over-long source line.
808 ///
809 /// Replaces four tests that each covered one fence shape.
810 #[test]
811 fn no_line_inside_a_fence_carries_the_transcript_rail() {
812 let long_source = "let x = ".to_string() + &"abcdef ".repeat(40);
813
814 for (label, content, width) in [
815 (
816 "short fence",
817 "SQL:\n```sql\nSELECT\nFROM customers\n```".to_string(),
818 80u16,
819 ),
820 (
821 "multi-line fence",
822 "Here's the query:\n```sql\nSELECT\n c.customer_id,\n c.name,\n \
823 COUNT(o.order_id) AS order_count\nFROM customers c\nJOIN orders o ON \
824 c.customer_id = o.customer_id;\n```"
825 .to_string(),
826 80,
827 ),
828 (
829 "fence with a blank line",
830 "```\nfn one() {}\n\nfn two() {}\n```".to_string(),
831 80,
832 ),
833 ("wrapped fence", format!("```\n{long_source}\n```"), 40),
834 ] {
835 let cell = HistoryCell::Assistant {
836 content,
837 streaming: false,
838 };
839 // Line 0 is the intro paragraph (or the fence opener); every line
840 // after it belongs to the code block.
841 for line in cell.lines(width).iter().skip(1) {
842 let text = line_text(line);
843 assert!(
844 !text.contains('\u{258F}'),
845 "[{label}] code line took the transcript rail: {text:?}"
846 );
847 }
848 }
849 }
850
851 /// Whose text gets interpreted is a trust boundary. The model's markdown is
852 /// rendered; the user's prompt is shown exactly as typed, including leading
853 /// hashes, dashes and runs of spaces. A cell holding only whitespace renders
854 /// nothing at all rather than an orphaned role glyph.
855 ///
856 /// Replaces three tests.
857 #[test]
858 fn authored_text_keeps_its_shape_on_both_sides_of_the_turn() {
859 let user = HistoryCell::User {
860 content: " # heading\n- item\n \nhello world".to_string(),
861 };
862 let visible: Vec<String> = user.lines(80).iter().map(line_text).collect();
863 assert!(
864 visible[0].trim_end().ends_with("# heading"),
865 "a user's literal `#` must not become a rendered heading: {visible:?}"
866 );
867 assert!(
868 visible[1].trim_end().ends_with("- item"),
869 "dash-prefixed user text stays literal: {visible:?}"
870 );
871 assert!(
872 visible[2].ends_with(" "),
873 "whitespace-only user lines survive: {visible:?}"
874 );
875 assert!(
876 visible[3].trim_end().ends_with("hello world"),
877 "internal spacing stays literal: {visible:?}"
878 );
879 assert!(
880 !visible.iter().any(|line| line.contains('\u{2500}')),
881 "user text must not gain a markdown heading rule: {visible:?}"
882 );
883
884 let assistant = HistoryCell::Assistant {
885 content: "# Heading\n\n- item".to_string(),
886 streaming: false,
887 };
888 let visible: Vec<String> = assistant.lines(80).iter().map(line_text).collect();
889 assert!(
890 visible[0].contains("Heading") && !visible[0].contains("# Heading"),
891 "the model's markdown is still parsed: {visible:?}"
892 );
893 assert!(
894 visible.iter().any(|line| line.contains('\u{2500}')),
895 "an assistant h1 still draws its rule: {visible:?}"
896 );
897
898 // A stray newline streamed between reasoning and a tool call used to render
899 // as a bare role glyph with nothing after it.
900 for content in ["", " ", "\n", "\n\n", " \t \n"] {
901 for streaming in [false, true] {
902 let cell = HistoryCell::Assistant {
903 content: content.to_string(),
904 streaming,
905 };
906 assert!(
907 cell.lines(80).is_empty(),
908 "whitespace-only assistant content {content:?} (streaming={streaming}) \
909 must render nothing"
910 );
911 }
912 }
913 let real = HistoryCell::Assistant {
914 content: "hi".to_string(),
915 streaming: false,
916 };
917 assert_eq!(
918 real.lines(80)[0].spans[0].content.as_ref(),
919 ASSISTANT_GLYPH,
920 "real content still gets its role marker"
921 );
922 }
923
924 /// Reasoning is neither the user's prompt nor the model's answer, and a reader
925 /// scanning the transcript has to be able to skip it. The markers below are
926 /// referenced as named constants rather than literal glyphs on purpose: this
927 /// protects the *distinction*, so a redesign that restyles reasoning stays
928 /// green while one that stops marking it at all fails.
929 #[test]
930 fn reasoning_is_marked_apart_from_both_the_prompt_and_the_answer() {
931 let body_text = "concrete reasoning content";
932 let reasoning = render_thinking(body_text, 80, false, Some(1.0), false, true);
933 assert!(reasoning.len() >= 2, "expected a header and a body line");
934
935 let header = line_text(&reasoning[0]);
936 assert!(
937 header.starts_with(REASONING_OPENER),
938 "the reasoning header opens with its own marker: {header:?}"
939 );
940 let body = line_text(&reasoning[1]);
941 assert!(
942 body.starts_with(REASONING_RAIL),
943 "the reasoning body carries its own rail: {body:?}"
944 );
945
946 let rail = REASONING_RAIL.trim();
947 for cell in [
948 HistoryCell::User {
949 content: body_text.to_string(),
950 },
951 HistoryCell::Assistant {
952 content: body_text.to_string(),
953 streaming: false,
954 },
955 ] {
956 let rendered = lines_text(&cell.lines(80));
957 assert!(
958 rendered.contains(body_text),
959 "sanity: the cell rendered its content: {rendered}"
960 );
961 assert!(
962 !rendered.contains(rail),
963 "only reasoning may wear the reasoning rail: {rendered}"
964 );
965 }
966 }
967
968 /// A filled background behind reasoning is unreadable on a transparent or
969 /// light terminal, so the highlight is configurable. With it off, not one span
970 /// may carry a background — a single tinted span is the bug. The enabled case
971 /// follows the terminal's actual color depth: capable terminals tint the body,
972 /// while ANSI-16 intentionally stays untinted because it cannot render the
973 /// subtle surface faithfully.
974 #[test]
975 fn disabling_the_reasoning_highlight_leaves_no_span_with_a_background() {
976 let render = |highlight: bool| {
977 render_thinking_with_analysis(
978 "reasoning without a filled surface",
979 80,
980 false,
981 Some(1.0),
982 false,
983 true,
984 highlight,
985 )
986 .0
987 };
988
989 assert!(
990 render(false)
991 .iter()
992 .flat_map(|line| line.spans.iter())
993 .all(|span| span.style.bg.is_none()),
994 "a disabled highlight must not tint any span"
995 );
996 let enabled_has_background = render(true)
997 .iter()
998 .flat_map(|line| line.spans.iter())
999 .any(|span| span.style.bg.is_some());
1000 assert_eq!(
1001 enabled_has_background,
1002 codewhale_palette::reasoning_surface_tint(cached_color_depth()).is_some(),
1003 "the enabled highlight must follow the terminal color-depth contract"
1004 );
1005 }
1006
1007 // ---------------------------------------------------------------------------
1008 // Motion — reduced motion is actually still
1009 // ---------------------------------------------------------------------------
1010
1011 /// The deleted tests pinned the frozen glyphs (`assert_eq!(spans[1], "⣤")`).
1012 /// That breaks on a skin change and passes on the bug that matters: a marker
1013 /// that keeps animating for a user who asked it to stop. The property is
1014 /// stillness — a running cell's rendered frame must not depend on how long it
1015 /// has been running once motion is reduced — and the full-motion case is
1016 /// asserted alongside it so a renderer that froze everything could not make
1017 /// this test vacuously true.
1018 ///
1019 /// Stillness is not enough on its own. Animation frame 0 is U+2800 BRAILLE
1020 /// PATTERN BLANK, an invisible cell. Freezing there (or on the Still path)
1021 /// looks like a missing marker, which is why reduced motion must freeze on a
1022 /// filled, legible bubble rather than the blank the spinner starts on.
1023 #[test]
1024 fn reduced_and_still_motion_render_a_frame_that_does_not_move() {
1025 let frame_symbols = super::TOOL_RUNNING_SYMBOLS.len() as u64;
1026 let frame_at = |elapsed_ms: u64, low_motion: bool, motion: MotionMode| {
1027 let mut exec = exec_tool("echo hi", ToolStatus::Running);
1028 exec.started_at = Some(Instant::now() - Duration::from_millis(elapsed_ms));
1029 let cell = HistoryCell::Tool(ToolCell::Exec(exec));
1030 lines_text(&cell.lines_with_options(
1031 80,
1032 TranscriptRenderOptions {
1033 low_motion,
1034 motion_mode: motion,
1035 ..TranscriptRenderOptions::default()
1036 },
1037 ))
1038 };
1039
1040 // Half a spinner cycle apart, and both well under the 3s elapsed-badge
1041 // threshold so the badge itself cannot be the thing that differs.
1042 let early = crate::tui::spinner::LIVE_MARKER_DELAY_MS;
1043 let late = early + super::TOOL_STATUS_SYMBOL_MS * (frame_symbols / 2);
1044 assert!(
1045 late < 3_000,
1046 "both samples must stay under the elapsed badge"
1047 );
1048
1049 // Two independent mechanisms are supposed to produce stillness — the
1050 // `low_motion` flag and the resolved `motion_mode`. Each is asserted on its
1051 // own so losing either one fails here, rather than only losing both.
1052 for (low_motion, motion) in [
1053 (true, MotionMode::Reduced),
1054 (true, MotionMode::Still),
1055 (false, MotionMode::Reduced),
1056 (false, MotionMode::Still),
1057 ] {
1058 let frozen = frame_at(early, low_motion, motion);
1059 assert_eq!(
1060 frozen,
1061 frame_at(late, low_motion, motion),
1062 "low_motion={low_motion} / {motion:?} must not animate the live marker"
1063 );
1064 assert!(
1065 !frozen.contains('\u{2800}'),
1066 "a frozen marker must still be visible: low_motion={low_motion} / {motion:?}: {frozen:?}"
1067 );
1068 }
1069 assert_ne!(
1070 frame_at(early, false, MotionMode::Full),
1071 frame_at(late, false, MotionMode::Full),
1072 "full motion must actually animate, or the stillness assertions above \
1073 prove nothing"
1074 );
1075
1076 // The same contract for the two other animated surfaces: the streaming
1077 // reasoning cursor and the assistant role marker's pulse.
1078 let cursor_off = lines_text(&render_thinking(
1079 "ongoing reasoning...",
1080 80,
1081 true,
1082 None,
1083 false,
1084 true,
1085 ));
1086 assert!(
1087 !cursor_off.contains(REASONING_CURSOR),
1088 "low motion must suppress the streaming reasoning cursor: {cursor_off}"
1089 );
1090 assert_eq!(
1091 assistant_label_style_for(true, true).fg,
1092 assistant_label_style_for(false, false).fg,
1093 "a streaming assistant marker under low motion must look exactly like an \
1094 idle one — no pulse"
1095 );
1096 }
1097
1098 /// Dual of the low-motion freeze above: when the cell is streaming and
1099 /// motion is allowed, the assistant marker must actually pulse. The deleted
1100 /// test slept up to 1s sampling `SystemTime` until the 2s sine dipped; the
1101 /// property is that the streaming+motion color is `pulse_brightness` of the
1102 /// idle source, which we can check without waiting on the wall clock.
1103 ///
1104 /// Around the sine crest, `pulse_brightness` rounds back to the source
1105 /// (~70ms of a 2s cycle). Matching the current instant would then also pass
1106 /// a renderer that never pulsed, so we only compare once the pure function
1107 /// itself is off the crest — a busy wait, not a sleep.
1108 #[test]
1109 fn assistant_marker_pulses_when_streaming_and_motion_is_allowed() {
1110 use codewhale_palette::{self as palette, pulse_brightness};
1111
1112 let idle = assistant_label_style_for(false, false).fg;
1113 assert_eq!(
1114 idle,
1115 Some(palette::WHALE_ACTION),
1116 "the idle marker is the unpulsed source; pulsing everything would make \
1117 the streaming assertion vacuously true"
1118 );
1119 assert_eq!(
1120 assistant_label_style_for(true, true).fg,
1121 idle,
1122 "low motion must keep the streaming marker at the unpulsed source"
1123 );
1124
1125 let epoch_ms = || {
1126 std::time::SystemTime::now()
1127 .duration_since(std::time::UNIX_EPOCH)
1128 .map(|d| d.as_millis() as u64)
1129 .unwrap_or(0)
1130 };
1131 let deadline = Instant::now() + Duration::from_millis(250);
1132 let (t0, actual) = loop {
1133 assert!(
1134 Instant::now() < deadline,
1135 "pulse_brightness stayed at the source color through a 250ms spin; \
1136 the 2s cycle leaves the crest in ~70ms"
1137 );
1138 let t0 = epoch_ms();
1139 // Skip the crest and a few ms of margin so the product read of
1140 // SystemTime cannot land back on identity between this sample and
1141 // the call under test.
1142 let near_crest = (t0.saturating_sub(8)..=t0.saturating_add(8))
1143 .any(|ms| pulse_brightness(palette::WHALE_ACTION, ms) == palette::WHALE_ACTION);
1144 if near_crest {
1145 continue;
1146 }
1147 break (t0, assistant_label_style_for(true, false).fg);
1148 };
1149 let t1 = epoch_ms();
1150 let matches_pulse =
1151 (t0..=t1.max(t0)).any(|ms| actual == Some(pulse_brightness(palette::WHALE_ACTION, ms)));
1152 assert!(
1153 matches_pulse,
1154 "streaming + motion must apply pulse_brightness to the assistant \
1155 marker, got {actual:?}"
1156 );
1157 assert_ne!(
1158 actual, idle,
1159 "streaming + motion must not sit at the idle color once the pulse \
1160 is off its crest"
1161 );
1162 }
1163
1164 /// The still-motion path rewrites the leading status marker in place. It once
1165 /// rewrote any braille cell it found, which silently ate braille that was part
1166 /// of the tool's own output.
1167 #[test]
1168 fn the_still_marker_rewrite_never_consumes_braille_tool_output() {
1169 let mut cell = generic_tool("read_file", ToolStatus::Running);
1170 cell.output = Some("⣿".to_string());
1171 let cell = HistoryCell::Tool(ToolCell::Generic(cell));
1172
1173 let lines = cell.lines_with_options(
1174 80,
1175 TranscriptRenderOptions {
1176 low_motion: true,
1177 motion_mode: MotionMode::Still,
1178 ..TranscriptRenderOptions::default()
1179 },
1180 );
1181
1182 assert!(
1183 lines
1184 .iter()
1185 .flat_map(|line| line.spans.iter())
1186 .any(|span| span.content.as_ref() == "⣿"),
1187 "tool output must survive the typed-header marker pass: {lines:?}"
1188 );
1189 }
1190
1191 // ---------------------------------------------------------------------------
1192 // Identity — a card never names a verb or a tool it did not run
1193 // ---------------------------------------------------------------------------
1194
1195 /// #4145: a completed grep grouped under the exploration card rendered
1196 /// `read done · Searching …` — the header verb contradicted the label directly
1197 /// under it. The verb must agree with the work, in every locale, and a locale
1198 /// must not fall back to the English status word.
1199 ///
1200 /// Replaces two tests, each of which hard-coded one direction.
1201 #[test]
1202 fn a_card_verb_agrees_with_its_own_label_in_every_locale() {
1203 use codewhale_localization::Locale;
1204
1205 for (label, expected_en, expected_zh, forbidden_en) in [
1206 (
1207 "Searching for `TranscriptScroll`",
1208 "find Done",
1209 "find 完成",
1210 "read Done",
1211 ),
1212 ("Reading src/foo.rs", "read Done", "read 完成", "find Done"),
1213 ] {
1214 let cell = super::ExploringCell {
1215 entries: vec![super::ExploringEntry {
1216 label: label.to_string(),
1217 status: ToolStatus::Success,
1218 }],
1219 };
1220
1221 let header_en = line_text(&cell.lines_with_motion(80, true)[0]);
1222 assert!(
1223 header_en.contains(expected_en),
1224 "{label:?} should read {expected_en:?}: {header_en:?}"
1225 );
1226 assert!(
1227 !header_en.contains(forbidden_en),
1228 "{label:?} must not be paired with {forbidden_en:?}: {header_en:?}"
1229 );
1230 assert!(
1231 header_en.contains(label),
1232 "the label itself must survive: {header_en:?}"
1233 );
1234
1235 let header_zh = line_text(&cell.lines_with_motion_and_locale(80, true, Locale::ZhHans)[0]);
1236 assert!(
1237 header_zh.contains(expected_zh),
1238 "{label:?} should read {expected_zh:?} in zh-Hans: {header_zh:?}"
1239 );
1240 assert!(
1241 !header_zh.to_lowercase().contains("done"),
1242 "zh-Hans must not leak the English status word: {header_zh:?}"
1243 );
1244 assert!(
1245 header_zh.contains(label),
1246 "the label itself must survive localization: {header_zh:?}"
1247 );
1248 }
1249 }
1250
1251 /// A read/find receipt reports a line count, so the count has to be real —
1252 /// including the singular/plural and the localized unit. A run receipt reports
1253 /// no count at all: inferring "3 lines" from rendered text that happens to
1254 /// contain `stdout:` would be inventing a number the shell never reported.
1255 #[test]
1256 fn receipts_count_only_what_they_actually_counted() {
1257 use crate::tui::widgets::tool_card::ToolFamily;
1258 use codewhale_localization::Locale;
1259
1260 for (locale, done, unit) in [(Locale::En, "Done", "line"), (Locale::ZhHans, "完成", "行")] {
1261 let label = |family, status, output| {
1262 super::tool_receipt_label(family, status, Some(output), locale)
1263 };
1264
1265 assert_eq!(label(ToolFamily::Read, ToolStatus::Success, ""), done);
1266 assert_eq!(
1267 label(ToolFamily::Read, ToolStatus::Success, "hello\n"),
1268 if locale == Locale::En {
1269 "1 line".to_string()
1270 } else {
1271 format!("1 {unit}")
1272 }
1273 );
1274 assert_eq!(
1275 label(ToolFamily::Read, ToolStatus::Success, "a\nb\nc\n"),
1276 if locale == Locale::En {
1277 "3 lines".to_string()
1278 } else {
1279 format!("3 {unit}")
1280 }
1281 );
1282 assert_eq!(
1283 label(ToolFamily::Find, ToolStatus::Success, "match 1\nmatch 2\n"),
1284 if locale == Locale::En {
1285 "2 lines".to_string()
1286 } else {
1287 format!("2 {unit}")
1288 }
1289 );
1290
1291 // Run never counts, whatever the body looks like.
1292 for body in [
1293 "stdout:\nok\nmore\nstderr:\nbad\n",
1294 "line 1\nline 2\nline 3\n",
1295 ] {
1296 assert_eq!(
1297 label(ToolFamily::Run, ToolStatus::Success, body),
1298 done,
1299 "a run receipt must not infer counts from {body:?}"
1300 );
1301 }
1302 }
1303
1304 assert_eq!(
1305 super::tool_receipt_label(
1306 ToolFamily::Read,
1307 ToolStatus::Running,
1308 Some("a\nb"),
1309 Locale::En
1310 ),
1311 "running",
1312 "an unfinished read has nothing to count yet"
1313 );
1314 }
1315
1316 /// The same truthfulness contract through the real shell render path, where a
1317 /// formatter has already rewritten the output: the header still reports a plain
1318 /// localized completion and never a fabricated line count or stream name.
1319 #[test]
1320 fn shell_headers_stay_truthful_through_the_output_formatters() {
1321 use codewhale_localization::Locale;
1322
1323 let cases = [
1324 (
1325 "printf redirect",
1326 "printf '%s\\n' 'hello' 'world' > src/main.rs",
1327 "printf > src/main.rs\nhello\nworld\n",
1328 ),
1329 (
1330 "logical-or fallback",
1331 "cargo build || echo fallback",
1332 " Compiling pkg v0.1.0\n Finished dev [unoptimized + debuginfo]\n",
1333 ),
1334 ];
1335
1336 for (label, command, output) in cases {
1337 let mut cell = exec_tool(command, ToolStatus::Success);
1338 cell.output = Some(output.to_string());
1339 cell.duration_ms = Some(42);
1340
1341 for (locale, done, unit) in [
1342 (Locale::En, "Done", "lines"),
1343 (Locale::ZhHans, "完成", "行"),
1344 ] {
1345 let header = line_text(&cell.render_with_locale(80, true, RenderMode::Live, locale)[0]);
1346 assert!(
1347 header.contains(done),
1348 "[{label}] header must carry the localized completion: {header}"
1349 );
1350 assert!(
1351 !header.contains(unit) && !header.contains("stdout") && !header.contains("stderr"),
1352 "[{label}] header must not invent counts or stream names: {header}"
1353 );
1354 }
1355 }
1356 }
1357
1358 /// #4133 / #4148: a spawn yields its card entirely to the DelegateCard, and an
1359 /// inspection (`peek` / `wait` / `status`) is a one-line check in every render
1360 /// mode. It must name the child it checked, must not read as a completed
1361 /// delegation, and must not leak the internal "unknown child" placeholder or
1362 /// echo the verb twice when the resolved identity collapses onto it.
1363 ///
1364 /// Replaces eight tests.
1365 #[test]
1366 fn agent_cards_stay_one_line_and_spawn_cards_yield_to_the_delegate_card() {
1367 let agent = |summary: &str, output: Option<&str>| {
1368 let mut cell = generic_tool("agent", ToolStatus::Success);
1369 cell.input_summary = Some(summary.to_string());
1370 cell.output = output.map(str::to_string);
1371 cell
1372 };
1373
1374 for mode in [RenderMode::Live, RenderMode::Transcript] {
1375 let spawn = agent(
1376 "prompt: map the repo",
1377 Some(r#"{"agent_id":"agent_scout_1","status":"running"}"#),
1378 );
1379 assert!(
1380 spawn.lines_with_mode(120, true, mode).is_empty(),
1381 "a spawn must not draw a generic card beside the DelegateCard in {mode:?}"
1382 );
1383
1384 for (summary, output, expected) in [
1385 (
1386 "action: peek agent_id: agent_scout_1",
1387 Some(r#"{"agent_id":"agent_scout_1","status":"running"}"#),
1388 "checked",
1389 ),
1390 (
1391 "action: wait",
1392 Some(r#"{"action":"wait","settled":[{"agent_id":"agent_scout_1"}]}"#),
1393 "waited",
1394 ),
1395 (
1396 "action: status agent_id: agent_scout_1",
1397 Some(r#"{"agent_id":"agent_scout_1","status":"running","terminal":false}"#),
1398 "checked",
1399 ),
1400 ] {
1401 let cell = agent(summary, output);
1402 let lines = cell.lines_with_mode(120, true, mode);
1403 let text = lines_text(&lines);
1404 assert_eq!(
1405 lines.len(),
1406 1,
1407 "{summary:?} must stay one line in {mode:?}: {lines:?}"
1408 );
1409 assert!(
1410 text.contains(expected),
1411 "{summary:?} should read as {expected:?}: {text:?}"
1412 );
1413 assert!(
1414 !text.to_lowercase().contains("delegate done"),
1415 "an inspection must not read as a finished delegation: {text:?}"
1416 );
1417 }
1418 }
1419
1420 // Identity fallbacks: no raw placeholder, no doubled verb.
1421 let unresolved = agent("action: peek agent_type: delegate", None);
1422 let text = lines_text(&unresolved.lines_with_mode(80, true, RenderMode::Live));
1423 assert!(
1424 !text.contains("unknown child"),
1425 "the internal fallback token must not reach the transcript: {text:?}"
1426 );
1427
1428 let collapsing = agent("action: peek role: delegate", None);
1429 let text = lines_text(&collapsing.lines_with_mode(80, true, RenderMode::Live));
1430 assert!(
1431 text.contains(" agent "),
1432 "the agent family label should appear once in the summary: {text:?}"
1433 );
1434 }
1435
1436 /// A tool the catalog does not have produces one useful sentence — the catalog
1437 /// error — and nothing else. The old rendering spent a `name:` / `args:` /
1438 /// `result:` block restating a call that never happened.
1439 #[test]
1440 fn an_unknown_tool_failure_shows_only_the_catalog_error() {
1441 let mut cell = generic_tool("item", ToolStatus::Failed);
1442 cell.input_summary = Some("status: pending".to_string());
1443 cell.output = Some(
1444 "Tool 'item' is not available in the current tool catalog. \
1445 Checklist entries are not separate tool calls."
1446 .to_string(),
1447 );
1448
1449 for mode in [RenderMode::Live, RenderMode::Transcript] {
1450 let lines = cell.lines_with_mode(120, true, mode);
1451 let text = lines_text(&lines);
1452 assert_eq!(lines.len(), 1, "single header line in {mode:?}: {lines:?}");
1453 assert!(
1454 text.contains("Tool 'item' is not available"),
1455 "the catalog error is the useful part: {text:?}"
1456 );
1457 assert!(
1458 !text.contains("name: item"),
1459 "no name/args/result block for a call that did not happen: {text:?}"
1460 );
1461 }
1462 }
1463
1464 // ---------------------------------------------------------------------------
1465 // Severity — the ranks stay distinguishable
1466 // ---------------------------------------------------------------------------
1467
1468 /// The deleted tests pinned each severity to a named palette constant, so a
1469 /// theme change broke four tests and a severity collapse broke none. What has
1470 /// to hold is the *relationship*: `Critical` reads exactly as loud as `Error`,
1471 /// `Warning` is visibly not an error, and `Info` is quieter than both — so a
1472 /// transient retry cannot be mistaken for a hard failure sitting next to it.
1473 #[test]
1474 fn error_severity_ranks_stay_visually_distinguishable() {
1475 use crate::error_taxonomy::ErrorSeverity;
1476
1477 let rank = |severity| {
1478 let cell = HistoryCell::Error {
1479 message: "Authentication failed: invalid API key".to_string(),
1480 severity,
1481 };
1482 let lines = cell.lines(80);
1483 assert!(!lines.is_empty(), "{severity:?} must render a line");
1484 let label = &lines[0].spans[0];
1485 (label.content.to_string(), label.style.fg)
1486 };
1487
1488 let (error_label, error_fg) = rank(ErrorSeverity::Error);
1489 let (critical_label, critical_fg) = rank(ErrorSeverity::Critical);
1490 let (warning_label, warning_fg) = rank(ErrorSeverity::Warning);
1491 let (info_label, info_fg) = rank(ErrorSeverity::Info);
1492
1493 assert_eq!(
1494 (critical_label, critical_fg),
1495 (error_label.clone(), error_fg),
1496 "Critical and Error both flip offline mode; they must read identically"
1497 );
1498 assert_ne!(
1499 warning_fg, error_fg,
1500 "a warning that reads as an error is the whole bug this guards"
1501 );
1502 assert_ne!(warning_label, error_label, "and the labels must differ too");
1503 assert_ne!(info_fg, error_fg, "info must not shout");
1504 assert_ne!(info_fg, warning_fg, "info must not read as a warning");
1505 assert_ne!(info_label, warning_label);
1506
1507 // The body inherits the label's rank rather than staying neutral, or the
1508 // colour would carry no information past the first word.
1509 let cell = HistoryCell::Error {
1510 message: "Authentication failed: invalid API key".to_string(),
1511 severity: ErrorSeverity::Error,
1512 };
1513 let body_fg = cell
1514 .lines(80)
1515 .iter()
1516 .flat_map(|line| line.spans.iter())
1517 .find(|span| span.content.contains("Authentication"))
1518 .expect("error body span")
1519 .style
1520 .fg;
1521 assert_eq!(body_fg, error_fg);
1522 }
1523
1524 /// A multiline failure can run past the bottom of the terminal while its full
1525 /// text stays in history. The live cell advertises the pager; the pager and the
1526 /// transcript must carry the recovery instruction verbatim and must not
1527 /// recursively advertise themselves.
1528 #[test]
1529 fn an_error_cell_advertises_the_pager_live_and_never_inside_it() {
1530 let recovery = "Refusing insecure base URL 'http://192.168.1.25:8000/v1'.\n\
1531 Loopback hosts (localhost, 127.0.0.1, [::1]) are auto-allowed.\n\
1532 Set CODEWHALE_ALLOW_INSECURE_HTTP=1 only for a trusted LAN host.";
1533 let cell = HistoryCell::Error {
1534 message: recovery.to_string(),
1535 severity: crate::error_taxonomy::ErrorSeverity::Error,
1536 };
1537
1538 let live_text = lines_text(&cell.lines(48));
1539 let transcript_text = lines_text(&cell.transcript_lines(200));
1540 let hint = crate::tui::key_shortcuts::tool_details_shortcut_action_hint("full error");
1541
1542 assert!(live_text.contains(&hint), "{live_text}");
1543 assert!(!transcript_text.contains(&hint), "{transcript_text}");
1544 assert!(
1545 transcript_text.contains("CODEWHALE_ALLOW_INSECURE_HTTP=1"),
1546 "the actionable instruction must survive verbatim: {transcript_text}"
1547 );
1548 assert!(
1549 transcript_text.contains("192.168.1.25"),
1550 "the offending host must survive verbatim: {transcript_text}"
1551 );
1552 }
1553
1554 // ---------------------------------------------------------------------------
1555 // Cards with a content contract
1556 // ---------------------------------------------------------------------------
1557
1558 /// A search receipt has to name where the answer came from and whether the
1559 /// provider it claimed was the provider it used.
1560 #[test]
1561 fn a_web_search_receipt_names_its_source_and_any_degradation() {
1562 let cell = WebSearchCell {
1563 query: "current release".to_string(),
1564 status: ToolStatus::Success,
1565 summary: Some("Found 2 results".to_string()),
1566 source: Some("provider-native/xai/grok-4.5".to_string()),
1567 degraded: Some("provider_native -> duckduckgo".to_string()),
1568 ref_count: 2,
1569 };
1570
1571 let rendered = lines_text(&cell.lines_with_motion(120, true));
1572
1573 for needle in [
1574 "source",
1575 "provider-native/xai/grok-4.5",
1576 "degraded",
1577 "provider_native -> duckduckgo",
1578 "citations",
1579 ] {
1580 assert!(rendered.contains(needle), "missing {needle:?}: {rendered}");
1581 }
1582 }
1583
1584 /// A workflow card stands in for a whole fan-out the user cannot see. The run
1585 /// card reports lifecycle, child count, phases and failures without repeating
1586 /// the header in the body; the expanded card adds the goal, the child labels,
1587 /// the final result and the error; the status card lists the runs it found.
1588 ///
1589 /// Replaces three tests, and drops assertions of the form
1590 /// `contains('s') || contains('m')` — true of essentially any English string.
1591 #[test]
1592 fn workflow_cards_report_lifecycle_children_phases_and_failures() {
1593 let run_output = serde_json::json!({
1594 "run_id": "workflow_2400c600",
1595 "status": "completed",
1596 "workflow_goal": "audit the FLEET and WORKFLOW docs",
1597 "child_ids": ["a1", "a2", "a3"],
1598 "progress": ["phase: Scan", "log: 3 findings"],
1599 "events": [
1600 {"type": "task_started", "task_id": "a1", "label": "scan-docs",
1601 "workflow_run_id": "workflow_2400c600", "workflow_phase_id": "Scan",
1602 "workflow_task_label": "scan-docs", "workflow_child_index": 0},
1603 {"type": "task_started", "task_id": "a2", "workflow_task_label": "check-fleet",
1604 "workflow_run_id": "workflow_2400c600", "workflow_child_index": 1},
1605 {"type": "task_started", "task_id": "a3", "label": "summarize",
1606 "workflow_run_id": "workflow_2400c600", "workflow_child_index": 2},
1607 ],
1608 "schema_errors": [],
1609 })
1610 .to_string();
1611 let mut run = generic_tool("workflow", ToolStatus::Success);
1612 run.input_summary = Some("action: run".to_string());
1613 run.output = Some(run_output);
1614 let text = lines_text(&run.lines_with_mode(120, true, RenderMode::Live));
1615 assert!(text.contains("children"), "child count: {text:?}");
1616 assert!(text.contains("phase"), "phase count: {text:?}");
1617 assert!(text.contains("fail"), "failure count: {text:?}");
1618 assert!(
1619 !text.contains("status:"),
1620 "the body must not repeat the header lifecycle: {text:?}"
1621 );
1622
1623 let failed_output = serde_json::json!({
1624 "run_id": "workflow_exp",
1625 "status": "failed",
1626 "workflow_goal": "ship v0.8.68",
1627 "started_at_ms": 1000,
1628 "completed_at_ms": 5000,
1629 "source_path": "workflows/demo.workflow.js",
1630 "error": "phase Verify failed",
1631 "result": {"summary": "2 of 3 children ok"},
1632 "events": [
1633 {"type": "run_started", "at_ms": 1000, "run_id": "workflow_exp",
1634 "workflow_goal": "ship v0.8.68"},
1635 {"type": "phase_started", "at_ms": 1100, "title": "Verify"},
1636 {"type": "task_started", "at_ms": 1200, "task_id": "t1", "label": "run tests",
1637 "workflow_task_label": "run tests", "profile": "implementer"},
1638 {"type": "task_completed", "at_ms": 4000, "task_id": "t1", "status": "failed"},
1639 {"type": "run_completed", "at_ms": 5000, "status": "failed",
1640 "error": "phase Verify failed"}
1641 ]
1642 })
1643 .to_string();
1644 let mut failed = generic_tool("workflow", ToolStatus::Failed);
1645 failed.input_summary = Some("action: run".to_string());
1646 failed.output = Some(failed_output);
1647 failed.spillover_path = Some(PathBuf::from("/tmp/wf-artifact.json"));
1648 let text = lines_text(&failed.lines_with_mode(140, true, RenderMode::Transcript));
1649 for needle in [
1650 "ship v0.8.68",
1651 "Verify",
1652 "run tests",
1653 "2 of 3",
1654 "phase Verify failed",
1655 ] {
1656 assert!(
1657 text.contains(needle),
1658 "the expanded card must carry {needle:?}: {text}"
1659 );
1660 }
1661
1662 let status_output = serde_json::json!({
1663 "action": "status",
1664 "count": 2,
1665 "runs": [
1666 {"run_id": "workflow_aaa", "status": "running", "child_count": 4},
1667 {"run_id": "workflow_bbb", "status": "completed", "child_count": 1},
1668 ],
1669 })
1670 .to_string();
1671 let mut status = generic_tool("workflow", ToolStatus::Success);
1672 status.input_summary = Some("action: status".to_string());
1673 status.output = Some(status_output);
1674 let text = lines_text(&status.lines_with_mode(120, true, RenderMode::Live));
1675 for needle in ["2 run(s)", "workflow_aaa", "running", "workflow_bbb"] {
1676 assert!(
1677 text.contains(needle),
1678 "the status card must list {needle:?}: {text:?}"
1679 );
1680 }
1681 }
1682
1683 #[test]
1684 fn degraded_workflow_receipt_is_terminal_warning_not_running_or_success() {
1685 let output = serde_json::json!({
1686 "run_id": "workflow_partial",
1687 "status": "degraded",
1688 "workflow_goal": "review the release",
1689 "started_at_ms": 1_000,
1690 "completed_at_ms": 2_000,
1691 "dispatch_failure_count": 1,
1692 "dispatch_failures": [{
1693 "label": "review docs",
1694 "message": "profile unavailable",
1695 "at_ms": 1_500,
1696 }],
1697 })
1698 .to_string();
1699 let mut run = generic_tool("workflow", ToolStatus::Success);
1700 run.output = Some(output);
1701
1702 let lines = run.lines_with_mode(120, false, RenderMode::Live);
1703 let text = lines_text(&lines);
1704 assert!(text.contains("issue"), "warning receipt missing: {text:?}");
1705 assert!(
1706 !text.to_lowercase().contains(" done"),
1707 "must not read as success: {text:?}"
1708 );
1709 assert!(
1710 !text.contains(" running"),
1711 "must not read as live: {text:?}"
1712 );
1713
1714 let warning = lines
1715 .iter()
1716 .flat_map(|line| line.spans.iter())
1717 .find(|span| span.content.as_ref() == "issue")
1718 .expect("terminal warning status span");
1719 assert_eq!(
1720 warning.style.fg,
1721 Some(super::tool_rail_color(ToolStatus::Warning)),
1722 "degraded receipt must use the terminal warning accent"
1723 );
1724 assert!(
1725 lines
1726 .iter()
1727 .flat_map(|line| line.spans.iter())
1728 .all(|span| !span
1729 .content
1730 .chars()
1731 .any(|ch| ('\u{2800}'..='\u{28ff}').contains(&ch))),
1732 "terminal receipt must not retain a spinner: {text:?}"
1733 );
1734 }
1735
1736 /// A checklist update names one item. Showing the rest would make every
1737 /// single-item edit cost the height of the whole list; showing none would make
1738 /// the row unreadable. An id past the end of the list falls back to a
1739 /// placeholder instead of panicking.
1740 #[test]
1741 fn a_checklist_update_shows_only_the_item_that_changed() {
1742 let snapshot = super::ChecklistSnapshot {
1743 items: vec![
1744 super::ChecklistItemSnapshot {
1745 content: "Read the spec".to_string(),
1746 status: "completed".to_string(),
1747 },
1748 super::ChecklistItemSnapshot {
1749 content: "Write the test".to_string(),
1750 status: "in_progress".to_string(),
1751 },
1752 super::ChecklistItemSnapshot {
1753 content: "Land the PR".to_string(),
1754 status: "pending".to_string(),
1755 },
1756 ],
1757 completion_pct: 33,
1758 completed: 1,
1759 total: 3,
1760 };
1761 let lines = super::render_checklist_change_card(
1762 "todo_update",
1763 ToolStatus::Success,
1764 &snapshot,
1765 &super::ChecklistChange {
1766 id: 2,
1767 status: "in_progress".to_string(),
1768 },
1769 80,
1770 true,
1771 );
1772 assert!(lines.len() >= 3, "header, change, summary: {}", lines.len());
1773
1774 let change = line_text(&lines[1]);
1775 for needle in ["#2", "Write the test", "in_progress"] {
1776 assert!(change.contains(needle), "missing {needle:?}: {change:?}");
1777 }
1778 for other in ["Land the PR", "Read the spec"] {
1779 assert!(
1780 !change.contains(other),
1781 "an update must not redraw the whole list: {change:?}"
1782 );
1783 }
1784
1785 let summary = line_text(lines.last().expect("summary row"));
1786 assert!(summary.contains("3 items"), "{summary:?}");
1787 assert!(
1788 summary.contains(&crate::tui::key_shortcuts::tool_details_shortcut_action_hint("list")),
1789 "the full list stays one keypress away: {summary:?}"
1790 );
1791
1792 let single = super::ChecklistSnapshot {
1793 items: vec![super::ChecklistItemSnapshot {
1794 content: "only item".to_string(),
1795 status: "pending".to_string(),
1796 }],
1797 completion_pct: 0,
1798 completed: 0,
1799 total: 1,
1800 };
1801 let lines = super::render_checklist_change_card(
1802 "todo_update",
1803 ToolStatus::Success,
1804 &single,
1805 &super::ChecklistChange {
1806 id: 99,
1807 status: "completed".to_string(),
1808 },
1809 80,
1810 true,
1811 );
1812 let change = line_text(&lines[1]);
1813 assert!(change.contains("#99") && change.contains("(missing title)"));
1814 }
1815
1816 /// The plan card is the only place a plan's supporting artifact is visible.
1817 /// Every populated section has to reach the surface, or the model can record
1818 /// context the user never sees.
1819 #[test]
1820 fn a_plan_card_surfaces_every_populated_artifact_section() {
1821 let cell = PlanUpdateCell {
1822 snapshot: PlanSnapshot {
1823 objective: Some("Make Plan mode reviewable".to_string()),
1824 context_summary: Some("Grounded in issue #2691".to_string()),
1825 sources_used: vec!["gh issue view 2691".to_string()],
1826 critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
1827 constraints: vec!["Keep To-do primary".to_string()],
1828 recommended_approach: Some(
1829 "Enrich update_plan without breaking legacy calls".to_string(),
1830 ),
1831 verification_plan: Some("Run focused renderer tests".to_string()),
1832 risks_and_unknowns: Some("Metadata-only plans can disappear".to_string()),
1833 handoff_packet: Some("Next agent should inspect relay output".to_string()),
1834 items: vec![crate::tools::plan::PlanItemArg {
1835 step: "Render artifact sections".to_string(),
1836 status: StepStatus::InProgress,
1837 }],
1838 ..PlanSnapshot::default()
1839 },
1840 status: ToolStatus::Success,
1841 };
1842
1843 let visible = lines_text(&cell.lines_with_motion(120, true));
1844
1845 for needle in [
1846 "objective:",
1847 "Make Plan mode reviewable",
1848 "source:",
1849 "gh issue view 2691",
1850 "file:",
1851 "verify:",
1852 "handoff:",
1853 "Render artifact sections",
1854 ] {
1855 assert!(visible.contains(needle), "missing {needle:?}: {visible}");
1856 }
1857 }
1858
1859 /// A fan-out tool's per-child prompts get one row each so the user can read
1860 /// what each child was asked; the inline `args:` summary that would otherwise
1861 /// say `prompts: <3 items>` is suppressed rather than printed alongside them.
1862 #[test]
1863 fn fan_out_prompts_replace_the_inline_argument_summary() {
1864 let mut cell = generic_tool("read_file", ToolStatus::Running);
1865 cell.input_summary = Some("prompts: <3 items>".to_string());
1866 cell.prompts = Some(vec![
1867 "Summarize the README".to_string(),
1868 "List the public types in client.rs".to_string(),
1869 "Diff this commit against main".to_string(),
1870 ]);
1871 let text = lines_text(&HistoryCell::Tool(ToolCell::Generic(cell)).lines(80));
1872
1873 assert!(text.contains("[0] Summarize the README"));
1874 assert!(text.contains("[1] List the public types in client.rs"));
1875 assert!(text.contains("[2] Diff this commit against main"));
1876 assert!(
1877 !text.contains("args: prompts:"),
1878 "the summary the rows replaced must not also render: {text}"
1879 );
1880
1881 let mut plain = generic_tool("file_search", ToolStatus::Running);
1882 plain.input_summary = Some("query: foo".to_string());
1883 let text = lines_text(&HistoryCell::Tool(ToolCell::Generic(plain)).lines(80));
1884 assert!(
1885 text.contains("query: foo"),
1886 "a non-fan-out tool keeps its argument summary: {text}"
1887 );
1888 }
1889
1890 /// A grouped activity row is metadata, not a tool card: exactly one line, and
1891 /// the synthetic tool name that carries it never reaches the screen.
1892 #[test]
1893 fn an_activity_group_renders_as_a_single_metadata_line() {
1894 let mut cell = generic_tool("activity_group", ToolStatus::Success);
1895 cell.input_summary = Some("Explored 2 files, 1 search".to_string());
1896
1897 let lines = cell.lines_with_mode(120, true, RenderMode::Live);
1898
1899 assert_eq!(lines.len(), 1);
1900 assert_eq!(lines_text(&lines), "Explored 2 files, 1 search");
1901 assert!(!lines_text(&lines).contains("activity_group"));
1902 }
1903
1904 // ---------------------------------------------------------------------------
1905 // Replay — wire messages project to the right typed cell
1906 // ---------------------------------------------------------------------------
1907
1908 /// The wire carries a `(reasoning omitted)` placeholder for turns whose
1909 /// reasoning the provider did not return. Replaying it as a reasoning cell
1910 /// would put words in the model's mouth.
1911 #[test]
1912 fn restored_history_drops_the_wire_reasoning_placeholder() {
1913 let message = Message {
1914 role: Role::Assistant,
1915 content: vec![
1916 ContentBlock::Thinking {
1917 thinking: "(reasoning omitted)".to_string(),
1918 signature: None,
1919 state: None,
1920 },
1921 ContentBlock::Thinking {
1922 thinking: "Actual model reasoning".to_string(),
1923 signature: None,
1924 state: None,
1925 },
1926 ],
1927 };
1928
1929 let cells = super::history_cells_from_message(&message);
1930 assert_eq!(cells.len(), 1);
1931 assert!(matches!(
1932 &cells[0],
1933 HistoryCell::Thinking { content, .. } if content == "Actual model reasoning"
1934 ));
1935 }
1936
1937 /// Compaction writes an `<archived_context>` envelope whose attributes are the
1938 /// only record of what was dropped. Attribute parsing must survive spaces and
1939 /// punctuation inside the values, or the summary reads with a mangled range.
1940 #[test]
1941 fn archived_context_metadata_survives_spaces_inside_attribute_values() {
1942 let msg = Message {
1943 role: Role::Assistant,
1944 content: vec![ContentBlock::Text {
1945 text: "<archived_context level=\"1\" range=\"msg 0-128\" tokens=\"2499\" \
1946 density=\"~2,500 tokens\" model=\"deepseek-v4-flash\" \
1947 timestamp=\"2026-04-28T00:00:00Z\">\nSummary body\n</archived_context>"
1948 .to_string(),
1949 cache_control: None,
1950 }],
1951 };
1952
1953 let cells = super::history_cells_from_message(&msg);
1954 assert_eq!(cells.len(), 1);
1955 let HistoryCell::ArchivedContext {
1956 level,
1957 range,
1958 tokens,
1959 density,
1960 model,
1961 timestamp,
1962 summary,
1963 } = &cells[0]
1964 else {
1965 panic!("expected archived context cell, got {:?}", cells[0]);
1966 };
1967
1968 assert_eq!(*level, 1);
1969 assert_eq!(range, "msg 0-128");
1970 assert_eq!(tokens, "2499");
1971 assert_eq!(density, "~2,500 tokens");
1972 assert_eq!(model, "deepseek-v4-flash");
1973 assert_eq!(timestamp, "2026-04-28T00:00:00Z");
1974 assert_eq!(summary, "Summary body");
1975 }
1976
1977 /// Two projections that must not become generic assistant prose: a repair
1978 /// receipt is a system note, and a replayed `update_plan` call rebuilds the
1979 /// typed plan cell with its snapshot intact.
1980 #[test]
1981 fn replay_routes_repair_receipts_and_plan_calls_to_typed_cells() {
1982 let repair = Message {
1983 role: Role::Assistant,
1984 content: vec![ContentBlock::Text {
1985 text: "[tool_history_repair] Repaired 1 crashed tool call(s); quarantined 0 \
1986 duplicate and 0 orphan terminal result(s)."
1987 .to_string(),
1988 cache_control: None,
1989 }],
1990 };
1991 assert!(matches!(
1992 super::history_cells_from_message(&repair).as_slice(),
1993 [HistoryCell::System { content }] if content.starts_with("[tool_history_repair]")
1994 ));
1995
1996 let plan = Message {
1997 role: Role::Assistant,
1998 content: vec![ContentBlock::ToolUse {
1999 id: "plan-1".to_string(),
2000 name: "update_plan".to_string(),
2001 input: serde_json::json!({
2002 "objective": "Make Plan mode reviewable",
2003 "sources_used": ["gh issue view 2691"],
2004 "critical_files": ["crates/tui/src/tools/plan.rs"],
2005 "plan": [
2006 { "step": "render replay card", "status": "completed" }
2007 ]
2008 }),
2009 caller: None,
2010 thought_signature: None,
2011 }],
2012 };
2013 let cells = super::history_cells_from_message(&plan);
2014 assert_eq!(cells.len(), 1);
2015 let HistoryCell::Tool(ToolCell::PlanUpdate(cell)) = &cells[0] else {
2016 panic!("expected update_plan replay cell");
2017 };
2018 assert_eq!(cell.status, ToolStatus::Success);
2019 assert_eq!(
2020 cell.snapshot.objective.as_deref(),
2021 Some("Make Plan mode reviewable")
2022 );
2023 assert_eq!(cell.snapshot.sources_used, vec!["gh issue view 2691"]);
2024 assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
2025 }
2026
2027 /// The runtime appends a `<turn_meta>` block to the user's message. It is
2028 /// scaffolding and must be hidden — but only when it is the trailing block the
2029 /// runtime appended. A user who types the same tag is quoting, not injecting,
2030 /// and their text must survive verbatim.
2031 #[test]
2032 fn user_history_hides_only_the_trailing_turn_metadata_block() {
2033 let visible = "Explain this literal: <turn_meta>example</turn_meta>";
2034 let turn_meta = concat!(
2035 "<turn_meta>\n",
2036 "Current local date: 2026-07-22\n",
2037 "Input provenance: external_user\n",
2038 "Input authority: external_current_turn\n",
2039 "</turn_meta>",
2040 );
2041 let msg = Message {
2042 role: Role::User,
2043 content: vec![
2044 ContentBlock::Text {
2045 text: visible.to_string(),
2046 cache_control: None,
2047 },
2048 ContentBlock::Text {
2049 text: turn_meta.to_string(),
2050 cache_control: None,
2051 },
2052 ],
2053 };
2054 assert!(matches!(
2055 super::history_cells_from_message(&msg).as_slice(),
2056 [HistoryCell::User { content }] if content == visible
2057 ));
2058
2059 let literal_only = Message {
2060 role: Role::User,
2061 content: vec![ContentBlock::Text {
2062 text: "<turn_meta>user-authored example</turn_meta>".to_string(),
2063 cache_control: None,
2064 }],
2065 };
2066 assert!(matches!(
2067 super::history_cells_from_message(&literal_only).as_slice(),
2068 [HistoryCell::User { content }]
2069 if content == "<turn_meta>user-authored example</turn_meta>"
2070 ));
2071 }
2072
2073 /// "Copy answer" must select the last completed assistant cell and serialize
2074 /// exactly its authored text — no reasoning, no tool bodies, no runtime status,
2075 /// no role marker, and never a half-streamed cell.
2076 #[test]
2077 fn the_answer_projection_copies_authored_text_and_nothing_else() {
2078 use crate::tui::ui_text::history_cell_to_clipboard_text;
2079
2080 let cells = [
2081 HistoryCell::User {
2082 content: "please summarize".to_string(),
2083 },
2084 HistoryCell::Thinking {
2085 content: "private reasoning trace".to_string(),
2086 streaming: false,
2087 duration_secs: Some(1.0),
2088 },
2089 HistoryCell::Tool(ToolCell::Generic({
2090 let mut cell = generic_tool("read_file", ToolStatus::Success);
2091 cell.input_summary = Some("src/lib.rs".to_string());
2092 cell.output = Some("raw tool result body".to_string());
2093 cell
2094 })),
2095 HistoryCell::System {
2096 content: "runtime status note".to_string(),
2097 },
2098 HistoryCell::Assistant {
2099 content: "still streaming partial".to_string(),
2100 streaming: true,
2101 },
2102 HistoryCell::Assistant {
2103 content: "## Final answer\nauthored markdown".to_string(),
2104 streaming: false,
2105 },
2106 ];
2107
2108 let answer = cells
2109 .iter()
2110 .rev()
2111 .find(|cell| cell.is_completed_assistant_answer())
2112 .expect("the completed assistant cell must qualify");
2113 let copied = history_cell_to_clipboard_text(answer, 80);
2114
2115 assert_eq!(copied, "## Final answer\nauthored markdown");
2116 for excluded in [
2117 "please summarize",
2118 "private reasoning trace",
2119 "raw tool result body",
2120 "runtime status note",
2121 "still streaming partial",
2122 ASSISTANT_GLYPH,
2123 ] {
2124 assert!(
2125 !copied.contains(excluded),
2126 "answer copy leaked {excluded:?}"
2127 );
2128 }
2129 }
2130
2131 // ---------------------------------------------------------------------------
2132 // Grouping and small parsers
2133 // ---------------------------------------------------------------------------
2134
2135 fn tool_cell(name: &str, status: ToolStatus) -> HistoryCell {
2136 let mut cell = generic_tool(name, status);
2137 cell.input_summary = Some(format!("args for {name}"));
2138 cell.output = Some(format!("output for {name}"));
2139 HistoryCell::Tool(ToolCell::Generic(cell))
2140 }
2141
2142 /// Collapsing a run of tool cards is only safe when nothing in the run needs
2143 /// the user's eyes: a failure, an in-flight call, or a shell command must all
2144 /// break the group and stay individually visible, and a run shorter than the
2145 /// threshold is not a run at all.
2146 ///
2147 /// Replaces three tests.
2148 #[test]
2149 fn only_contiguous_finished_safe_tool_calls_collapse_into_a_run() {
2150 let history = vec![
2151 HistoryCell::User {
2152 content: "go".to_string(),
2153 },
2154 tool_cell("read_file", ToolStatus::Success),
2155 tool_cell("list_dir", ToolStatus::Success),
2156 tool_cell("web_search", ToolStatus::Success),
2157 HistoryCell::Assistant {
2158 content: "done".to_string(),
2159 streaming: false,
2160 },
2161 ];
2162 let runs = super::detect_tool_runs(&history, 3);
2163 assert_eq!(runs.len(), 1);
2164 assert_eq!((runs[0].start, runs[0].count), (1, 3));
2165 assert_eq!(
2166 runs[0].tool_families,
2167 vec!["read_file", "list_dir", "web_search"]
2168 );
2169 assert_eq!(runs[0].activity.files, 2);
2170 assert_eq!(runs[0].activity.searches, 1);
2171
2172 assert!(
2173 super::detect_tool_runs(
2174 &[
2175 tool_cell("read_file", ToolStatus::Success),
2176 tool_cell("list_dir", ToolStatus::Success),
2177 ],
2178 3
2179 )
2180 .is_empty(),
2181 "a run below the threshold is not collapsed"
2182 );
2183
2184 assert!(
2185 super::detect_tool_runs(
2186 &[
2187 tool_cell("read_file", ToolStatus::Success),
2188 HistoryCell::Assistant {
2189 content: "pause".to_string(),
2190 streaming: false,
2191 },
2192 tool_cell("list_dir", ToolStatus::Success),
2193 tool_cell("web_search", ToolStatus::Success),
2194 ],
2195 3
2196 )
2197 .is_empty(),
2198 "assistant prose breaks the run"
2199 );
2200
2201 // Each of failure, in-flight and shell breaks the group; only the clean
2202 // trailing triple survives.
2203 let mut mixed = Vec::new();
2204 for breaker in [
2205 tool_cell("web_search", ToolStatus::Failed),
2206 tool_cell("web_search", ToolStatus::Running),
2207 HistoryCell::Tool(ToolCell::Exec({
2208 let mut exec = exec_tool("rm -rf target", ToolStatus::Success);
2209 exec.output = Some("ok".to_string());
2210 exec
2211 })),
2212 ] {
2213 mixed.push(tool_cell("read_file", ToolStatus::Success));
2214 mixed.push(tool_cell("list_dir", ToolStatus::Success));
2215 mixed.push(breaker);
2216 }
2217 let tail_start = mixed.len();
2218 mixed.push(tool_cell("read_file", ToolStatus::Success));
2219 mixed.push(tool_cell("list_dir", ToolStatus::Success));
2220 mixed.push(tool_cell("web_search", ToolStatus::Success));
2221
2222 let runs = super::detect_tool_runs(&mixed, 3);
2223 assert_eq!(runs.len(), 1, "only the clean tail collapses: {runs:?}");
2224 assert_eq!((runs[0].start, runs[0].count), (tail_start, 3));
2225 }
2226
2227 /// The one-line summary that replaces a collapsed run is the user's only
2228 /// record of it, so it must name what actually happened — the right verb, the
2229 /// right counts, and only the tool families that belong to each clause.
2230 ///
2231 /// Replaces four tests.
2232 #[test]
2233 fn a_collapsed_run_summary_names_what_actually_happened() {
2234 let run = |families: &[&str], activity: super::ToolRunActivitySummary| super::ToolRun {
2235 start: 4,
2236 count: families.len(),
2237 tool_families: families.iter().map(|f| f.to_string()).collect(),
2238 activity,
2239 };
2240
2241 assert_eq!(
2242 super::tool_run_summary(&run(
2243 &["read_file", "list_dir"],
2244 super::ToolRunActivitySummary {
2245 files: 4,
2246 searches: 1,
2247 ..Default::default()
2248 }
2249 )),
2250 "Explored 4 files, 1 search: read_file, list_dir"
2251 );
2252
2253 assert_eq!(
2254 super::tool_run_summary(&run(
2255 &["read_file", "run_tests", "validate_data"],
2256 super::ToolRunActivitySummary {
2257 files: 2,
2258 commands: 2,
2259 ..Default::default()
2260 }
2261 )),
2262 "Explored 2 files: read_file, ran 2 commands: run_tests, validate_data",
2263 "each clause lists only its own families"
2264 );
2265
2266 assert_eq!(
2267 super::tool_run_summary(&run(
2268 &["session_sync"],
2269 super::ToolRunActivitySummary {
2270 other: 2,
2271 ..Default::default()
2272 }
2273 )),
2274 "Updated metadata",
2275 "a run of tools with no user-facing family falls back to a plain note"
2276 );
2277
2278 // Classification is derived from the real cells, not hand-set counters:
2279 // command tools count as commands, git history tools count as files.
2280 let commands = super::detect_tool_runs(
2281 &[
2282 tool_cell("run_tests", ToolStatus::Success),
2283 tool_cell("run_verifiers", ToolStatus::Success),
2284 tool_cell("validate_data", ToolStatus::Success),
2285 ],
2286 3,
2287 );
2288 assert_eq!(commands[0].activity.commands, 3);
2289 assert_eq!(
2290 super::tool_run_summary(&commands[0]),
2291 "Ran 3 commands: run_tests, run_verifiers, validate_data"
2292 );
2293
2294 let git = super::detect_tool_runs(
2295 &[
2296 tool_cell("git_log", ToolStatus::Success),
2297 tool_cell("git_show", ToolStatus::Success),
2298 tool_cell("git_blame", ToolStatus::Success),
2299 ],
2300 3,
2301 );
2302 assert_eq!(git[0].activity.files, 3);
2303 assert_eq!(
2304 super::tool_run_summary(&git[0]),
2305 "Explored 3 files: git_log, git_show, git_blame"
2306 );
2307 }
2308
2309 /// The small pure helpers behind the cards, as one table each. Every row is a
2310 /// documented input shape or a documented rejection; a helper that guesses on
2311 /// malformed input is worse than one that declines.
2312 ///
2313 /// Replaces ten single-case tests.
2314 #[test]
2315 fn the_card_helpers_accept_their_documented_forms_and_decline_the_rest() {
2316 // Agent ids come out of a JSON body that the renderer must not fully parse.
2317 for (input, expected) in [
2318 (
2319 r#"{"agent_id": "agent-abc12", "nickname": "Beluga"}"#,
2320 Some("agent-abc12"),
2321 ),
2322 (
2323 "{\n \"agent_id\" : \"agent-xyz\",\n \"model\": \"x\"\n}",
2324 Some("agent-xyz"),
2325 ),
2326 (r#"{"nickname": "Orca", "model": "x"}"#, None),
2327 (r#"{"agent_id": "", "model": "x"}"#, None),
2328 ("(not json)", None),
2329 ("", None),
2330 ] {
2331 assert_eq!(
2332 super::extract_agent_id(input),
2333 expected,
2334 "extract_agent_id({input:?})"
2335 );
2336 }
2337
2338 // Checklist update prefixes: both vocabularies, and no guessing.
2339 for (input, expected) in [
2340 (
2341 "Updated todo #3 to in_progress\n{ \"items\": [...] }",
2342 Some(super::ChecklistChange {
2343 id: 3,
2344 status: "in_progress".to_string(),
2345 }),
2346 ),
2347 (
2348 "Updated checklist #7 to completed\n{ \"items\": [] }",
2349 Some(super::ChecklistChange {
2350 id: 7,
2351 status: "completed".to_string(),
2352 }),
2353 ),
2354 ("{ \"items\": [] }", None),
2355 ("Wrote 5 todos\n{}", None),
2356 ("Updated todo #3\n", None),
2357 ("Updated todo #foo to done\n", None),
2358 ] {
2359 assert_eq!(
2360 super::parse_update_prefix(input),
2361 expected,
2362 "parse_update_prefix({input:?})"
2363 );
2364 }
2365
2366 // The elapsed badge appears at three seconds and not before, so quick
2367 // reads and greps do not visually churn.
2368 for secs in [0, 1, 2] {
2369 assert_eq!(running_status_label_with_elapsed(secs), "running");
2370 }
2371 for secs in [3u64, 7, 120] {
2372 assert_eq!(
2373 running_status_label_with_elapsed(secs),
2374 format!("running ({secs}s)")
2375 );
2376 }
2377
2378 // A reasoning summary prefers an explicit Summary block, and otherwise is
2379 // the reasoning itself rather than nothing.
2380 assert_eq!(
2381 extract_reasoning_summary("Thinking...\nSummary: First line\nSecond line\n\nTail")
2382 .expect("summary"),
2383 "First line\nSecond line"
2384 );
2385 assert_eq!(
2386 extract_reasoning_summary("Line one\nLine two").expect("summary"),
2387 "Line one\nLine two"
2388 );
2389 }
2390
2391 /// The card rail is the block's border, so it carries the cell's own state.
2392 /// Property, not token: every status paints a distinct rail, the rail is never
2393 /// left unstyled — an unstyled rail is what shipped before, and it made a
2394 /// failed card look exactly like a finished one from the border in — and it
2395 /// agrees with the glyph beside it on everything that needs attention while
2396 /// receding on a settled card, which is the whole point of the split.
2397 #[test]
2398 fn card_rail_carries_the_cell_status() {
2399 let mut rails = Vec::new();
2400 for status in [
2401 ToolStatus::Running,
2402 ToolStatus::Success,
2403 ToolStatus::Hydrated,
2404 ToolStatus::Warning,
2405 ToolStatus::Failed,
2406 ] {
2407 let cell = exec_tool("cargo test", status);
2408 let lines = cell.render(80, /*low_motion*/ true, RenderMode::Live);
2409 let first = &lines[0];
2410
2411 let rail = first.spans.first().expect("card rail span");
2412 assert!(
2413 matches!(rail.content.as_ref(), "─ " | "╭ "),
2414 "{status:?} lost its card rail: {:?}",
2415 rail.content
2416 );
2417 let rail_color = rail.style.fg.unwrap_or_else(|| {
2418 panic!("{status:?} left the card rail unstyled — the border must follow the state")
2419 });
2420
2421 let glyph_color = first.spans[1]
2422 .style
2423 .fg
2424 .expect("header status glyph must be styled");
2425 if status == ToolStatus::Success {
2426 // A settled card dims its border but keeps an identifying glyph.
2427 assert_ne!(
2428 rail_color, glyph_color,
2429 "a settled card must not dim its glyph along with its rail"
2430 );
2431 } else {
2432 assert_eq!(
2433 rail_color, glyph_color,
2434 "{status:?} must read the same on the rail and the glyph"
2435 );
2436 }
2437
2438 rails.push((status, rail_color));
2439 }
2440
2441 for (i, (status, color)) in rails.iter().enumerate() {
2442 for (other_status, other_color) in &rails[i + 1..] {
2443 assert_ne!(
2444 color, other_color,
2445 "{status:?} and {other_status:?} draw the same rail"
2446 );
2447 }
2448 }
2449 }
2450
2451 /// The header glyph reports identity, not just lifecycle: a passed `verify`
2452 /// card keeps its green tick where a finished `read` keeps the family accent
2453 /// the mockup draws as a blue magnifier. Relationship, not token — the two must
2454 /// simply not collapse into one another.
2455 #[test]
2456 fn a_settled_verify_glyph_does_not_read_as_a_settled_read() {
2457 let verify = generic_tool("run_tests", ToolStatus::Success);
2458 let read = generic_tool("read_file", ToolStatus::Success);
2459
2460 let glyph_color = |cell: &GenericToolCell| {
2461 cell.lines_with_mode_and_locale(
2462 80,
2463 /*low_motion*/ true,
2464 RenderMode::Live,
2465 codewhale_localization::Locale::En,
2466 )[0]
2467 .spans[1]
2468 .style
2469 .fg
2470 .expect("header status glyph must be styled")
2471 };
2472
2473 assert_ne!(
2474 glyph_color(&verify),
2475 glyph_color(&read),
2476 "a passed verify and a finished read must not share a glyph colour"
2477 );
2478 }
2479
2480 /// An exploring cell rolls its parallel entries up into one state, and a
2481 /// single failure is never averaged away by its successful siblings. This is
2482 /// the fold that used to exist in three places and could not produce `Failed`
2483 /// in the one that painted the header.
2484 #[test]
2485 fn exploring_cell_status_keeps_the_loudest_terminal_state() {
2486 use super::{ExploringCell, ExploringEntry};
2487
2488 let cell = |statuses: &[ToolStatus]| ExploringCell {
2489 entries: statuses
2490 .iter()
2491 .map(|status| ExploringEntry {
2492 label: "Reading src/foo.rs".to_string(),
2493 status: *status,
2494 })
2495 .collect(),
2496 };
2497
2498 for (statuses, expected) in [
2499 (
2500 vec![ToolStatus::Success, ToolStatus::Success],
2501 ToolStatus::Success,
2502 ),
2503 (
2504 vec![ToolStatus::Success, ToolStatus::Running],
2505 ToolStatus::Running,
2506 ),
2507 (
2508 vec![ToolStatus::Failed, ToolStatus::Running],
2509 ToolStatus::Running,
2510 ),
2511 (
2512 vec![ToolStatus::Success, ToolStatus::Failed],
2513 ToolStatus::Failed,
2514 ),
2515 (
2516 vec![ToolStatus::Warning, ToolStatus::Failed],
2517 ToolStatus::Failed,
2518 ),
2519 (
2520 vec![ToolStatus::Success, ToolStatus::Warning],
2521 ToolStatus::Warning,
2522 ),
2523 (
2524 vec![ToolStatus::Success, ToolStatus::Hydrated],
2525 ToolStatus::Hydrated,
2526 ),
2527 ] {
2528 assert_eq!(
2529 cell(&statuses).status(),
2530 expected,
2531 "{statuses:?} should roll up to {expected:?}"
2532 );
2533 }
2534
2535 // And the cell reports that state through the ToolCell it lives in.
2536 assert_eq!(
2537 ToolCell::Exploring(cell(&[ToolStatus::Success, ToolStatus::Failed])).status(),
2538 Some(ToolStatus::Failed)
2539 );
2540 }
2541
2542 // ---------------------------------------------------------------------------
2543 // Tool-card ink: rail vs glyph
2544 // ---------------------------------------------------------------------------
2545
2546 /// The rail is the card border and follows OMP's rule: every status draws a
2547 /// distinct border. Two statuses sharing a rail is the failure this mapping
2548 /// exists to prevent — it is how `Hydrated` once sat on the running accent and
2549 /// read as live work.
2550 #[test]
2551 fn tool_rail_is_distinct_for_every_status() {
2552 let statuses = [
2553 ToolStatus::Running,
2554 ToolStatus::Success,
2555 ToolStatus::Hydrated,
2556 ToolStatus::Warning,
2557 ToolStatus::Failed,
2558 ];
2559 for (i, status) in statuses.iter().enumerate() {
2560 for other in &statuses[i + 1..] {
2561 assert_ne!(
2562 super::tool_rail_color(*status),
2563 super::tool_rail_color(*other),
2564 "{status:?} and {other:?} draw the same rail"
2565 );
2566 }
2567 }
2568 }
2569
2570 /// The rail reports lifecycle, the glyph reports identity, and each half of
2571 /// that split is load-bearing: a settled card must dim its border while
2572 /// keeping an identifying glyph, a passed verify must not share a glyph with a
2573 /// finished read, and the two must never disagree about trouble.
2574 #[test]
2575 fn rail_and_glyph_split_only_where_the_card_has_settled() {
2576 use crate::tui::widgets::tool_card::ToolFamily;
2577 for family in [ToolFamily::Read, ToolFamily::Verify] {
2578 for status in [ToolStatus::Running, ToolStatus::Warning, ToolStatus::Failed] {
2579 assert_eq!(
2580 super::tool_rail_color(status),
2581 super::tool_glyph_color(status, family),
2582 "{status:?} must read the same on the rail and the glyph"
2583 );
2584 }
2585 assert_ne!(
2586 super::tool_rail_color(ToolStatus::Success),
2587 super::tool_glyph_color(ToolStatus::Success, family),
2588 "a settled {family:?} card must dim its border without dimming its glyph"
2589 );
2590 assert_eq!(
2591 super::tool_glyph_color(ToolStatus::Hydrated, family),
2592 super::tool_rail_color(ToolStatus::Hydrated),
2593 "a hydrated {family:?} card has not succeeded at anything and must not borrow an accent"
2594 );
2595 }
2596 assert_ne!(
2597 super::tool_glyph_color(ToolStatus::Success, ToolFamily::Verify),
2598 super::tool_glyph_color(ToolStatus::Success, ToolFamily::Read),
2599 "a passed verify and a finished read must not share a glyph colour"
2600 );
2601 assert_eq!(
2602 super::tool_glyph_color(ToolStatus::Success, ToolFamily::Read),
2603 super::tool_glyph_color(ToolStatus::Running, ToolFamily::Read),
2604 "a finished read keeps the accent it wore while running"
2605 );
2606 }
2607
2608 /// Issue #5871: `todo_write` replaces the whole list on every call, so a long
2609 /// session stacked full checklist cards that could only be cleared by `/clear`
2610 /// or `/new` — and both of those also drop `api_messages` and the compaction
2611 /// summary. Only the newest snapshot keeps its card; the ones it replaced keep
2612 /// their header (the progress reading) and a details affordance.
2613 #[test]
2614 fn superseded_todo_snapshots_collapse_to_their_header() {
2615 let snapshot = |done: usize| {
2616 let items: Vec<String> = (0..3)
2617 .map(|i| {
2618 let status = if i < done { "completed" } else { "pending" };
2619 format!(r#"{{"content":"step {i}","status":"{status}"}}"#)
2620 })
2621 .collect();
2622 let mut cell = generic_tool("todo_write", ToolStatus::Success);
2623 cell.output = Some(format!(r#"{{"items":[{}]}}"#, items.join(",")));
2624 HistoryCell::Tool(ToolCell::Generic(cell))
2625 };
2626
2627 let mut options = TranscriptRenderOptions {
2628 show_tool_details: true,
2629 ..Default::default()
2630 };
2631 let older = snapshot(1);
2632
2633 let expanded = older.lines_with_options(120, options);
2634 assert!(
2635 expanded.len() > 2,
2636 "the newest snapshot renders its full card: {expanded:?}"
2637 );
2638
2639 options.superseded_work_receipt = true;
2640 let collapsed = older.lines_with_options(120, options);
2641 assert_eq!(
2642 collapsed.len(),
2643 2,
2644 "a replaced snapshot keeps its header plus the details affordance"
2645 );
2646 let header: String = collapsed[0]
2647 .spans
2648 .iter()
2649 .map(|span| span.content.as_ref())
2650 .collect();
2651 assert!(
2652 header.contains("1/3"),
2653 "the collapsed row keeps the progress reading: {header}"
2654 );
2655 }
2656
2657 /// One click is one request to open one file.
2658 ///
2659 /// The old `try_open_file_at_line` looped over every line of the cell and
2660 /// spawned a detached editor per match, so a stack trace or a grep result could
2661 /// launch several at once, all fighting the still-raw-mode TUI for the tty
2662 /// (#6235). The parser now returns the first resolvable reference and nothing
2663 /// else; spawning belongs to `external_editor`.
2664 #[test]
2665 fn first_file_line_reference_returns_one_match_and_resolves_it() {
2666 let dir = tempfile::tempdir().unwrap();
2667 let workspace = dir.path();
2668 std::fs::create_dir_all(workspace.join("src")).unwrap();
2669 std::fs::write(workspace.join("src/first.rs"), "fn a() {}\n").unwrap();
2670 std::fs::write(workspace.join("src/second.rs"), "fn b() {}\n").unwrap();
2671
2672 let text = "note: two frames below\n src/first.rs:12\n src/second.rs:34\n";
2673 let (path, line) = super::first_file_line_reference(text, workspace)
2674 .expect("the first resolvable reference is returned");
2675 assert_eq!(path, workspace.join("src/first.rs"));
2676 assert_eq!(line, 12);
2677 }
2678
2679 #[test]
2680 fn first_file_line_reference_skips_unresolvable_and_malformed_rows() {
2681 let dir = tempfile::tempdir().unwrap();
2682 let workspace = dir.path();
2683 std::fs::create_dir_all(workspace.join("src")).unwrap();
2684 std::fs::write(workspace.join("src/real.rs"), "fn a() {}\n").unwrap();
2685
2686 // A path that does not exist, a bare word, a non-numeric suffix and an
2687 // empty suffix all fall through to the one row that resolves.
2688 let text = concat!(
2689 " src/missing.rs:9\n",
2690 " notafile:12\n",
2691 " src/real.rs:abc\n",
2692 " src/real.rs:\n",
2693 " src/real.rs:7\n",
2694 );
2695 let (path, line) =
2696 super::first_file_line_reference(text, workspace).expect("the only resolvable row wins");
2697 assert_eq!(path, workspace.join("src/real.rs"));
2698 assert_eq!(line, 7);
2699
2700 assert!(
2701 super::first_file_line_reference("no references here\n", workspace).is_none(),
2702 "a cell with nothing to open must report nothing, not a default"
2703 );
2704 }
2705
2705 lines RUST