From e24069ca79f835fe01815abdeddbb1b2f923da73 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sat, 22 Nov 2025 18:01:43 +0900 Subject: [PATCH] feat(tui): implement incremental markdown parser with pulldown-cmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add markdown_parser.rs with: - MarkdownParser with caching for efficient re-rendering - pulldown-cmark integration with full options - EventRenderer for converting markdown to styled Ratatui lines - Support for headings, code blocks, lists, emphasis, links - Blockquotes, horizontal rules, task lists - Graceful handling of incomplete/malformed markdown - 14 unit tests covering all features Dependencies added: pulldown-cmark Closes #11 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 1 + crates/miyabi-tui/Cargo.toml | 1 + crates/miyabi-tui/src/lib.rs | 2 + crates/miyabi-tui/src/markdown_parser.rs | 443 +++++++++++++++++++++++ 4 files changed, 447 insertions(+) create mode 100644 crates/miyabi-tui/src/markdown_parser.rs diff --git a/Cargo.toml b/Cargo.toml index 7b70ed1..f8f2635 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ crossterm = { version = "0.29.0", features = ["bracketed-paste", "event-stream"] # Text Processing textwrap = "0.16" unicode-width = "0.2" +pulldown-cmark = "0.12" # CLI clap = { version = "4", features = ["derive"] } diff --git a/crates/miyabi-tui/Cargo.toml b/crates/miyabi-tui/Cargo.toml index 24bccdb..e146c99 100644 --- a/crates/miyabi-tui/Cargo.toml +++ b/crates/miyabi-tui/Cargo.toml @@ -28,6 +28,7 @@ crossterm = { workspace = true } # Text Processing textwrap = { workspace = true } unicode-width = { workspace = true } +pulldown-cmark = { workspace = true } # Async Runtime tokio = { workspace = true } diff --git a/crates/miyabi-tui/src/lib.rs b/crates/miyabi-tui/src/lib.rs index 636d455..909c41a 100644 --- a/crates/miyabi-tui/src/lib.rs +++ b/crates/miyabi-tui/src/lib.rs @@ -10,6 +10,7 @@ pub mod history_cell; pub mod markdown_render; pub mod markdown_stream; pub mod diff_render; +pub mod markdown_parser; pub use app::App; pub use event::{Event, EventHandler}; @@ -18,3 +19,4 @@ pub use history_cell::{HistoryCell, UserMessageCell, AssistantMessageCell, ToolR pub use markdown_render::{MarkdownRenderer, MarkdownStyles}; pub use markdown_stream::{MarkdownStream, StreamState, StreamBuffer}; pub use diff_render::{DiffRender, DiffLine, DiffLineType, DiffHunk, FileDiff}; +pub use markdown_parser::MarkdownParser; diff --git a/crates/miyabi-tui/src/markdown_parser.rs b/crates/miyabi-tui/src/markdown_parser.rs new file mode 100644 index 0000000..4278e47 --- /dev/null +++ b/crates/miyabi-tui/src/markdown_parser.rs @@ -0,0 +1,443 @@ +//! Incremental Markdown Parser +//! +//! This module provides incremental parsing of markdown content using pulldown-cmark, +//! with caching for efficient re-rendering during streaming. + +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, CodeBlockKind}; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; + +/// Incremental markdown parser with caching +#[derive(Debug, Clone)] +pub struct MarkdownParser { + /// Cached parsed lines + cache: Vec>, + /// Last parsed content length + last_len: usize, + /// Parser options + options: Options, +} + +impl Default for MarkdownParser { + fn default() -> Self { + Self::new() + } +} + +impl MarkdownParser { + /// Create a new parser + pub fn new() -> Self { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + Self { + cache: Vec::new(), + last_len: 0, + options, + } + } + + /// Parse markdown content + /// + /// Uses caching to only reparse when content has changed + pub fn parse(&mut self, content: &str) -> Vec> { + // If content length is same, use cache + if content.len() == self.last_len && !self.cache.is_empty() { + return self.cache.clone(); + } + + let lines = self.parse_content(content); + self.cache = lines.clone(); + self.last_len = content.len(); + + lines + } + + /// Force a full reparse + pub fn invalidate(&mut self) { + self.cache.clear(); + self.last_len = 0; + } + + /// Parse content into styled lines + fn parse_content(&self, content: &str) -> Vec> { + let parser = Parser::new_ext(content, self.options); + let mut renderer = EventRenderer::new(); + + for event in parser { + renderer.handle_event(event); + } + + renderer.finish() + } + + /// Get cached line count + pub fn line_count(&self) -> usize { + self.cache.len() + } +} + +/// Renderer that converts pulldown-cmark events to Ratatui lines +struct EventRenderer { + lines: Vec>, + current_spans: Vec>, + style_stack: Vec