feat(tui): Sprint 2 — markdown rendering, ConversationContent enum, diff viewer

- Create src/markdown.rs with MarkdownRenderer using pulldown-cmark + syntect
  (lazy-loaded via once_cell::Lazy, theme fallback chain, syntect 5.x API)
- ConversationContent enum: Plain/Markdown/CodeDiff variants
- push_output() auto-detects markdown via looks_like_markdown() heuristic
- build_wrapped_conversation() routes through MarkdownRenderer for markdown entries
- render_diff() for syntax-colored unified diffs (green+/red-/cyan @@)
- push_diff() convenience method for /diff command
- once_cell added to Cargo.toml
- 27 new tests for markdown rendering, detection, and diff colors
- All existing tests pass (240 unit tests, 108 contract tests)

Sprint 2 of 7.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 19:00:46 -05:00
parent 755659c916
commit e8e8c72185
4 changed files with 554 additions and 45 deletions

View File

@ -14,6 +14,7 @@ api = { path = "../api" }
commands = { path = "../commands" }
crossterm = "0.28"
pulldown-cmark = "0.13"
once_cell = "1"
ratatui = "0.29"
rustyline = "15"
tui-textarea = "0.7"

View File

@ -18,6 +18,7 @@ mod init;
mod input;
mod render;
mod setup_wizard;
mod markdown;
mod tui;
mod tui_error;
mod tui_update;

View File

