1use 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#[derive(Clone, Debug)]
19pub struct MdLink {
20 pub text: String,
21 pub url: String,
22}
23
24pub 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 let wrap_width = area.width.saturating_sub(4) as usize;
34
35 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)] fn 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 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 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 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 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 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
406const PASTE_COLLAPSED_LINES: usize = 3;
409
410#[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn 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 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 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 link_text_buf: String,
621 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 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 assert!(lines[0][0].content.contains("rust"));
1032 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 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 assert_eq!(lines[0][0].style, theme.thinking_message);
1120 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 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 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 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 assert_eq!(lines.len(), 6);
1197 let top: String = lines[0].iter().map(|s| s.content.as_ref()).collect();
1198 assert!(!top.contains('\u{252c}'));
1200 assert!(top.starts_with('\u{250c}'));
1201 assert!(top.ends_with('\u{2510}'));
1202 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 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 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 let header: String = lines[1].iter().map(|s| s.content.as_ref()).collect();
1231 assert!(header.contains("LongerHeader"));
1232 let data: String = lines[3].iter().map(|s| s.content.as_ref()).collect();
1234 assert!(data.contains(" x ") || data.contains(" x "));
1236 }
1237
1238 #[test]
1239 fn render_md_table_empty_data_cells() {
1240 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 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 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}