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:
parent
755659c916
commit
e8e8c72185
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod init;
|
|||
mod input;
|
||||
mod render;
|
||||
mod setup_wizard;
|
||||
mod markdown;
|
||||
mod tui;
|
||||
mod tui_error;
|
||||
mod tui_update;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue