Skip to main content

zeph_tui/widgets/
chat.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
5use ratatui::Frame;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Paragraph};
10use throbber_widgets_tui::{BRAILLE_SIX, Throbber, WhichUse};
11
12use crate::app::{App, MessageRole, RenderCache, RenderCacheKey, content_hash};
13use crate::highlight::SYNTAX_HIGHLIGHTER;
14use crate::hyperlink;
15use crate::theme::{SyntaxTheme, Theme};
16
17/// A markdown link extracted during rendering: visible display text and target URL.
18#[derive(Clone, Debug)]
19pub struct MdLink {
20    pub text: String,
21    pub url: String,
22}
23
24/// Returns the maximum scroll offset for the rendered content.
25pub fn render(app: &mut App, frame: &mut Frame, area: Rect, cache: &mut RenderCache) -> usize {
26    if area.width == 0 || area.height == 0 {
27        return 0;
28    }
29
30    let theme = Theme::default();
31    let inner_height = area.height.saturating_sub(2) as usize;
32    // 2 for block borders + 2 for accent prefix ("▎ ") added per line
33    let wrap_width = area.width.saturating_sub(4) as usize;
34
35    // Use visible_messages() to support subagent transcript view.
36    let messages = app.visible_messages();
37    let truncation_info = app.transcript_truncation_info();
38    let title = if let Some(ref name) = app.view_target().subagent_name().map(str::to_owned) {
39        format!(" Subagent: {name} ")
40    } else {
41        " Chat ".to_owned()
42    };
43
44    let (mut lines, all_md_links) = collect_message_lines_from(
45        &messages,
46        truncation_info.as_deref(),
47        cache,
48        area.width,
49        wrap_width,
50        &theme,
51        app.tool_expanded(),
52        app.compact_tools(),
53        app.show_source_labels(),
54        usize::try_from(
55            app.throbber_state()
56                .index()
57                .rem_euclid(i8::try_from(BRAILLE_SIX.symbols.len()).unwrap_or(i8::MAX)),
58        )
59        .unwrap_or(0),
60    );
61
62    let total = lines.len();
63
64    if total < inner_height {
65        let padding = inner_height - total;
66        let mut padded = vec![Line::default(); padding];
67        padded.append(&mut lines);
68        lines = padded;
69    }
70
71    let total = lines.len();
72    let max_scroll = total.saturating_sub(inner_height);
73    let effective_offset = app.scroll_offset().min(max_scroll);
74    let scroll = max_scroll - effective_offset;
75
76    let paragraph = Paragraph::new(lines)
77        .block(
78            Block::default()
79                .borders(Borders::ALL)
80                .border_style(theme.panel_border)
81                .title(title),
82        )
83        .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
84
85    frame.render_widget(paragraph, area);
86
87    app.set_hyperlinks(hyperlink::collect_from_buffer_with_md_links(
88        frame.buffer_mut(),
89        area,
90        &all_md_links,
91    ));
92
93    if total > inner_height {
94        render_scrollbar(
95            frame,
96            area,
97            inner_height,
98            total,
99            scroll,
100            effective_offset,
101            max_scroll,
102        );
103    }
104
105    max_scroll
106}
107
108#[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
109fn collect_message_lines_from(
110    messages: &[crate::app::ChatMessage],
111    truncation_info: Option<&str>,
112    cache: &mut RenderCache,
113    terminal_width: u16,
114    wrap_width: usize,
115    theme: &Theme,
116    tool_expanded: bool,
117    compact_tools: bool,
118    show_labels: bool,
119    throbber_idx: usize,
120) -> (Vec<Line<'static>>, Vec<MdLink>) {
121    let mut lines: Vec<Line<'static>> = Vec::new();
122    let mut all_md_links: Vec<MdLink> = Vec::new();
123
124    // Show truncation marker at the top when transcript was truncated (W4).
125    if let Some(info) = truncation_info {
126        lines.push(Line::from(Span::styled(
127            format!("  {info}"),
128            theme.system_message,
129        )));
130        lines.push(Line::default());
131    }
132
133    for (idx, msg) in messages.iter().enumerate() {
134        let accent = match msg.role {
135            MessageRole::User => theme.user_message,
136            MessageRole::Assistant => theme.assistant_accent,
137            MessageRole::Tool => theme.tool_accent,
138            MessageRole::System => theme.system_message,
139        };
140
141        if idx > 0 {
142            lines.push(Line::default());
143        }
144
145        let cache_key = RenderCacheKey {
146            content_hash: content_hash(&msg.content),
147            terminal_width,
148            tool_expanded,
149            compact_tools,
150            show_labels,
151        };
152
153        // All messages (including streaming) use the render cache. For streaming
154        // messages the cache key includes content_hash, so a new chunk causes a
155        // cache miss and triggers re-render; unchanged content reuses the cached
156        // result, eliminating redundant pulldown-cmark + tree-sitter work every frame.
157        //
158        // Note: tool messages with streaming=true use throbber_idx for the braille
159        // spinner. Between content chunks the spinner freezes, but tool output
160        // typically arrives in one batch, making this trade-off acceptable.
161        let (msg_lines, msg_md_links) =
162            if let Some((cached_lines, cached_links)) = cache.get(idx, &cache_key) {
163                (cached_lines.to_vec(), cached_links.to_vec())
164            } else {
165                let (rendered, extracted) = render_message_lines(
166                    msg,
167                    tool_expanded,
168                    compact_tools,
169                    throbber_idx,
170                    theme,
171                    wrap_width,
172                    show_labels,
173                );
174                cache.put(idx, cache_key, rendered.clone(), extracted.clone());
175                (rendered, extracted)
176            };
177
178        all_md_links.extend(msg_md_links);
179
180        let time_str = &msg.timestamp;
181        for (i, mut line) in msg_lines.into_iter().enumerate() {
182            if msg.role == MessageRole::User {
183                line.spans.insert(0, Span::styled("\u{258e} ", accent));
184            } else {
185                line.spans.insert(0, Span::raw("  "));
186            }
187            if i == 0 {
188                let content_width: usize =
189                    line.spans.iter().map(|s| s.content.chars().count()).sum();
190                let pad = wrap_width
191                    .saturating_sub(content_width)
192                    .saturating_sub(time_str.len());
193                if pad > 0 {
194                    line.spans.push(Span::raw(" ".repeat(pad)));
195                    line.spans
196                        .push(Span::styled(time_str.clone(), theme.system_message));
197                }
198            }
199            lines.push(line);
200        }
201    }
202
203    (lines, all_md_links)
204}
205
206pub fn render_activity(app: &mut App, frame: &mut Frame, area: Rect) {
207    if area.height == 0 || area.width == 0 {
208        return;
209    }
210    let theme = Theme::default();
211
212    // Primary status label (tool running, status update, etc.).
213    if let Some(label) = app.status_label() {
214        let label = format!(" {label}");
215        let throbber = Throbber::default()
216            .label(label)
217            .style(theme.assistant_message)
218            .throbber_style(theme.highlight)
219            .throbber_set(BRAILLE_SIX)
220            .use_type(WhichUse::Spin);
221        frame.render_stateful_widget(throbber, area, app.throbber_state_mut());
222        return;
223    }
224
225    // Fallback: show active TaskSupervisor tasks with a braille spinner.
226    if let Some(task_label) = app.supervisor_activity_label() {
227        let label = format!(" {task_label}");
228        let throbber = Throbber::default()
229            .label(label)
230            .style(theme.assistant_message)
231            .throbber_style(theme.highlight)
232            .throbber_set(BRAILLE_SIX)
233            .use_type(WhichUse::Spin);
234        frame.render_stateful_widget(throbber, area, app.throbber_state_mut());
235    }
236}
237
238fn render_message_lines(
239    msg: &crate::app::ChatMessage,
240    tool_expanded: bool,
241    compact_tools: bool,
242    throbber_idx: usize,
243    theme: &Theme,
244    wrap_width: usize,
245    show_labels: bool,
246) -> (Vec<Line<'static>>, Vec<MdLink>) {
247    let mut lines = Vec::new();
248    let md_links = if msg.role == MessageRole::Tool {
249        render_tool_message(
250            msg,
251            tool_expanded,
252            compact_tools,
253            throbber_idx,
254            theme,
255            wrap_width,
256            show_labels,
257            &mut lines,
258        );
259        Vec::new()
260    } else {
261        render_chat_message(
262            msg,
263            tool_expanded,
264            theme,
265            wrap_width,
266            show_labels,
267            &mut lines,
268        )
269    };
270    (lines, md_links)
271}
272
273fn render_chat_message(
274    msg: &crate::app::ChatMessage,
275    tool_expanded: bool,
276    theme: &Theme,
277    wrap_width: usize,
278    _show_labels: bool,
279    lines: &mut Vec<Line<'static>>,
280) -> Vec<MdLink> {
281    let base_style = match msg.role {
282        MessageRole::User => theme.user_message,
283        MessageRole::Assistant => theme.assistant_message,
284        MessageRole::System => theme.system_message,
285        MessageRole::Tool => unreachable!(),
286    };
287    let prefix = "";
288
289    let indent = " ".repeat(prefix.len());
290    let is_assistant = msg.role == MessageRole::Assistant;
291
292    // Collapsible paste block: show first PASTE_COLLAPSED_LINES lines and an
293    // expand hint when the message was submitted from a multiline paste and the
294    // global expand toggle is off. This reuses tool_expanded deliberately —
295    // 'e' means "show full content" for all collapsible blocks (paste and tool output).
296    if let Some(total_lines) = msg.paste_line_count
297        && !tool_expanded
298        && total_lines > PASTE_COLLAPSED_LINES
299    {
300        let content_lines: Vec<&str> = msg.content.lines().collect();
301        let visible: Vec<&str> = content_lines
302            .iter()
303            .take(PASTE_COLLAPSED_LINES)
304            .copied()
305            .collect();
306        let preview = visible.join("\n");
307        let (styled_lines, md_links) = render_md(&preview, base_style, theme);
308        for (i, spans) in styled_lines.iter().enumerate() {
309            let mut line_spans = Vec::with_capacity(spans.len() + 1);
310            let pfx = if i == 0 {
311                prefix.to_string()
312            } else {
313                indent.clone()
314            };
315            line_spans.push(Span::styled(pfx, base_style));
316            line_spans.extend(spans.iter().cloned());
317            lines.extend(wrap_spans(line_spans, wrap_width));
318        }
319        let hidden = total_lines - PASTE_COLLAPSED_LINES;
320        let dim = Style::default().add_modifier(Modifier::DIM);
321        lines.push(Line::from(Span::styled(
322            format!("[... {hidden} more lines — press e to expand]"),
323            dim,
324        )));
325        return md_links;
326    }
327
328    let (styled_lines, md_links) = if is_assistant {
329        render_with_thinking(&msg.content, base_style, theme)
330    } else {
331        render_md(&msg.content, base_style, theme)
332    };
333
334    for (i, spans) in styled_lines.iter().enumerate() {
335        let mut line_spans = Vec::with_capacity(spans.len() + 1);
336        let pfx = if i == 0 {
337            prefix.to_string()
338        } else {
339            indent.clone()
340        };
341        let pfx_style = if is_assistant && !spans.is_empty() {
342            spans[0].style
343        } else {
344            base_style
345        };
346        line_spans.push(Span::styled(pfx, pfx_style));
347        line_spans.extend(spans.iter().cloned());
348
349        let is_last_line = i == styled_lines.len() - 1;
350        if msg.streaming && is_last_line {
351            line_spans.push(Span::styled("\u{2502}".to_string(), theme.streaming_cursor));
352        }
353
354        lines.extend(wrap_spans(line_spans, wrap_width));
355    }
356
357    if styled_lines.is_empty() {
358        let mut pfx_spans = vec![Span::styled(prefix.to_string(), base_style)];
359        if msg.streaming {
360            pfx_spans.push(Span::styled("\u{2502}".to_string(), theme.streaming_cursor));
361        }
362        lines.extend(wrap_spans(pfx_spans, wrap_width));
363    }
364
365    md_links
366}
367
368fn render_scrollbar(
369    frame: &mut Frame,
370    area: Rect,
371    inner_height: usize,
372    total: usize,
373    scroll: usize,
374    _effective_offset: usize,
375    max_scroll: usize,
376) {
377    let track_height = inner_height;
378    if track_height == 0 {
379        return;
380    }
381    let thumb_size = (inner_height * track_height)
382        .checked_div(total)
383        .unwrap_or(track_height)
384        .clamp(1, track_height);
385    let thumb_pos = ((track_height - thumb_size) * scroll)
386        .checked_div(max_scroll)
387        .unwrap_or(0);
388    let track_top = area.y + 1;
389    let bar_x = area.x + area.width.saturating_sub(1);
390    let dim = Style::default().fg(ratatui::style::Color::DarkGray);
391    for row in 0..track_height {
392        let ch = if row >= thumb_pos && row < thumb_pos + thumb_size {
393            "\u{2502}"
394        } else {
395            " "
396        };
397        let row_y = u16::try_from(row).unwrap_or(u16::MAX);
398        frame
399            .buffer_mut()
400            .set_string(bar_x, track_top + row_y, ch, dim);
401    }
402}
403
404const TOOL_OUTPUT_COLLAPSED_LINES: usize = 3;
405
406/// Number of lines shown in a collapsed paste block before the expand hint.
407/// Mirrors `TOOL_OUTPUT_COLLAPSED_LINES` for visual consistency.
408const PASTE_COLLAPSED_LINES: usize = 3;
409
410#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // complex algorithm function; both suppressions justified until the function is decomposed in a future refactor
411fn render_tool_message(
412    msg: &crate::app::ChatMessage,
413    tool_expanded: bool,
414    compact_tools: bool,
415    throbber_idx: usize,
416    theme: &Theme,
417    wrap_width: usize,
418    _show_labels: bool,
419    lines: &mut Vec<Line<'static>>,
420) {
421    let name = msg
422        .tool_name
423        .as_ref()
424        .map_or("tool", zeph_common::ToolName::as_str);
425    let content_lines: Vec<&str> = msg.content.lines().collect();
426    let cmd_line = content_lines.first().copied().unwrap_or("");
427    let dim = Style::default().add_modifier(Modifier::DIM);
428
429    let status_span = if msg.streaming {
430        let symbol = BRAILLE_SIX.symbols[throbber_idx];
431        Span::styled(format!("{symbol} "), theme.streaming_cursor)
432    } else {
433        Span::styled("\u{2714} ", dim)
434    };
435    let cmd_spans: Vec<Span<'static>> = vec![
436        status_span,
437        Span::styled(format!("{name} "), dim),
438        Span::styled(cmd_line.to_string(), dim),
439    ];
440    lines.extend(wrap_spans(cmd_spans, wrap_width));
441    let indent = "  ";
442
443    // Diff rendering for write/edit tools
444    if let Some(ref diff_data) = msg.diff_data {
445        let diff_lines = super::diff::compute_diff(&diff_data.old_content, &diff_data.new_content);
446        let rendered = super::diff::render_diff_lines(&diff_lines, &diff_data.file_path, theme);
447        let mut wrapped: Vec<Line<'static>> = Vec::new();
448        for line in rendered {
449            let mut prefixed_spans = vec![Span::styled(indent.to_string(), Style::default())];
450            prefixed_spans.extend(line.spans);
451            wrapped.push(Line::from(prefixed_spans));
452        }
453        let total_visual = wrapped.len();
454        let show_all = tool_expanded || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES;
455        if show_all {
456            lines.extend(wrapped);
457        } else {
458            lines.extend(wrapped.into_iter().take(TOOL_OUTPUT_COLLAPSED_LINES));
459            let remaining = total_visual - TOOL_OUTPUT_COLLAPSED_LINES;
460            let dim = Style::default().add_modifier(Modifier::DIM);
461            lines.push(Line::from(Span::styled(
462                format!(
463                    "{indent}... ({remaining} hidden, {total_visual} total, press 'e' to expand)"
464                ),
465                dim,
466            )));
467        }
468        return;
469    }
470
471    // Output lines (everything after the command)
472    if content_lines.len() > 1 {
473        if compact_tools {
474            let line_count = content_lines.len() - 1;
475            let noun = if line_count == 1 { "line" } else { "lines" };
476            let summary = format!("{indent}-- {line_count} {noun}");
477            lines.push(Line::from(Span::styled(
478                summary,
479                Style::default().add_modifier(Modifier::DIM),
480            )));
481        } else {
482            let output_lines = &content_lines[1..];
483            let has_diagnostics = tool_expanded
484                && msg.kept_lines.is_some()
485                && !msg.kept_lines.as_ref().unwrap().is_empty();
486
487            let mut wrapped: Vec<Line<'static>> = Vec::new();
488            if has_diagnostics {
489                let kept_set: std::collections::HashSet<usize> =
490                    msg.kept_lines.as_ref().unwrap().iter().copied().collect();
491                for (raw_idx, line) in output_lines.iter().enumerate() {
492                    let is_kept = kept_set.contains(&raw_idx);
493                    let line_style = if is_kept {
494                        theme.code_block
495                    } else {
496                        theme.code_block.add_modifier(Modifier::DIM)
497                    };
498                    let spans = vec![
499                        Span::styled(indent.to_string(), Style::default()),
500                        Span::styled((*line).to_string(), line_style),
501                    ];
502                    wrapped.extend(wrap_spans(spans, wrap_width));
503                }
504                lines.extend(wrapped);
505                let legend_style = Style::default()
506                    .fg(ratatui::style::Color::Indexed(243))
507                    .add_modifier(Modifier::ITALIC);
508                lines.push(Line::from(Span::styled(
509                    format!("{indent}[filter diagnostics: highlighted = kept, dim = filtered out]"),
510                    legend_style,
511                )));
512            } else {
513                for line in output_lines {
514                    let spans = vec![
515                        Span::styled(indent.to_string(), Style::default()),
516                        Span::styled((*line).to_string(), theme.code_block),
517                    ];
518                    wrapped.extend(wrap_spans(spans, wrap_width));
519                }
520
521                let total_visual = wrapped.len();
522                let show_all = tool_expanded || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES;
523
524                if show_all {
525                    lines.extend(wrapped);
526                } else {
527                    lines.extend(wrapped.into_iter().take(TOOL_OUTPUT_COLLAPSED_LINES));
528                    let remaining = total_visual - TOOL_OUTPUT_COLLAPSED_LINES;
529                    let dim = Style::default().add_modifier(Modifier::DIM);
530                    let stats_style = Style::default().fg(ratatui::style::Color::Indexed(243));
531                    let mut spans = vec![Span::styled(
532                        format!(
533                            "{indent}... ({remaining} hidden, {total_visual} total, press 'e' to expand)"
534                        ),
535                        dim,
536                    )];
537                    if let Some(ref stats) = msg.filter_stats {
538                        spans.push(Span::styled(format!(" | {stats}"), stats_style));
539                    }
540                    lines.push(Line::from(spans));
541                }
542            }
543        }
544    }
545}
546
547fn render_with_thinking(
548    content: &str,
549    base_style: Style,
550    theme: &Theme,
551) -> (Vec<Vec<Span<'static>>>, Vec<MdLink>) {
552    let mut all_lines = Vec::new();
553    let mut md_links_buf: Vec<MdLink> = Vec::new();
554    let mut remaining = content;
555    let mut in_thinking = false;
556
557    while !remaining.is_empty() {
558        if in_thinking {
559            if let Some(end) = remaining.find("</think>") {
560                let segment = &remaining[..end];
561                if !segment.trim().is_empty() {
562                    let (rendered, collected) = render_md(segment, theme.thinking_message, theme);
563                    all_lines.extend(rendered);
564                    md_links_buf.extend(collected);
565                }
566                remaining = &remaining[end + "</think>".len()..];
567                in_thinking = false;
568            } else {
569                if !remaining.trim().is_empty() {
570                    let (rendered, collected) = render_md(remaining, theme.thinking_message, theme);
571                    all_lines.extend(rendered);
572                    md_links_buf.extend(collected);
573                }
574                break;
575            }
576        } else if let Some(start) = remaining.find("<think>") {
577            let segment = &remaining[..start];
578            if !segment.trim().is_empty() {
579                let (rendered, collected) = render_md(segment, base_style, theme);
580                all_lines.extend(rendered);
581                md_links_buf.extend(collected);
582            }
583            remaining = &remaining[start + "<think>".len()..];
584            in_thinking = true;
585        } else {
586            let (rendered, collected) = render_md(remaining, base_style, theme);
587            all_lines.extend(rendered);
588            md_links_buf.extend(collected);
589            break;
590        }
591    }
592
593    (all_lines, md_links_buf)
594}
595
596fn render_md(
597    content: &str,
598    base_style: Style,
599    theme: &Theme,
600) -> (Vec<Vec<Span<'static>>>, Vec<MdLink>) {
601    let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
602    let parser = Parser::new_ext(content, options);
603    let mut renderer = MdRenderer::new(base_style, theme);
604    for event in parser {
605        renderer.push_event(event);
606    }
607    renderer.finish()
608}
609
610struct MdRenderer<'t> {
611    lines: Vec<Vec<Span<'static>>>,
612    current: Vec<Span<'static>>,
613    style_stack: Vec<Style>,
614    base_style: Style,
615    theme: &'t Theme,
616    in_code_block: bool,
617    code_lang: Option<String>,
618    link_url: Option<String>,
619    /// Accumulated text content of the current link being parsed.
620    link_text_buf: String,
621    /// Collected markdown links for this render pass.
622    md_links: Vec<MdLink>,
623    in_table: bool,
624    table_rows: Vec<Vec<String>>,
625    current_cell: String,
626}
627
628impl<'t> MdRenderer<'t> {
629    fn new(base_style: Style, theme: &'t Theme) -> Self {
630        Self {
631            lines: Vec::new(),
632            current: Vec::new(),
633            style_stack: vec![base_style],
634            base_style,
635            theme,
636            in_code_block: false,
637            code_lang: None,
638            link_url: None,
639            link_text_buf: String::new(),
640            md_links: Vec::new(),
641            in_table: false,
642            table_rows: Vec::new(),
643            current_cell: String::new(),
644        }
645    }
646
647    fn push_event(&mut self, event: Event<'_>) {
648        match event {
649            Event::Start(Tag::Heading { .. }) => {
650                self.push_style(self.theme.highlight.add_modifier(Modifier::BOLD));
651            }
652            Event::End(TagEnd::Heading { .. }) => {
653                self.pop_style();
654                self.newline();
655            }
656            Event::Start(Tag::Strong) => {
657                let s = self.current_style().add_modifier(Modifier::BOLD);
658                self.push_style(s);
659            }
660            Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) => {
661                self.pop_style();
662            }
663            Event::Start(Tag::Emphasis) => {
664                let s = self.current_style().add_modifier(Modifier::ITALIC);
665                self.push_style(s);
666            }
667            Event::Start(Tag::Strikethrough) => {
668                let s = self.current_style().add_modifier(Modifier::CROSSED_OUT);
669                self.push_style(s);
670            }
671            Event::Start(Tag::CodeBlock(kind)) => {
672                self.in_code_block = true;
673                if let CodeBlockKind::Fenced(lang) = kind {
674                    let lang = lang.trim();
675                    if !lang.is_empty() {
676                        self.code_lang = Some(lang.to_string());
677                        self.current.push(Span::styled(
678                            format!(" {lang} "),
679                            self.base_style.add_modifier(Modifier::DIM),
680                        ));
681                        self.newline();
682                    }
683                }
684            }
685            Event::End(TagEnd::CodeBlock) => {
686                self.in_code_block = false;
687                self.code_lang = None;
688                self.newline();
689            }
690            Event::Code(text) => {
691                if self.link_url.is_some() {
692                    self.link_text_buf.push_str(&text);
693                }
694                self.current
695                    .push(Span::styled(text.to_string(), self.theme.code_inline));
696            }
697            Event::Text(text) => self.push_text_event(&text),
698            Event::Start(Tag::Item) => {
699                self.current
700                    .push(Span::styled("\u{2022} ".to_string(), self.theme.highlight));
701            }
702            Event::End(TagEnd::Item | TagEnd::Paragraph) | Event::SoftBreak | Event::HardBreak => {
703                self.newline();
704            }
705            Event::Rule => {
706                self.current.push(Span::styled(
707                    "\u{2500}".repeat(20),
708                    self.base_style.add_modifier(Modifier::DIM),
709                ));
710                self.newline();
711            }
712            Event::Start(Tag::Link { dest_url, .. }) => {
713                self.link_url = Some(dest_url.to_string());
714                self.link_text_buf.clear();
715                self.push_style(self.theme.link);
716            }
717            Event::End(TagEnd::Link) => self.end_link(),
718            Event::Start(Tag::BlockQuote(_)) => {
719                self.current.push(Span::styled(
720                    "\u{2502} ".to_string(),
721                    self.base_style.add_modifier(Modifier::DIM),
722                ));
723            }
724            Event::Start(Tag::Table(_)) => {
725                self.in_table = true;
726                self.table_rows.clear();
727            }
728            Event::Start(Tag::TableHead | Tag::TableRow) => {
729                self.table_rows.push(Vec::new());
730            }
731            Event::Start(Tag::TableCell) => {
732                self.current_cell.clear();
733            }
734            Event::End(TagEnd::TableCell) => self.push_table_cell(),
735            Event::End(TagEnd::Table) => {
736                self.emit_table();
737                self.in_table = false;
738            }
739            _ => {}
740        }
741    }
742
743    fn end_link(&mut self) {
744        if let Some(url) = self.link_url.take() {
745            let text = std::mem::take(&mut self.link_text_buf);
746            if !text.is_empty() {
747                self.md_links.push(MdLink { text, url });
748            }
749        } else {
750            self.link_text_buf.clear();
751        }
752        self.pop_style();
753    }
754
755    fn push_table_cell(&mut self) {
756        let cell = self.current_cell.clone();
757        if let Some(row) = self.table_rows.last_mut() {
758            row.push(cell);
759        }
760    }
761
762    fn push_text_event(&mut self, text: &str) {
763        if self.in_table && !self.in_code_block {
764            self.current_cell.push_str(text);
765        } else if self.in_code_block {
766            self.push_code_block_text(text);
767        } else {
768            if self.link_url.is_some() {
769                self.link_text_buf.push_str(text);
770            }
771            let style = self.current_style();
772            for (i, segment) in text.split('\n').enumerate() {
773                if i > 0 {
774                    self.newline();
775                }
776                if !segment.is_empty() {
777                    self.current.push(Span::styled(segment.to_string(), style));
778                }
779            }
780        }
781    }
782
783    fn emit_table(&mut self) {
784        if self.table_rows.is_empty() {
785            return;
786        }
787        let col_count = self.table_rows.iter().map(Vec::len).max().unwrap_or(0);
788        if col_count == 0 {
789            return;
790        }
791
792        if !self.current.is_empty() {
793            self.newline();
794        }
795
796        let mut col_widths = vec![3usize; col_count];
797        for row in &self.table_rows {
798            for (ci, cell) in row.iter().enumerate() {
799                col_widths[ci] = col_widths[ci].max(cell.chars().count());
800            }
801        }
802
803        let border_style = self.theme.table_border;
804        let base_style = self.base_style;
805
806        let top = {
807            let mut spans = Vec::new();
808            spans.push(Span::styled("\u{250c}".to_string(), border_style));
809            for (ci, &w) in col_widths.iter().enumerate() {
810                spans.push(Span::styled("\u{2500}".repeat(w + 2), border_style));
811                if ci + 1 < col_count {
812                    spans.push(Span::styled("\u{252c}".to_string(), border_style));
813                }
814            }
815            spans.push(Span::styled("\u{2510}".to_string(), border_style));
816            spans
817        };
818        self.current = top;
819        self.newline();
820
821        let sep = {
822            let mut spans = Vec::new();
823            spans.push(Span::styled("\u{251c}".to_string(), border_style));
824            for (ci, &w) in col_widths.iter().enumerate() {
825                spans.push(Span::styled("\u{2500}".repeat(w + 2), border_style));
826                if ci + 1 < col_count {
827                    spans.push(Span::styled("\u{253c}".to_string(), border_style));
828                }
829            }
830            spans.push(Span::styled("\u{2524}".to_string(), border_style));
831            spans
832        };
833
834        let bottom = {
835            let mut spans = Vec::new();
836            spans.push(Span::styled("\u{2514}".to_string(), border_style));
837            for (ci, &w) in col_widths.iter().enumerate() {
838                spans.push(Span::styled("\u{2500}".repeat(w + 2), border_style));
839                if ci + 1 < col_count {
840                    spans.push(Span::styled("\u{2534}".to_string(), border_style));
841                }
842            }
843            spans.push(Span::styled("\u{2518}".to_string(), border_style));
844            spans
845        };
846
847        let rows = std::mem::take(&mut self.table_rows);
848        for (ri, row) in rows.iter().enumerate() {
849            let cell_style = if ri == 0 {
850                base_style.add_modifier(Modifier::BOLD)
851            } else {
852                base_style
853            };
854            let mut spans = Vec::new();
855            spans.push(Span::styled("\u{2502}".to_string(), border_style));
856            for (ci, &w) in col_widths.iter().enumerate() {
857                let text = row.get(ci).map_or("", String::as_str);
858                let padded = format!(" {text:<w$} ");
859                spans.push(Span::styled(padded, cell_style));
860                spans.push(Span::styled("\u{2502}".to_string(), border_style));
861            }
862            self.current = spans;
863            self.newline();
864
865            if ri == 0 {
866                self.current.clone_from(&sep);
867                self.newline();
868            }
869        }
870
871        self.current = bottom;
872        self.newline();
873    }
874
875    fn push_code_block_text(&mut self, text: &str) {
876        let syntax_theme = SyntaxTheme::default();
877        let highlighted = self
878            .code_lang
879            .as_deref()
880            .and_then(|lang| SYNTAX_HIGHLIGHTER.highlight(lang, text, &syntax_theme));
881
882        if let Some(spans) = highlighted {
883            let prefix = Span::styled("  ".to_string(), self.theme.code_block);
884            self.current.push(prefix.clone());
885            for span in spans {
886                let parts: Vec<&str> = span.content.split('\n').collect();
887                for (i, part) in parts.iter().enumerate() {
888                    if i > 0 {
889                        self.newline();
890                        self.current.push(prefix.clone());
891                    }
892                    if !part.is_empty() {
893                        self.current
894                            .push(Span::styled((*part).to_string(), span.style));
895                    }
896                }
897            }
898        } else {
899            let style = self.theme.code_block;
900            for (i, segment) in text.split('\n').enumerate() {
901                if i > 0 {
902                    self.newline();
903                }
904                self.current
905                    .push(Span::styled(format!("  {segment}"), style));
906            }
907        }
908    }
909
910    fn current_style(&self) -> Style {
911        self.style_stack.last().copied().unwrap_or(self.base_style)
912    }
913
914    fn push_style(&mut self, style: Style) {
915        self.style_stack.push(style);
916    }
917
918    fn pop_style(&mut self) {
919        if self.style_stack.len() > 1 {
920            self.style_stack.pop();
921        }
922    }
923
924    fn newline(&mut self) {
925        let line = std::mem::take(&mut self.current);
926        self.lines.push(line);
927    }
928
929    fn finish(mut self) -> (Vec<Vec<Span<'static>>>, Vec<MdLink>) {
930        if !self.current.is_empty() {
931            self.newline();
932        }
933        // Remove trailing empty lines
934        while self.lines.last().is_some_and(Vec::is_empty) {
935            self.lines.pop();
936        }
937        (self.lines, self.md_links)
938    }
939}
940
941fn wrap_spans(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Line<'static>> {
942    if max_width == 0 {
943        return vec![Line::from(spans)];
944    }
945
946    let total: usize = spans.iter().map(|s| s.content.chars().count()).sum();
947    if total <= max_width {
948        return vec![Line::from(spans)];
949    }
950
951    let mut result: Vec<Line<'static>> = Vec::new();
952    let mut current: Vec<Span<'static>> = Vec::new();
953    let mut width = 0;
954
955    for span in spans {
956        let chars: Vec<char> = span.content.chars().collect();
957        let mut pos = 0;
958
959        while pos < chars.len() {
960            let space = max_width.saturating_sub(width);
961            if space == 0 {
962                result.push(Line::from(std::mem::take(&mut current)));
963                width = 0;
964                continue;
965            }
966            let take = space.min(chars.len() - pos);
967            let chunk: String = chars[pos..pos + take].iter().collect();
968            current.push(Span::styled(chunk, span.style));
969            width += take;
970            pos += take;
971
972            if width >= max_width && pos < chars.len() {
973                result.push(Line::from(std::mem::take(&mut current)));
974                width = 0;
975            }
976        }
977    }
978
979    if !current.is_empty() {
980        result.push(Line::from(current));
981    }
982
983    if result.is_empty() {
984        result.push(Line::default());
985    }
986
987    result
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    #[test]
995    fn render_md_plain() {
996        let theme = Theme::default();
997        let (rendered_lines, link_refs) = render_md("hello world", theme.assistant_message, &theme);
998        assert_eq!(rendered_lines.len(), 1);
999        assert_eq!(rendered_lines[0][0].content, "hello world");
1000        assert!(link_refs.is_empty());
1001    }
1002
1003    #[test]
1004    fn render_md_bold() {
1005        let theme = Theme::default();
1006        let base = theme.assistant_message;
1007        let (lines, _) = render_md("say **hello** now", base, &theme);
1008        assert_eq!(lines.len(), 1);
1009        assert_eq!(lines[0].len(), 3);
1010        assert_eq!(lines[0][0].content, "say ");
1011        assert_eq!(lines[0][1].content, "hello");
1012        assert_eq!(lines[0][1].style, base.add_modifier(Modifier::BOLD));
1013        assert_eq!(lines[0][2].content, " now");
1014    }
1015
1016    #[test]
1017    fn render_md_inline_code() {
1018        let theme = Theme::default();
1019        let (lines, _) = render_md("use `foo` here", theme.assistant_message, &theme);
1020        assert_eq!(lines.len(), 1);
1021        assert_eq!(lines[0][1].content, "foo");
1022        assert_eq!(lines[0][1].style, theme.code_inline);
1023    }
1024
1025    #[test]
1026    fn render_md_code_block() {
1027        let theme = Theme::default();
1028        let (lines, _) = render_md("```rust\nlet x = 1;\n```", theme.assistant_message, &theme);
1029        assert!(lines.len() >= 2);
1030        // Language tag line
1031        assert!(lines[0][0].content.contains("rust"));
1032        // Code content — with syntax highlighting, spans are split by token
1033        let code_line = &lines[1];
1034        let full_text: String = code_line.iter().map(|s| s.content.as_ref()).collect();
1035        assert!(full_text.contains("let x = 1"));
1036    }
1037
1038    #[test]
1039    fn render_md_list() {
1040        let theme = Theme::default();
1041        let (lines, _) = render_md("- first\n- second", theme.assistant_message, &theme);
1042        assert!(lines.len() >= 2);
1043        assert!(lines[0].iter().any(|s| s.content.contains('\u{2022}')));
1044    }
1045
1046    #[test]
1047    fn render_md_heading() {
1048        let theme = Theme::default();
1049        let base = theme.assistant_message;
1050        let (lines, _) = render_md("# Title", base, &theme);
1051        assert!(!lines.is_empty());
1052        let heading_span = &lines[0][0];
1053        assert_eq!(heading_span.content, "Title");
1054        assert_eq!(
1055            heading_span.style,
1056            theme.highlight.add_modifier(Modifier::BOLD)
1057        );
1058    }
1059
1060    #[test]
1061    fn render_md_link_single() {
1062        let theme = Theme::default();
1063        let (rendered_lines, link_refs) =
1064            render_md("[click](https://x.com)", theme.assistant_message, &theme);
1065        assert!(!rendered_lines.is_empty());
1066        assert_eq!(link_refs.len(), 1);
1067        assert_eq!(link_refs[0].text, "click");
1068        assert_eq!(link_refs[0].url, "https://x.com");
1069    }
1070
1071    #[test]
1072    fn render_md_link_bold_text() {
1073        let theme = Theme::default();
1074        let (rendered_lines, link_refs) =
1075            render_md("[**bold**](https://x.com)", theme.assistant_message, &theme);
1076        assert!(!rendered_lines.is_empty());
1077        assert_eq!(link_refs.len(), 1);
1078        assert_eq!(link_refs[0].text, "bold");
1079        assert_eq!(link_refs[0].url, "https://x.com");
1080    }
1081
1082    #[test]
1083    fn render_md_link_no_links() {
1084        let theme = Theme::default();
1085        let (_, links) = render_md("no links here", theme.assistant_message, &theme);
1086        assert!(links.is_empty());
1087    }
1088
1089    #[test]
1090    fn render_md_link_multiple() {
1091        let theme = Theme::default();
1092        let (_, links) = render_md(
1093            "[a](https://url1.com) and [b](https://url2.com)",
1094            theme.assistant_message,
1095            &theme,
1096        );
1097        assert_eq!(links.len(), 2);
1098        assert_eq!(links[0].text, "a");
1099        assert_eq!(links[0].url, "https://url1.com");
1100        assert_eq!(links[1].text, "b");
1101        assert_eq!(links[1].url, "https://url2.com");
1102    }
1103
1104    #[test]
1105    fn render_md_link_empty_text() {
1106        // [](url) — empty display text should produce no MdLink entry.
1107        let theme = Theme::default();
1108        let (_, links) = render_md("[](https://x.com)", theme.assistant_message, &theme);
1109        assert!(links.is_empty());
1110    }
1111
1112    #[test]
1113    fn render_with_thinking_segments() {
1114        let theme = Theme::default();
1115        let content = "<think>reasoning</think>result";
1116        let (lines, _) = render_with_thinking(content, theme.assistant_message, &theme);
1117        assert!(lines.len() >= 2);
1118        // Thinking segment uses thinking style
1119        assert_eq!(lines[0][0].style, theme.thinking_message);
1120        // Result uses normal style
1121        let last = lines.last().unwrap();
1122        assert_eq!(last[0].style, theme.assistant_message);
1123    }
1124
1125    #[test]
1126    fn render_with_thinking_streaming() {
1127        let theme = Theme::default();
1128        let content = "<think>still thinking";
1129        let (lines, _) = render_with_thinking(content, theme.assistant_message, &theme);
1130        assert!(!lines.is_empty());
1131        assert_eq!(lines[0][0].style, theme.thinking_message);
1132    }
1133
1134    #[test]
1135    fn wrap_spans_no_wrap() {
1136        let spans = vec![Span::raw("short")];
1137        let result = wrap_spans(spans, 80);
1138        assert_eq!(result.len(), 1);
1139    }
1140
1141    #[test]
1142    fn wrap_spans_splits() {
1143        let spans = vec![Span::raw("abcdef".to_string())];
1144        let result = wrap_spans(spans, 3);
1145        assert_eq!(result.len(), 2);
1146        assert_eq!(result[0].spans[0].content, "abc");
1147        assert_eq!(result[1].spans[0].content, "def");
1148    }
1149
1150    #[test]
1151    fn render_md_table_basic() {
1152        let theme = Theme::default();
1153        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
1154        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1155        // top border + header + separator + data row + bottom border = 5 lines
1156        assert_eq!(lines.len(), 5);
1157        let top: String = lines[0].iter().map(|s| s.content.as_ref()).collect();
1158        assert!(top.starts_with('\u{250c}'));
1159        assert!(top.ends_with('\u{2510}'));
1160        let sep: String = lines[2].iter().map(|s| s.content.as_ref()).collect();
1161        assert!(sep.starts_with('\u{251c}'));
1162        let bottom: String = lines[4].iter().map(|s| s.content.as_ref()).collect();
1163        assert!(bottom.starts_with('\u{2514}'));
1164        assert!(bottom.ends_with('\u{2518}'));
1165    }
1166
1167    #[test]
1168    fn render_md_table_header_bold() {
1169        let theme = Theme::default();
1170        let md = "| Col |\n|-----|\n| val |";
1171        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1172        // header row is lines[1]; cell text span should be bold
1173        let header_line = &lines[1];
1174        let cell_span = header_line
1175            .iter()
1176            .find(|s| s.content.contains("Col"))
1177            .expect("Col span not found");
1178        assert!(cell_span.style.add_modifier == Modifier::BOLD);
1179    }
1180
1181    #[test]
1182    fn render_md_table_header_only() {
1183        let theme = Theme::default();
1184        let md = "| X | Y |\n|---|---|";
1185        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1186        // top + header + separator + bottom = 4 lines (no data rows)
1187        assert_eq!(lines.len(), 4);
1188    }
1189
1190    #[test]
1191    fn render_md_table_single_column() {
1192        let theme = Theme::default();
1193        let md = "| Name |\n|------|\n| Alice |\n| Bob |";
1194        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1195        // top + header + sep + 2 data rows + bottom = 6 lines
1196        assert_eq!(lines.len(), 6);
1197        let top: String = lines[0].iter().map(|s| s.content.as_ref()).collect();
1198        // single column: top border has no ┬ in the middle
1199        assert!(!top.contains('\u{252c}'));
1200        assert!(top.starts_with('\u{250c}'));
1201        assert!(top.ends_with('\u{2510}'));
1202        // verify data row contains cell text
1203        let row1: String = lines[3].iter().map(|s| s.content.as_ref()).collect();
1204        assert!(row1.contains("Alice"));
1205    }
1206
1207    #[test]
1208    fn render_md_table_many_columns() {
1209        let theme = Theme::default();
1210        let md = "| A | B | C | D | E |\n|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 |";
1211        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1212        // top + header + sep + data + bottom = 5 lines
1213        assert_eq!(lines.len(), 5);
1214        let header: String = lines[1].iter().map(|s| s.content.as_ref()).collect();
1215        assert!(header.contains('A'));
1216        assert!(header.contains('E'));
1217        let data: String = lines[3].iter().map(|s| s.content.as_ref()).collect();
1218        assert!(data.contains('1'));
1219        assert!(data.contains('5'));
1220    }
1221
1222    #[test]
1223    fn render_md_table_column_width_alignment() {
1224        // Cells with varying widths — column should expand to widest cell
1225        let theme = Theme::default();
1226        let md = "| Short | LongerHeader |\n|-------|--------|\n| x | y |";
1227        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1228        assert_eq!(lines.len(), 5);
1229        // Header row: "LongerHeader" cell must appear in full
1230        let header: String = lines[1].iter().map(|s| s.content.as_ref()).collect();
1231        assert!(header.contains("LongerHeader"));
1232        // Data row: cell content must be padded to same column width
1233        let data: String = lines[3].iter().map(|s| s.content.as_ref()).collect();
1234        // "x" cell is padded to width of "Short" (5 chars)
1235        assert!(data.contains(" x     ") || data.contains(" x    "));
1236    }
1237
1238    #[test]
1239    fn render_md_table_empty_data_cells() {
1240        // Row with missing cells — should not panic, missing cells render as empty
1241        let theme = Theme::default();
1242        let md = "| A | B | C |\n|---|---|---|\n| 1 |   |   |";
1243        let (lines, _) = render_md(md, theme.assistant_message, &theme);
1244        assert_eq!(lines.len(), 5);
1245        let data: String = lines[3].iter().map(|s| s.content.as_ref()).collect();
1246        assert!(data.contains('1'));
1247    }
1248
1249    fn make_paste_msg(content: &str, paste_line_count: Option<usize>) -> crate::app::ChatMessage {
1250        let mut msg = crate::app::ChatMessage::new(crate::app::MessageRole::User, content);
1251        msg.paste_line_count = paste_line_count;
1252        msg
1253    }
1254
1255    fn lines_text(lines: &[ratatui::text::Line<'_>]) -> String {
1256        lines
1257            .iter()
1258            .map(|l| {
1259                l.spans
1260                    .iter()
1261                    .map(|s| s.content.as_ref())
1262                    .collect::<String>()
1263            })
1264            .collect::<Vec<_>>()
1265            .join("\n")
1266    }
1267
1268    #[test]
1269    fn paste_collapsed_shows_first_3_lines() {
1270        let theme = Theme::default();
1271        let content = "alpha\nbeta\ngamma\ndelta\nepsilon";
1272        let msg = make_paste_msg(content, Some(5));
1273        let mut lines = Vec::new();
1274        render_chat_message(&msg, false, &theme, 80, false, &mut lines);
1275        let text = lines_text(&lines);
1276        assert!(text.contains("alpha"), "first line must be visible");
1277        assert!(text.contains("beta"), "second line must be visible");
1278        assert!(text.contains("gamma"), "third line must be visible");
1279        assert!(
1280            !text.contains("delta"),
1281            "fourth line must be hidden when collapsed"
1282        );
1283        assert!(
1284            text.contains("more lines"),
1285            "expand hint must be present: {text}"
1286        );
1287    }
1288
1289    #[test]
1290    fn paste_collapsed_no_hint_for_3_or_fewer_lines() {
1291        let theme = Theme::default();
1292        // 2 lines — no hint (nothing hidden beyond PASTE_COLLAPSED_LINES=3)
1293        let content_2 = "one\ntwo";
1294        let msg_2 = make_paste_msg(content_2, Some(2));
1295        let mut lines_2 = Vec::new();
1296        render_chat_message(&msg_2, false, &theme, 80, false, &mut lines_2);
1297        assert!(
1298            !lines_text(&lines_2).contains("more lines"),
1299            "no hint for 2-line paste"
1300        );
1301        // Exactly 3 lines — still no hint (collapse only triggers when > 3)
1302        let content_3 = "one\ntwo\nthree";
1303        let msg_3 = make_paste_msg(content_3, Some(3));
1304        let mut lines_3 = Vec::new();
1305        render_chat_message(&msg_3, false, &theme, 80, false, &mut lines_3);
1306        assert!(
1307            !lines_text(&lines_3).contains("more lines"),
1308            "no hint for 3-line paste"
1309        );
1310    }
1311
1312    #[test]
1313    fn paste_expanded_shows_all_lines() {
1314        let theme = Theme::default();
1315        let content = "alpha\nbeta\ngamma\ndelta\nepsilon";
1316        let msg = make_paste_msg(content, Some(5));
1317        let mut lines = Vec::new();
1318        render_chat_message(&msg, true, &theme, 80, false, &mut lines);
1319        let text = lines_text(&lines);
1320        assert!(
1321            text.contains("delta"),
1322            "fourth line must be visible when expanded"
1323        );
1324        assert!(
1325            text.contains("epsilon"),
1326            "fifth line must be visible when expanded"
1327        );
1328        assert!(
1329            !text.contains("more lines"),
1330            "no hint when expanded: {text}"
1331        );
1332    }
1333}