1use ratatui::prelude::*;
9
10use crate::tui::app::{ToolResultData, TranscriptBlock, UsageStats};
11use crate::tui::ui::theme::Theme;
12use crate::tui::ui::{diff, markdown};
13
14pub fn render_blocks(
18 blocks: &[TranscriptBlock],
19 _usage: &UsageStats,
20 th: &Theme,
21) -> Vec<Line<'static>> {
22 let mut lines: Vec<Line<'static>> = Vec::new();
23 for (i, b) in blocks.iter().enumerate() {
24 if i > 0 {
25 lines.push(Line::raw(""));
27 if matches!(b, TranscriptBlock::User { .. }) {
30 lines.push(Line::raw(""));
31 }
32 }
33 lines.extend(render_block(b, th));
34 if matches!(b, TranscriptBlock::User { .. }) {
37 lines.push(Line::raw(""));
38 }
39 }
40 lines
41}
42
43pub fn render_block(block: &TranscriptBlock, th: &Theme) -> Vec<Line<'static>> {
45 match block {
46 TranscriptBlock::User { text } => render_user(text, th),
47 TranscriptBlock::Assistant {
48 text,
49 streaming,
50 latency_ms,
51 } => render_assistant(text, *streaming, *latency_ms, th),
52 TranscriptBlock::Reasoning { text } => render_reasoning(text),
53 TranscriptBlock::ToolCall {
54 name,
55 args_preview,
56 result,
57 ..
58 } => render_tool_call(name, args_preview, result, th),
59 TranscriptBlock::Diff { path, hunks } => render_diff(path, hunks),
60 TranscriptBlock::Compacted { removed, kept } => render_compacted(*removed, *kept),
61 TranscriptBlock::System { text } => render_system(text),
62 TranscriptBlock::Error { text } => render_error(text, th),
63 TranscriptBlock::PlanProposal {
64 plan_text,
65 tool_calls,
66 } => render_plan_proposal(plan_text, tool_calls),
67 TranscriptBlock::PlanModeRequest { reason, approved } => {
68 render_plan_mode_request(reason, *approved)
69 }
70 #[cfg(feature = "weixin")]
71 TranscriptBlock::WeixinMessage { user_id, text } => render_weixin_message(user_id, text),
72 }
73}
74
75#[cfg(feature = "weixin")]
76fn render_weixin_message(user_id: &str, text: &str) -> Vec<Line<'static>> {
77 use ratatui::{
78 style::{Color, Modifier, Style},
79 text::{Line, Span},
80 };
81 let prefix_style = Style::default()
82 .fg(Color::Rgb(0, 190, 100))
83 .add_modifier(Modifier::BOLD);
84 let body_style = Style::default().fg(Color::White);
85
86 let prefix = Span::styled(format!("π± {user_id}: "), prefix_style);
87 let mut out: Vec<Line<'static>> = Vec::new();
88 let lines: Vec<&str> = text.lines().collect();
89 if lines.is_empty() {
90 out.push(Line::from(vec![prefix]));
91 return out;
92 }
93 let first = lines[0];
94 out.push(Line::from(vec![
95 prefix.clone(),
96 Span::styled(first.to_string(), body_style),
97 ]));
98 for line in &lines[1..] {
99 let indent = " ".repeat(user_id.len() + 6); out.push(Line::from(vec![
101 Span::styled(indent, Style::default()),
102 Span::styled(line.to_string(), body_style),
103 ]));
104 }
105 out
106}
107
108fn render_user(text: &str, _th: &Theme) -> Vec<Line<'static>> {
115 let prefix_style = Style::default()
116 .fg(Color::DarkGray)
117 .add_modifier(Modifier::BOLD);
118 let body_bg = Color::Rgb(50, 50, 55);
119 let body_style = Style::default().bg(body_bg).fg(Color::White);
120
121 let prefix = Span::styled("> ", prefix_style);
122 let mut out: Vec<Line<'static>> = Vec::new();
123 let lines: Vec<&str> = text.lines().collect();
124
125 if lines.is_empty() {
126 out.push(Line::from(vec![
129 prefix,
130 Span::styled(" ".to_string(), body_style),
131 ]));
132 return out;
133 }
134
135 for line in lines {
136 out.push(Line::from(vec![
137 prefix.clone(),
138 Span::styled(format!(" {line}"), body_style),
139 ]));
140 }
141 out
142}
143
144fn render_assistant(
153 text: &str,
154 _streaming: bool,
155 _latency_ms: Option<u64>,
156 _th: &Theme,
157) -> Vec<Line<'static>> {
158 let bullet_style = Style::default()
159 .fg(Color::Cyan)
160 .add_modifier(Modifier::BOLD);
161 let indent = Span::raw(" ");
162 let mut out: Vec<Line<'static>> = Vec::new();
163
164 if text.is_empty() {
165 out.push(Line::from(vec![Span::styled("β’", bullet_style), indent]));
166 return out;
167 }
168
169 let md_lines = markdown::render_markdown(text, 0);
175 let mut iter = md_lines.into_iter();
176 if let Some(first) = iter.next() {
177 let mut spans: Vec<Span<'static>> = Vec::with_capacity(first.spans.len() + 2);
178 spans.push(Span::styled("β’", bullet_style));
179 spans.push(indent.clone());
180 spans.extend(first.spans);
181 out.push(Line::from(spans));
182 } else {
183 out.push(Line::from(vec![
185 Span::styled("β’", bullet_style),
186 indent.clone(),
187 ]));
188 return out;
189 }
190 for line in iter {
191 let mut spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 1);
192 spans.push(indent.clone());
193 spans.extend(line.spans);
194 out.push(Line::from(spans));
195 }
196 out
197}
198
199fn render_reasoning(text: &str) -> Vec<Line<'static>> {
206 let header_style = Style::default()
207 .fg(Color::Yellow)
208 .add_modifier(Modifier::BOLD);
209 let body_style = Style::default()
212 .fg(Color::Rgb(170, 170, 170))
213 .add_modifier(Modifier::ITALIC);
214 let indent = Span::raw(" ");
215
216 let mut out = vec![Line::from(vec![
217 indent,
218 Span::styled("β΄ Thinkingβ¦".to_string(), header_style),
219 ])];
220
221 for line in text.lines() {
222 if line.is_empty() {
223 out.push(Line::raw(""));
224 } else {
225 out.push(Line::from(vec![
226 Span::raw(" "),
227 Span::styled(line.to_string(), body_style),
228 ]));
229 }
230 }
231
232 out
233}
234
235fn render_tool_call(
248 name: &str,
249 args_preview: &str,
250 result: &Option<ToolResultData>,
251 th: &Theme,
252) -> Vec<Line<'static>> {
253 let bullet_color = match result {
254 None => Color::Yellow,
255 Some(ToolResultData { success: false, .. }) => Color::Red,
256 Some(_) => Color::Green,
257 };
258 let body_color = match result {
259 Some(ToolResultData { success: false, .. }) => th.tool_err_fg,
260 _ => th.status_fg,
261 };
262 let size = result
263 .as_ref()
264 .map(|r| format_size(r.output.len()))
265 .unwrap_or_default();
266 let args_display = if args_preview.is_empty() {
267 String::new()
268 } else {
269 format!("({args_preview})")
270 };
271
272 let mut out = vec![Line::from(vec![
273 Span::raw(" "),
274 Span::styled(
275 "βΊ".to_string(),
276 Style::default()
277 .fg(bullet_color)
278 .add_modifier(Modifier::BOLD),
279 ),
280 Span::raw(" "),
281 Span::styled(
282 name.to_string(),
283 Style::default()
284 .fg(bullet_color)
285 .add_modifier(Modifier::BOLD),
286 ),
287 Span::styled(args_display, Style::default().fg(body_color)),
291 ])];
292
293 match result {
294 None => {
295 out.push(Line::from(vec![
296 Span::styled(" βΏ ", Style::default().fg(body_color)),
297 Span::styled(
298 "Runningβ¦".to_string(),
299 Style::default()
300 .fg(body_color)
301 .add_modifier(Modifier::ITALIC),
302 ),
303 ]));
304 }
305 Some(ToolResultData {
306 success: _,
307 output,
308 expanded,
309 }) => {
310 if !size.is_empty() {
311 out.push(Line::from(vec![
312 Span::styled(" βΏ ", Style::default().fg(body_color)),
313 Span::styled(
314 size,
315 Style::default()
316 .fg(body_color)
317 .add_modifier(Modifier::ITALIC),
318 ),
319 ]));
320 }
321 let collected: Vec<&str> = output.lines().collect();
322 let n = collected.len();
323 let visible: Vec<&&str> = if *expanded || n <= 6 {
324 collected.iter().collect()
325 } else {
326 collected.iter().take(3).collect()
327 };
328 for line in visible {
329 out.push(Line::from(vec![
330 Span::styled(" βΏ ", Style::default().fg(body_color)),
331 Span::styled((*line).to_string(), Style::default().fg(body_color)),
332 ]));
333 }
334 if !*expanded && n > 6 {
335 out.push(Line::from(vec![
336 Span::styled(" βΏ ", Style::default().fg(body_color)),
337 Span::styled(
338 format!("β¦ ({} more lines, press Ctrl+E to expand)", n - 3),
339 Style::default()
340 .fg(body_color)
341 .add_modifier(Modifier::ITALIC),
342 ),
343 ]));
344 }
345 }
346 }
347
348 out
349}
350
351fn format_size(bytes: usize) -> String {
352 if bytes < 1024 {
353 format!("{bytes} B")
354 } else if bytes < 1024 * 1024 {
355 format!("{:.1} KB", bytes as f64 / 1024.0)
356 } else {
357 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
358 }
359}
360
361fn render_diff(path: &str, hunks: &[crate::tui::app::DiffHunk]) -> Vec<Line<'static>> {
364 let mut out = vec![diff::header_line(path)];
365 if hunks.is_empty() {
366 out.push(diff::empty_stub_line(path));
367 } else {
368 out.extend(diff::body_lines(hunks));
369 }
370 out
371}
372
373fn render_compacted(removed: usize, kept: usize) -> Vec<Line<'static>> {
376 vec![Line::from(vec![
377 Span::raw(" "),
378 Span::styled("β", Style::default().fg(Color::Gray)),
379 Span::raw(" "),
380 Span::styled(
381 format!("Conversation compacted: {removed} messages β {kept} summary"),
382 Style::default()
383 .fg(Color::Gray)
384 .add_modifier(Modifier::ITALIC),
385 ),
386 ])]
387}
388
389fn render_system(text: &str) -> Vec<Line<'static>> {
390 vec![Line::from(vec![Span::styled(
391 text.to_string(),
392 Style::default()
393 .fg(Color::Gray)
394 .add_modifier(Modifier::ITALIC),
395 )])]
396}
397
398fn render_error(text: &str, th: &Theme) -> Vec<Line<'static>> {
399 vec![Line::from(vec![Span::styled(
400 text.to_string(),
401 Style::default().fg(th.tool_err_fg),
402 )])]
403}
404
405fn render_plan_proposal(plan_text: &str, tool_calls: &[serde_json::Value]) -> Vec<Line<'static>> {
421 let border = Style::default().fg(Color::Cyan);
422 let header = Style::default()
423 .fg(Color::Cyan)
424 .add_modifier(Modifier::BOLD);
425 let body = Style::default().fg(Color::White);
426 let dim = Style::default().fg(Color::DarkGray);
427 let key = Style::default().fg(Color::Cyan);
428 let tool_name_style = Style::default().fg(Color::Yellow);
429
430 let mut out: Vec<Line<'static>> = Vec::new();
431
432 out.push(Line::from(vec![
434 Span::styled("ββ ", border),
435 Span::styled("β‘ Plan Proposal ", header),
436 Span::styled("βββββββββββββββββββββββββββββββββββββ", border),
437 ]));
438
439 for raw in plan_text.lines() {
441 out.push(Line::from(vec![
442 Span::styled("β ", border),
443 Span::styled(raw.to_string(), body),
444 ]));
445 }
446
447 out.push(Line::from(vec![Span::styled("β", border)]));
449 out.push(Line::from(vec![
450 Span::styled("β ", border),
451 Span::styled(
452 format!("Pending tools ({}):", tool_calls.len()),
453 Style::default()
454 .fg(Color::Yellow)
455 .add_modifier(Modifier::BOLD),
456 ),
457 ]));
458
459 if tool_calls.is_empty() {
460 out.push(Line::from(vec![
461 Span::styled("β ", border),
462 Span::styled(" (none)", dim),
463 ]));
464 } else {
465 for tc in tool_calls {
466 let name = tc
467 .get("name")
468 .and_then(|v| v.as_str())
469 .unwrap_or("<unknown>");
470 let args = tc
471 .get("arguments")
472 .map(|v| plan_args_preview(v, 50))
473 .unwrap_or_default();
474 out.push(Line::from(vec![
475 Span::styled("β β’ ", border),
476 Span::styled(name.to_string(), tool_name_style),
477 Span::styled(format!("({args})"), body),
478 ]));
479 }
480 }
481
482 out.push(Line::from(vec![Span::styled("β", border)]));
484 out.push(Line::from(vec![
485 Span::styled("β ", border),
486 Span::styled("[y/Enter] ", key),
487 Span::styled(
488 "Approve",
489 Style::default()
490 .fg(Color::Green)
491 .add_modifier(Modifier::BOLD),
492 ),
493 Span::raw(" "),
494 Span::styled("[n/Esc] ", key),
495 Span::styled(
496 "Reject",
497 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
498 ),
499 Span::raw(" "),
500 Span::styled("[e] ", key),
501 Span::styled("Edit", Style::default().fg(Color::Yellow)),
502 ]));
503
504 out.push(Line::from(vec![Span::styled(
506 "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ",
507 border,
508 )]));
509
510 out
511}
512
513fn plan_args_preview(value: &serde_json::Value, limit: usize) -> String {
515 use serde_json::Value;
516 let raw = match value {
517 Value::String(s) => format!("\"{s}\""),
518 Value::Object(map) => {
519 let mut parts = Vec::new();
520 for (k, v) in map.iter().take(2) {
521 let v_str = match v {
522 Value::String(s) => {
523 let s = if s.chars().count() > 20 {
524 let h: String = s.chars().take(19).collect();
525 format!("{h}β¦")
526 } else {
527 s.clone()
528 };
529 format!("\"{s}\"")
530 }
531 other => other.to_string(),
532 };
533 parts.push(format!("{k}={v_str}"));
534 }
535 parts.join(", ")
536 }
537 Value::Null => String::new(),
538 other => other.to_string(),
539 };
540 if raw.chars().count() > limit {
541 let head: String = raw.chars().take(limit - 1).collect();
542 format!("{head}β¦")
543 } else {
544 raw
545 }
546}
547
548fn render_plan_mode_request(reason: &str, approved: Option<bool>) -> Vec<Line<'static>> {
565 let border = Style::default().fg(Color::Blue);
566 let header_style = Style::default()
567 .fg(Color::Blue)
568 .add_modifier(Modifier::BOLD);
569 let body = Style::default().fg(Color::White);
570 let key = Style::default().fg(Color::Cyan);
571
572 let mut out: Vec<Line<'static>> = Vec::new();
573 out.push(Line::raw(""));
574
575 match approved {
576 Some(true) => {
577 out.push(Line::from(vec![
578 Span::styled("ββ ", border),
579 Span::styled(
580 "β Plan mode allowed",
581 Style::default()
582 .fg(Color::Green)
583 .add_modifier(Modifier::BOLD),
584 ),
585 Span::styled(" βββββββββββββββββββββββββββββββββββββββββ", border),
586 ]));
587 out.push(Line::from(vec![
588 Span::styled("β ", border),
589 Span::styled(reason.to_owned(), Style::default().fg(Color::DarkGray)),
590 ]));
591 out.push(Line::from(vec![Span::styled(
592 "ββββββββββββββββββββββββββββββββββββββββββββββββββββββ",
593 border,
594 )]));
595 }
596 Some(false) => {
597 out.push(Line::from(vec![
598 Span::styled("ββ ", border),
599 Span::styled(
600 "β Plan mode skipped",
601 Style::default()
602 .fg(Color::DarkGray)
603 .add_modifier(Modifier::BOLD),
604 ),
605 Span::styled(" βββββββββββββββββββββββββββββββββββββββββ", border),
606 ]));
607 out.push(Line::from(vec![
608 Span::styled("β ", border),
609 Span::styled(reason.to_owned(), Style::default().fg(Color::DarkGray)),
610 ]));
611 out.push(Line::from(vec![Span::styled(
612 "ββββββββββββββββββββββββββββββββββββββββββββββββββββββ",
613 border,
614 )]));
615 }
616 None => {
617 out.push(Line::from(vec![
619 Span::styled("ββ ", border),
620 Span::styled("β Plan Mode Request", header_style),
621 Span::styled(" βββββββββββββββββββββββββββββββββββββββββ", border),
622 ]));
623 out.push(Line::from(vec![
624 Span::styled("β ", border),
625 Span::styled(
626 "Agent wants to enter plan mode:",
627 body.add_modifier(Modifier::BOLD),
628 ),
629 ]));
630 out.push(Line::from(vec![Span::styled("β", border)]));
631 for line in reason.lines() {
632 out.push(Line::from(vec![
633 Span::styled("β ", border),
634 Span::styled(line.to_owned(), Style::default().fg(Color::Yellow)),
635 ]));
636 }
637 out.push(Line::from(vec![Span::styled("β", border)]));
638 out.push(Line::from(vec![
639 Span::styled("β ", border),
640 Span::styled("Allow agent to explore and create a plan?", body),
641 ]));
642 out.push(Line::raw("β"));
643 out.push(Line::from(vec![
644 Span::styled("β ", border),
645 Span::styled("[y/Enter] ", key),
646 Span::styled(
647 "Allow",
648 Style::default()
649 .fg(Color::Green)
650 .add_modifier(Modifier::BOLD),
651 ),
652 Span::raw(" "),
653 Span::styled("[n/Esc] ", key),
654 Span::styled("Skip β execute directly", Style::default().fg(Color::Red)),
655 ]));
656 out.push(Line::from(vec![Span::styled(
657 "ββββββββββββββββββββββββββββββββββββββββββββββββββββββ",
658 border,
659 )]));
660 }
661 }
662
663 out.push(Line::raw(""));
664 out
665}
666
667#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::tui::app::{DiffHunk, DiffLine, DiffLineKind, ToolResultData, TranscriptBlock};
675 use crate::tui::ui::theme;
676
677 fn line_text(line: &Line) -> String {
678 line.spans.iter().map(|s| s.content.as_ref()).collect()
679 }
680
681 fn full_text(lines: &[Line]) -> String {
682 lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
683 }
684
685 #[test]
686 fn user_block_renders_quote_prefix_and_body() {
687 let lines = render_block(
688 &TranscriptBlock::User {
689 text: "hello world".into(),
690 },
691 &theme::DARK,
692 );
693 let txt = line_text(&lines[0]);
694 assert!(txt.starts_with("> "), "first line should start with `> `");
695 assert!(txt.contains("hello world"));
696 }
697
698 #[test]
699 fn user_block_multiline_stacks_with_quote_prefix() {
700 let lines = render_block(
701 &TranscriptBlock::User {
702 text: "line1\nline2".into(),
703 },
704 &theme::DARK,
705 );
706 assert_eq!(lines.len(), 2);
707 assert!(line_text(&lines[0]).starts_with("> "));
708 assert!(line_text(&lines[1]).starts_with("> "));
709 assert!(line_text(&lines[1]).contains("line2"));
710 }
711
712 #[test]
713 fn user_block_carries_background_highlight() {
714 let lines = render_block(&TranscriptBlock::User { text: "hi".into() }, &theme::DARK);
715 let has_bg = lines[0]
716 .spans
717 .iter()
718 .any(|s| s.style.bg.is_some() && s.style.bg != Some(Color::Reset));
719 assert!(has_bg, "user message should have a background highlight");
720 }
721
722 #[test]
723 fn reasoning_block_has_thinking_header_and_italic_body() {
724 let lines = render_block(
725 &TranscriptBlock::Reasoning {
726 text: "let me think about this\nmaybe this way".into(),
727 },
728 &theme::DARK,
729 );
730 let header = line_text(&lines[0]);
731 assert!(
732 header.contains("Thinking") || header.contains("β΄"),
733 "missing thinking header"
734 );
735 let body = full_text(&lines);
736 assert!(body.contains("let me think about this"));
737 assert!(body.contains("maybe this way"));
738 let has_italic = lines[1..]
740 .iter()
741 .flat_map(|l| l.spans.iter())
742 .any(|s| s.style.add_modifier.contains(Modifier::ITALIC));
743 assert!(has_italic, "reasoning body should be italic");
744 }
745
746 #[test]
747 fn reasoning_block_empty_text_still_shows_header() {
748 let lines = render_block(
749 &TranscriptBlock::Reasoning {
750 text: String::new(),
751 },
752 &theme::DARK,
753 );
754 let h = line_text(&lines[0]);
755 assert!(h.contains("Thinking") || h.contains("β΄"));
756 }
757
758 #[test]
759 fn assistant_block_renders_bullet_no_label() {
760 let lines = render_block(
761 &TranscriptBlock::Assistant {
762 text: "hello".into(),
763 streaming: false,
764 latency_ms: None,
765 },
766 &theme::DARK,
767 );
768 let txt = line_text(&lines[0]);
769 assert!(txt.contains("β’"), "assistant must lead with a bullet");
770 assert!(!txt.contains("Agent"), "old `Agent` label should be gone");
771 assert!(!txt.contains("β±"), "latency should not be in the block");
772 assert!(!txt.contains("streaming"), "no streaming label anymore");
773 }
774
775 #[test]
776 fn tool_call_without_result_shows_running_in_yellow() {
777 let lines = render_block(
778 &TranscriptBlock::ToolCall {
779 id: "1".into(),
780 name: "read_file".into(),
781 args_preview: "path=\"foo\"".into(),
782 result: None,
783 },
784 &theme::DARK,
785 );
786 let header = &lines[0];
787 assert!(line_text(header).contains("βΊ"));
788 assert!(line_text(header).contains("read_file"));
789 let bullet_yellow = header
791 .spans
792 .iter()
793 .any(|s| s.content == "βΊ" && s.style.fg == Some(Color::Yellow));
794 assert!(bullet_yellow, "running bullet should be yellow");
795 assert!(line_text(&lines[1]).contains("Running"));
797 }
798
799 #[test]
800 fn tool_call_with_successful_result_turns_bullet_green() {
801 let lines = render_block(
802 &TranscriptBlock::ToolCall {
803 id: "1".into(),
804 name: "read_file".into(),
805 args_preview: String::new(),
806 result: Some(ToolResultData {
807 success: true,
808 output: "abc".into(),
809 expanded: false,
810 }),
811 },
812 &theme::DARK,
813 );
814 let header = &lines[0];
815 let bullet_green = header
816 .spans
817 .iter()
818 .any(|s| s.content == "βΊ" && s.style.fg == Some(Color::Green));
819 assert!(bullet_green, "successful bullet should be green");
820 assert!(full_text(&lines).contains("abc"));
821 }
822
823 #[test]
824 fn tool_call_with_failed_result_turns_bullet_red() {
825 let lines = render_block(
826 &TranscriptBlock::ToolCall {
827 id: "1".into(),
828 name: "x".into(),
829 args_preview: String::new(),
830 result: Some(ToolResultData {
831 success: false,
832 output: "boom".into(),
833 expanded: false,
834 }),
835 },
836 &theme::DARK,
837 );
838 let header = &lines[0];
839 let has_red = header.spans.iter().any(|s| s.style.fg == Some(Color::Red));
840 assert!(has_red, "failed bullet should be red");
841 }
842
843 #[test]
844 fn tool_result_long_output_truncated_with_hint() {
845 let output = (0..10)
846 .map(|i| format!("line {i}"))
847 .collect::<Vec<_>>()
848 .join("\n");
849 let lines = render_block(
850 &TranscriptBlock::ToolCall {
851 id: "1".into(),
852 name: "read_file".into(),
853 args_preview: String::new(),
854 result: Some(ToolResultData {
855 success: true,
856 output,
857 expanded: false,
858 }),
859 },
860 &theme::DARK,
861 );
862 let txt = full_text(&lines);
863 assert!(txt.contains("Ctrl+E"));
864 assert!(txt.contains("more lines"));
865 }
866
867 #[test]
868 fn tool_result_expanded_shows_all() {
869 let output = (0..10)
870 .map(|i| format!("line {i}"))
871 .collect::<Vec<_>>()
872 .join("\n");
873 let lines = render_block(
874 &TranscriptBlock::ToolCall {
875 id: "1".into(),
876 name: "read_file".into(),
877 args_preview: String::new(),
878 result: Some(ToolResultData {
879 success: true,
880 output,
881 expanded: true,
882 }),
883 },
884 &theme::DARK,
885 );
886 for i in 0..10 {
887 assert!(full_text(&lines).contains(&format!("line {i}")));
888 }
889 }
890
891 #[test]
892 fn diff_block_renders_path_header_and_hunks() {
893 let block = TranscriptBlock::Diff {
894 path: "src/x.rs".into(),
895 hunks: vec![DiffHunk {
896 lines: vec![DiffLine {
897 kind: DiffLineKind::Add,
898 text: "x".into(),
899 }],
900 }],
901 };
902 let lines = render_block(&block, &theme::DARK);
903 assert!(line_text(&lines[0]).contains("src/x.rs"));
904 let has_green = lines
906 .iter()
907 .flat_map(|l| l.spans.iter())
908 .any(|s| s.style.fg == Some(Color::Green));
909 assert!(has_green);
910 }
911
912 #[test]
913 fn diff_block_with_no_hunks_renders_stub() {
914 let block = TranscriptBlock::Diff {
915 path: "src/x.rs".into(),
916 hunks: vec![],
917 };
918 let lines = render_block(&block, &theme::DARK);
919 assert!(lines.iter().any(|l| line_text(l).contains("Updated")));
920 }
921
922 #[test]
923 fn compacted_block_renders_with_summary() {
924 let lines = render_block(
925 &TranscriptBlock::Compacted {
926 removed: 12,
927 kept: 1,
928 },
929 &theme::DARK,
930 );
931 let s = line_text(&lines[0]);
932 assert!(s.contains("12"));
933 assert!(s.contains("compacted"));
934 }
935}