Skip to main content

recursive/tui/ui/
transcript.rs

1//! Block-aware transcript renderer.
2//!
3//! Goal-144 replaces the single-line `StyledMessage::to_line()` ladder
4//! with one render function per [`TranscriptBlock`] variant. A block
5//! produces 1-or-more [`Line`]s; the chat panel concatenates the
6//! results separated by a blank line.
7
8use ratatui::prelude::*;
9
10use crate::tui::app::{ToolResultData, TranscriptBlock, UsageStats};
11use crate::tui::ui::theme::Theme;
12use crate::tui::ui::{diff, markdown};
13
14/// Convert the entire transcript into a flat `Vec<Line>` with one
15/// blank line between adjacent blocks. Folded ToolResult blocks
16/// honour the `expanded` flag.
17pub 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            // Always insert one blank line between consecutive blocks.
26            lines.push(Line::raw(""));
27            // Add a second blank line before User blocks so the conversation
28            // has clear visual breathing room between turns.
29            if matches!(b, TranscriptBlock::User { .. }) {
30                lines.push(Line::raw(""));
31            }
32        }
33        lines.extend(render_block(b, th));
34        // Add a trailing blank line after each User block so the AI response
35        // that follows feels spacious (same two-line gap on both sides).
36        if matches!(b, TranscriptBlock::User { .. }) {
37            lines.push(Line::raw(""));
38        }
39    }
40    lines
41}
42
43/// Render a single block. Exposed for unit tests.
44pub 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); // align continuation lines
100        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
108// ── User ──────────────────────────────────────────────────────────────
109
110/// Render a user message in Claude-Code style: `> text` with a
111/// white foreground on a dim grey background highlight. Multi-line
112/// messages stay stacked, one `>` per line, so the visual shape
113/// mirrors the indentation of a quoted block reply.
114fn 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        // Empty placeholder: a bare `> ` with a single background
127        // pixel so the row still picks up the highlight.
128        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
144// ── Assistant ─────────────────────────────────────────────────────────
145
146/// Render an assistant message in Claude-Code style: one cyan
147/// bullet, then a `  ` indent for the body. The legacy "β–Ž Agent"
148/// header, inline `⏱` latency, and `…streaming` text are removed;
149/// streaming and latency are now expressed through the in-flight
150/// bullet colour and the status bar respectively. Empty text
151/// collapses to a bare bullet.
152fn 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    // Use the full pulldown-cmark based renderer for proper markdown
170    // parsing (handles multi-paragraph blocks, nested lists, fenced
171    // code with syntax highlighting, etc.). The first line picks up
172    // the bullet prefix; subsequent lines share the 2-space indent
173    // so wrapping reads naturally.
174    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        // Empty markdown output (only whitespace): a bare bullet.
184        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
199// ── Reasoning / thinking ──────────────────────────────────────────────
200
201/// Render a reasoning / thinking block in Claude-Code style:
202/// a `∴ Thinking…` header in dim yellow, followed by the
203/// reasoning text in a slightly muted gray. Empty / whitespace-only
204/// reasoning collapses to just the header.
205fn render_reasoning(text: &str) -> Vec<Line<'static>> {
206    let header_style = Style::default()
207        .fg(Color::Yellow)
208        .add_modifier(Modifier::BOLD);
209    // Slightly muted gray-white so thinking content is visually distinct
210    // from regular assistant text (which is pure white).
211    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
235// ── ToolCall (with paired result) ─────────────────────────────────────
236
237/// Render a tool call in Claude-Code style: one `⏺` bullet whose
238/// colour reflects the tool state, followed by the args in a
239/// function-call style (`name(args)`). Below the bullet sits an
240/// indented result block (`⎿`) when the tool has finished; while
241/// the tool is still running, the result line shows `Running…`.
242///
243/// States:
244/// - `result == None`        β†’ bullet Yellow, body `Running…`
245/// - `result.success == true` β†’ bullet Green,  body output (collapsed/expanded)
246/// - `result.success == false`β†’ bullet Red,    body output in error colour
247fn 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        // Args preview shares the body color so the whole tool line
288        // reads in one tone; the bullet and tool name still pop in
289        // the status colour.
290        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
361// ── Diff ──────────────────────────────────────────────────────────────
362
363fn 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
373// ── Compacted / System / Error ────────────────────────────────────────
374
375fn 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
405// ── PlanProposal ──────────────────────────────────────────────────────
406
407/// Render a plan proposal inline in the transcript.
408///
409/// Layout:
410/// ```text
411/// β•” ⚑ Plan Proposal ──────────────────╗
412/// β•‘ <plan_text, line by line>          β•‘
413/// β•‘                                    β•‘
414/// β•‘ Pending tools (N):                 β•‘
415/// β•‘   β€’ tool_name(args_preview)        β•‘
416/// β•‘                                    β•‘
417/// β•‘ [y/Enter] Approve  [n] Reject  [e] Edit
418/// β•šβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β•
419/// ```
420fn 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    // Top border + title
433    out.push(Line::from(vec![
434        Span::styled("β”Œβ”€ ", border),
435        Span::styled("⚑ Plan Proposal ", header),
436        Span::styled("─────────────────────────────────────", border),
437    ]));
438
439    // Plan text body
440    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    // Separator before tool list
448    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    // Action hint row
483    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    // Bottom border
505    out.push(Line::from(vec![Span::styled(
506        "└─────────────────────────────────────────────────────────",
507        border,
508    )]));
509
510    out
511}
512
513/// Compact preview of a tool's `arguments` JSON (max `limit` chars).
514fn 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
548// ── PlanModeRequest (Goal-202) ────────────────────────────────────────
549
550/// Render the inline plan-mode entry request block:
551///
552/// ```text
553/// ╔─ β“˜ Plan Mode Request ────────────────╗
554/// β•‘ Agent wants to enter plan mode:       β•‘
555/// β•‘                                       β•‘
556/// β•‘   <reason>                            β•‘
557/// β•‘                                       β•‘
558/// β•‘ Allow agent to explore and plan?      β•‘
559/// β•‘  [y/Enter] Allow   [n/Esc] Skip       β•‘
560/// β•šβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β•
561/// ```
562///
563/// After decision: shows `βœ“ Plan mode allowed` or `βœ— Plan mode skipped`.
564fn 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            // Pending β€” show full request UI.
618            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// ──────────────────────────────────────────────────────────────────────
668// Tests
669// ──────────────────────────────────────────────────────────────────────
670
671#[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        // body lines should be italic
739        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        // Bullet must be yellow.
790        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        // Second line should be the Running… placeholder.
796        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        // body should have at least one Green span (diff adds are always green)
905        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}