@ -0,0 +1,441 @@
//! Markdown → ratatui `Line` renderer with syntect syntax highlighting.
//!
//! Syntect assets (`SyntaxSet`, `ThemeSet`) are loaded once via `once_cell::Lazy`.
use once_cell::sync::Lazy;
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
/// Loaded once on first access (~10-50ms), cached for process lifetime.
pub static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
pub static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
#[derive(Clone)]
pub struct MarkdownRenderer {
code_theme_name: String,
}
impl MarkdownRenderer {
pub fn new() -> Self {
Self {
code_theme_name: "base16-ocean.dark".to_string(),
}
}
pub fn set_code_theme(&mut self, name: &str) {
self.code_theme_name = name.to_string();
}
/// Render a markdown string into ratatui Lines at the given terminal width.
pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> {
let parser = Parser::new(markdown);
let events: Vec<Event> = parser.collect();
self.render_events(&events, width)
}
fn render_events(&self, events: &[Event], width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut style_stack: Vec<Style> = Vec::new();
let mut in_code_block = false;
let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new();
let mut list_depth: usize = 0;
for event in events {
match event {
Event::Start(tag) => match tag {
Tag::Heading { .. } => {
flush_line(&mut lines, &mut current_spans);
style_stack.push(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
}
Tag::Paragraph => {
style_stack.push(Style::default());
}
Tag::CodeBlock(kind) => {
flush_line(&mut lines, &mut current_spans);
in_code_block = true;
code_block_content.clear();
code_block_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
let s = lang.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
_ => None,
};
}
Tag::List(_) => {
list_depth += 1;
}
Tag::Item => {
let indent = " ".repeat(list_depth.saturating_sub(1));
current_spans.push(Span::raw(format!("{indent}")));
}
Tag::BlockQuote(_) => {
current_spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
style_stack.push(Style::default().fg(Color::DarkGray));
}
Tag::Emphasis => {
style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
}
Tag::Strong => {
style_stack.push(Style::default().add_modifier(Modifier::BOLD));
}
Tag::Link { .. } => {
style_stack.push(
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
);
}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Heading(_) => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::Paragraph => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::CodeBlock => {
let code_lines =
self.render_code_block(&code_block_content, code_block_lang.as_deref());
lines.extend(code_lines);
in_code_block = false;
code_block_lang = None;
}
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
}
TagEnd::Item => {
flush_line(&mut lines, &mut current_spans);
}
TagEnd::BlockQuote(_) => {
flush_line(&mut lines, &mut current_spans);
style_stack.pop();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
style_stack.pop();
}
_ => {}
},
Event::Text(text) => {
if in_code_block {
code_block_content.push_str(&text);
} else {
let style = style_stack.last().copied().unwrap_or_default();
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::Code(code) => {
let style = Style::default().fg(Color::Yellow).bg(Color::DarkGray);
current_spans.push(Span::styled(format!(" {code} "), style));
}
Event::SoftBreak | Event::HardBreak => {
if in_code_block {
code_block_content.push('\n');
} else {
flush_line(&mut lines, &mut current_spans);
}
}
Event::Rule => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(Span::styled(
"".repeat(width as usize),
Style::default().fg(Color::DarkGray),
)));
}
_ => {}
}
}
flush_line(&mut lines, &mut current_spans);
lines
}
fn render_code_block(&self, code: &str, language: Option<&str>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let syntax = language
.and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
// Theme lookup with fallback chain — never panic on missing theme
let theme = THEME_SET
.themes
.get(&self.code_theme_name)
.or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
.or_else(|| THEME_SET.themes.values().next())
.expect("syntect has no themes");
let lang_label = language.unwrap_or("text");
lines.push(Line::from(Span::styled(
format!("╭─ {lang_label}"),
Style::default().fg(Color::DarkGray),
)));
// syntect 5.x: HighlightLines::new() returns directly (not Result)
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
for line in code.lines() {
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(ranges) => {
let mut line_spans: Vec<Span<'static>> = vec![Span::styled(
"",
Style::default().fg(Color::DarkGray),
)];
for (style, text) in ranges {
let fg = style.foreground;
line_spans.push(Span::styled(
text.to_string(),
Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)),
));
}
lines.push(Line::from(line_spans));
}
Err(_) => {
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::raw(line.to_string()),
]));
}
}
}
lines.push(Line::from(Span::styled(
"╰─",
Style::default().fg(Color::DarkGray),
)));
lines
}
}
/// Flush accumulated spans into a new line.
fn flush_line(lines: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>) {
if !spans.is_empty() {
lines.push(Line::from(spans.clone()));
spans.clear();
}
}
/// Heuristic: does this text look like it contains markdown formatting?
pub fn looks_like_markdown(text: &str) -> bool {
let lines: Vec<&str> = text.lines().collect();
let has_header = lines.iter().any(|l| l.starts_with('#'));
let has_code_block = text.contains("```");
let has_list = lines
.iter()
.any(|l| l.starts_with("- ") || l.starts_with("* "));
let has_bold = text.contains("**");
let has_inline_code = text.matches('`').count() >= 2;
let multi_line = lines.len() > 3;
has_code_block || (has_header && multi_line) || (has_list && multi_line) || (has_bold && has_inline_code)
}
/// Render a unified diff with color-coded lines.
pub fn render_diff(diff: &str) -> Vec<Line<'static>> {
diff.lines()
.map(|raw_line| {
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
(raw_line.to_string(), Color::White)
} else if raw_line.starts_with("@@") {
(raw_line.to_string(), Color::Cyan)
} else if raw_line.starts_with('+') {
(raw_line.to_string(), Color::Green)
} else if raw_line.starts_with('-') {
(raw_line.to_string(), Color::Red)
} else {
(raw_line.to_string(), Color::DarkGray)
};
Line::from(Span::styled(text, Style::default().fg(color)))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
// --- Markdown rendering ---
#[test]
fn test_plain_paragraph() {
let r = MarkdownRenderer::new();
let lines = r.render("Just a paragraph.", 80);
assert_eq!(lines.len(), 2); // text + blank
}
#[test]
fn test_h1_rendering() {
let r = MarkdownRenderer::new();
let lines = r.render("# Hello", 80);
assert!(lines[0]
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Cyan)));
}
#[test]
fn test_rust_code_block() {
let r = MarkdownRenderer::new();
let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80);
// At minimum: top border + at least 1 code line + bottom border
assert!(lines.len() >= 3);
// Should contain code border characters
let text: String = lines.iter().map(|l| {
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}).collect();
assert!(text.contains("╭─") && text.contains("╰─"));
}
#[test]
fn test_python_code_block() {
let r = MarkdownRenderer::new();
let lines = r.render("```python\ndef hello():\n pass\n```", 80);
assert!(lines.len() >= 3);
let text: String = lines.iter().map(|l| {
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}).collect();
assert!(text.contains("python"));
}
#[test]
fn test_inline_code() {
let r = MarkdownRenderer::new();
let lines = r.render("Use `git status` to check", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_bold_and_italic() {
let r = MarkdownRenderer::new();
let lines = r.render("**bold** and *italic*", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_nested_list() {
let r = MarkdownRenderer::new();
let lines = r.render("- item 1\n- item 2\n - nested", 80);
// At least 2 list items rendered (nested may be part of item 2)
assert!(lines.len() >= 2);
}
#[test]
fn test_blockquote() {
let r = MarkdownRenderer::new();
let lines = r.render("> quoted\n> text", 80);
// 2 blockquote lines + possible blank lines
assert!(lines.len() >= 2);
}
#[test]
fn test_horizontal_rule() {
let r = MarkdownRenderer::new();
let lines = r.render("---", 80);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_empty_input() {
let r = MarkdownRenderer::new();
let lines = r.render("", 80);
assert!(lines.is_empty());
}
#[test]
fn test_mixed_content() {
let r = MarkdownRenderer::new();
let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item";
let lines = r.render(md, 80);
// Header + blank + text + blank + code block (3+) + blank + list item + blanks
assert!(lines.len() >= 5);
}
#[test]
fn test_theme_fallback() {
let mut r = MarkdownRenderer::new();
r.set_code_theme("nonexistent_theme");
// Should not panic — falls back gracefully
let lines = r.render("```rust\nfn main() {}\n```", 80);
assert!(lines.len() >= 3);
}
// --- looks_like_markdown ---
#[test]
fn test_markdown_detection_code_block() {
assert!(looks_like_markdown(
"some text\n```rust\ncode\n```\nmore text"
));
}
#[test]
fn test_markdown_detection_header() {
assert!(looks_like_markdown(
"# Title\n\nParagraph one.\n\nParagraph two."
));
}
#[test]
fn test_markdown_detection_list() {
assert!(looks_like_markdown(
"Items:\n\n- one\n- two\n- three"
));
}
#[test]
fn test_markdown_detection_plain() {
assert!(!looks_like_markdown(
"Just a simple response without any formatting."
));
}
#[test]
fn test_markdown_detection_short() {
assert!(!looks_like_markdown("Short"));
}
// --- Diff renderer ---
#[test]
fn test_diff_additions_green() {
let lines = render_diff("+new line");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green));
}
#[test]
fn test_diff_deletions_red() {
let lines = render_diff("-old line");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_diff_hunk_header() {
let lines = render_diff("@@ -1,3 +1,4 @@");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
}
#[test]
fn test_diff_file_headers() {
let lines = render_diff("--- a/file.rs\n+++ b/file.rs");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
}
}

View File

@ -121,11 +121,45 @@ pub struct BannerLine {
pub color: Color,
}
/// Content variant — determines how a conversation entry is rendered.
#[derive(Debug, Clone)]
pub enum ConversationContent {
Plain {
text: String,
color: Color,
bold: bool,
},
Markdown {
source: String,
},
CodeDiff {
diff: String,
},
}
#[derive(Debug, Clone)]
pub struct ConversationLine {
pub text: String,
pub color: Color,
pub bold: bool,
pub content: ConversationContent,
}
impl ConversationLine {
pub fn plain(text: String, color: Color, bold: bool) -> Self {
Self {
content: ConversationContent::Plain { text, color, bold },
}
}
pub fn markdown(source: String) -> Self {
Self {
content: ConversationContent::Markdown { source },
}
}
pub fn diff(diff: String) -> Self {
Self {
content: ConversationContent::CodeDiff { diff },
}
}
}
// ---------------------------------------------------------------------------
@ -134,7 +168,7 @@ pub struct ConversationLine {
pub struct TuiApp {
dashboard: SharedDashboardState,
conversation: Vec<ConversationLine>,
pub conversation: Vec<ConversationLine>,
conversation_scroll: u16,
input: TextArea<'static>,
should_exit: bool,
@ -144,6 +178,7 @@ pub struct TuiApp {
showing_completions: bool,
spinner_frame: usize,
needs_redraw: bool,
pub markdown_renderer: crate::markdown::MarkdownRenderer,
}
const SPINNER_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
@ -195,6 +230,7 @@ impl TuiApp {
completion_index: 0,
showing_completions: false,
spinner_frame: 0,
markdown_renderer: crate::markdown::MarkdownRenderer::new(),
needs_redraw: true,
};
me.draw_screen()?;
@ -266,33 +302,24 @@ impl TuiApp {
pub fn push_banner(&mut self, lines: &[BannerLine]) {
for bl in lines {
self.conversation.push(ConversationLine {
text: bl.text.clone(),
color: bl.color,
bold: true,
});
self.conversation
.push(ConversationLine::plain(bl.text.clone(), bl.color, true));
}
self.auto_scroll();
}
pub fn push_user_input(&mut self, text: &str) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine {
text: raw_line.to_string(),
color: Color::Cyan,
bold: true,
});
self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Cyan, true));
}
self.auto_scroll();
}
pub fn push_system_message(&mut self, text: &str) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine {
text: raw_line.to_string(),
color: Color::Yellow,
bold: false,
});
self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Yellow, false));
}
self.auto_scroll();
}
@ -301,21 +328,30 @@ impl TuiApp {
if text.is_empty() {
return;
}
// Strip any ANSI escape codes that may have leaked through from the
// runtime's stdout rendering. The conversation pane renders plain text
// with ratatui styles, so ANSI bytes would corrupt the layout and
// confuse wrap_line()'s character counting.
let clean = crate::tui_update::strip_ansi(text);
for raw_line in clean.lines() {
self.conversation.push(ConversationLine {
text: raw_line.to_string(),
color: if is_error { Color::Red } else { Color::White },
bold: false,
});
if is_error {
for raw_line in clean.lines() {
self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::Red, false));
}
} else if crate::markdown::looks_like_markdown(&clean) {
self.conversation.push(ConversationLine::markdown(clean));
} else {
for raw_line in clean.lines() {
self.conversation
.push(ConversationLine::plain(raw_line.to_string(), Color::White, false));
}
}
self.auto_scroll();
}
pub fn push_diff(&mut self, diff: &str) {
self.conversation
.push(ConversationLine::diff(diff.to_string()));
self.auto_scroll();
self.needs_redraw = true;
}
/// Force a full TUI clear + redraw. With Architecture C (buffered
/// output) this is no longer needed to clean up stdout debris, but
/// kept as a safety net for edge cases.
@ -343,11 +379,11 @@ impl TuiApp {
let drain_count = self.conversation.len() - MAX_CONVERSATION_LINES;
self.conversation.drain(..drain_count);
// Insert trim notice
self.conversation.insert(0, ConversationLine {
text: "... (earlier messages trimmed)".to_string(),
color: Color::DarkGray,
bold: false,
});
self.conversation.insert(0, ConversationLine::plain(
"... (earlier messages trimmed)".to_string(),
Color::DarkGray,
false,
));
}
self.conversation_scroll = 0;
self.needs_redraw = true;
@ -388,6 +424,7 @@ impl TuiApp {
let showing_completions = self.showing_completions;
let spinner_frame = self.spinner_frame;
let input_lines: Vec<String> = self.input.lines().iter().cloned().collect();
let renderer = self.markdown_renderer.clone();
self.terminal.draw(|f| {
draw_frame(
@ -401,6 +438,7 @@ impl TuiApp {
completion_index,
showing_completions,
spinner_frame,
&renderer,
);
})?;
self.terminal.backend_mut().flush()?;
@ -530,6 +568,7 @@ fn draw_frame(
completion_index: usize,
showing_completions: bool,
spinner_frame: usize,
markdown_renderer: &crate::markdown::MarkdownRenderer,
) {
let size = f.area();
let main = Layout::default()
@ -547,6 +586,7 @@ fn draw_frame(
slash_completions,
completion_index,
showing_completions,
markdown_renderer,
);
draw_right_pane(f, main[1], dashboard, spinner_frame);
}
@ -606,26 +646,51 @@ fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
fn build_wrapped_conversation<'a>(
conversation: &[ConversationLine],
content_width: usize,
markdown_renderer: &crate::markdown::MarkdownRenderer,
) -> (Vec<Line<'a>>, Vec<usize>) {
let mut all_lines: Vec<Line<'a>> = Vec::new();
let mut expand_counts: Vec<usize> = Vec::new(); // visual lines per logical line
let mut expand_counts: Vec<usize> = Vec::new();
for cl in conversation {
let mut style = Style::default().fg(cl.color);
if cl.bold {
style = style.add_modifier(Modifier::BOLD);
match &cl.content {
ConversationContent::Plain { text, color, bold } => {
let mut style = Style::default().fg(*color);
if *bold {
style = style.add_modifier(Modifier::BOLD);
}
let wrapped = wrap_line(text, content_width, style);
let count = wrapped.len().max(1);
expand_counts.push(count);
all_lines.extend(wrapped);
}
ConversationContent::Markdown { source } => {
let rendered = markdown_renderer.render(source, content_width as u16);
let count = rendered.len().max(1);
expand_counts.push(count);
// rendered is Vec<Line<'static>> — safe to extend
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
// Convert Line<'static> to Line<'a> via into_owned pattern
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
ConversationContent::CodeDiff { diff } => {
let rendered = crate::markdown::render_diff(diff);
let count = rendered.len().max(1);
expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
}
let wrapped = wrap_line(&cl.text, content_width, style);
let count = wrapped.len().max(1);
expand_counts.push(count);
all_lines.extend(wrapped);
}
(all_lines, expand_counts)
}
#[allow(clippy::too_many_arguments)]
fn draw_left_pane(
f: &mut Frame,
area: Rect,
@ -636,6 +701,7 @@ fn draw_left_pane(
slash_completions: &[String],
completion_index: usize,
showing_completions: bool,
markdown_renderer: &crate::markdown::MarkdownRenderer,
) {
let left = Layout::default()
.direction(Direction::Vertical)
@ -645,7 +711,7 @@ fn draw_left_pane(
// --- conversation with word-wrapping ---
// Subtract 1 for the top border, 2 for left/right block padding
let content_width = (left[0].width as usize).saturating_sub(2);
let (wrapped, expand_counts) = build_wrapped_conversation(conversation, content_width);
let (wrapped, expand_counts) = build_wrapped_conversation(conversation, content_width, markdown_renderer);
let pane_rows = (left[0].height.saturating_sub(1) as usize).max(1);
let total_visual = wrapped.len();