返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / history / tests.rs
1 use super::constants::{
2 TOOL_OUTPUT_HEAD_LINES, TOOL_OUTPUT_LINE_LIMIT, TOOL_OUTPUT_TAIL_LINES,
3 TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
4 };
5 use super::{
6 ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, McpToolCell,
7 PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
8 TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH,
9 WebSearchCell, assistant_label_style_for, extract_reasoning_summary,
10 render_spillover_annotation, render_thinking, render_thinking_with_highlight,
11 running_status_label_with_elapsed,
12 };
13 use crate::deepseek_theme::Theme;
14 use crate::models::{ContentBlock, Message};
15 use crate::palette;
16 use crate::tools::plan::{PlanSnapshot, StepStatus};
17 use crate::tui::motion::MotionMode;
18 use crate::tui::ui_text::{line_to_plain, slice_text, text_display_width};
19 use ratatui::style::Modifier;
20 use std::time::{Duration, Instant};
21
22 #[test]
23 fn web_search_cell_renders_receipt_source_degradation_and_citations() {
24 let cell = WebSearchCell {
25 query: "current release".to_string(),
26 status: ToolStatus::Success,
27 summary: Some("Found 2 results".to_string()),
28 source: Some("provider-native/xai/grok-4.5".to_string()),
29 degraded: Some("provider_native -> duckduckgo".to_string()),
30 ref_count: 2,
31 };
32 let rendered = cell
33 .lines_with_motion(120, true)
34 .iter()
35 .map(line_to_plain)
36 .collect::<Vec<_>>()
37 .join("\n");
38
39 assert!(rendered.contains("source"));
40 assert!(rendered.contains("provider-native/xai/grok-4.5"));
41 assert!(rendered.contains("degraded"));
42 assert!(rendered.contains("provider_native -> duckduckgo"));
43 assert!(rendered.contains("citations"));
44 }
45
46 // ---- elapsed-seconds badge for long-running tools ----
47 //
48 // Below 3s the label stays "running" — quick reads/greps shouldn't
49 // visually churn. From 3s onward the badge appears and ticks each
50 // second so the user can tell the call hasn't hung.
51 // ---- #4619 adaptive evidence UI receipt ----
52 //
53 // When a tool result carries a `spillover_path` (set by the
54 // tool-routing layer when the tool's `metadata.spillover_path` is
55 // populated), expanded live detail appends a calm path-free receipt.
56 // Transcript-mode replay leaves the hint off because the full output is
57 // already inline.
58
59 #[test]
60 fn compact_live_row_does_not_leak_evidence_path() {
61 use std::path::PathBuf;
62 let cell = GenericToolCell {
63 name: "read_file".to_string(),
64 status: ToolStatus::Success,
65 input_summary: Some("cmd: cargo build --release".to_string()),
66 output: Some("very large output...".to_string()),
67 prompts: None,
68 spillover_path: Some(PathBuf::from(
69 "/Users/dev/.deepseek/tool_outputs/call-abc12.txt",
70 )),
71 output_summary: None,
72 is_diff: false,
73 };
74 let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
75 let joined: String = lines
76 .iter()
77 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
78 .collect();
79 assert!(
80 joined.contains("read done · cmd: cargo build --release"),
81 "expected compact live summary: {joined:?}"
82 );
83 assert!(
84 !joined.contains("full output:"),
85 "spillover paths stay out of compact live rows: {joined:?}"
86 );
87 }
88
89 #[test]
90 fn render_spillover_annotation_omitted_in_transcript_mode() {
91 use std::path::PathBuf;
92 // Transcript mode is for replay; the full output is already
93 // inline so the annotation would just be redundant.
94 let cell = GenericToolCell {
95 name: "read_file".to_string(),
96 status: ToolStatus::Success,
97 input_summary: None,
98 output: Some("output".to_string()),
99 prompts: None,
100 spillover_path: Some(PathBuf::from("/tmp/spill.txt")),
101 output_summary: None,
102 is_diff: false,
103 };
104 let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript);
105 let joined: String = lines
106 .iter()
107 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
108 .collect();
109 assert!(
110 !joined.contains("full output:"),
111 "annotation should be omitted in transcript mode: {joined:?}"
112 );
113 }
114
115 #[test]
116 fn workflow_tool_renders_run_card_instead_of_generic_oneliner() {
117 let output = serde_json::json!({
118 "run_id": "workflow_2400c600",
119 "status": "completed",
120 "workflow_goal": "audit the FLEET and WORKFLOW docs",
121 "child_ids": ["a1", "a2", "a3"],
122 "progress": ["phase: Scan", "log: 3 findings"],
123 "events": [
124 {
125 "type": "task_started",
126 "task_id": "a1",
127 "label": "scan-docs",
128 "workflow_run_id": "workflow_2400c600",
129 "workflow_phase_id": "Scan",
130 "workflow_task_label": "scan-docs",
131 "workflow_child_index": 0,
132 },
133 {
134 "type": "task_started",
135 "task_id": "a2",
136 "workflow_task_label": "check-fleet",
137 "workflow_run_id": "workflow_2400c600",
138 "workflow_child_index": 1,
139 },
140 {
141 "type": "task_started",
142 "task_id": "a3",
143 "label": "summarize",
144 "workflow_run_id": "workflow_2400c600",
145 "workflow_child_index": 2,
146 },
147 ],
148 "schema_errors": [],
149 })
150 .to_string();
151 let cell = GenericToolCell {
152 name: "workflow".to_string(),
153 status: ToolStatus::Success,
154 input_summary: Some("action: run".to_string()),
155 output: Some(output),
156 prompts: None,
157 spillover_path: None,
158 output_summary: None,
159 is_diff: false,
160 };
161 let joined: String = cell
162 .lines_with_mode(120, true, super::RenderMode::Live)
163 .iter()
164 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
165 .collect();
166 // Compact (#4122): lifecycle, children, phases, failures, elapsed.
167 assert!(
168 joined.contains("3 children") || joined.contains("children"),
169 "child count: {joined:?}"
170 );
171 assert!(
172 joined.contains("success") || joined.contains("done"),
173 "header lifecycle: {joined:?}"
174 );
175 assert!(joined.contains("phase"), "phase count: {joined:?}");
176 assert!(joined.contains("fail"), "failure count present: {joined:?}");
177 assert!(
178 joined.contains('s') || joined.contains('m'),
179 "elapsed: {joined:?}"
180 );
181 assert!(
182 !joined.contains("status:"),
183 "body must not repeat the header lifecycle: {joined:?}"
184 );
185 }
186
187 #[test]
188 fn workflow_tool_expanded_card_shows_phase_child_result_and_failures() {
189 let output = serde_json::json!({
190 "run_id": "workflow_exp",
191 "status": "failed",
192 "workflow_goal": "ship v0.8.68",
193 "started_at_ms": 1000,
194 "completed_at_ms": 5000,
195 "source_path": "workflows/demo.workflow.js",
196 "error": "phase Verify failed",
197 "result": {"summary": "2 of 3 children ok"},
198 "events": [
199 {
200 "type": "run_started",
201 "at_ms": 1000,
202 "run_id": "workflow_exp",
203 "workflow_goal": "ship v0.8.68"
204 },
205 {"type": "phase_started", "at_ms": 1100, "title": "Verify"},
206 {
207 "type": "task_started",
208 "at_ms": 1200,
209 "task_id": "t1",
210 "label": "run tests",
211 "workflow_task_label": "run tests",
212 "profile": "implementer"
213 },
214 {
215 "type": "task_completed",
216 "at_ms": 4000,
217 "task_id": "t1",
218 "status": "failed"
219 },
220 {
221 "type": "run_completed",
222 "at_ms": 5000,
223 "status": "failed",
224 "error": "phase Verify failed"
225 }
226 ]
227 })
228 .to_string();
229 let cell = GenericToolCell {
230 name: "workflow".to_string(),
231 status: ToolStatus::Failed,
232 input_summary: Some("action: run".to_string()),
233 output: Some(output),
234 prompts: None,
235 spillover_path: Some(std::path::PathBuf::from("/tmp/wf-artifact.json")),
236 output_summary: None,
237 is_diff: false,
238 };
239 let joined: String = cell
240 .lines_with_mode(140, true, super::RenderMode::Transcript)
241 .iter()
242 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
243 .collect::<Vec<_>>()
244 .join("\n");
245 assert!(joined.contains("ship v0.8.68"), "goal: {joined}");
246 assert!(
247 joined.contains("phases:") || joined.contains("Verify"),
248 "phase: {joined}"
249 );
250 assert!(
251 joined.contains("children:") || joined.contains("child"),
252 "child: {joined}"
253 );
254 assert!(joined.contains("run tests"), "child label: {joined}");
255 assert!(
256 joined.contains("result:") || joined.contains("2 of 3"),
257 "final result: {joined}"
258 );
259 assert!(
260 joined.contains("artifact:")
261 || joined.contains("source:")
262 || joined.contains("transcript:"),
263 "links: {joined}"
264 );
265 assert!(
266 joined.contains("error:") || joined.contains("phase Verify failed"),
267 "failure details: {joined}"
268 );
269 }
270
271 #[test]
272 fn workflow_tool_renders_status_list_card() {
273 let output = serde_json::json!({
274 "action": "status",
275 "count": 2,
276 "runs": [
277 {"run_id": "workflow_aaa", "status": "running", "child_count": 4},
278 {"run_id": "workflow_bbb", "status": "completed", "child_count": 1},
279 ],
280 })
281 .to_string();
282 let cell = GenericToolCell {
283 name: "workflow".to_string(),
284 status: ToolStatus::Success,
285 input_summary: Some("action: status".to_string()),
286 output: Some(output),
287 prompts: None,
288 spillover_path: None,
289 output_summary: None,
290 is_diff: false,
291 };
292 let joined: String = cell
293 .lines_with_mode(120, true, super::RenderMode::Live)
294 .iter()
295 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
296 .collect();
297 assert!(joined.contains("2 run(s)"), "count header: {joined:?}");
298 assert!(joined.contains("workflow_aaa"), "first run row: {joined:?}");
299 assert!(joined.contains("running"), "run status: {joined:?}");
300 assert!(
301 joined.contains("workflow_bbb"),
302 "second run row: {joined:?}"
303 );
304 }
305
306 #[test]
307 fn render_spillover_annotation_omitted_when_no_path_set() {
308 // The common case: most tool results don't trigger spillover.
309 let cell = GenericToolCell {
310 name: "read_file".to_string(),
311 status: ToolStatus::Success,
312 input_summary: None,
313 output: Some("contents".to_string()),
314 prompts: None,
315 spillover_path: None,
316 output_summary: None,
317 is_diff: false,
318 };
319 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
320 let joined: String = lines
321 .iter()
322 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
323 .collect();
324 assert!(!joined.contains("full output:"), "{joined:?}");
325 }
326
327 #[test]
328 fn summarize_tool_args_ignores_control_only_defaults() {
329 let summary = super::summarize_tool_args(&serde_json::json!({
330 "max_count": 15,
331 "timeout_ms": 30_000
332 }));
333
334 assert_eq!(summary, None);
335 }
336
337 #[test]
338 fn summarize_tool_args_falls_back_to_meaningful_unknown_key() {
339 let summary = super::summarize_tool_args(&serde_json::json!({
340 "max_count": 15,
341 "branch": "main"
342 }));
343
344 assert_eq!(summary.as_deref(), Some("branch: main"));
345 }
346
347 #[test]
348 fn compact_git_tool_header_names_tool_not_control_default() {
349 let cell = GenericToolCell {
350 name: "git_log".to_string(),
351 status: ToolStatus::Success,
352 input_summary: Some("max_count: 15".to_string()),
353 output: None,
354 prompts: None,
355 spillover_path: None,
356 output_summary: None,
357 is_diff: false,
358 };
359
360 let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
361 let joined: String = lines
362 .iter()
363 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
364 .collect();
365
366 assert_eq!(lines.len(), 1);
367 assert!(
368 joined.contains("read done · git_log"),
369 "expected exact tool name in compact row: {joined:?}"
370 );
371 assert!(
372 !joined.contains("max_count"),
373 "control defaults should not become the visible tool summary: {joined:?}"
374 );
375 }
376
377 #[test]
378 fn compact_unknown_tool_header_names_tool_not_control_default() {
379 let cell = GenericToolCell {
380 name: "future_private_tool".to_string(),
381 status: ToolStatus::Success,
382 input_summary: Some("max_count: 15".to_string()),
383 output: None,
384 prompts: None,
385 spillover_path: None,
386 output_summary: None,
387 is_diff: false,
388 };
389
390 let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
391 let joined: String = lines
392 .iter()
393 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
394 .collect();
395
396 assert_eq!(lines.len(), 1);
397 assert!(
398 joined.contains("tool done · future_private_tool"),
399 "expected exact tool name in compact row: {joined:?}"
400 );
401 assert!(
402 !joined.contains("max_count"),
403 "control defaults should not become the visible tool summary: {joined:?}"
404 );
405 }
406
407 #[test]
408 fn render_spillover_annotation_truncates_to_width() {
409 use std::path::PathBuf;
410 let long_path = "/Users/dev/.deepseek/tool_outputs/this-is-a-very-long-tool-call-id-that-will-not-fit-in-narrow-widths.txt";
411 let cell = GenericToolCell {
412 name: "read_file".to_string(),
413 status: ToolStatus::Success,
414 input_summary: None,
415 output: Some("output".to_string()),
416 prompts: None,
417 spillover_path: Some(PathBuf::from(long_path)),
418 output_summary: None,
419 is_diff: false,
420 };
421 let width = 80;
422 let lines = cell.lines_with_mode(width, true, super::RenderMode::Live);
423 let rendered: String = lines
424 .iter()
425 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
426 .collect();
427 assert!(
428 rendered.contains("Output shortened"),
429 "compact live rows should expose the calm expand affordance: {rendered:?}"
430 );
431 assert!(
432 rendered.contains(":output"),
433 "expected cap:verb affordance, got {rendered:?}"
434 );
435 assert!(!rendered.contains("opens full output"), "{rendered:?}");
436 assert!(text_display_width(&rendered) <= usize::from(width));
437 assert!(!rendered.contains(long_path));
438 }
439
440 #[test]
441 fn specialized_bash_and_mcp_cells_share_the_calm_expand_affordance() {
442 let receipt = "head\n\n… 200 KiB of output omitted — view full output in the tool details view\n\n…\ntail";
443 let bash = ExecCell {
444 command: "cargo test".to_string(),
445 status: ToolStatus::Failed,
446 output: Some(receipt.to_string()),
447 live_output: None,
448 shell_task_id: None,
449 owner_agent_id: None,
450 owner_agent_name: None,
451 started_at: None,
452 duration_ms: Some(50),
453 stale_elapsed_since_output_ms: None,
454 source: ExecSource::Assistant,
455 interaction: None,
456 output_summary: None,
457 };
458 let mcp = McpToolCell {
459 tool: "mcp_fixture".to_string(),
460 status: ToolStatus::Failed,
461 content: Some(receipt.to_string()),
462 is_image: false,
463 };
464 for rendered in [
465 lines_text(&ToolCell::Exec(bash).lines_with_motion(80, true)),
466 lines_text(&ToolCell::Mcp(mcp).lines_with_motion(80, true)),
467 ] {
468 assert!(rendered.contains("Output shortened"), "{rendered}");
469 assert!(
470 rendered.contains(":output"),
471 "expected cap:verb affordance, got {rendered}"
472 );
473 assert!(!rendered.contains("opens full output"), "{rendered}");
474 // The chord is a global details affordance, not a per-card stamp (#4718).
475 assert!(!rendered.contains("Option+V to inspect"), "{rendered}");
476 assert!(!rendered.contains("retrieve_tool_result"), "{rendered}");
477 assert!(!rendered.contains("head"), "{rendered}");
478 }
479 }
480
481 #[test]
482 fn adaptive_evidence_affordance_is_calm_path_free_and_width_bounded() {
483 use std::path::Path;
484
485 let secret_path = Path::new("/Users/private/.codewhale/sessions/session-a/artifacts/hash.txt");
486 let expected = format!(
487 "Output shortened — {}",
488 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("output")
489 );
490 for width in [18_u16, 40, 80, 120] {
491 let rendered = line_to_plain(&render_spillover_annotation(width));
492 assert!(
493 text_display_width(&rendered) <= usize::from(width),
494 "affordance exceeds width {width}: {rendered:?}"
495 );
496 assert!(!rendered.contains("/Users"));
497 assert!(!rendered.contains("hash.txt"));
498 assert!(!rendered.contains("Option+V"));
499 if usize::from(width) >= text_display_width(&expected) {
500 assert_eq!(rendered, expected);
501 }
502 }
503 // The path parameter is gone: the annotation never exposes storage paths.
504 let _ = secret_path;
505 }
506
507 #[test]
508 fn activity_group_renders_as_single_metadata_line() {
509 let cell = GenericToolCell {
510 name: "activity_group".to_string(),
511 status: ToolStatus::Success,
512 input_summary: Some("Explored 2 files, 1 search".to_string()),
513 output: None,
514 prompts: None,
515 spillover_path: None,
516 output_summary: None,
517 is_diff: false,
518 };
519
520 let lines = cell.lines_with_mode(120, true, super::RenderMode::Live);
521 let joined: String = lines
522 .iter()
523 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
524 .collect();
525
526 assert_eq!(lines.len(), 1);
527 assert_eq!(joined, "Explored 2 files, 1 search");
528 assert!(!joined.contains("activity_group"));
529 }
530
531 // ---- Compact agent rendering ----
532 //
533 // The DelegateCard owns live state for spawned sub-agents; the
534 // generic tool block previously duplicated that signal at 3-4 lines
535 // per spawn. In live mode we now render a single compact line that
536 // points at the spawned agent id; transcript-mode replay keeps the
537 // full block so debug history is intact.
538
539 #[test]
540 fn extract_agent_id_pulls_id_from_json_output() {
541 let output =
542 r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#;
543 assert_eq!(super::extract_agent_id(output), Some("agent-abc12"));
544 }
545
546 #[test]
547 fn extract_agent_id_handles_extra_whitespace() {
548 let output = r#"{
549 "agent_id" : "agent-xyz",
550 "model": "x"
551 }"#;
552 assert_eq!(super::extract_agent_id(output), Some("agent-xyz"));
553 }
554
555 #[test]
556 fn extract_agent_id_returns_none_when_missing() {
557 let output = r#"{"nickname": "Orca", "model": "x"}"#;
558 assert!(super::extract_agent_id(output).is_none());
559 assert!(super::extract_agent_id("(not json)").is_none());
560 assert!(super::extract_agent_id("").is_none());
561 }
562
563 #[test]
564 fn extract_agent_id_returns_none_for_empty_id() {
565 let output = r#"{"agent_id": "", "model": "x"}"#;
566 assert!(super::extract_agent_id(output).is_none());
567 }
568
569 #[test]
570 fn agent_spawn_suppresses_generic_card_in_live_mode() {
571 // #4133: spawn cards yield entirely to DelegateCard — no generic tool row.
572 let cell = GenericToolCell {
573 name: "agent".to_string(),
574 status: ToolStatus::Running,
575 input_summary: Some("prompt: do thing".to_string()),
576 output: Some(
577 r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#
578 .to_string(),
579 ),
580 prompts: None,
581 spillover_path: None,
582 output_summary: None,
583 is_diff: false,
584 };
585 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
586 assert!(
587 lines.is_empty(),
588 "spawn generic tool card must be suppressed: {lines:?}"
589 );
590 }
591
592 #[test]
593 fn agent_inspection_renders_single_compact_line_in_live_mode() {
594 let cell = GenericToolCell {
595 name: "agent".to_string(),
596 status: ToolStatus::Running,
597 input_summary: Some("action: peek agent_id: agent-abc12".to_string()),
598 output: Some(
599 r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#
600 .to_string(),
601 ),
602 prompts: None,
603 spillover_path: None,
604 output_summary: None,
605 is_diff: false,
606 };
607 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
608 assert_eq!(lines.len(), 1, "expected exactly 1 line, got {lines:?}");
609 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
610 assert!(
611 rendered.contains("agent-abc12"),
612 "expected agent id in header: {rendered:?}"
613 );
614 assert!(
615 rendered.contains("checking"),
616 "expected inspection status in header: {rendered:?}"
617 );
618 assert!(
619 !rendered.contains("args"),
620 "args should be hidden: {rendered:?}"
621 );
622 }
623
624 #[test]
625 fn agent_pending_inspection_uses_fallback_token() {
626 // Pending inspection (no agent_id yet) still renders a compact check line.
627 let cell = GenericToolCell {
628 name: "agent".to_string(),
629 status: ToolStatus::Running,
630 input_summary: Some("action: peek prompt: do thing".to_string()),
631 output: None,
632 prompts: None,
633 spillover_path: None,
634 output_summary: None,
635 is_diff: false,
636 };
637 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
638 assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}");
639 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
640 assert!(
641 rendered.contains("checking") || rendered.contains("subagent"),
642 "{rendered:?}"
643 );
644 assert!(!rendered.contains('\u{2026}'), "{rendered:?}");
645 }
646
647 #[test]
648 fn agent_spawn_suppresses_generic_card_in_transcript_mode() {
649 // #4133: spawn cards are suppressed in both Live and Transcript; DelegateCard
650 // is the sole visible spawn artifact.
651 let cell = GenericToolCell {
652 name: "agent".to_string(),
653 status: ToolStatus::Success,
654 input_summary: Some("prompt: do thing".to_string()),
655 output: Some(r#"{"agent_id": "agent-abc12", "model": "deepseek-v4-flash"}"#.to_string()),
656 prompts: None,
657 spillover_path: None,
658 output_summary: None,
659 is_diff: false,
660 };
661 let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript);
662 assert!(
663 lines.is_empty(),
664 "spawn generic tool card must be suppressed in transcript: {lines:?}"
665 );
666 }
667
668 #[test]
669 fn other_tools_are_unaffected_by_agent_compact_path() {
670 // Live-mode tool rows are compact by default; raw detail remains
671 // available through the detail pager.
672 let cell = GenericToolCell {
673 name: "read_file".to_string(),
674 status: ToolStatus::Success,
675 input_summary: Some("path: foo.rs".to_string()),
676 output: Some("first line\nsecond line\nthird line".to_string()),
677 prompts: None,
678 spillover_path: None,
679 output_summary: None,
680 is_diff: false,
681 };
682 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
683 assert_eq!(lines.len(), 1, "live tools should use compact rows");
684 }
685
686 #[test]
687 fn agent_compact_header_omits_unknown_child_fallback() {
688 // #4148: an inspection whose identity can't be resolved must not leak the
689 // raw internal "unknown child" token into the default transcript.
690 let cell = GenericToolCell {
691 name: "agent".to_string(),
692 status: ToolStatus::Running,
693 input_summary: Some("action: peek agent_type: delegate".to_string()),
694 output: None,
695 prompts: None,
696 spillover_path: None,
697 output_summary: None,
698 is_diff: false,
699 };
700 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
701 assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}");
702 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
703 assert!(
704 !rendered.contains("unknown child"),
705 "raw fallback token must not leak: {rendered:?}"
706 );
707 assert!(
708 rendered.contains("subagent"),
709 "friendly fallback label should be shown: {rendered:?}"
710 );
711 }
712
713 #[test]
714 fn agent_compact_header_does_not_duplicate_delegate_verb() {
715 // #4148: when the resolved identity collapses to the "delegate" verb, the
716 // compact inspection header must not render a redundant "delegate · delegate".
717 let cell = GenericToolCell {
718 name: "agent".to_string(),
719 status: ToolStatus::Running,
720 input_summary: Some("action: peek role: delegate".to_string()),
721 output: None,
722 prompts: None,
723 spillover_path: None,
724 output_summary: None,
725 is_diff: false,
726 };
727 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
728 assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}");
729 let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
730 assert!(
731 !rendered.contains("delegate delegate"),
732 "no adjacent duplicate: {rendered:?}"
733 );
734 assert_eq!(
735 rendered.matches("delegate").count(),
736 1,
737 "verb must not be echoed by the summary: {rendered:?}"
738 );
739 }
740
741 // ---- #403 concise todo / checklist update rendering ----
742 //
743 // The tool emits an "Updated todo #N to STATUS" leading line plus a
744 // JSON snapshot. The renderer should detect the prefix and produce
745 // a compact one-line state-change card instead of dumping the full
746 // item list every time.
747
748 #[test]
749 fn parse_update_prefix_recognises_todo_form() {
750 let parsed = super::parse_update_prefix("Updated todo #3 to in_progress\n{ \"items\": [...] }");
751 assert_eq!(
752 parsed,
753 Some(super::ChecklistChange {
754 id: 3,
755 status: "in_progress".to_string(),
756 }),
757 );
758 }
759
760 #[test]
761 fn parse_update_prefix_recognises_checklist_form() {
762 let parsed = super::parse_update_prefix("Updated checklist #7 to completed\n{ \"items\": [] }");
763 assert_eq!(
764 parsed,
765 Some(super::ChecklistChange {
766 id: 7,
767 status: "completed".to_string(),
768 }),
769 );
770 }
771
772 #[test]
773 fn parse_update_prefix_returns_none_for_writes() {
774 // `todo_write` / `checklist_write` outputs don't start with
775 // "Updated …" — they should fall through to the full-card path.
776 assert!(super::parse_update_prefix("{ \"items\": [] }").is_none());
777 assert!(super::parse_update_prefix("Wrote 5 todos\n{}").is_none());
778 }
779
780 #[test]
781 fn parse_update_prefix_returns_none_for_malformed() {
782 // Missing arrow/status → fall through.
783 assert!(super::parse_update_prefix("Updated todo #3\n").is_none());
784 // Non-numeric id → fall through.
785 assert!(super::parse_update_prefix("Updated todo #foo to done\n").is_none());
786 }
787
788 #[test]
789 fn render_checklist_change_card_shows_only_changed_item() {
790 // Build a snapshot with three items; render the change for #2.
791 let snapshot = super::ChecklistSnapshot {
792 items: vec![
793 super::ChecklistItemSnapshot {
794 content: "Read the spec".to_string(),
795 status: "completed".to_string(),
796 },
797 super::ChecklistItemSnapshot {
798 content: "Write the test".to_string(),
799 status: "in_progress".to_string(),
800 },
801 super::ChecklistItemSnapshot {
802 content: "Land the PR".to_string(),
803 status: "pending".to_string(),
804 },
805 ],
806 completion_pct: 33,
807 completed: 1,
808 total: 3,
809 };
810 let change = super::ChecklistChange {
811 id: 2,
812 status: "in_progress".to_string(),
813 };
814 let lines = super::render_checklist_change_card(
815 "todo_update",
816 ToolStatus::Success,
817 &snapshot,
818 &change,
819 80,
820 true,
821 );
822 // Header + change line + summary affordance = 3 lines.
823 assert!(lines.len() >= 3, "expected ≥3 lines, got {}", lines.len());
824
825 // The change line should mention the title and the new status,
826 // and should NOT include the other two item titles (that's the
827 // whole point — concise rendering).
828 let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
829 assert!(change_line.contains("#2"), "missing id: {change_line:?}");
830 assert!(
831 change_line.contains("Write the test"),
832 "missing title: {change_line:?}"
833 );
834 assert!(
835 change_line.contains("in_progress"),
836 "missing status: {change_line:?}"
837 );
838 assert!(
839 !change_line.contains("Land the PR"),
840 "should not show other items: {change_line:?}"
841 );
842 assert!(
843 !change_line.contains("Read the spec"),
844 "should not show other items: {change_line:?}"
845 );
846
847 // The summary line carries the count + explicit details-pager hint.
848 let summary_line: String = lines
849 .last()
850 .unwrap()
851 .spans
852 .iter()
853 .map(|s| s.content.as_ref())
854 .collect();
855 assert!(summary_line.contains("3 items"), "{summary_line:?}");
856 let expected_hint = crate::tui::key_shortcuts::tool_details_shortcut_action_hint("list");
857 assert!(summary_line.contains(&expected_hint), "{summary_line:?}");
858 }
859
860 #[test]
861 fn render_checklist_change_card_handles_missing_title_gracefully() {
862 // If the change targets an out-of-range id, the title falls
863 // back to a placeholder rather than crashing.
864 let snapshot = super::ChecklistSnapshot {
865 items: vec![super::ChecklistItemSnapshot {
866 content: "only item".to_string(),
867 status: "pending".to_string(),
868 }],
869 completion_pct: 0,
870 completed: 0,
871 total: 1,
872 };
873 let change = super::ChecklistChange {
874 id: 99,
875 status: "completed".to_string(),
876 };
877 let lines = super::render_checklist_change_card(
878 "todo_update",
879 ToolStatus::Success,
880 &snapshot,
881 &change,
882 80,
883 true,
884 );
885 let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
886 assert!(change_line.contains("#99"));
887 assert!(change_line.contains("(missing title)"));
888 }
889
890 #[test]
891 fn running_status_label_omits_elapsed_below_threshold() {
892 assert_eq!(running_status_label_with_elapsed(0), "running");
893 assert_eq!(running_status_label_with_elapsed(1), "running");
894 assert_eq!(running_status_label_with_elapsed(2), "running");
895 }
896
897 #[test]
898 fn running_status_label_appends_elapsed_at_three_seconds() {
899 assert_eq!(running_status_label_with_elapsed(3), "running (3s)");
900 assert_eq!(running_status_label_with_elapsed(7), "running (7s)");
901 assert_eq!(running_status_label_with_elapsed(120), "running (120s)");
902 }
903
904 #[test]
905 fn extract_reasoning_summary_prefers_summary_block() {
906 let text = "Thinking...\nSummary: First line\nSecond line\n\nTail";
907 let summary = extract_reasoning_summary(text).expect("summary should exist");
908 assert_eq!(summary, "First line\nSecond line");
909 }
910
911 #[test]
912 fn extract_reasoning_summary_falls_back_to_full_text() {
913 let text = "Line one\nLine two";
914 let summary = extract_reasoning_summary(text).expect("summary should exist");
915 assert_eq!(summary, "Line one\nLine two");
916 }
917
918 #[test]
919 fn archived_context_metadata_preserves_spaces_in_attributes() {
920 let msg = Message {
921 role: "assistant".to_string(),
922 content: vec![ContentBlock::Text {
923 text: "<archived_context level=\"1\" range=\"msg 0-128\" tokens=\"2499\" density=\"~2,500 tokens\" model=\"deepseek-v4-flash\" timestamp=\"2026-04-28T00:00:00Z\">\nSummary body\n</archived_context>".to_string(),
924 cache_control: None,
925 }],
926 };
927
928 let cells = super::history_cells_from_message(&msg);
929 assert_eq!(cells.len(), 1);
930 let HistoryCell::ArchivedContext {
931 level,
932 range,
933 tokens,
934 density,
935 model,
936 timestamp,
937 summary,
938 } = &cells[0]
939 else {
940 panic!("expected archived context cell");
941 };
942
943 assert_eq!(*level, 1);
944 assert_eq!(range, "msg 0-128");
945 assert_eq!(tokens, "2499");
946 assert_eq!(density, "~2,500 tokens");
947 assert_eq!(model, "deepseek-v4-flash");
948 assert_eq!(timestamp, "2026-04-28T00:00:00Z");
949 assert_eq!(summary, "Summary body");
950 }
951
952 #[test]
953 fn tool_history_repair_receipt_renders_as_system_history() {
954 let msg = Message {
955 role: "assistant".to_string(),
956 content: vec![ContentBlock::Text {
957 text: "[tool_history_repair] Repaired 1 crashed tool call(s); quarantined 0 duplicate and 0 orphan terminal result(s).".to_string(),
958 cache_control: None,
959 }],
960 };
961
962 let cells = super::history_cells_from_message(&msg);
963
964 assert!(matches!(
965 cells.as_slice(),
966 [HistoryCell::System { content }] if content.starts_with("[tool_history_repair]")
967 ));
968 }
969
970 #[test]
971 fn user_history_hides_only_the_trailing_turn_metadata_block() {
972 let visible = "Explain this literal: <turn_meta>example</turn_meta>";
973 let turn_meta = concat!(
974 "<turn_meta>\n",
975 "Current local date: 2026-07-22\n",
976 "Input provenance: external_user\n",
977 "Input authority: external_current_turn\n",
978 "</turn_meta>",
979 );
980 let msg = Message {
981 role: "user".to_string(),
982 content: vec![
983 ContentBlock::Text {
984 text: visible.to_string(),
985 cache_control: None,
986 },
987 ContentBlock::Text {
988 text: turn_meta.to_string(),
989 cache_control: None,
990 },
991 ],
992 };
993
994 let cells = super::history_cells_from_message(&msg);
995
996 assert!(matches!(
997 cells.as_slice(),
998 [HistoryCell::User { content }] if content == visible
999 ));
1000
1001 let literal_only = Message {
1002 role: "user".to_string(),
1003 content: vec![ContentBlock::Text {
1004 text: "<turn_meta>user-authored example</turn_meta>".to_string(),
1005 cache_control: None,
1006 }],
1007 };
1008 let literal_cells = super::history_cells_from_message(&literal_only);
1009 assert!(matches!(
1010 literal_cells.as_slice(),
1011 [HistoryCell::User { content }]
1012 if content == "<turn_meta>user-authored example</turn_meta>"
1013 ));
1014 }
1015
1016 #[test]
1017 fn history_replays_update_plan_tool_use_as_plan_card() {
1018 let msg = Message {
1019 role: "assistant".to_string(),
1020 content: vec![ContentBlock::ToolUse {
1021 id: "plan-1".to_string(),
1022 name: "update_plan".to_string(),
1023 input: serde_json::json!({
1024 "objective": "Make Plan mode reviewable",
1025 "sources_used": ["gh issue view 2691"],
1026 "critical_files": ["crates/tui/src/tools/plan.rs"],
1027 "plan": [
1028 { "step": "render replay card", "status": "completed" }
1029 ]
1030 }),
1031 caller: None,
1032 }],
1033 };
1034
1035 let cells = super::history_cells_from_message(&msg);
1036 assert_eq!(cells.len(), 1);
1037 let HistoryCell::Tool(ToolCell::PlanUpdate(cell)) = &cells[0] else {
1038 panic!("expected update_plan replay cell");
1039 };
1040
1041 assert_eq!(cell.status, ToolStatus::Success);
1042 assert_eq!(
1043 cell.snapshot.objective.as_deref(),
1044 Some("Make Plan mode reviewable")
1045 );
1046 assert_eq!(cell.snapshot.sources_used, vec!["gh issue view 2691"]);
1047 assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
1048 }
1049
1050 #[test]
1051 fn render_thinking_collapsed_shows_details_affordance() {
1052 let lines = render_thinking(
1053 "Summary: First line\nSecond line\nThird line\nFourth line\nFifth line",
1054 80,
1055 false,
1056 Some(2.0),
1057 true,
1058 false,
1059 );
1060 let text = lines
1061 .iter()
1062 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
1063 .collect::<String>();
1064 assert!(text.contains("Ctrl+O:detail"), "{text}");
1065 assert!(text.contains("Space:expand"), "{text}");
1066 // Pin the actual header shape ("… reasoning done") — a bare
1067 // `contains("reasoning")` is already satisfied by the Ctrl+O
1068 // affordance line above and would never fail on its own.
1069 let header = lines
1070 .first()
1071 .map(|line| {
1072 line.spans
1073 .iter()
1074 .map(|span| span.content.as_ref())
1075 .collect::<String>()
1076 })
1077 .unwrap_or_default();
1078 assert!(
1079 header.starts_with(REASONING_OPENER),
1080 "header opens with the dotted opener: {header:?}"
1081 );
1082 assert!(
1083 header.contains("reasoning done"),
1084 "header carries the reasoning title and done status: {header:?}"
1085 );
1086 }
1087
1088 #[test]
1089 fn render_thinking_streaming_collapsed_shows_live_content() {
1090 // #861 RC4 / #1324: during a live thinking block in collapsed view,
1091 // the body must NOT be blanked out. Users want to watch the model
1092 // think; the previous behaviour stalled on a "thinking..." spinner
1093 // until ThinkingComplete fired.
1094 let lines = render_thinking(
1095 "Step 1: read the code\nStep 2: trace the call\nStep 3: form a hypothesis",
1096 80,
1097 true, // streaming
1098 None, // no duration yet
1099 true, // collapsed
1100 true, // low_motion (no cursor noise to grep)
1101 );
1102 let text = lines
1103 .iter()
1104 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
1105 .collect::<String>();
1106 assert!(
1107 text.contains("Step 3: form a hypothesis"),
1108 "the most recent thinking line must be visible during streaming, got: {text}"
1109 );
1110 // "thinking..." placeholder must not be the only thing rendered.
1111 assert!(
1112 !text.contains("thinking..."),
1113 "raw content present means the placeholder line should not be drawn, got: {text}"
1114 );
1115 }
1116
1117 #[test]
1118 fn render_hidden_streaming_thinking_shows_activity_without_content() {
1119 let cell = HistoryCell::Thinking {
1120 content: "private chain of thought that must not be shown".to_string(),
1121 streaming: true,
1122 duration_secs: None,
1123 };
1124
1125 let lines = cell.lines_with_options(
1126 80,
1127 TranscriptRenderOptions {
1128 show_thinking: false,
1129 low_motion: true,
1130 ..TranscriptRenderOptions::default()
1131 },
1132 );
1133 let text = lines_text(&lines);
1134
1135 assert!(
1136 text.contains("reasoning hidden"),
1137 "hidden live thinking should still show progress: {text}"
1138 );
1139 assert_eq!(
1140 lines.len(),
1141 1,
1142 "hidden reasoning should have one compact status treatment: {text}"
1143 );
1144 assert!(
1145 !text.contains("reasoning live") && !text.contains("model is still working"),
1146 "hidden reasoning should not stack duplicate live-state copy: {text}"
1147 );
1148 assert!(
1149 !text.contains("private chain of thought"),
1150 "hidden live thinking must not reveal content: {text}"
1151 );
1152 }
1153
1154 #[test]
1155 fn render_hidden_completed_thinking_stays_hidden() {
1156 let cell = HistoryCell::Thinking {
1157 content: "completed hidden reasoning".to_string(),
1158 streaming: false,
1159 duration_secs: Some(1.0),
1160 };
1161
1162 let lines = cell.lines_with_options(
1163 80,
1164 TranscriptRenderOptions {
1165 show_thinking: false,
1166 ..TranscriptRenderOptions::default()
1167 },
1168 );
1169
1170 assert!(
1171 lines.is_empty(),
1172 "completed hidden thinking should stay out of the transcript"
1173 );
1174 }
1175
1176 #[test]
1177 fn render_thinking_streaming_truncated_shows_continues_affordance() {
1178 // #861 RC4: when a streaming thinking block exceeds the line cap,
1179 // surface a live affordance pointing at Ctrl+O. The earlier code
1180 // suppressed the affordance unless `!streaming`.
1181 let long = (1..=16)
1182 .map(|i| format!("Reasoning line {i}"))
1183 .collect::<Vec<_>>()
1184 .join("\n");
1185 let lines = render_thinking(&long, 80, true, None, true, true);
1186 let text = lines
1187 .iter()
1188 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
1189 .collect::<String>();
1190 assert!(
1191 text.contains("Ctrl+O:more"),
1192 "streaming-truncation affordance missing, got: {text}"
1193 );
1194 // The most recent line must be the visible tail (head dropped).
1195 assert!(
1196 text.contains("Reasoning line 16"),
1197 "tail line missing, got: {text}"
1198 );
1199 assert!(
1200 !text.contains("Reasoning line 1\n"),
1201 "head should be clipped, got: {text}"
1202 );
1203 }
1204
1205 #[test]
1206 fn tool_lines_with_options_respects_low_motion_in_default_path() {
1207 // Use a 2× cycle offset so the animated frame lands on index 2,
1208 // which is maximally far from index 0. This avoids flaky failures on
1209 // platforms with coarse timer resolution (Windows ≈ 15.6 ms) and
1210 // gives several frame intervals of headroom before the index could
1211 // wrap back to 0.
1212 let started_at = Some(
1213 Instant::now()
1214 - Duration::from_millis(
1215 crate::tui::spinner::LIVE_MARKER_DELAY_MS + TOOL_STATUS_SYMBOL_MS * 2,
1216 ),
1217 );
1218 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
1219 command: "echo hi".to_string(),
1220 status: ToolStatus::Running,
1221 output: None,
1222 live_output: None,
1223 shell_task_id: None,
1224 owner_agent_id: None,
1225 owner_agent_name: None,
1226 started_at,
1227 duration_ms: None,
1228 stale_elapsed_since_output_ms: None,
1229 source: ExecSource::Assistant,
1230 interaction: None,
1231 output_summary: None,
1232 }));
1233
1234 let animated = cell.lines_with_options(80, TranscriptRenderOptions::default());
1235 let low_motion = cell.lines_with_options(
1236 80,
1237 TranscriptRenderOptions {
1238 low_motion: true,
1239 motion_mode: MotionMode::Reduced,
1240 ..TranscriptRenderOptions::default()
1241 },
1242 );
1243 let still = cell.lines_with_options(
1244 80,
1245 TranscriptRenderOptions {
1246 low_motion: true,
1247 motion_mode: MotionMode::Still,
1248 ..TranscriptRenderOptions::default()
1249 },
1250 );
1251
1252 // Index 0 is card-rail glyph (╭); the animated symbol is at index 1.
1253 let animated_symbol = animated[0].spans[1].content.trim();
1254 let low_motion_symbol = low_motion[0].spans[1].content.trim();
1255 let still_symbol = still[0].spans[1].content.trim();
1256
1257 // Reduced motion freezes at a filled, legible bubble rather than an
1258 // invisible blank braille cell.
1259 assert_eq!(low_motion_symbol, "⣤");
1260 assert_eq!(still_symbol, "›");
1261 // The animated path should be on a different frame (index 2).
1262 assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
1263 }
1264
1265 #[test]
1266 fn reduced_verify_marker_uses_the_shared_calm_glyph() {
1267 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1268 name: "run_verifiers".to_string(),
1269 status: ToolStatus::Running,
1270 input_summary: None,
1271 output: None,
1272 prompts: None,
1273 spillover_path: None,
1274 output_summary: None,
1275 is_diff: false,
1276 }));
1277 let lines = cell.lines_with_options(
1278 80,
1279 TranscriptRenderOptions {
1280 low_motion: true,
1281 motion_mode: MotionMode::Reduced,
1282 ..TranscriptRenderOptions::default()
1283 },
1284 );
1285
1286 assert_eq!(lines[0].spans[1].content.trim(), "⣤");
1287 }
1288
1289 #[test]
1290 fn still_fanout_marker_uses_the_shared_chevron() {
1291 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1292 name: "workflow".to_string(),
1293 status: ToolStatus::Running,
1294 input_summary: Some("action: run".to_string()),
1295 output: None,
1296 prompts: None,
1297 spillover_path: None,
1298 output_summary: None,
1299 is_diff: false,
1300 }));
1301 let lines = cell.lines_with_options(
1302 80,
1303 TranscriptRenderOptions {
1304 low_motion: true,
1305 motion_mode: MotionMode::Still,
1306 ..TranscriptRenderOptions::default()
1307 },
1308 );
1309
1310 assert_eq!(lines[0].spans[1].content.trim(), "›");
1311 assert_eq!(lines[0].spans[2].content.trim(), "⋮⋮");
1312 }
1313
1314 #[test]
1315 fn still_marker_rewrite_never_consumes_braille_tool_output() {
1316 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1317 name: "read_file".to_string(),
1318 status: ToolStatus::Running,
1319 input_summary: None,
1320 output: Some("⣿".to_string()),
1321 prompts: None,
1322 spillover_path: None,
1323 output_summary: None,
1324 is_diff: false,
1325 }));
1326 let lines = cell.lines_with_options(
1327 80,
1328 TranscriptRenderOptions {
1329 low_motion: true,
1330 motion_mode: MotionMode::Still,
1331 ..TranscriptRenderOptions::default()
1332 },
1333 );
1334
1335 assert_eq!(lines[0].spans[1].content.trim(), "›");
1336 assert!(
1337 lines
1338 .iter()
1339 .flat_map(|line| line.spans.iter())
1340 .any(|span| span.content.as_ref() == "⣿"),
1341 "tool output must survive the typed-header marker pass: {lines:?}"
1342 );
1343 }
1344
1345 // === Speaker glyph tests (v0.6.6 UI redesign) ===
1346 //
1347 // The literal "Assistant" / "You" labels are replaced by the calmer
1348 // bullet/bar glyphs (`●` / `▎`). Only the assistant glyph pulses, and
1349 // only while the cell is streaming — finished turns sit at the source
1350 // sky color so the transcript reads as solid history.
1351
1352 #[test]
1353 fn user_cell_renders_with_bar_glyph_not_literal_label() {
1354 let cell = HistoryCell::User {
1355 content: "hello".to_string(),
1356 };
1357 let lines = cell.lines(80);
1358 let head = &lines[0];
1359 assert_eq!(head.spans[0].content.as_ref(), USER_GLYPH);
1360 assert_eq!(head.spans[0].style.fg, Some(palette::USER_BODY));
1361 assert_eq!(head.style.bg, Some(palette::SURFACE_ELEVATED));
1362 assert_eq!(head.width(), 80);
1363 assert!(
1364 head.spans.iter().any(|span| span.style.bg.is_none()),
1365 "content spans should keep their own styles and inherit the line background"
1366 );
1367 // No "You" literal anywhere in the rendered head line.
1368 let visible: String = head
1369 .spans
1370 .iter()
1371 .map(|s| s.content.as_ref())
1372 .collect::<String>();
1373 assert!(!visible.contains("You"), "user label dropped: {visible:?}");
1374 assert!(visible.contains("hello"));
1375 }
1376
1377 #[test]
1378 fn user_cell_wraps_fill_transcript_rows() {
1379 let cell = HistoryCell::User {
1380 content: "hello world this prompt wraps onto multiple transcript lines".to_string(),
1381 };
1382 let lines = cell.lines(18);
1383
1384 assert!(lines.len() > 1, "expected wrapped user message");
1385 assert!(
1386 lines
1387 .iter()
1388 .all(|line| line.style.bg == Some(palette::SURFACE_ELEVATED)),
1389 "wrapped user message lines should keep the highlighted block background"
1390 );
1391 assert!(
1392 lines.iter().all(|line| line.width() == 18),
1393 "wrapped user message lines should fill the rendered row width"
1394 );
1395 }
1396
1397 #[test]
1398 fn user_transcript_lines_do_not_append_visual_padding() {
1399 let cell = HistoryCell::User {
1400 content: "hello".to_string(),
1401 };
1402 let lines = cell.transcript_lines(80);
1403 let head = &lines[0];
1404 let visible: String = head.spans.iter().map(|s| s.content.as_ref()).collect();
1405
1406 assert_eq!(visible, format!("{USER_GLYPH} hello"));
1407 assert!(head.width() < 80);
1408 assert_eq!(head.style.bg, None);
1409 }
1410
1411 #[test]
1412 fn user_cell_renders_plain_text_without_markdown_interpretation() {
1413 let cell = HistoryCell::User {
1414 content: " # heading\n- item\n \nhello world".to_string(),
1415 };
1416 let visible: Vec<String> = cell.lines(80).iter().map(line_text).collect();
1417
1418 assert_eq!(visible[0].trim_end(), format!("{USER_GLYPH} # heading"));
1419 assert!(
1420 visible[1].trim_end().ends_with("- item"),
1421 "dash-prefixed text must remain literal: {visible:?}"
1422 );
1423 assert!(
1424 visible[2].ends_with(" "),
1425 "whitespace-only lines must survive: {visible:?}"
1426 );
1427 assert!(
1428 visible[3].trim_end().ends_with("hello world"),
1429 "internal spacing must remain literal: {visible:?}"
1430 );
1431 assert!(
1432 !visible.iter().any(|line| line.contains('\u{2500}')),
1433 "plain user heading must not add markdown heading rule: {visible:?}"
1434 );
1435 }
1436
1437 #[test]
1438 fn assistant_cell_renders_with_bullet_glyph_not_literal_label() {
1439 let cell = HistoryCell::Assistant {
1440 content: "ready".to_string(),
1441 streaming: false,
1442 };
1443 let lines = cell.lines(80);
1444 let head = &lines[0];
1445 assert_eq!(head.spans[0].content.as_ref(), ASSISTANT_GLYPH);
1446 let visible: String = head
1447 .spans
1448 .iter()
1449 .map(|s| s.content.as_ref())
1450 .collect::<String>();
1451 assert!(
1452 !visible.contains("Assistant"),
1453 "assistant label dropped: {visible:?}"
1454 );
1455 assert!(visible.contains("ready"));
1456 assert_ne!(head.style.bg, Some(palette::SURFACE_ELEVATED));
1457 }
1458
1459 #[test]
1460 fn copy_metadata_strips_tool_receipt_chrome_but_keeps_text() {
1461 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
1462 command: "printf 'receipt'".to_string(),
1463 status: ToolStatus::Success,
1464 output: Some("receipt".to_string()),
1465 live_output: None,
1466 shell_task_id: None,
1467 owner_agent_id: None,
1468 owner_agent_name: None,
1469 started_at: None,
1470 duration_ms: None,
1471 stale_elapsed_since_output_ms: None,
1472 source: ExecSource::Assistant,
1473 interaction: None,
1474 output_summary: None,
1475 }));
1476 let rendered = cell.lines_with_copy_metadata(80, TranscriptRenderOptions::default());
1477 let header = rendered.first().expect("tool receipt header");
1478 assert!(
1479 header.copy_prefix_width >= 4,
1480 "missing status/family chrome width"
1481 );
1482 assert!(
1483 header
1484 .line
1485 .spans
1486 .iter()
1487 .any(|span| span.content.contains("receipt")),
1488 "receipt text must remain in the rendered copy source"
1489 );
1490 let header_text = line_to_plain(&ratatui::text::Line::from(
1491 header
1492 .line
1493 .spans
1494 .iter()
1495 .skip(1)
1496 .cloned()
1497 .collect::<Vec<_>>(),
1498 ));
1499 let copied = slice_text(
1500 &header_text,
1501 header.copy_prefix_width,
1502 text_display_width(&header_text),
1503 );
1504 assert!(
1505 !copied.contains('✓'),
1506 "status chrome leaked into copy: {copied:?}"
1507 );
1508 assert!(
1509 !copied.contains('●'),
1510 "family chrome leaked into copy: {copied:?}"
1511 );
1512 assert!(
1513 copied.contains("run done"),
1514 "receipt text was clipped: {copied:?}"
1515 );
1516 }
1517
1518 #[test]
1519 fn copy_metadata_tracks_wrapped_assistant_code_prefix_in_display_columns() {
1520 let cell = HistoryCell::Assistant {
1521 content: "```text\n 中文 = 1\n```".to_string(),
1522 streaming: false,
1523 };
1524 let rendered = cell.lines_with_copy_metadata(24, TranscriptRenderOptions::default());
1525 let code_line = rendered
1526 .iter()
1527 .find(|line| {
1528 line.line
1529 .spans
1530 .iter()
1531 .any(|span| span.content.contains("中文"))
1532 })
1533 .expect("wrapped fenced code line");
1534 assert_eq!(
1535 code_line.copy_prefix_width, 2,
1536 "code continuation prefix uses the role marker's two display columns"
1537 );
1538 let code = line_to_plain(&code_line.line);
1539 let copied = slice_text(
1540 &code,
1541 code_line.copy_prefix_width,
1542 text_display_width(&code),
1543 );
1544 assert!(
1545 copied.contains("中文 = 1"),
1546 "code text was clipped: {copied:?}"
1547 );
1548 assert!(
1549 copied.starts_with(" 中文"),
1550 "code indentation or visual prefix was wrong: {copied:?}"
1551 );
1552 }
1553
1554 #[test]
1555 fn copy_metadata_keeps_fenced_code_indentation_after_prefix_removal() {
1556 let cell = HistoryCell::Assistant {
1557 content: "```rust\n let answer = 42;\n```".to_string(),
1558 streaming: false,
1559 };
1560 let rendered = cell.lines_with_copy_metadata(40, TranscriptRenderOptions::default());
1561 let code_line = rendered
1562 .iter()
1563 .find(|line| {
1564 line.line
1565 .spans
1566 .iter()
1567 .any(|span| span.content.contains("answer"))
1568 })
1569 .expect("fenced code body");
1570 let text = line_to_plain(&code_line.line);
1571 let content = slice_text(
1572 &text,
1573 code_line.copy_prefix_width,
1574 text_display_width(&text),
1575 );
1576 assert!(
1577 content.contains(" let answer = 42;"),
1578 "code indentation was not preserved: {content:?}"
1579 );
1580 for glyph in ['╎', '▎', '●', '│', '┃'] {
1581 assert!(
1582 !content.contains(glyph),
1583 "decorative glyph leaked: {content:?}"
1584 );
1585 }
1586 }
1587
1588 #[test]
1589 fn whitespace_only_assistant_cell_renders_nothing() {
1590 // Regression: a stray newline/space streamed between reasoning and a
1591 // tool call produced a whitespace-only Assistant cell that rendered as
1592 // a bare, orphaned role glyph — the "blue dot with nothing after it"
1593 // artifact. It must collapse to zero lines instead.
1594 for content in ["", " ", "\n", "\n\n", " \t \n"] {
1595 for streaming in [false, true] {
1596 let cell = HistoryCell::Assistant {
1597 content: content.to_string(),
1598 streaming,
1599 };
1600 assert!(
1601 cell.lines(80).is_empty(),
1602 "whitespace-only assistant content {content:?} (streaming={streaming}) \
1603 must render no lines",
1604 );
1605 }
1606 }
1607
1608 // Sanity: real prose still renders the role glyph as its first span.
1609 let cell = HistoryCell::Assistant {
1610 content: "hi".to_string(),
1611 streaming: false,
1612 };
1613 assert_eq!(
1614 cell.lines(80)[0].spans[0].content.as_ref(),
1615 ASSISTANT_GLYPH,
1616 "non-empty assistant content must still render the role glyph",
1617 );
1618 }
1619
1620 #[test]
1621 fn assistant_cell_still_renders_markdown() {
1622 let cell = HistoryCell::Assistant {
1623 content: "# Heading\n\n- item".to_string(),
1624 streaming: false,
1625 };
1626 let visible: Vec<String> = cell.lines(80).iter().map(line_text).collect();
1627
1628 assert!(
1629 visible[0].contains("Heading"),
1630 "assistant heading text should render: {visible:?}"
1631 );
1632 assert!(
1633 !visible[0].contains("# Heading"),
1634 "assistant heading should still be parsed as markdown: {visible:?}"
1635 );
1636 assert!(
1637 visible.iter().any(|line| line.contains('\u{2500}')),
1638 "assistant h1 markdown should still add a heading rule: {visible:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn assistant_code_block_lines_do_not_get_transcript_rail() {
1644 let cell = HistoryCell::Assistant {
1645 content: "SQL:\n```sql\nSELECT\nFROM customers\n```".to_string(),
1646 streaming: false,
1647 };
1648 let visible: Vec<String> = cell
1649 .lines(80)
1650 .iter()
1651 .map(|line| {
1652 line.spans
1653 .iter()
1654 .map(|span| span.content.as_ref())
1655 .collect::<String>()
1656 })
1657 .collect();
1658
1659 assert_eq!(visible[0], format!("{ASSISTANT_GLYPH} SQL:"));
1660 for line in visible
1661 .iter()
1662 .filter(|line| line.contains("SELECT") || line.contains("FROM customers"))
1663 {
1664 assert!(
1665 !line.contains('\u{258F}'),
1666 "code block line should not inherit the transcript rail: {line:?}"
1667 );
1668 }
1669 }
1670
1671 /// Issue #1212 repro: a multi-line SQL fence rendered after a short
1672 /// intro paragraph. Every code-block line — not just the first or last —
1673 /// must avoid the `▏` rail.
1674 #[test]
1675 fn assistant_long_code_block_keeps_every_line_rail_free() {
1676 let cell = HistoryCell::Assistant {
1677 content: "Here's the query:\n```sql\nSELECT\n c.customer_id,\n c.name,\n COUNT(o.order_id) AS order_count\nFROM customers c\nJOIN orders o ON c.customer_id = o.customer_id;\n```".to_string(),
1678 streaming: false,
1679 };
1680 let visible: Vec<String> = cell
1681 .lines(80)
1682 .iter()
1683 .map(|line| {
1684 line.spans
1685 .iter()
1686 .map(|span| span.content.as_ref())
1687 .collect::<String>()
1688 })
1689 .collect();
1690
1691 let code_markers = ["SELECT", "customer_id", "name,", "COUNT", "FROM", "JOIN"];
1692 for marker in code_markers {
1693 let line = visible
1694 .iter()
1695 .find(|line| line.contains(marker))
1696 .unwrap_or_else(|| panic!("expected code line containing {marker:?}"));
1697 assert!(
1698 !line.contains('\u{258F}'),
1699 "code block line containing {marker:?} must not have the transcript rail: {line:?}"
1700 );
1701 }
1702 }
1703
1704 /// Edge case: a blank line inside a fence is still a code line; it must
1705 /// not regress to the rail because the empty body falls through a
1706 /// different wrap branch.
1707 #[test]
1708 fn assistant_code_block_blank_line_keeps_no_rail() {
1709 let cell = HistoryCell::Assistant {
1710 content: "```\nfn one() {}\n\nfn two() {}\n```".to_string(),
1711 streaming: false,
1712 };
1713 for line in cell.lines(80).iter().skip(1) {
1714 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1715 assert!(
1716 !text.contains('\u{258F}'),
1717 "fence body line must stay rail-free: {text:?}"
1718 );
1719 }
1720 }
1721
1722 /// Wrapped code lines (a single source line longer than the viewport)
1723 /// emit multiple rendered lines from one `Block::Code`. None of them
1724 /// should leak the rail.
1725 #[test]
1726 fn assistant_wrapped_code_lines_keep_no_rail() {
1727 let long = "let x = ".to_string() + &"abcdef ".repeat(40);
1728 let content = format!("```\n{long}\n```");
1729 let cell = HistoryCell::Assistant {
1730 content,
1731 streaming: false,
1732 };
1733 for line in cell.lines(40).iter().skip(1) {
1734 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1735 assert!(
1736 !text.contains('\u{258F}'),
1737 "wrapped code line must stay rail-free: {text:?}"
1738 );
1739 }
1740 }
1741
1742 #[test]
1743 fn assistant_glyph_holds_full_brightness_when_idle() {
1744 // Idle (streaming=false) and low_motion both pin the colour to the
1745 // source sky — pulse only fires when actively streaming.
1746 let idle = assistant_label_style_for(false, false);
1747 let low_motion = assistant_label_style_for(true, true);
1748 assert_eq!(idle.fg, Some(palette::WHALE_INFO));
1749 assert_eq!(low_motion.fg, Some(palette::WHALE_INFO));
1750 }
1751
1752 #[test]
1753 fn assistant_glyph_pulses_when_streaming_and_motion_allowed() {
1754 // The streaming path runs through `pulse_brightness`, which yields
1755 // an RGB colour scaled within 30%..100% of the source. Sample twice
1756 // — at least one of the samples must fall below 100% brightness, or
1757 // the test wouldn't be exercising the pulse at all. (We can't pin
1758 // the value because the function reads SystemTime::now().)
1759 use ratatui::style::Color;
1760 let mut saw_dimmed = false;
1761 for _ in 0..50 {
1762 if let Some(Color::Rgb(_, _, b)) = assistant_label_style_for(true, false).fg {
1763 let Color::Rgb(_, _, src_b) = palette::WHALE_INFO else {
1764 panic!("WHALE_INFO must be RGB");
1765 };
1766 if b < src_b {
1767 saw_dimmed = true;
1768 break;
1769 }
1770 }
1771 std::thread::sleep(std::time::Duration::from_millis(20));
1772 }
1773 assert!(
1774 saw_dimmed,
1775 "expected the streaming pulse to dip below source brightness at least once",
1776 );
1777 }
1778
1779 // === Tool-card verb-glyph tests (v0.6.6 UI redesign) ===
1780
1781 #[test]
1782 fn exec_cell_header_uses_run_verb_glyph_and_label() {
1783 let cell = ExecCell {
1784 command: "ls".to_string(),
1785 status: ToolStatus::Success,
1786 output: Some("a\nb\n".to_string()),
1787 live_output: None,
1788 shell_task_id: None,
1789 owner_agent_id: None,
1790 owner_agent_name: None,
1791 started_at: None,
1792 duration_ms: Some(10),
1793 stale_elapsed_since_output_ms: None,
1794 source: ExecSource::Assistant,
1795 interaction: None,
1796 output_summary: None,
1797 };
1798 let header = &cell.lines_with_motion(80, true)[0];
1799 let visible: String = header
1800 .spans
1801 .iter()
1802 .map(|s| s.content.as_ref())
1803 .collect::<String>();
1804 assert!(
1805 visible.contains('\u{25B6}'),
1806 "Run glyph `▶` present: {visible:?}"
1807 );
1808 assert!(visible.contains(" run "), "verb label `run`: {visible:?}");
1809 // Old literal title must be gone.
1810 assert!(
1811 !visible.contains("Shell"),
1812 "old `Shell` literal is gone: {visible:?}"
1813 );
1814 }
1815
1816 #[test]
1817 fn exec_cell_header_includes_compact_command_summary() {
1818 let cell = ExecCell {
1819 command: "cargo test --workspace --all-features".to_string(),
1820 status: ToolStatus::Running,
1821 output: None,
1822 live_output: None,
1823 shell_task_id: None,
1824 owner_agent_id: None,
1825 owner_agent_name: None,
1826 started_at: None,
1827 duration_ms: None,
1828 stale_elapsed_since_output_ms: None,
1829 source: ExecSource::Assistant,
1830 interaction: None,
1831 output_summary: None,
1832 };
1833
1834 let header = &cell.lines_with_motion(80, true)[0];
1835 let visible: String = header
1836 .spans
1837 .iter()
1838 .map(|s| s.content.as_ref())
1839 .collect::<String>();
1840 assert!(visible.contains("run running"));
1841 assert!(
1842 visible.contains("Ctrl+B"),
1843 "foreground wait header should expose Ctrl+B hint, not command: {visible:?}"
1844 );
1845 assert!(
1846 !visible.contains("cargo test"),
1847 "foreground wait live header must not repeat command target: {visible:?}"
1848 );
1849
1850 let transcript_visible: String = HistoryCell::Tool(ToolCell::Exec(ExecCell {
1851 command: "cargo test --workspace --all-features".to_string(),
1852 status: ToolStatus::Running,
1853 output: None,
1854 live_output: None,
1855 shell_task_id: None,
1856 owner_agent_id: None,
1857 owner_agent_name: None,
1858 started_at: None,
1859 duration_ms: None,
1860 stale_elapsed_since_output_ms: None,
1861 source: ExecSource::Assistant,
1862 interaction: None,
1863 output_summary: None,
1864 }))
1865 .transcript_lines(80)[0]
1866 .spans
1867 .iter()
1868 .map(|s| s.content.as_ref())
1869 .collect::<String>();
1870 assert!(
1871 transcript_visible.contains("Ctrl+B"),
1872 "transcript compact wait should expose Ctrl+B hint: {transcript_visible:?}"
1873 );
1874 assert!(
1875 !transcript_visible.contains("cargo test --workspace --all-features"),
1876 "transcript compact wait must not repeat command target: {transcript_visible:?}"
1877 );
1878 }
1879
1880 #[test]
1881 fn generic_tool_cell_picks_family_from_tool_name() {
1882 // Use an inspection call so the compact Delegate header still renders;
1883 // spawn cards are suppressed entirely (#4133).
1884 let cell = GenericToolCell {
1885 name: "agent".to_string(),
1886 status: ToolStatus::Running,
1887 input_summary: Some("action: peek foo".to_string()),
1888 output: None,
1889 prompts: None,
1890 spillover_path: None,
1891 output_summary: None,
1892 is_diff: false,
1893 };
1894 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
1895 assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}");
1896 let header_visible: String = lines[0]
1897 .spans
1898 .iter()
1899 .map(|s| s.content.as_ref())
1900 .collect::<String>();
1901 // agent → Delegate family (◐ delegate).
1902 assert!(
1903 header_visible.contains('\u{25D0}'),
1904 "Delegate glyph `◐`: {header_visible:?}"
1905 );
1906 assert!(
1907 header_visible.contains(" delegate "),
1908 "verb label `delegate`: {header_visible:?}"
1909 );
1910 }
1911
1912 #[test]
1913 fn generic_tool_cell_renders_rlm_with_rlm_label_not_swarm() {
1914 let cell = GenericToolCell {
1915 name: "rlm".to_string(),
1916 status: ToolStatus::Running,
1917 input_summary: Some("task: compare source trees".to_string()),
1918 output: None,
1919 prompts: None,
1920 spillover_path: None,
1921 output_summary: None,
1922 is_diff: false,
1923 };
1924 let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
1925 let header_visible: String = lines[0]
1926 .spans
1927 .iter()
1928 .map(|s| s.content.as_ref())
1929 .collect::<String>();
1930
1931 assert!(
1932 header_visible.contains(" rlm "),
1933 "RLM card should identify RLM work: {header_visible:?}"
1934 );
1935 assert!(
1936 !header_visible.contains("swarm"),
1937 "RLM card must not use removed swarm wording: {header_visible:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn exploring_card_search_reads_as_find_not_read() {
1943 // #4145: a completed grep grouped under the exploration card must not
1944 // render `read done · Searching …`; the header verb has to agree with the
1945 // `Searching for …` label.
1946 let cell = super::ExploringCell {
1947 entries: vec![super::ExploringEntry {
1948 label: "Searching for `TranscriptScroll`".to_string(),
1949 status: ToolStatus::Success,
1950 }],
1951 };
1952 let header: String = cell.lines_with_motion(80, true)[0]
1953 .spans
1954 .iter()
1955 .map(|s| s.content.as_ref())
1956 .collect::<String>();
1957 assert!(
1958 header.contains("find done"),
1959 "search card header should read `find done`: {header:?}"
1960 );
1961 assert!(
1962 !header.contains("read done"),
1963 "search card must not pair `read done` with a search label: {header:?}"
1964 );
1965 assert!(
1966 header.contains("Searching for `TranscriptScroll`"),
1967 "search label should remain intact: {header:?}"
1968 );
1969 }
1970
1971 #[test]
1972 fn exploring_card_read_keeps_read_verb() {
1973 // The fix only re-verbs search-only cards — a plain read stays `read`.
1974 let cell = super::ExploringCell {
1975 entries: vec![super::ExploringEntry {
1976 label: "Reading src/foo.rs".to_string(),
1977 status: ToolStatus::Success,
1978 }],
1979 };
1980 let header: String = cell.lines_with_motion(80, true)[0]
1981 .spans
1982 .iter()
1983 .map(|s| s.content.as_ref())
1984 .collect::<String>();
1985 assert!(
1986 header.contains("read done"),
1987 "read card header should read `read done`: {header:?}"
1988 );
1989 }
1990
1991 // === Reasoning treatment tests (v0.6.6 UI redesign) ===
1992
1993 #[test]
1994 fn render_thinking_uses_dotted_opener_in_header() {
1995 let lines = render_thinking("Step one\nStep two", 80, false, Some(2.0), false, true);
1996 let header = &lines[0];
1997 // First span carries `…` followed by a space.
1998 assert!(
1999 header.spans[0].content.starts_with(REASONING_OPENER),
2000 "header opener: {:?}",
2001 header.spans[0].content
2002 );
2003 }
2004
2005 #[test]
2006 fn render_thinking_body_lines_use_dashed_rail_and_italic() {
2007 let lines = render_thinking(
2008 "concrete reasoning content",
2009 80,
2010 /*streaming*/ false,
2011 Some(1.0),
2012 /*collapsed*/ false,
2013 /*low_motion*/ true,
2014 );
2015 // Header is index 0; first body line is index 1.
2016 assert!(lines.len() >= 2, "expected at least one body line");
2017 let body = &lines[1];
2018 assert_eq!(
2019 body.spans[0].content.as_ref(),
2020 REASONING_RAIL,
2021 "body rail must be the dashed `╎ ` glyph"
2022 );
2023 // The body span should carry italic.
2024 let italic_seen = body
2025 .spans
2026 .iter()
2027 .skip(1)
2028 .any(|span| span.style.add_modifier.contains(Modifier::ITALIC));
2029 assert!(italic_seen, "body content should carry italic modifier");
2030 }
2031
2032 #[test]
2033 fn render_thinking_can_omit_background_highlight() {
2034 let lines = render_thinking_with_highlight(
2035 "reasoning without a filled surface",
2036 80,
2037 false,
2038 Some(1.0),
2039 false,
2040 true,
2041 false,
2042 );
2043
2044 assert!(
2045 lines
2046 .iter()
2047 .flat_map(|line| line.spans.iter())
2048 .all(|span| span.style.bg.is_none()),
2049 "disabled thinking highlight must not apply a background to any span"
2050 );
2051 }
2052
2053 #[test]
2054 fn render_thinking_streaming_appends_cursor_when_motion_allowed() {
2055 let lines = render_thinking(
2056 "ongoing reasoning...",
2057 80,
2058 /*streaming*/ true,
2059 None,
2060 /*collapsed*/ false,
2061 /*low_motion*/ false,
2062 );
2063 // Last line is the most recent body line — cursor lives there.
2064 let last = lines.last().expect("body line present");
2065 let last_span = last.spans.last().expect("trailing span present");
2066 assert!(
2067 last_span.content.contains(REASONING_CURSOR),
2068 "expected trailing cursor `▎` on last streaming body line, got {:?}",
2069 last_span.content
2070 );
2071 }
2072
2073 #[test]
2074 fn render_thinking_streaming_omits_cursor_when_low_motion() {
2075 let lines = render_thinking(
2076 "ongoing reasoning...",
2077 80,
2078 /*streaming*/ true,
2079 None,
2080 /*collapsed*/ false,
2081 /*low_motion*/ true,
2082 );
2083 let last = lines.last().expect("body line present");
2084 let visible: String = last
2085 .spans
2086 .iter()
2087 .map(|s| s.content.as_ref())
2088 .collect::<String>();
2089 assert!(
2090 !visible.contains(REASONING_CURSOR),
2091 "low_motion must suppress the streaming cursor: {visible:?}"
2092 );
2093 }
2094
2095 // === Theme parity tests ===
2096 //
2097 // These lock the visible color/style choices for one plan cell and one
2098 // tool cell against `deepseek_theme::Theme::dark()`. The render path is
2099 // unchanged in shape; the assertions just guarantee a future skin swap
2100 // (or accidental drift) is caught here instead of at runtime.
2101
2102 #[test]
2103 fn plan_update_cell_renders_with_dark_theme_tokens() {
2104 let theme = Theme::dark();
2105 let cell = PlanUpdateCell {
2106 snapshot: PlanSnapshot {
2107 items: vec![
2108 crate::tools::plan::PlanItemArg {
2109 step: "scan repo".to_string(),
2110 status: StepStatus::Completed,
2111 },
2112 crate::tools::plan::PlanItemArg {
2113 step: "extract theme".to_string(),
2114 status: StepStatus::InProgress,
2115 },
2116 crate::tools::plan::PlanItemArg {
2117 step: "land tests".to_string(),
2118 status: StepStatus::Pending,
2119 },
2120 ],
2121 ..PlanSnapshot::default()
2122 },
2123 status: ToolStatus::Running,
2124 };
2125
2126 let lines = cell.lines_with_motion(80, true);
2127
2128 // Header: "<spinner> <family-glyph> <verb> <state>" (v0.6.6 layout).
2129 // PlanUpdate has no canonical family yet, so it falls into the
2130 // Generic bullet glyph + "tool" verb. The shape and colour wiring
2131 // is what matters for the theme parity; the verb text moves with
2132 // the redesign.
2133 // PlanUpdate does NOT use card-rail wrapping (separate render path).
2134 let header = &lines[0];
2135 let symbol_span = &header.spans[0];
2136 let glyph_span = &header.spans[1];
2137 let title_span = &header.spans[2];
2138 let state_span = &header.spans[4];
2139
2140 assert_eq!(
2141 symbol_span.style.fg,
2142 Some(theme.tool_running_accent),
2143 "running header symbol should use the dark theme running accent"
2144 );
2145 assert_eq!(
2146 glyph_span.style.fg,
2147 Some(theme.tool_running_accent),
2148 "family glyph rides the same status colour as the spinner"
2149 );
2150 assert_eq!(
2151 title_span.content.as_ref(),
2152 "tool",
2153 "PlanUpdate routes to Generic family → 'tool' verb",
2154 );
2155 assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
2156 assert!(
2157 title_span.style.add_modifier.contains(Modifier::BOLD),
2158 "tool title should be bold"
2159 );
2160 assert_eq!(
2161 state_span.content.as_ref(),
2162 "running",
2163 "running PlanUpdate should label state as 'running'"
2164 );
2165 assert_eq!(state_span.style.fg, Some(theme.tool_running_accent));
2166
2167 // Each step row: ["▏ ", "<marker>:", " ", "<step>"]
2168 let step_line = &lines[1];
2169 let label_span = &step_line.spans[1];
2170 let value_span = &step_line.spans[3];
2171 assert_eq!(
2172 label_span.style.fg,
2173 Some(theme.tool_label_color),
2174 "step label should use theme.tool_label_color"
2175 );
2176 assert_eq!(
2177 value_span.style.fg,
2178 Some(theme.tool_value_color),
2179 "step value should use theme.tool_value_color"
2180 );
2181
2182 // Plain content stays identical so visible output does not move.
2183 let visible = lines
2184 .iter()
2185 .map(|l| {
2186 l.spans
2187 .iter()
2188 .map(|s| s.content.as_ref())
2189 .collect::<String>()
2190 })
2191 .collect::<Vec<_>>();
2192 assert_eq!(visible[1].trim_end(), "▏ done: scan repo");
2193 assert_eq!(visible[2].trim_end(), "▏ live: extract theme");
2194 assert_eq!(visible[3].trim_end(), "▏ next: land tests");
2195 }
2196
2197 #[test]
2198 fn plan_update_cell_renders_rich_artifact_metadata() {
2199 let cell = PlanUpdateCell {
2200 snapshot: PlanSnapshot {
2201 objective: Some("Make Plan mode reviewable".to_string()),
2202 context_summary: Some("Grounded in issue #2691".to_string()),
2203 sources_used: vec!["gh issue view 2691".to_string()],
2204 critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()],
2205 constraints: vec!["Keep To-do primary".to_string()],
2206 recommended_approach: Some(
2207 "Enrich update_plan without breaking legacy calls".to_string(),
2208 ),
2209 verification_plan: Some("Run focused renderer tests".to_string()),
2210 risks_and_unknowns: Some("Metadata-only plans can disappear".to_string()),
2211 handoff_packet: Some("Next agent should inspect relay output".to_string()),
2212 items: vec![crate::tools::plan::PlanItemArg {
2213 step: "Render artifact sections".to_string(),
2214 status: StepStatus::InProgress,
2215 }],
2216 ..PlanSnapshot::default()
2217 },
2218 status: ToolStatus::Success,
2219 };
2220
2221 let visible = cell
2222 .lines_with_motion(120, true)
2223 .into_iter()
2224 .map(|line| {
2225 line.spans
2226 .into_iter()
2227 .map(|span| span.content.into_owned())
2228 .collect::<String>()
2229 })
2230 .collect::<Vec<_>>()
2231 .join("\n");
2232
2233 assert!(visible.contains("objective:"));
2234 assert!(visible.contains("Make Plan mode reviewable"));
2235 assert!(visible.contains("source:"));
2236 assert!(visible.contains("gh issue view 2691"));
2237 assert!(visible.contains("file:"));
2238 assert!(visible.contains("verify:"));
2239 assert!(visible.contains("handoff:"));
2240 assert!(visible.contains("Render artifact sections"));
2241 }
2242
2243 #[test]
2244 fn exec_cell_failed_status_renders_with_dark_theme_tokens() {
2245 let theme = Theme::dark();
2246 let cell = ExecCell {
2247 command: "false".to_string(),
2248 status: ToolStatus::Failed,
2249 output: Some("boom".to_string()),
2250 live_output: None,
2251 shell_task_id: None,
2252 owner_agent_id: None,
2253 owner_agent_name: None,
2254 started_at: None,
2255 duration_ms: Some(42),
2256 stale_elapsed_since_output_ms: None,
2257 source: ExecSource::Assistant,
2258 interaction: None,
2259 output_summary: None,
2260 };
2261
2262 let lines = cell.lines_with_motion(80, true);
2263
2264 let header = &lines[0];
2265 let symbol_span = &header.spans[1];
2266 let glyph_span = &header.spans[2];
2267 let title_span = &header.spans[3];
2268 let state_span = &header.spans[5];
2269
2270 assert_eq!(
2271 symbol_span.style.fg,
2272 Some(theme.tool_failed_accent),
2273 "failed exec header symbol should use the dark theme failed accent"
2274 );
2275 // ExecCell is family Run → glyph `▶ ` and verb `run`.
2276 assert!(
2277 glyph_span.content.starts_with('\u{25B6}'),
2278 "Run family glyph: {:?}",
2279 glyph_span.content
2280 );
2281 assert_eq!(
2282 title_span.content.as_ref(),
2283 "run",
2284 "ExecCell routes to Run family → 'run' verb",
2285 );
2286 assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
2287 assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
2288 assert_eq!(state_span.content.as_ref(), "issue");
2289 assert_eq!(state_span.style.fg, Some(theme.tool_failed_accent));
2290 }
2291
2292 // === display_lines (lines_with_options) vs transcript_lines parity ===
2293 //
2294 // These lock the contract for CX#8: live view keeps reasoning compact
2295 // and caps tool output, transcript view shows the full body. Completed
2296 // reasoning without an explicit Summary stays out of the main flow so it
2297 // cannot masquerade as user text.
2298
2299 fn line_text(line: &ratatui::text::Line<'static>) -> String {
2300 line.spans
2301 .iter()
2302 .map(|span| span.content.as_ref())
2303 .collect()
2304 }
2305
2306 fn lines_text(lines: &[ratatui::text::Line<'static>]) -> String {
2307 lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
2308 }
2309
2310 #[test]
2311 fn exec_cell_renders_live_shell_output_before_final_output() {
2312 let cell = ExecCell {
2313 command: "cargo test".to_string(),
2314 status: ToolStatus::Running,
2315 output: None,
2316 live_output: Some("running line 1\nrunning line 2".to_string()),
2317 shell_task_id: Some("shell_live".to_string()),
2318 owner_agent_id: None,
2319 owner_agent_name: None,
2320 started_at: None,
2321 duration_ms: None,
2322 stale_elapsed_since_output_ms: None,
2323 source: ExecSource::Assistant,
2324 interaction: None,
2325 output_summary: None,
2326 };
2327
2328 let live_text = lines_text(&cell.lines_with_motion(80, true));
2329 assert!(
2330 !live_text.contains("running line 1"),
2331 "foreground shell live output belongs in sidebar/jobs, not main transcript: {live_text}"
2332 );
2333 assert!(
2334 live_text.contains("Ctrl+B"),
2335 "compact foreground wait must keep Ctrl+B hint: {live_text}"
2336 );
2337 assert!(!live_text.contains("command:"));
2338 assert!(!live_text.contains("Ctrl+B backgrounds this command"));
2339 assert!(!live_text.contains("Ctrl+B moves this shell wait to /jobs"));
2340
2341 let transcript_text = lines_text(&HistoryCell::Tool(ToolCell::Exec(cell)).transcript_lines(80));
2342 assert!(
2343 !transcript_text.contains("running line 1"),
2344 "foreground shell live output belongs in sidebar/jobs, not transcript: {transcript_text}"
2345 );
2346 assert!(!transcript_text.contains("command:"));
2347 assert!(transcript_text.contains("Ctrl+B"));
2348 }
2349
2350 #[test]
2351 fn exec_cell_prefers_final_output_over_live_shell_tail() {
2352 let cell = ExecCell {
2353 command: "cargo test".to_string(),
2354 status: ToolStatus::Success,
2355 output: Some("final output".to_string()),
2356 live_output: Some("stale live tail".to_string()),
2357 shell_task_id: Some("shell_live".to_string()),
2358 owner_agent_id: None,
2359 owner_agent_name: None,
2360 started_at: None,
2361 duration_ms: None,
2362 stale_elapsed_since_output_ms: None,
2363 source: ExecSource::Assistant,
2364 interaction: None,
2365 output_summary: None,
2366 };
2367
2368 let text = lines_text(&cell.lines_with_motion(80, true));
2369
2370 assert!(text.contains("cargo test"));
2371 assert!(!text.contains("stale live tail"));
2372 }
2373
2374 #[test]
2375 fn long_thinking_display_is_shorter_than_transcript() {
2376 // Build a multi-paragraph thinking body so the live view has
2377 // something to compress. Without an explicit Summary block, the live
2378 // surface should show a bounded preview plus affordance; Ctrl+O
2379 // remains the path to the full body.
2380 let body = "First paragraph lede.\n\
2381 Second sentence of the first paragraph.\n\n\
2382 Second paragraph: deeper analysis follows.\n\
2383 More detail in paragraph two.\n\n\
2384 Third paragraph: even more reasoning.\n\
2385 With another line.\n\n\
2386 Fourth paragraph: the conclusion.\n\
2387 And one more line for good measure.\n\n\
2388 Fifth paragraph: final verification.\n\
2389 One last supporting detail.";
2390 let cell = HistoryCell::Thinking {
2391 content: body.to_string(),
2392 streaming: false,
2393 duration_secs: Some(3.2),
2394 };
2395
2396 let live = cell.lines_with_options(
2397 80,
2398 TranscriptRenderOptions {
2399 low_motion: true,
2400 ..TranscriptRenderOptions::default()
2401 },
2402 );
2403 let transcript = cell.transcript_lines(80);
2404
2405 assert!(
2406 live.len() < transcript.len(),
2407 "live thinking should compress (live = {} lines, transcript = {} lines)",
2408 live.len(),
2409 transcript.len()
2410 );
2411
2412 let live_text = lines_text(&live);
2413 let transcript_text = lines_text(&transcript);
2414
2415 assert!(
2416 transcript_text.contains("First paragraph lede"),
2417 "transcript thinking must keep the lede"
2418 );
2419 assert!(
2420 live_text.contains("First paragraph lede"),
2421 "live thinking should preview completed reasoning: {live_text}"
2422 );
2423 assert!(
2424 transcript_text.contains("Fifth paragraph"),
2425 "transcript thinking must keep the full body"
2426 );
2427 assert!(
2428 !live_text.contains("Fifth paragraph"),
2429 "live thinking must drop the tail when collapsed"
2430 );
2431 assert!(
2432 live_text.contains("Ctrl+O:detail"),
2433 "live thinking must offer the pager affordance"
2434 );
2435 assert!(
2436 !transcript_text.contains("Ctrl+O:detail"),
2437 "transcript thinking must not include the live affordance"
2438 );
2439 }
2440
2441 #[test]
2442 fn completed_short_thinking_without_summary_stays_visible_in_live_view() {
2443 // Short completed reasoning should not become a dead "Full reasoning
2444 // in Ctrl+O" card. The reasoning rail and tint already distinguish it
2445 // from the user's prompt, so show the useful body inline.
2446 let cell = HistoryCell::Thinking {
2447 content: "One brief reasoning step.".to_string(),
2448 streaming: false,
2449 duration_secs: Some(0.4),
2450 };
2451
2452 let live = cell.lines_with_options(
2453 80,
2454 TranscriptRenderOptions {
2455 low_motion: true,
2456 ..TranscriptRenderOptions::default()
2457 },
2458 );
2459 let transcript = cell.transcript_lines(80);
2460
2461 let live_text = lines_text(&live);
2462 let transcript_text = lines_text(&transcript);
2463
2464 assert!(
2465 live_text.contains("One brief reasoning step."),
2466 "live thinking must preview short completed reasoning: {live_text}"
2467 );
2468 assert!(
2469 transcript_text.contains("One brief reasoning step."),
2470 "transcript thinking must keep the full reasoning body"
2471 );
2472 assert!(
2473 !live_text.contains("Ctrl+O:detail"),
2474 "complete short reasoning should not need the detail affordance: {live_text}"
2475 );
2476 }
2477
2478 #[test]
2479 fn completed_reasoning_receipt_shows_verbatim_body_and_expands() {
2480 // The old #4146/#4148 scrub could not tell CodeWhale's identifiers from
2481 // the user's, and in a coding harness the user's dominate: it rendered
2482 // `short_dated_radar.py` as `….py`, `data/market_data/` as `data/…/`, and
2483 // every env var and module name as a bare `…`, which made the default
2484 // reasoning view unreadable. It also protected nothing — the full body
2485 // was always one keypress away on Space/Ctrl+O. A reasoning receipt now
2486 // shows the model's own words verbatim; only the line budget truncates.
2487 let cell = HistoryCell::Thinking {
2488 content: "I will call refresh_catalog_cache to refresh the model list.".to_string(),
2489 streaming: false,
2490 duration_secs: Some(1.0),
2491 };
2492
2493 // Default collapsed view: the identifier is shown, not scrubbed, and a
2494 // short body needs no expand affordance.
2495 let collapsed = cell.lines_with_options(
2496 80,
2497 TranscriptRenderOptions {
2498 low_motion: true,
2499 ..TranscriptRenderOptions::default()
2500 },
2501 );
2502 let collapsed_text = lines_text(&collapsed);
2503 assert!(
2504 collapsed_text.contains("refresh_catalog_cache"),
2505 "reasoning must be verbatim in the collapsed receipt: {collapsed_text}"
2506 );
2507 assert!(
2508 collapsed_text.contains("refresh the model list"),
2509 "surrounding prose must still read: {collapsed_text}"
2510 );
2511 assert!(
2512 !collapsed_text.contains("Ctrl+O:detail"),
2513 "a short completed receipt fits the budget and needs no affordance: {collapsed_text}"
2514 );
2515
2516 // A long body truncates at the line budget and offers the expand
2517 // affordance; expanding restores every line, identifiers intact.
2518 let long_body = (1..=20)
2519 .map(|i| format!("step {i:02}: refresh_catalog_cache iteration"))
2520 .collect::<Vec<_>>()
2521 .join("\n");
2522 let long_cell = HistoryCell::Thinking {
2523 content: long_body.clone(),
2524 streaming: false,
2525 duration_secs: Some(1.0),
2526 };
2527 let long_collapsed = long_cell.lines_with_options(
2528 80,
2529 TranscriptRenderOptions {
2530 low_motion: true,
2531 ..TranscriptRenderOptions::default()
2532 },
2533 );
2534 let long_collapsed_text = lines_text(&long_collapsed);
2535 assert!(
2536 long_collapsed_text.contains("Space:expand · Ctrl+O:detail"),
2537 "a truncated receipt must offer the expand affordance: {long_collapsed_text}"
2538 );
2539 assert!(
2540 long_collapsed_text.contains("refresh_catalog_cache"),
2541 "the shown head must keep identifiers verbatim: {long_collapsed_text}"
2542 );
2543
2544 // Expanded view (Space toggles the fold relative to the default): every
2545 // line is restored.
2546 let expanded = long_cell.lines_with_options_folded(
2547 80,
2548 TranscriptRenderOptions {
2549 low_motion: true,
2550 ..TranscriptRenderOptions::default()
2551 },
2552 true,
2553 );
2554 let expanded_text = lines_text(&expanded);
2555 for i in 1..=20 {
2556 assert!(
2557 expanded_text.contains(&format!("step {i:02}: refresh_catalog_cache iteration")),
2558 "expanded reasoning must restore every line ({i}): {expanded_text}"
2559 );
2560 }
2561 }
2562
2563 #[test]
2564 fn thinking_default_expanded_inverts_but_preserves_the_space_toggle() {
2565 // A 20-line body guarantees the collapsed fold actually truncates, so the
2566 // Space toggle is observable: default-expanded shows everything, Space
2567 // collapses to the 10-line budget with the expand affordance, and both
2568 // states show the model's identifiers verbatim (no #4146/#4148 scrub).
2569 let long_body = (1..=20)
2570 .map(|i| format!("step {i:02}: refresh_catalog_cache iteration"))
2571 .collect::<Vec<_>>()
2572 .join("\n");
2573 let cell = HistoryCell::Thinking {
2574 content: long_body.clone(),
2575 streaming: false,
2576 duration_secs: Some(1.0),
2577 };
2578 let options = TranscriptRenderOptions {
2579 thinking_default_expanded: true,
2580 low_motion: true,
2581 ..TranscriptRenderOptions::default()
2582 };
2583
2584 let expanded = cell.lines_with_options_folded(80, options, false);
2585 let expanded_text = lines_text(&expanded);
2586 for i in 1..=20 {
2587 assert!(
2588 expanded_text.contains(&format!("step {i:02}: refresh_catalog_cache iteration")),
2589 "the configured default must show the full reasoning body ({i}): {expanded_text}"
2590 );
2591 }
2592
2593 let collapsed = cell.lines_with_options_folded(80, options, true);
2594 let collapsed_text = lines_text(&collapsed);
2595 assert!(
2596 collapsed_text.contains("refresh_catalog_cache"),
2597 "Space must still collapse a default-expanded reasoning cell, verbatim: {collapsed_text}"
2598 );
2599 assert!(
2600 collapsed_text.contains("Space:expand · Ctrl+O:detail"),
2601 "the collapsed state must retain the full-reasoning affordance"
2602 );
2603 assert!(
2604 !collapsed_text.contains("step 20:"),
2605 "the collapsed fold must truncate the long body"
2606 );
2607 }
2608
2609 /// The live card must spend the whole output budget it advertises.
2610 ///
2611 /// `selected_output_indices` fills head + tail, then tops up from lines that
2612 /// look important (error / warning / path). Plain output — a list of names, a
2613 /// table, a clean build log — matches none of those, so the top-up found
2614 /// nothing and the card silently forfeited the rest of its budget: it showed
2615 /// `head + tail` rows and reported the remainder as "omitted". That is the
2616 /// "even truncated mode over-truncates" complaint.
2617 #[test]
2618 fn live_tool_output_spends_its_whole_line_budget_on_unremarkable_output() {
2619 let total_output_lines = 40usize;
2620 // Deliberately bland: no error/warning keywords, no slashes, no dots, so
2621 // `output_importance_rank` returns None for every single line.
2622 let output = (0..total_output_lines)
2623 .map(|i| format!("row {i:02} plain content"))
2624 .collect::<Vec<_>>()
2625 .join("\n");
2626
2627 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
2628 command: "list_things".to_string(),
2629 status: ToolStatus::Failed,
2630 output: Some(output),
2631 live_output: None,
2632 shell_task_id: None,
2633 owner_agent_id: None,
2634 owner_agent_name: None,
2635 started_at: None,
2636 duration_ms: Some(120),
2637 stale_elapsed_since_output_ms: None,
2638 source: ExecSource::Assistant,
2639 interaction: None,
2640 output_summary: None,
2641 }));
2642
2643 let live = cell.lines_with_options(
2644 80,
2645 TranscriptRenderOptions {
2646 low_motion: true,
2647 ..TranscriptRenderOptions::default()
2648 },
2649 );
2650 let live_text = lines_text(&live);
2651 let shown = (0..total_output_lines)
2652 .filter(|i| live_text.contains(&format!("row {i:02} plain content")))
2653 .count();
2654
2655 assert_eq!(
2656 shown, TOOL_OUTPUT_LINE_LIMIT,
2657 "a live card promising {TOOL_OUTPUT_LINE_LIMIT} output rows must show \
2658 {TOOL_OUTPUT_LINE_LIMIT}, not stop at head+tail: {live_text}"
2659 );
2660 // The shown region stays readable: a contiguous head, then the tail.
2661 for i in 0..TOOL_OUTPUT_HEAD_LINES {
2662 assert!(
2663 live_text.contains(&format!("row {i:02} plain content")),
2664 "head row {i} missing: {live_text}"
2665 );
2666 }
2667 for i in (total_output_lines - TOOL_OUTPUT_TAIL_LINES)..total_output_lines {
2668 assert!(
2669 live_text.contains(&format!("row {i:02} plain content")),
2670 "tail row {i} missing: {live_text}"
2671 );
2672 }
2673 }
2674
2675 #[test]
2676 fn tool_exec_live_caps_failed_output_transcript_does_not() {
2677 // A *failed* exec keeps its output in live mode, capped to head+tail
2678 // with a "lines omitted" marker. Transcript mode emits it uncapped.
2679 let total_output_lines = 30usize;
2680 let output = (0..total_output_lines)
2681 .map(|i| format!("output line {i:02}"))
2682 .collect::<Vec<_>>()
2683 .join("\n");
2684
2685 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
2686 command: "noisy_script.sh".to_string(),
2687 status: ToolStatus::Failed,
2688 output: Some(output),
2689 live_output: None,
2690 shell_task_id: None,
2691 owner_agent_id: None,
2692 owner_agent_name: None,
2693 started_at: None,
2694 duration_ms: Some(120),
2695 stale_elapsed_since_output_ms: None,
2696 source: ExecSource::Assistant,
2697 interaction: None,
2698 output_summary: None,
2699 }));
2700
2701 let live = cell.lines_with_options(
2702 80,
2703 TranscriptRenderOptions {
2704 low_motion: true,
2705 ..TranscriptRenderOptions::default()
2706 },
2707 );
2708 let transcript = cell.transcript_lines(80);
2709
2710 let live_text = lines_text(&live);
2711 let transcript_text = lines_text(&transcript);
2712
2713 assert!(
2714 live.len() < transcript.len(),
2715 "live exec output must be shorter than transcript exec output (live={}, transcript={})",
2716 live.len(),
2717 transcript.len()
2718 );
2719 assert!(
2720 live_text.contains("lines omitted"),
2721 "live failed-exec output must surface the omission marker: {live_text}"
2722 );
2723 assert!(
2724 !transcript_text.contains("lines omitted"),
2725 "transcript exec output must not include the omission marker"
2726 );
2727 assert!(transcript_text.contains("output line 00"));
2728 // The middle should only appear in the transcript, since the live
2729 // view truncates the head/tail around the cap.
2730 assert!(
2731 transcript_text.contains("output line 15"),
2732 "transcript must include the middle of the exec output"
2733 );
2734 // Last line should appear in both because the live view shows
2735 // head + tail around an omission marker.
2736 let last = format!("output line {:02}", total_output_lines - 1);
2737 assert!(transcript_text.contains(&last));
2738 }
2739
2740 #[test]
2741 fn tool_exec_live_previews_successful_command_without_its_full_body() {
2742 // A *successful* exec does not earn its full body in live mode — no
2743 // command echo, and only `TOOL_SUCCESS_OUTPUT_PREVIEW_LINES` of output.
2744 // It used to collapse to the bare header, which meant a run card told you
2745 // a command finished and nothing whatsoever about what it produced.
2746 // Transcript mode still records everything for the pager/clipboard.
2747 let output = (0..30usize)
2748 .map(|i| format!("output line {i:02}"))
2749 .collect::<Vec<_>>()
2750 .join("\n");
2751 let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
2752 command: "noisy_script.sh".to_string(),
2753 status: ToolStatus::Success,
2754 output: Some(output),
2755 live_output: None,
2756 shell_task_id: None,
2757 owner_agent_id: None,
2758 owner_agent_name: None,
2759 started_at: None,
2760 duration_ms: Some(120),
2761 stale_elapsed_since_output_ms: None,
2762 source: ExecSource::Assistant,
2763 interaction: None,
2764 output_summary: None,
2765 }));
2766
2767 let live_text = lines_text(&cell.lines_with_options(
2768 80,
2769 TranscriptRenderOptions {
2770 low_motion: true,
2771 ..TranscriptRenderOptions::default()
2772 },
2773 ));
2774 let transcript_text = lines_text(&cell.transcript_lines(80));
2775
2776 // Live: a bounded preview from the top of the output.
2777 let previewed = (0..30usize)
2778 .filter(|i| live_text.contains(&format!("output line {i:02}")))
2779 .count();
2780 assert_eq!(
2781 previewed, TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
2782 "a successful exec should preview exactly \
2783 {TOOL_SUCCESS_OUTPUT_PREVIEW_LINES} output rows: {live_text}"
2784 );
2785 assert!(
2786 live_text.contains("output line 00"),
2787 "the preview reads from the top of the output: {live_text}"
2788 );
2789 assert!(
2790 !live_text.contains("output line 29"),
2791 "a successful exec must not render its full body in live mode: {live_text}"
2792 );
2793 assert!(
2794 !live_text.contains("command:"),
2795 "a successful exec still skips the command echo; the header carries \
2796 the summary: {live_text}"
2797 );
2798 // Transcript still has the full output.
2799 assert!(transcript_text.contains("output line 00"));
2800 assert!(transcript_text.contains("output line 29"));
2801 }
2802
2803 #[test]
2804 fn generic_tool_cell_renders_prompts_as_indexed_rows() {
2805 // When prompts are populated by a fan-out tool, each child shows on
2806 // its own row instead of the inline `args:` summary so the user can
2807 // read what each child was asked.
2808 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2809 name: "read_file".to_string(),
2810 status: ToolStatus::Running,
2811 input_summary: Some("prompts: <3 items>".to_string()),
2812 output: None,
2813 prompts: Some(vec![
2814 "Summarize the README".to_string(),
2815 "List the public types in client.rs".to_string(),
2816 "Diff this commit against main".to_string(),
2817 ]),
2818 spillover_path: None,
2819 output_summary: None,
2820 is_diff: false,
2821 }));
2822 let text = lines_text(&cell.lines(80));
2823
2824 assert!(text.contains("[0] Summarize the README"));
2825 assert!(text.contains("[1] List the public types in client.rs"));
2826 assert!(text.contains("[2] Diff this commit against main"));
2827 // The inline args summary must not also be emitted — we replaced it
2828 // with the per-child rows.
2829 assert!(
2830 !text.contains("args: prompts:"),
2831 "inline `args:` summary must be suppressed when per-prompt rows render"
2832 );
2833 }
2834
2835 #[test]
2836 fn generic_tool_cell_falls_back_to_args_when_prompts_none() {
2837 // Non-fan-out tools keep the existing `args:` summary so behavior
2838 // doesn't drift for everything else.
2839 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2840 name: "file_search".to_string(),
2841 status: ToolStatus::Running,
2842 input_summary: Some("query: foo".to_string()),
2843 output: None,
2844 prompts: None,
2845 spillover_path: None,
2846 output_summary: None,
2847 is_diff: false,
2848 }));
2849 let text = lines_text(&cell.lines(80));
2850 assert!(text.contains("query: foo"));
2851 }
2852
2853 #[test]
2854 fn known_generic_tool_hides_raw_name_in_live_mode() {
2855 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2856 name: "run_verifiers".to_string(),
2857 status: ToolStatus::Running,
2858 input_summary: Some("profile: auto, level: quick".to_string()),
2859 output: None,
2860 prompts: None,
2861 spillover_path: None,
2862 output_summary: None,
2863 is_diff: false,
2864 }));
2865
2866 let text = lines_text(&cell.lines(80));
2867 assert!(text.contains("verify running"), "{text}");
2868 assert!(
2869 !text.contains("name: run_verifiers"),
2870 "live card should not spend a row on internal tool id: {text}"
2871 );
2872 assert!(
2873 !text.contains("run_verifiers"),
2874 "known tool id should not leak into compact live card: {text}"
2875 );
2876 }
2877
2878 #[test]
2879 fn known_generic_tool_keeps_raw_name_in_transcript_mode() {
2880 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2881 name: "run_verifiers".to_string(),
2882 status: ToolStatus::Running,
2883 input_summary: Some("profile: auto, level: quick".to_string()),
2884 output: None,
2885 prompts: None,
2886 spillover_path: None,
2887 output_summary: None,
2888 is_diff: false,
2889 }));
2890
2891 let text = lines_text(&cell.transcript_lines(80));
2892 assert!(text.contains("verify running"), "{text}");
2893 assert!(
2894 text.contains("name: run_verifiers"),
2895 "transcript replay should preserve exact tool id: {text}"
2896 );
2897 }
2898
2899 #[test]
2900 fn unknown_generic_tool_keeps_raw_name_in_live_mode() {
2901 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2902 name: "future_private_tool".to_string(),
2903 status: ToolStatus::Running,
2904 input_summary: Some("query: foo".to_string()),
2905 output: None,
2906 prompts: None,
2907 spillover_path: None,
2908 output_summary: None,
2909 is_diff: false,
2910 }));
2911
2912 let text = lines_text(&cell.lines(80));
2913 // Unknown/Generic tools collapse to a single header line in live mode.
2914 assert!(
2915 !text.is_empty(),
2916 "collapsed header must still render: {text}"
2917 );
2918 }
2919
2920 #[test]
2921 fn generic_tool_cell_preserves_multi_line_output_in_transcript() {
2922 // Repro for #80: a `git diff --stat`-shaped tool result should keep
2923 // its newlines on the transcript surface — one file per row, not
2924 // squashed into a single line.
2925 let diff_stat = "Cargo.lock | 1 +\n\
2926 crates/cli/Cargo.toml | 1 +\n\
2927 crates/cli/src/main.rs | 47 ++++++\n\
2928 crates/config/src/lib.rs | 27 ++++\n\
2929 crates/tui/src/mcp.rs | 384 +++++";
2930
2931 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2932 name: "read_file".to_string(),
2933 status: ToolStatus::Success,
2934 input_summary: Some("command: git diff --stat".to_string()),
2935 output: Some(diff_stat.to_string()),
2936 prompts: None,
2937 spillover_path: None,
2938 output_summary: None,
2939 is_diff: false,
2940 }));
2941
2942 let transcript_text = lines_text(&cell.transcript_lines(80));
2943
2944 // Each file path must appear on its own row in the transcript.
2945 for needle in [
2946 "Cargo.lock",
2947 "crates/cli/Cargo.toml",
2948 "crates/cli/src/main.rs",
2949 "crates/config/src/lib.rs",
2950 "crates/tui/src/mcp.rs",
2951 ] {
2952 assert!(
2953 transcript_text.contains(needle),
2954 "transcript missing '{needle}': {transcript_text}"
2955 );
2956 }
2957 // The pre-fix bug: result line containing
2958 // "Cargo.lock | 1 + crates/cli/Cargo.toml" — joined into one row.
2959 // With the fix, the diff-stat pipes are still present per-line, but
2960 // adjacent file paths are on separate rendered rows. Assert that the
2961 // first file's line ends before the second begins.
2962 let lines: Vec<&str> = transcript_text.lines().collect();
2963 let cargo_lock_line = lines
2964 .iter()
2965 .find(|l| l.contains("Cargo.lock"))
2966 .expect("Cargo.lock row must exist");
2967 assert!(
2968 !cargo_lock_line.contains("crates/cli/Cargo.toml"),
2969 "Cargo.lock row must not also contain the second file: {cargo_lock_line}"
2970 );
2971 }
2972
2973 #[test]
2974 fn generic_tool_cell_expands_failed_multi_line_output_in_live() {
2975 // Failed tools should auto-expand in live mode so the command/input summary
2976 // and full error output remain immediately visible.
2977 let total = 30usize;
2978 let output = (0..total)
2979 .map(|i| format!("row {i:02}: payload"))
2980 .collect::<Vec<_>>()
2981 .join("\n");
2982
2983 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2984 name: "read_file".to_string(),
2985 status: ToolStatus::Failed,
2986 input_summary: Some("command: ls".to_string()),
2987 output: Some(output),
2988 prompts: None,
2989 spillover_path: None,
2990 output_summary: None,
2991 is_diff: false,
2992 }));
2993
2994 let live = cell.lines_with_options(80, TranscriptRenderOptions::default());
2995 let transcript = cell.transcript_lines(80);
2996 let live_text = lines_text(&live);
2997 let transcript_text = lines_text(&transcript);
2998
2999 assert!(live_text.contains("command: ls"), "{live_text}");
3000 assert!(
3001 !live_text.contains("lines omitted"),
3002 "failed output must not be hidden behind an omission marker: {live_text}"
3003 );
3004 assert!(transcript_text.contains("row 29"));
3005 assert!(live_text.contains("row 29"));
3006 }
3007
3008 #[test]
3009 fn generic_tool_failed_output_live_renders_card_rail() {
3010 let output = (0..24usize)
3011 .map(|i| format!("line {i:02}"))
3012 .collect::<Vec<_>>()
3013 .join("\n");
3014 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3015 name: "read_file".to_string(),
3016 status: ToolStatus::Failed,
3017 input_summary: Some("command: noisy".to_string()),
3018 output: Some(output),
3019 prompts: None,
3020 spillover_path: None,
3021 output_summary: None,
3022 is_diff: false,
3023 }));
3024
3025 let live_text = lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
3026
3027 // Card-rail wrapping: first line starts with ╭, last with ╰.
3028 assert!(
3029 live_text.starts_with('\u{256D}'),
3030 "live view must start with card-rail top glyph ╭: {live_text}"
3031 );
3032 assert!(!live_text.contains("lines omitted"), "{live_text}");
3033 assert!(live_text.contains("line 00"));
3034 assert!(live_text.contains("line 23"));
3035 }
3036
3037 #[test]
3038 fn hidden_tool_details_keeps_failed_generic_output_expanded() {
3039 let output = (0..30usize)
3040 .map(|i| format!("row {i:02}: payload"))
3041 .collect::<Vec<_>>()
3042 .join("\n");
3043 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3044 name: "read_file".to_string(),
3045 status: ToolStatus::Failed,
3046 input_summary: Some("command: noisy".to_string()),
3047 output: Some(output),
3048 prompts: None,
3049 spillover_path: None,
3050 output_summary: None,
3051 is_diff: false,
3052 }));
3053
3054 let live_text = lines_text(&cell.lines_with_options(
3055 80,
3056 TranscriptRenderOptions {
3057 show_tool_details: false,
3058 ..TranscriptRenderOptions::default()
3059 },
3060 ));
3061
3062 assert!(
3063 !live_text.contains("lines omitted") && !live_text.contains("details"),
3064 "failed output must not be hidden behind a details affordance: {live_text}"
3065 );
3066 assert!(live_text.contains("row 29"), "{live_text}");
3067 }
3068
3069 #[test]
3070 fn calm_mode_keeps_failed_generic_output_expanded() {
3071 let output = (0..30usize)
3072 .map(|i| format!("row {i:02}: payload"))
3073 .collect::<Vec<_>>()
3074 .join("\n");
3075 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3076 name: "read_file".to_string(),
3077 status: ToolStatus::Failed,
3078 input_summary: Some("command: noisy".to_string()),
3079 output: Some(output),
3080 prompts: None,
3081 spillover_path: None,
3082 output_summary: None,
3083 is_diff: false,
3084 }));
3085
3086 let live_text = lines_text(&cell.lines_with_options(
3087 80,
3088 TranscriptRenderOptions {
3089 calm_mode: true,
3090 ..TranscriptRenderOptions::default()
3091 },
3092 ));
3093
3094 assert!(
3095 !live_text.contains("lines omitted") && !live_text.contains("details"),
3096 "failed output must not be hidden behind a details affordance: {live_text}"
3097 );
3098 assert!(live_text.contains("row 29"), "{live_text}");
3099 }
3100
3101 #[test]
3102 fn generic_tool_success_live_collapses_output_transcript_keeps_it() {
3103 let output = (0..24usize)
3104 .map(|i| format!("row {i:02}: payload"))
3105 .collect::<Vec<_>>()
3106 .join("\n");
3107 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3108 name: "read_file".to_string(),
3109 status: ToolStatus::Success,
3110 input_summary: Some("path: crates/tui/src/main.rs".to_string()),
3111 output: Some(output),
3112 prompts: None,
3113 spillover_path: None,
3114 output_summary: None,
3115 is_diff: false,
3116 }));
3117
3118 let live_text = lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
3119 let transcript_text = lines_text(&cell.transcript_lines(80));
3120
3121 assert!(
3122 !live_text.contains("row 00"),
3123 "successful generic tool output should be hidden live: {live_text}"
3124 );
3125 assert!(
3126 !live_text.contains("lines omitted"),
3127 "collapsed success should not spend a row on an omission marker: {live_text}"
3128 );
3129 assert!(transcript_text.contains("row 00"));
3130 assert!(transcript_text.contains("row 23"));
3131 }
3132
3133 #[test]
3134 fn tool_output_live_preserves_error_card_rail() {
3135 let output = [
3136 "start",
3137 "still starting",
3138 "middle noise 1",
3139 "fatal: failed to read /tmp/deepseek/config.toml",
3140 "middle noise 2",
3141 "see https://example.test/build/log for details",
3142 "middle noise 3",
3143 "almost done",
3144 "final line",
3145 ]
3146 .join("\n");
3147 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3148 name: "read_file".to_string(),
3149 status: ToolStatus::Failed,
3150 input_summary: Some("command: tool".to_string()),
3151 output: Some(output),
3152 prompts: None,
3153 spillover_path: None,
3154 output_summary: Some("Error: failed to read config".to_string()),
3155 is_diff: false,
3156 }));
3157
3158 let live_text = lines_text(&cell.lines_with_options(80, TranscriptRenderOptions::default()));
3159
3160 assert!(
3161 !live_text.contains("lines omitted"),
3162 "failed output must not be hidden behind an omission marker: {live_text}"
3163 );
3164 assert!(
3165 live_text.contains("Error:") || live_text.contains("fatal:"),
3166 "live summary should capture error text: {live_text}"
3167 );
3168 assert!(live_text.contains("final line"), "{live_text}");
3169 }
3170
3171 // === ErrorEnvelope severity → cell color tests (#66) ===
3172
3173 /// Snapshot: an `Error`-severity cell uses the red status palette token
3174 /// for both the leading "Error" label glyph and the body. This is the
3175 /// load-bearing visual signal that distinguishes an error cell from a
3176 /// neutral system note.
3177 #[test]
3178 fn error_severity_cell_renders_in_red() {
3179 let cell = HistoryCell::Error {
3180 message: "Authentication failed: invalid API key".to_string(),
3181 severity: crate::error_taxonomy::ErrorSeverity::Error,
3182 };
3183 let lines = cell.lines(80);
3184 assert!(
3185 !lines.is_empty(),
3186 "error cell must render at least one line"
3187 );
3188
3189 let head = &lines[0];
3190 let label_span = &head.spans[0];
3191 assert_eq!(label_span.content.as_ref(), "Error");
3192 assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
3193 assert!(label_span.style.add_modifier.contains(Modifier::BOLD));
3194
3195 // The body carries the error message and is rendered in the same red.
3196 let body_text = lines
3197 .iter()
3198 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
3199 .collect::<String>();
3200 assert!(body_text.contains("Authentication failed"));
3201 // Find a span whose text contains "Authentication" and verify its color.
3202 let body_span = lines
3203 .iter()
3204 .flat_map(|line| line.spans.iter())
3205 .find(|span| span.content.contains("Authentication"))
3206 .expect("error body span must exist");
3207 assert_eq!(body_span.style.fg, Some(palette::STATUS_ERROR));
3208 }
3209
3210 /// `Warning`-severity uses amber, not red — distinguishes a transient
3211 /// retry hiccup from a hard failure.
3212 #[test]
3213 fn warning_severity_cell_renders_in_amber() {
3214 let cell = HistoryCell::Error {
3215 message: "Stream stalled: no data received for 60s, closing stream".to_string(),
3216 severity: crate::error_taxonomy::ErrorSeverity::Warning,
3217 };
3218 let lines = cell.lines(80);
3219 let label_span = &lines[0].spans[0];
3220 assert_eq!(label_span.content.as_ref(), "Warn");
3221 assert_eq!(label_span.style.fg, Some(palette::STATUS_WARNING));
3222 }
3223
3224 /// `Critical` severity collapses to the same red as `Error` — both flip
3225 /// offline mode and both should read as the loudest signal in the
3226 /// transcript.
3227 #[test]
3228 fn critical_severity_cell_renders_in_red() {
3229 let cell = HistoryCell::Error {
3230 message: "API key expired".to_string(),
3231 severity: crate::error_taxonomy::ErrorSeverity::Critical,
3232 };
3233 let lines = cell.lines(80);
3234 let label_span = &lines[0].spans[0];
3235 assert_eq!(label_span.content.as_ref(), "Error");
3236 assert_eq!(label_span.style.fg, Some(palette::STATUS_ERROR));
3237 }
3238
3239 /// `Info` severity stays neutral / dim so it doesn't draw the eye away
3240 /// from real failures sitting alongside it in the transcript.
3241 #[test]
3242 fn info_severity_cell_renders_in_dim() {
3243 let cell = HistoryCell::Error {
3244 message: "Reconnected".to_string(),
3245 severity: crate::error_taxonomy::ErrorSeverity::Info,
3246 };
3247 let lines = cell.lines(80);
3248 let label_span = &lines[0].spans[0];
3249 assert_eq!(label_span.content.as_ref(), "Info");
3250 assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM));
3251 }
3252
3253 fn success_generic_tool(name: &str) -> HistoryCell {
3254 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3255 name: name.to_string(),
3256 status: ToolStatus::Success,
3257 input_summary: Some(format!("args for {name}")),
3258 output: Some(format!("output for {name}")),
3259 prompts: None,
3260 spillover_path: None,
3261 output_summary: None,
3262 is_diff: false,
3263 }))
3264 }
3265
3266 fn failed_generic_tool(name: &str) -> HistoryCell {
3267 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3268 name: name.to_string(),
3269 status: ToolStatus::Failed,
3270 input_summary: None,
3271 output: Some("failed".to_string()),
3272 prompts: None,
3273 spillover_path: None,
3274 output_summary: None,
3275 is_diff: false,
3276 }))
3277 }
3278
3279 fn running_generic_tool(name: &str) -> HistoryCell {
3280 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
3281 name: name.to_string(),
3282 status: ToolStatus::Running,
3283 input_summary: None,
3284 output: None,
3285 prompts: None,
3286 spillover_path: None,
3287 output_summary: None,
3288 is_diff: false,
3289 }))
3290 }
3291
3292 fn shell_tool(command: &str) -> HistoryCell {
3293 HistoryCell::Tool(ToolCell::Exec(ExecCell {
3294 command: command.to_string(),
3295 status: ToolStatus::Success,
3296 output: Some("ok".to_string()),
3297 live_output: None,
3298 shell_task_id: None,
3299 owner_agent_id: None,
3300 owner_agent_name: None,
3301 started_at: None,
3302 duration_ms: None,
3303 stale_elapsed_since_output_ms: None,
3304 source: ExecSource::Assistant,
3305 interaction: None,
3306 output_summary: None,
3307 }))
3308 }
3309
3310 #[test]
3311 fn detect_tool_runs_finds_contiguous_successful_safe_tools() {
3312 let history = vec![
3313 HistoryCell::User {
3314 content: "go".to_string(),
3315 },
3316 success_generic_tool("read_file"),
3317 success_generic_tool("list_dir"),
3318 success_generic_tool("web_search"),
3319 HistoryCell::Assistant {
3320 content: "done".to_string(),
3321 streaming: false,
3322 },
3323 ];
3324
3325 let runs = super::detect_tool_runs(&history, 3);
3326
3327 assert_eq!(runs.len(), 1);
3328 assert_eq!(runs[0].start, 1);
3329 assert_eq!(runs[0].count, 3);
3330 assert_eq!(
3331 runs[0].tool_families,
3332 vec!["read_file", "list_dir", "web_search"]
3333 );
3334 assert_eq!(runs[0].activity.files, 2);
3335 assert_eq!(runs[0].activity.searches, 1);
3336 }
3337
3338 #[test]
3339 fn detect_tool_runs_honors_threshold_and_boundaries() {
3340 let short = vec![
3341 success_generic_tool("read_file"),
3342 success_generic_tool("list_dir"),
3343 ];
3344 assert!(super::detect_tool_runs(&short, 3).is_empty());
3345
3346 let with_assistant_boundary = vec![
3347 success_generic_tool("read_file"),
3348 HistoryCell::Assistant {
3349 content: "pause".to_string(),
3350 streaming: false,
3351 },
3352 success_generic_tool("list_dir"),
3353 success_generic_tool("web_search"),
3354 ];
3355 assert!(super::detect_tool_runs(&with_assistant_boundary, 3).is_empty());
3356 }
3357
3358 #[test]
3359 fn detect_tool_runs_keeps_failed_running_and_shell_cells_visible() {
3360 let history = vec![
3361 success_generic_tool("read_file"),
3362 success_generic_tool("list_dir"),
3363 failed_generic_tool("web_search"),
3364 success_generic_tool("read_file"),
3365 success_generic_tool("list_dir"),
3366 running_generic_tool("web_search"),
3367 success_generic_tool("read_file"),
3368 success_generic_tool("list_dir"),
3369 shell_tool("rm -rf target"),
3370 success_generic_tool("read_file"),
3371 success_generic_tool("list_dir"),
3372 success_generic_tool("web_search"),
3373 ];
3374
3375 let runs = super::detect_tool_runs(&history, 3);
3376
3377 assert_eq!(runs.len(), 1);
3378 assert_eq!(runs[0].start, 9);
3379 assert_eq!(runs[0].count, 3);
3380 }
3381
3382 #[test]
3383 fn detect_tool_runs_summarizes_safe_command_tools() {
3384 let history = vec![
3385 success_generic_tool("run_tests"),
3386 success_generic_tool("run_verifiers"),
3387 success_generic_tool("validate_data"),
3388 ];
3389
3390 let runs = super::detect_tool_runs(&history, 3);
3391
3392 assert_eq!(runs.len(), 1);
3393 assert_eq!(runs[0].start, 0);
3394 assert_eq!(runs[0].count, 3);
3395 assert_eq!(runs[0].activity.commands, 3);
3396 assert_eq!(
3397 runs[0].tool_families,
3398 vec!["run_tests", "run_verifiers", "validate_data"]
3399 );
3400 assert_eq!(
3401 super::tool_run_summary(&runs[0]),
3402 "Ran 3 commands: run_tests, run_verifiers, validate_data"
3403 );
3404 }
3405
3406 #[test]
3407 fn tool_run_summary_reports_compact_success_group() {
3408 let run = super::ToolRun {
3409 start: 4,
3410 count: 5,
3411 tool_families: vec!["read_file".to_string(), "list_dir".to_string()],
3412 activity: super::ToolRunActivitySummary {
3413 files: 4,
3414 searches: 1,
3415 ..Default::default()
3416 },
3417 };
3418
3419 let summary = super::tool_run_summary(&run);
3420
3421 assert_eq!(summary, "Explored 4 files, 1 search: read_file, list_dir");
3422 }
3423
3424 #[test]
3425 fn tool_run_summary_keeps_git_history_tools_visible() {
3426 let history = vec![
3427 success_generic_tool("git_log"),
3428 success_generic_tool("git_show"),
3429 success_generic_tool("git_blame"),
3430 ];
3431
3432 let runs = super::detect_tool_runs(&history, 3);
3433
3434 assert_eq!(runs.len(), 1);
3435 assert_eq!(runs[0].activity.files, 3);
3436 assert_eq!(
3437 super::tool_run_summary(&runs[0]),
3438 "Explored 3 files: git_log, git_show, git_blame"
3439 );
3440 }
3441
3442 #[test]
3443 fn tool_run_summary_lists_only_command_families_for_command_clause() {
3444 let run = super::ToolRun {
3445 start: 4,
3446 count: 4,
3447 tool_families: vec![
3448 "read_file".to_string(),
3449 "run_tests".to_string(),
3450 "validate_data".to_string(),
3451 ],
3452 activity: super::ToolRunActivitySummary {
3453 files: 2,
3454 commands: 2,
3455 ..Default::default()
3456 },
3457 };
3458
3459 assert_eq!(
3460 super::tool_run_summary(&run),
3461 "Explored 2 files: read_file, ran 2 commands: run_tests, validate_data"
3462 );
3463 }
3464
3465 #[test]
3466 fn tool_run_summary_uses_metadata_fallback_for_unknown_groups() {
3467 let run = super::ToolRun {
3468 start: 4,
3469 count: 2,
3470 tool_families: vec!["session_sync".to_string()],
3471 activity: super::ToolRunActivitySummary {
3472 other: 2,
3473 ..Default::default()
3474 },
3475 };
3476
3477 assert_eq!(super::tool_run_summary(&run), "Updated metadata");
3478 }
3479
3480 // ---- #4112 / dogfood A5: transcript noise ----
3481
3482 fn agent_cell(
3483 action_summary: Option<&str>,
3484 status: ToolStatus,
3485 output: Option<&str>,
3486 ) -> GenericToolCell {
3487 GenericToolCell {
3488 name: "agent".to_string(),
3489 status,
3490 input_summary: action_summary.map(str::to_string),
3491 output: output.map(str::to_string),
3492 prompts: None,
3493 spillover_path: None,
3494 output_summary: None,
3495 is_diff: false,
3496 }
3497 }
3498
3499 fn joined_lines(cell: &GenericToolCell, mode: super::RenderMode) -> String {
3500 cell.lines_with_mode(120, true, mode)
3501 .iter()
3502 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref().to_string()))
3503 .collect()
3504 }
3505
3506 #[test]
3507 fn unknown_tool_failure_collapses_to_one_line() {
3508 let cell = GenericToolCell {
3509 name: "item".to_string(),
3510 status: ToolStatus::Failed,
3511 input_summary: Some("status: pending".to_string()),
3512 output: Some(
3513 "Tool 'item' is not available in the current tool catalog. \
3514 Checklist entries are not separate tool calls."
3515 .to_string(),
3516 ),
3517 prompts: None,
3518 spillover_path: None,
3519 output_summary: None,
3520 is_diff: false,
3521 };
3522 for mode in [super::RenderMode::Live, super::RenderMode::Transcript] {
3523 let lines = cell.lines_with_mode(120, true, mode);
3524 assert_eq!(
3525 lines.len(),
3526 1,
3527 "unknown-tool failure should be a single header line in {mode:?}: {lines:?}"
3528 );
3529 let joined = joined_lines(&cell, mode);
3530 assert!(
3531 joined.contains("Tool 'item' is not available"),
3532 "the catalog error is the useful part: {joined:?}"
3533 );
3534 assert!(
3535 !joined.contains("name: item"),
3536 "no name:/args:/result: block for unknown tools: {joined:?}"
3537 );
3538 }
3539 }
3540
3541 #[test]
3542 fn agent_peek_renders_checked_not_done() {
3543 let cell = agent_cell(
3544 Some("action: peek agent_id: agent_scout_1"),
3545 ToolStatus::Success,
3546 Some(r#"{"agent_id":"agent_scout_1","status":"running"}"#),
3547 );
3548 let joined = joined_lines(&cell, super::RenderMode::Live);
3549 assert!(
3550 joined.contains("checked") && joined.contains("agent_scout_1"),
3551 "peek should read as a check, not a completed delegate: {joined:?}"
3552 );
3553 assert!(
3554 !joined.contains("delegate done"),
3555 "peek must not draw the spawn-completion line: {joined:?}"
3556 );
3557 }
3558
3559 #[test]
3560 fn agent_wait_renders_waited_label() {
3561 let cell = agent_cell(
3562 Some("action: wait"),
3563 ToolStatus::Success,
3564 Some(r#"{"action":"wait","settled":[{"agent_id":"agent_scout_1"}]}"#),
3565 );
3566 let joined = joined_lines(&cell, super::RenderMode::Live);
3567 assert!(
3568 joined.contains("waited"),
3569 "wait cells should read as a join: {joined:?}"
3570 );
3571 }
3572
3573 #[test]
3574 fn agent_inspection_stays_compact_in_transcript_mode() {
3575 let cell = agent_cell(
3576 Some("action: status agent_id: agent_scout_1"),
3577 ToolStatus::Success,
3578 Some(r#"{"agent_id":"agent_scout_1","status":"running","terminal":false}"#),
3579 );
3580 let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript);
3581 assert_eq!(
3582 lines.len(),
3583 1,
3584 "status checks should not dump full projections in the pager: {lines:?}"
3585 );
3586 }
3587
3588 #[test]
3589 fn agent_spawn_suppresses_generic_card_in_favor_of_delegate_card() {
3590 let cell = agent_cell(
3591 Some("prompt: map the repo"),
3592 ToolStatus::Success,
3593 Some(r#"{"agent_id":"agent_scout_1","status":"running"}"#),
3594 );
3595 for mode in [super::RenderMode::Live, super::RenderMode::Transcript] {
3596 let lines = cell.lines_with_mode(120, true, mode);
3597 assert!(
3598 lines.is_empty(),
3599 "spawn generic tool card must yield to DelegateCard (#4133): {mode:?} {lines:?}"
3600 );
3601 }
3602 }
3603
3603 lines RUST