diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 5279f5b..3bf1287 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -1,6 +1,5 @@ // JSON tool call filtering for display (moved from g3-core) pub mod filter_json; -pub mod syntax_highlight; pub mod streaming_markdown; use anyhow::Result; diff --git a/crates/g3-cli/src/syntax_highlight.rs b/crates/g3-cli/src/syntax_highlight.rs deleted file mode 100644 index 55a1536..0000000 --- a/crates/g3-cli/src/syntax_highlight.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Syntax highlighting for code blocks using syntect. -//! -//! This module provides functionality to extract code blocks from markdown, -//! apply syntax highlighting using syntect, and return the highlighted output -//! while leaving the rest of the markdown intact. - -use once_cell::sync::Lazy; -use syntect::easy::HighlightLines; -use syntect::highlighting::ThemeSet; -use syntect::parsing::SyntaxSet; -use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; - -/// Lazily loaded syntax set with default syntaxes. -static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); - -/// Lazily loaded theme set with default themes. -static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); - -/// A segment of markdown content - either plain text or a code block. -#[derive(Debug)] -enum MarkdownSegment<'a> { - /// Plain markdown text (not a code block) - Text(&'a str), - /// A fenced code block with optional language and content - CodeBlock { lang: Option<&'a str>, code: &'a str }, -} - -/// Parse markdown into segments of text and code blocks. -fn parse_markdown_segments(markdown: &str) -> Vec> { - let mut segments = Vec::new(); - let mut remaining = markdown; - - while !remaining.is_empty() { - // Look for the start of a code block (``` at start of line or after newline) - if let Some(fence_start) = find_code_fence_start(remaining) { - // Add any text before the fence - if fence_start > 0 { - segments.push(MarkdownSegment::Text(&remaining[..fence_start])); - } - - // Parse the code block - let after_fence = &remaining[fence_start..]; - if let Some((lang, code, end_pos)) = parse_code_block(after_fence) { - segments.push(MarkdownSegment::CodeBlock { lang, code }); - remaining = &after_fence[end_pos..]; - } else { - // Malformed fence - treat as text and continue - segments.push(MarkdownSegment::Text(&remaining[..fence_start + 3])); - remaining = &remaining[fence_start + 3..]; - } - } else { - // No more code blocks - rest is plain text - segments.push(MarkdownSegment::Text(remaining)); - break; - } - } - - segments -} - -/// Find the start position of a code fence (```) that begins a line. -fn find_code_fence_start(text: &str) -> Option { - let mut pos = 0; - for line in text.lines() { - let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - // Return position at start of the ``` (after any leading whitespace on line) - let whitespace_len = line.len() - trimmed.len(); - return Some(pos + whitespace_len); - } - pos += line.len() + 1; // +1 for newline - } - None -} - -/// Parse a code block starting at the opening fence. -/// Returns (language, code_content, end_position_after_closing_fence). -fn parse_code_block(text: &str) -> Option<(Option<&str>, &str, usize)> { - // text starts with ``` - let first_line_end = text.find('\n')?; - let first_line = &text[3..first_line_end].trim(); - - // Extract language (if any) - let lang = if first_line.is_empty() { - None - } else { - // Language is the first word on the line - let lang_str = first_line.split_whitespace().next().unwrap_or(*first_line); - Some(lang_str) - }; - - // Find the closing fence - let code_start = first_line_end + 1; - let after_opening = &text[code_start..]; - - // Look for closing ``` at start of a line - let mut search_pos = 0; - for line in after_opening.lines() { - if line.trim_start().starts_with("```") { - // Found closing fence - let code = &after_opening[..search_pos]; - let closing_fence_end = search_pos + line.len(); - // Include the newline after closing fence if present - let total_end = if after_opening.len() > closing_fence_end - && after_opening.as_bytes().get(closing_fence_end) == Some(&b'\n') - { - code_start + closing_fence_end + 1 - } else { - code_start + closing_fence_end - }; - return Some((lang, code, total_end)); - } - search_pos += line.len() + 1; // +1 for newline - } - - // No closing fence found - treat entire rest as code - Some((lang, after_opening, text.len())) -} - -/// Highlight a code block with the given language. -fn highlight_code(code: &str, lang: Option<&str>) -> String { - let syntax = lang - .and_then(|l| SYNTAX_SET.find_syntax_by_token(l)) - .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); - - // Use a dark theme suitable for terminals - let theme = &THEME_SET.themes["base16-ocean.dark"]; - let mut highlighter = HighlightLines::new(syntax, theme); - - let mut output = String::new(); - - for line in LinesWithEndings::from(code) { - match highlighter.highlight_line(line, &SYNTAX_SET) { - Ok(ranges) => { - let escaped = as_24_bit_terminal_escaped(&ranges[..], false); - output.push_str(&escaped); - } - Err(_) => { - // Fallback: just append the line without highlighting - output.push_str(line); - } - } - } - - // Reset terminal colors at the end - output.push_str("\x1b[0m"); - output -} - -/// Render markdown with syntax-highlighted code blocks. -/// -/// This function: -/// 1. Parses the markdown to find code blocks -/// 2. Applies syntect highlighting to code blocks -/// 3. Renders non-code portions with termimad -/// 4. Combines everything into the final output -pub fn render_markdown_with_highlighting(markdown: &str, skin: &termimad::MadSkin) -> String { - let segments = parse_markdown_segments(markdown); - let mut output = String::new(); - - for segment in segments { - match segment { - MarkdownSegment::Text(text) => { - if !text.is_empty() { - // Render with termimad - let rendered = skin.term_text(text); - output.push_str(&format!("{}", rendered)); - } - } - MarkdownSegment::CodeBlock { lang, code } => { - // Add a subtle header showing the language - if let Some(l) = lang { - output.push_str(&format!("\x1b[2;3m{}\x1b[0m\n", l)); - } - // Highlight and append the code - let highlighted = highlight_code(code, lang); - output.push_str(&highlighted); - // Ensure we end with a newline - if !output.ends_with('\n') { - output.push('\n'); - } - } - } - } - - output -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_simple_code_block() { - let md = "Some text\n```rust\nfn main() {}\n```\nMore text"; - let segments = parse_markdown_segments(md); - - assert_eq!(segments.len(), 3); - assert!(matches!(segments[0], MarkdownSegment::Text("Some text\n"))); - assert!(matches!( - segments[1], - MarkdownSegment::CodeBlock { - lang: Some("rust"), - code: "fn main() {}\n" - } - )); - assert!(matches!(segments[2], MarkdownSegment::Text("More text"))); - } - - #[test] - fn test_parse_no_language() { - let md = "```\nplain code\n```"; - let segments = parse_markdown_segments(md); - - assert_eq!(segments.len(), 1); - assert!(matches!( - segments[0], - MarkdownSegment::CodeBlock { - lang: None, - code: "plain code\n" - } - )); - } - - #[test] - fn test_highlight_rust_code() { - let code = "fn main() {\n println!(\"Hello\");\n}\n"; - let highlighted = highlight_code(code, Some("rust")); - - // Should contain ANSI escape codes - assert!(highlighted.contains("\x1b[")); - // Should end with reset - assert!(highlighted.ends_with("\x1b[0m")); - } - - #[test] - fn test_no_code_blocks() { - let md = "Just plain markdown with **bold** and *italic*."; - let segments = parse_markdown_segments(md); - - assert_eq!(segments.len(), 1); - assert!(matches!(segments[0], MarkdownSegment::Text(_))); - } -} diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 1aa9ac0..483285c 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,5 +1,4 @@ use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state}; -use crate::syntax_highlight::render_markdown_with_highlighting; use crate::streaming_markdown::StreamingMarkdownFormatter; use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; @@ -354,38 +353,30 @@ impl UiWriter for ConsoleUiWriter { } fn print_final_output(&self, summary: &str) { - // Show spinner while "formatting" - let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let message = "compacting work done..."; - - // Brief spinner animation (about 0.5 seconds) - for i in 0..5 { - let frame = spinner_frames[i % spinner_frames.len()]; - print!("\r\x1b[36m{} {}\x1b[0m", frame, message); - let _ = io::stdout().flush(); - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Clear the spinner line - print!("\r\x1b[2K"); - let _ = io::stdout().flush(); - - // Create a styled markdown skin - let mut skin = MadSkin::default(); - // Customize colors for better terminal appearance - skin.bold.set_fg(termimad::crossterm::style::Color::Green); - skin.italic.set_fg(termimad::crossterm::style::Color::Cyan); - skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { r: 216, g: 177, b: 114 }); - skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta); - skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta); - // Print a header separator println!("\x1b[1;35m━━━ Summary ━━━\x1b[0m"); println!(); - - // Render the markdown with syntax-highlighted code blocks - let rendered = render_markdown_with_highlighting(summary, &skin); - print!("{}", rendered); + + // Use the same streaming markdown formatter for consistency + let mut skin = MadSkin::default(); + skin.bold.set_fg(termimad::crossterm::style::Color::Green); + skin.italic.set_fg(termimad::crossterm::style::Color::Cyan); + skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { + r: 216, + g: 177, + b: 114, + }); + + let mut formatter = StreamingMarkdownFormatter::new(skin); + + // Process the entire summary through the formatter + let formatted = formatter.process(summary); + print!("{}", formatted); + + // Flush any remaining buffered content + let remaining = formatter.finish(); + print!("{}", remaining); + let _ = io::stdout().flush(); // Print a footer separator println!(); diff --git a/crates/g3-cli/tests/test_final_output.rs b/crates/g3-cli/tests/test_final_output.rs index 08e304c..96cff3d 100644 --- a/crates/g3-cli/tests/test_final_output.rs +++ b/crates/g3-cli/tests/test_final_output.rs @@ -1,16 +1,13 @@ -//! Quick test to verify syntax highlighting works +//! Quick test to verify final_output rendering works with streaming markdown //! Run with: cargo test -p g3-cli --test test_final_output -- --nocapture use std::io::{self, Write}; -// We'll directly test the syntax_highlight module's public function -// by importing it and calling it with a MadSkin - #[test] -fn test_syntax_highlighting_visual() { - // Import what we need +fn test_final_output_visual() { + use g3_cli::streaming_markdown::StreamingMarkdownFormatter; use termimad::MadSkin; - + // Create the test markdown let test_markdown = r##"# Task Completed Successfully @@ -158,18 +155,24 @@ All changes have been tested and verified. The implementation: let mut skin = MadSkin::default(); skin.bold.set_fg(termimad::crossterm::style::Color::Green); skin.italic.set_fg(termimad::crossterm::style::Color::Cyan); - skin.headers[0].set_fg(termimad::crossterm::style::Color::Magenta); - skin.headers[1].set_fg(termimad::crossterm::style::Color::Magenta); + skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { + r: 216, + g: 177, + b: 114, + }); // Print header println!("\n\x1b[1;35m━━━ Summary ━━━\x1b[0m\n"); - // Use the syntax highlighting renderer - let rendered = g3_cli::syntax_highlight::render_markdown_with_highlighting(test_markdown, &skin); - print!("{}", rendered); + // Use the streaming markdown formatter (same as print_final_output now uses) + let mut formatter = StreamingMarkdownFormatter::new(skin); + let formatted = formatter.process(test_markdown); + print!("{}", formatted); + let remaining = formatter.finish(); + print!("{}", remaining); // Print footer println!("\n\x1b[1;35m━━━━━━━━━━━━━━━\x1b[0m"); - + let _ = io::stdout().flush(); }