notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e6d97deb326add0a025aea2179d2bdf566684b1a
parent 6148633d984ade53bce1ab47422e5df1242009ea
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 13 Feb 2026 12:55:33 -0800

Merge streaming markdown parser and renderer for dave

Diffstat:
MCargo.lock | 5+++++
MCargo.toml | 2++
Acrates/egui-md-stream/Cargo.toml | 12++++++++++++
Acrates/egui-md-stream/src/element.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/egui-md-stream/src/inline.rs | 552+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/egui-md-stream/src/lib.rs | 17+++++++++++++++++
Acrates/egui-md-stream/src/parser.rs | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/egui-md-stream/src/partial.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/egui-md-stream/src/tests.rs | 445+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/Cargo.toml | 1+
Mcrates/notedeck_dave/src/backend/claude.rs | 2+-
Mcrates/notedeck_dave/src/lib.rs | 16++++++++++++----
Mcrates/notedeck_dave/src/messages.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/session.rs | 5+++--
Mcrates/notedeck_dave/src/ui/dave.rs | 18++++++++----------
Acrates/notedeck_dave/src/ui/markdown_ui.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 1+
17 files changed, 2018 insertions(+), 19 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1692,6 +1692,10 @@ dependencies = [ ] [[package]] +name = "egui-md-stream" +version = "0.1.0" + +[[package]] name = "egui-wgpu" version = "0.31.1" source = "git+https://github.com/damus-io/egui?rev=e05638c40ef734312b3b3e36397d389d0a78b10b#e05638c40ef734312b3b3e36397d389d0a78b10b" @@ -4143,6 +4147,7 @@ dependencies = [ "dirs", "eframe", "egui", + "egui-md-stream", "egui-wgpu", "egui_extras", "enostr", diff --git a/Cargo.toml b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/notedeck_ui", "crates/notedeck_clndash", "crates/notedeck_dashboard", + "crates/egui-md-stream", "crates/tokenator", "crates/enostr", ] @@ -74,6 +75,7 @@ notedeck_messages = { path = "crates/notedeck_messages" } notedeck_notebook = { path = "crates/notedeck_notebook" } notedeck_ui = { path = "crates/notedeck_ui" } tokenator = { path = "crates/tokenator" } +egui-md-stream = { path = "crates/egui-md-stream" } once_cell = "1.19.0" robius-open = "0.1" poll-promise = { version = "0.3.0", features = ["tokio"] } diff --git a/crates/egui-md-stream/Cargo.toml b/crates/egui-md-stream/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "egui-md-stream" +version = "0.1.0" +edition = "2021" +description = "Incremental markdown parser for streaming LLM output in egui" +license = "MIT" + +[dependencies] +# No deps for core parser - keep it minimal + +[dev-dependencies] +# For testing streaming behavior diff --git a/crates/egui-md-stream/src/element.rs b/crates/egui-md-stream/src/element.rs @@ -0,0 +1,74 @@ +//! Markdown elements - the stable output of parsing. + +/// A complete, stable markdown element ready for rendering. +#[derive(Debug, Clone, PartialEq)] +pub enum MdElement { + /// Heading with level (1-6) and content + Heading { level: u8, content: String }, + + /// Paragraph of text (may contain inline elements) + Paragraph(Vec<InlineElement>), + + /// Fenced code block + CodeBlock(CodeBlock), + + /// Blockquote (contains nested elements) + BlockQuote(Vec<MdElement>), + + /// Unordered list + UnorderedList(Vec<ListItem>), + + /// Ordered list (starting number) + OrderedList { start: u32, items: Vec<ListItem> }, + + /// Thematic break (---, ***, ___) + ThematicBreak, + + /// Raw text (when nothing else matches) + Text(String), +} + +/// A fenced code block with optional language. +#[derive(Debug, Clone, PartialEq)] +pub struct CodeBlock { + pub language: Option<String>, + pub content: String, +} + +/// A list item (may contain nested elements). +#[derive(Debug, Clone, PartialEq)] +pub struct ListItem { + pub content: Vec<InlineElement>, + pub nested: Option<Box<MdElement>>, // Nested list +} + +/// Inline elements within a paragraph or list item. +#[derive(Debug, Clone, PartialEq)] +pub enum InlineElement { + /// Plain text + Text(String), + + /// Styled text (bold, italic, etc.) + Styled { style: InlineStyle, content: String }, + + /// Inline code (`code`) + Code(String), + + /// Link [text](url) + Link { text: String, url: String }, + + /// Image ![alt](url) + Image { alt: String, url: String }, + + /// Hard line break + LineBreak, +} + +/// Inline text styles (can be combined). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InlineStyle { + Bold, + Italic, + BoldItalic, + Strikethrough, +} diff --git a/crates/egui-md-stream/src/inline.rs b/crates/egui-md-stream/src/inline.rs @@ -0,0 +1,552 @@ +//! Inline element parsing for bold, italic, code, links, etc. + +use crate::element::{InlineElement, InlineStyle}; +use crate::partial::PartialKind; + +/// Parses inline elements from text. +/// Returns a vector of inline elements. +/// +/// Note: This is called on complete paragraph text, not streaming. +/// For streaming, we use PartialKind to track incomplete markers. +pub fn parse_inline(text: &str) -> Vec<InlineElement> { + let mut result = Vec::new(); + let mut chars = text.char_indices().peekable(); + let mut plain_start = 0; + + while let Some((i, c)) = chars.next() { + match c { + // Backtick - inline code + '`' => { + // Flush any pending plain text + if i > plain_start { + result.push(InlineElement::Text(text[plain_start..i].to_string())); + } + + // Count backticks + let mut backtick_count = 1; + while chars.peek().map(|(_, c)| *c == '`').unwrap_or(false) { + chars.next(); + backtick_count += 1; + } + + let start_pos = i + backtick_count; + + // Find closing backticks (same count) + if let Some(end_pos) = find_closing_backticks(&text[start_pos..], backtick_count) { + let code_content = &text[start_pos..start_pos + end_pos]; + // Strip single leading/trailing space if present (CommonMark rule) + let trimmed = if code_content.starts_with(' ') + && code_content.ends_with(' ') + && code_content.len() > 1 + { + &code_content[1..code_content.len() - 1] + } else { + code_content + }; + result.push(InlineElement::Code(trimmed.to_string())); + + // Advance past closing backticks + let skip_to = start_pos + end_pos + backtick_count; + while chars.peek().map(|(idx, _)| *idx < skip_to).unwrap_or(false) { + chars.next(); + } + plain_start = skip_to; + } else { + // No closing - treat as plain text + plain_start = i; + } + } + + // Asterisk or underscore - potential bold/italic + '*' | '_' => { + let marker = c; + let marker_start = i; + + // Count consecutive markers + let mut count = 1; + while chars.peek().map(|(_, ch)| *ch == marker).unwrap_or(false) { + chars.next(); + count += 1; + } + + // Limit to 3 for bold+italic + let effective_count = count.min(3); + + // Check if this could be an opener (not preceded by whitespace at word boundary for _) + let can_open = if marker == '_' { + // Underscore: check word boundary rules + i == 0 + || text[..i] + .chars() + .last() + .map(|c| c.is_whitespace() || c.is_ascii_punctuation()) + .unwrap_or(true) + } else { + true // Asterisk can always open + }; + + if !can_open { + // Not a valid opener, treat as plain text + continue; + } + + let content_start = marker_start + count; + + // Look for closing marker + if let Some((content, close_len, end_pos)) = + find_closing_emphasis(&text[content_start..], marker, effective_count) + { + // Flush pending plain text + if marker_start > plain_start { + result.push(InlineElement::Text( + text[plain_start..marker_start].to_string(), + )); + } + + let style = match close_len { + 1 => InlineStyle::Italic, + 2 => InlineStyle::Bold, + _ => InlineStyle::BoldItalic, + }; + + result.push(InlineElement::Styled { + style, + content: content.to_string(), + }); + + // Advance past the content and closing marker + let skip_to = content_start + end_pos + close_len; + while chars.peek().map(|(idx, _)| *idx < skip_to).unwrap_or(false) { + chars.next(); + } + plain_start = skip_to; + } + // If no closing found, leave as plain text (will be collected) + } + + // Tilde - potential strikethrough + '~' => { + if chars.peek().map(|(_, c)| *c == '~').unwrap_or(false) { + chars.next(); // consume second ~ + + // Flush pending text + if i > plain_start { + result.push(InlineElement::Text(text[plain_start..i].to_string())); + } + + let content_start = i + 2; + + // Find closing ~~ + if let Some(end_pos) = text[content_start..].find("~~") { + let content = &text[content_start..content_start + end_pos]; + result.push(InlineElement::Styled { + style: InlineStyle::Strikethrough, + content: content.to_string(), + }); + + let skip_to = content_start + end_pos + 2; + while chars.peek().map(|(idx, _)| *idx < skip_to).unwrap_or(false) { + chars.next(); + } + plain_start = skip_to; + } else { + // No closing, revert + plain_start = i; + } + } + } + + // Square bracket - potential link or image + '[' => { + // Flush pending text + if i > plain_start { + result.push(InlineElement::Text(text[plain_start..i].to_string())); + } + + if let Some((text_content, url, total_len)) = parse_link(&text[i..]) { + result.push(InlineElement::Link { + text: text_content, + url, + }); + + let skip_to = i + total_len; + while chars.peek().map(|(idx, _)| *idx < skip_to).unwrap_or(false) { + chars.next(); + } + plain_start = skip_to; + } else { + // Not a valid link, treat [ as plain text + plain_start = i; + } + } + + // Exclamation - potential image + '!' => { + if chars.peek().map(|(_, c)| *c == '[').unwrap_or(false) { + // Flush pending text + if i > plain_start { + result.push(InlineElement::Text(text[plain_start..i].to_string())); + } + + chars.next(); // consume [ + + if let Some((alt, url, link_len)) = parse_link(&text[i + 1..]) { + result.push(InlineElement::Image { alt, url }); + + let skip_to = i + 1 + link_len; + while chars.peek().map(|(idx, _)| *idx < skip_to).unwrap_or(false) { + chars.next(); + } + plain_start = skip_to; + } else { + // Not a valid image + plain_start = i; + } + } + } + + // Newline - could be hard break + '\n' => { + // Check for hard line break (two spaces before newline) + if i >= 2 && text[..i].ends_with(" ") { + // Flush text without trailing spaces + let text_end = i - 2; + if text_end > plain_start { + result.push(InlineElement::Text(text[plain_start..text_end].to_string())); + } + result.push(InlineElement::LineBreak); + plain_start = i + 1; + } + // Otherwise soft line break, keep in text + } + + _ => { + // Regular character, continue + } + } + } + + // Flush remaining plain text + if plain_start < text.len() { + let remaining = &text[plain_start..]; + if !remaining.is_empty() { + result.push(InlineElement::Text(remaining.to_string())); + } + } + + // Collapse adjacent Text elements + collapse_text_elements(&mut result); + + result +} + +/// Find closing backticks matching the opening count. +fn find_closing_backticks(text: &str, count: usize) -> Option<usize> { + let target: String = "`".repeat(count); + let mut i = 0; + + while i < text.len() { + if text[i..].starts_with(&target) { + // Make sure it's exactly this many backticks + let after = i + count; + if after >= text.len() || !text[after..].starts_with('`') { + return Some(i); + } + // More backticks - skip them + while i < text.len() && text[i..].starts_with('`') { + i += 1; + } + } else { + i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1); + } + } + None +} + +/// Find closing emphasis marker. +/// Returns (content, actual_close_len, end_position) if found. +fn find_closing_emphasis( + text: &str, + marker: char, + open_count: usize, +) -> Option<(&str, usize, usize)> { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let mut i = 0; + + while i < chars.len() { + let (pos, c) = chars[i]; + + if c == marker { + // Count consecutive markers + let mut count = 1; + while i + count < chars.len() && chars[i + count].1 == marker { + count += 1; + } + + // Check if this could close (not followed by alphanumeric for _) + let can_close = if marker == '_' { + i + count >= chars.len() || { + let next_char = chars.get(i + count).map(|(_, c)| *c); + next_char + .map(|c| c.is_whitespace() || c.is_ascii_punctuation()) + .unwrap_or(true) + } + } else { + true + }; + + if can_close && count >= open_count.min(3) { + let close_len = count.min(open_count).min(3); + return Some((&text[..pos], close_len, pos)); + } + + i += count; + } else { + i += 1; + } + } + None +} + +/// Parse a link starting with [ +/// Returns (text, url, total_bytes_consumed) +fn parse_link(text: &str) -> Option<(String, String, usize)> { + if !text.starts_with('[') { + return None; + } + + // Find closing ] + let mut bracket_depth = 0; + let mut bracket_end = None; + + for (i, c) in text.char_indices() { + match c { + '[' => bracket_depth += 1, + ']' => { + bracket_depth -= 1; + if bracket_depth == 0 { + bracket_end = Some(i); + break; + } + } + _ => {} + } + } + + let bracket_end = bracket_end?; + let link_text = &text[1..bracket_end]; + + // Check for ( immediately after ] + let rest = &text[bracket_end + 1..]; + if !rest.starts_with('(') { + return None; + } + + // Find closing ) + let mut paren_depth = 0; + let mut paren_end = None; + + for (i, c) in rest.char_indices() { + match c { + '(' => paren_depth += 1, + ')' => { + paren_depth -= 1; + if paren_depth == 0 { + paren_end = Some(i); + break; + } + } + _ => {} + } + } + + let paren_end = paren_end?; + let url = &rest[1..paren_end]; + + // Total consumed: [ + text + ] + ( + url + ) + let total = bracket_end + 1 + paren_end + 1; + + Some((link_text.to_string(), url.to_string(), total)) +} + +/// Collapse adjacent Text elements into one. +fn collapse_text_elements(elements: &mut Vec<InlineElement>) { + let mut i = 0; + while i + 1 < elements.len() { + if let (InlineElement::Text(a), InlineElement::Text(b)) = (&elements[i], &elements[i + 1]) { + let combined = format!("{}{}", a, b); + elements[i] = InlineElement::Text(combined); + elements.remove(i + 1); + } else { + i += 1; + } + } +} + +/// Streaming inline parser state. +/// Tracks partial inline elements across token boundaries. +pub struct InlineState { + /// Accumulated text waiting to be parsed + buffer: String, + /// Current partial element being built + partial: Option<PartialKind>, +} + +impl InlineState { + pub fn new() -> Self { + Self { + buffer: String::new(), + partial: None, + } + } + + /// Push new text and try to extract complete inline elements. + /// Returns elements that are definitely complete. + pub fn push(&mut self, text: &str) -> Vec<InlineElement> { + self.buffer.push_str(text); + self.extract_complete() + } + + /// Get current buffer content for speculative rendering. + pub fn buffer(&self) -> &str { + &self.buffer + } + + /// Check if we might be in the middle of an inline element. + pub fn has_potential_partial(&self) -> bool { + self.partial.is_some() + || self.buffer.ends_with('`') + || self.buffer.ends_with('*') + || self.buffer.ends_with('_') + || self.buffer.ends_with('~') + || self.buffer.ends_with('[') + || self.buffer.ends_with('!') + } + + /// Finalize - return whatever we have as parsed elements. + pub fn finalize(self) -> Vec<InlineElement> { + parse_inline(&self.buffer) + } + + /// Extract complete inline elements from the buffer. + fn extract_complete(&mut self) -> Vec<InlineElement> { + // For streaming, we're conservative - only return elements when + // we're confident they won't change. + // + // Strategy: Parse the whole buffer, but only return elements that + // end before any trailing ambiguous characters. + + let result = parse_inline(&self.buffer); + + // Check if the buffer might have incomplete markers at the end + if self.has_incomplete_tail() { + // Keep the buffer, don't return anything yet + return Vec::new(); + } + + // Buffer is stable, clear it and return parsed result + self.buffer.clear(); + result + } + + /// Check if the buffer ends with potentially incomplete markers. + fn has_incomplete_tail(&self) -> bool { + let s = &self.buffer; + + // Check for unclosed backticks + let backtick_count = s.chars().filter(|&c| c == '`').count(); + if backtick_count % 2 != 0 { + return true; + } + + // Check for unclosed brackets + let open_brackets = s.chars().filter(|&c| c == '[').count(); + let close_brackets = s.chars().filter(|&c| c == ']').count(); + if open_brackets > close_brackets { + return true; + } + + // Check for trailing asterisks/underscores that might start formatting + if s.ends_with('*') || s.ends_with('_') || s.ends_with('~') { + return true; + } + + false + } +} + +impl Default for InlineState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inline_code() { + let result = parse_inline("some `code` here"); + assert!(result + .iter() + .any(|e| matches!(e, InlineElement::Code(s) if s == "code"))); + } + + #[test] + fn test_bold() { + let result = parse_inline("some **bold** text"); + assert!(result.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Bold, content } if content == "bold" + ))); + } + + #[test] + fn test_italic() { + let result = parse_inline("some *italic* text"); + assert!(result.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Italic, content } if content == "italic" + ))); + } + + #[test] + fn test_link() { + let result = parse_inline("check [this](https://example.com) out"); + assert!(result.iter().any(|e| matches!( + e, + InlineElement::Link { text, url } if text == "this" && url == "https://example.com" + ))); + } + + #[test] + fn test_image() { + let result = parse_inline("see ![alt](img.png) here"); + assert!(result.iter().any(|e| matches!( + e, + InlineElement::Image { alt, url } if alt == "alt" && url == "img.png" + ))); + } + + #[test] + fn test_strikethrough() { + let result = parse_inline("some ~~deleted~~ text"); + assert!(result.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Strikethrough, content } if content == "deleted" + ))); + } + + #[test] + fn test_mixed() { + let result = parse_inline("**bold** and *italic* and `code`"); + assert_eq!( + result + .iter() + .filter(|e| !matches!(e, InlineElement::Text(_))) + .count(), + 3 + ); + } +} diff --git a/crates/egui-md-stream/src/lib.rs b/crates/egui-md-stream/src/lib.rs @@ -0,0 +1,17 @@ +//! Incremental markdown parser for streaming LLM output. +//! +//! Designed for chat interfaces where markdown arrives token-by-token +//! and needs to be rendered progressively. + +mod element; +mod inline; +mod parser; +mod partial; + +pub use element::{CodeBlock, InlineElement, InlineStyle, ListItem, MdElement}; +pub use inline::{parse_inline, InlineState}; +pub use parser::StreamParser; +pub use partial::{LinkState, Partial, PartialKind}; + +#[cfg(test)] +mod tests; diff --git a/crates/egui-md-stream/src/parser.rs b/crates/egui-md-stream/src/parser.rs @@ -0,0 +1,427 @@ +//! Core streaming parser implementation. + +use crate::element::{CodeBlock, MdElement}; +use crate::inline::parse_inline; +use crate::partial::{Partial, PartialKind}; + +/// Incremental markdown parser for streaming input. +/// +/// Maintains a buffer of incoming tokens and tracks parsing state +/// to allow progressive rendering as content streams in. +pub struct StreamParser { + /// Raw token chunks from the stream + tokens: Vec<String>, + + /// Total bytes in tokens (for efficient length tracking) + total_bytes: usize, + + /// Completed markdown elements + parsed: Vec<MdElement>, + + /// Current in-progress element (if any) + partial: Option<Partial>, + + /// Index of first unprocessed token + process_idx: usize, + + /// Byte offset within the token at process_idx + process_offset: usize, + + /// Are we at the start of a line? (for block-level detection) + at_line_start: bool, +} + +impl StreamParser { + pub fn new() -> Self { + Self { + tokens: Vec::new(), + total_bytes: 0, + parsed: Vec::new(), + partial: None, + process_idx: 0, + process_offset: 0, + at_line_start: true, + } + } + + /// Push a new token chunk and process it. + pub fn push(&mut self, token: &str) { + if token.is_empty() { + return; + } + + self.tokens.push(token.to_string()); + self.total_bytes += token.len(); + self.process_new_content(); + } + + /// Get completed elements for rendering. + pub fn parsed(&self) -> &[MdElement] { + &self.parsed + } + + /// Get the current partial state (for speculative rendering). + pub fn partial(&self) -> Option<&Partial> { + self.partial.as_ref() + } + + /// Get the speculative content that would render from partial state. + /// Returns the raw accumulated text that isn't yet a complete element. + pub fn partial_content(&self) -> Option<&str> { + self.partial.as_ref().map(|p| p.content.as_str()) + } + + /// Check if we're currently inside a code block. + pub fn in_code_block(&self) -> bool { + matches!( + self.partial.as_ref().map(|p| &p.kind), + Some(PartialKind::CodeFence { .. }) + ) + } + + /// Process newly added content. + fn process_new_content(&mut self) { + while self.process_idx < self.tokens.len() { + // Clone the remaining text to avoid borrow conflicts + let remaining = { + let token = &self.tokens[self.process_idx]; + let slice = &token[self.process_offset..]; + if slice.is_empty() { + self.process_idx += 1; + self.process_offset = 0; + continue; + } + slice.to_string() + }; + + // Handle based on current partial state + let partial_kind = self.partial.as_ref().map(|p| p.kind.clone()); + if let Some(kind) = partial_kind { + match kind { + PartialKind::CodeFence { + fence_char, + fence_len, + .. + } => { + if self.process_code_fence(fence_char, fence_len, &remaining) { + continue; + } + return; // Need more input + } + PartialKind::Heading { level } => { + if self.process_heading(level, &remaining) { + continue; + } + return; + } + PartialKind::Paragraph => { + // For paragraphs, check if we're at a line start that could be a block element + if self.at_line_start { + if let Some(consumed) = self.try_block_start(&remaining) { + // Emit the current paragraph before starting the new block + if let Some(partial) = self.partial.take() { + if !partial.content.trim().is_empty() { + let inline_elements = parse_inline(partial.content.trim()); + self.parsed.push(MdElement::Paragraph(inline_elements)); + } + } + self.advance(consumed); + continue; + } + } + // Continue with inline processing + if self.process_inline(&remaining) { + continue; + } + return; + } + _ => { + // For other inline elements, process character by character + if self.process_inline(&remaining) { + continue; + } + return; + } + } + } + + // No partial state - detect new elements + if self.at_line_start { + if let Some(consumed) = self.try_block_start(&remaining) { + self.advance(consumed); + continue; + } + } + + // Fall back to inline processing + if self.process_inline(&remaining) { + continue; + } + return; + } + } + + /// Try to detect a block-level element at line start. + /// Returns bytes consumed if successful. + fn try_block_start(&mut self, text: &str) -> Option<usize> { + let trimmed = text.trim_start(); + let leading_space = text.len() - trimmed.len(); + + // Heading: # ## ### etc + if trimmed.starts_with('#') { + let level = trimmed.chars().take_while(|&c| c == '#').count(); + if level <= 6 { + if let Some(rest) = trimmed.get(level..) { + if rest.starts_with(' ') || rest.is_empty() { + self.partial = Some(Partial::new( + PartialKind::Heading { level: level as u8 }, + self.process_idx, + )); + self.at_line_start = false; + return Some(leading_space + level + rest.starts_with(' ') as usize); + } + } + } + } + + // Code fence: ``` or ~~~ + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + let fence_char = trimmed.chars().next().unwrap(); + let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count(); + + if fence_len >= 3 { + let after_fence = &trimmed[fence_len..]; + let (language, consumed_lang) = if let Some(nl_pos) = after_fence.find('\n') { + let lang = after_fence[..nl_pos].trim(); + ( + if lang.is_empty() { + None + } else { + Some(lang.to_string()) + }, + nl_pos + 1, + ) + } else { + // No newline yet - language might be incomplete + let lang = after_fence.trim(); + ( + if lang.is_empty() { + None + } else { + Some(lang.to_string()) + }, + after_fence.len(), + ) + }; + + self.partial = Some(Partial::new( + PartialKind::CodeFence { + fence_char, + fence_len, + language, + }, + self.process_idx, + )); + self.at_line_start = false; + return Some(leading_space + fence_len + consumed_lang); + } + } + + // Thematic break: --- *** ___ + if (trimmed.starts_with("---") || trimmed.starts_with("***") || trimmed.starts_with("___")) + && trimmed.chars().filter(|&c| !c.is_whitespace()).count() >= 3 + { + let break_char = trimmed.chars().next().unwrap(); + if trimmed + .chars() + .all(|c| c == break_char || c.is_whitespace()) + { + if let Some(nl_pos) = text.find('\n') { + self.parsed.push(MdElement::ThematicBreak); + self.at_line_start = true; + return Some(nl_pos + 1); + } + } + } + + None + } + + /// Process content inside a code fence. + /// Returns true if we should continue processing, false if we need more input. + fn process_code_fence(&mut self, fence_char: char, fence_len: usize, text: &str) -> bool { + let closing = std::iter::repeat_n(fence_char, fence_len).collect::<String>(); + + // Look for closing fence at start of line + let partial = self.partial.as_mut().unwrap(); + + for line in text.split_inclusive('\n') { + let trimmed = line.trim_start(); + if trimmed.starts_with(&closing) { + // Check it's a valid closing fence (only fence chars and whitespace after) + let after_fence = &trimmed[fence_len..]; + if after_fence.trim().is_empty() || after_fence.starts_with('\n') { + // Found closing fence! Complete the code block + let language = if let PartialKind::CodeFence { language, .. } = &partial.kind { + language.clone() + } else { + None + }; + + let content = std::mem::take(&mut partial.content); + self.parsed + .push(MdElement::CodeBlock(CodeBlock { language, content })); + self.partial = None; + self.at_line_start = true; + + // Advance past the closing fence line + let consumed = text.find(line).unwrap() + line.len(); + self.advance(consumed); + return true; + } + } + + // Not a closing fence - add to content + partial.content.push_str(line); + } + + // Consumed all available text, need more + self.advance(text.len()); + false + } + + /// Process heading content until newline. + fn process_heading(&mut self, level: u8, text: &str) -> bool { + if let Some(nl_pos) = text.find('\n') { + let partial = self.partial.as_mut().unwrap(); + partial.content.push_str(&text[..nl_pos]); + + let content = std::mem::take(&mut partial.content).trim().to_string(); + self.parsed.push(MdElement::Heading { level, content }); + self.partial = None; + self.at_line_start = true; + self.advance(nl_pos + 1); + true + } else { + // No newline yet - accumulate + let partial = self.partial.as_mut().unwrap(); + partial.content.push_str(text); + self.advance(text.len()); + false + } + } + + /// Process inline content. + fn process_inline(&mut self, text: &str) -> bool { + if let Some(nl_pos) = text.find("\n\n") { + // Double newline = paragraph break + // Combine accumulated partial content with text before \n\n + let para_text = if let Some(ref mut partial) = self.partial { + partial.content.push_str(&text[..nl_pos]); + std::mem::take(&mut partial.content) + } else { + text[..nl_pos].to_string() + }; + self.partial = None; + + if !para_text.trim().is_empty() { + // Parse inline elements from the full paragraph text + let inline_elements = parse_inline(para_text.trim()); + self.parsed.push(MdElement::Paragraph(inline_elements)); + } + self.at_line_start = true; + self.advance(nl_pos + 2); + return true; + } + + if let Some(nl_pos) = text.find('\n') { + // Single newline - continue accumulating but track position + if let Some(ref mut partial) = self.partial { + partial.content.push_str(&text[..=nl_pos]); + } else { + // Start accumulating paragraph + let content = text[..=nl_pos].to_string(); + self.partial = Some(Partial { + kind: PartialKind::Paragraph, + start_idx: self.process_idx, + byte_offset: self.process_offset, + content, + }); + } + self.at_line_start = true; + self.advance(nl_pos + 1); + return true; + } + + // No newline - accumulate + if let Some(ref mut partial) = self.partial { + partial.content.push_str(text); + } else { + self.partial = Some(Partial { + kind: PartialKind::Paragraph, + start_idx: self.process_idx, + byte_offset: self.process_offset, + content: text.to_string(), + }); + } + self.advance(text.len()); + false + } + + /// Advance the processing position by n bytes. + fn advance(&mut self, n: usize) { + let mut remaining = n; + while remaining > 0 && self.process_idx < self.tokens.len() { + let token_remaining = self.tokens[self.process_idx].len() - self.process_offset; + if remaining >= token_remaining { + remaining -= token_remaining; + self.process_idx += 1; + self.process_offset = 0; + } else { + self.process_offset += remaining; + remaining = 0; + } + } + } + + /// Finalize parsing (call when stream ends). + /// Converts any remaining partial state to complete elements. + pub fn finalize(&mut self) { + if let Some(partial) = self.partial.take() { + match partial.kind { + PartialKind::CodeFence { language, .. } => { + // Unclosed code block - emit what we have + self.parsed.push(MdElement::CodeBlock(CodeBlock { + language, + content: partial.content, + })); + } + PartialKind::Heading { level } => { + self.parsed.push(MdElement::Heading { + level, + content: partial.content.trim().to_string(), + }); + } + PartialKind::Paragraph => { + if !partial.content.trim().is_empty() { + let inline_elements = parse_inline(partial.content.trim()); + self.parsed.push(MdElement::Paragraph(inline_elements)); + } + } + _ => { + // Other partial kinds (lists, blockquotes, etc.) - emit as paragraph for now + if !partial.content.trim().is_empty() { + let inline_elements = parse_inline(partial.content.trim()); + self.parsed.push(MdElement::Paragraph(inline_elements)); + } + } + } + } + } +} + +impl Default for StreamParser { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/egui-md-stream/src/partial.rs b/crates/egui-md-stream/src/partial.rs @@ -0,0 +1,102 @@ +//! Partial state tracking for incomplete markdown elements. + +/// Tracks an in-progress markdown element that might be completed +/// when more tokens arrive. +#[derive(Debug, Clone)] +pub struct Partial { + /// What kind of element we're building + pub kind: PartialKind, + + /// Index into the token buffer where this element starts. + /// Used to resume parsing from the right spot. + pub start_idx: usize, + + /// Byte offset within the starting token (for mid-token starts) + pub byte_offset: usize, + + /// Accumulated content so far (for elements that need it) + pub content: String, +} + +impl Partial { + pub fn new(kind: PartialKind, start_idx: usize) -> Self { + Self { + kind, + start_idx, + byte_offset: 0, + content: String::new(), + } + } + + pub fn with_offset(kind: PartialKind, start_idx: usize, byte_offset: usize) -> Self { + Self { + kind, + start_idx, + byte_offset, + content: String::new(), + } + } +} + +/// The kind of partial element being tracked. +#[derive(Debug, Clone, PartialEq)] +pub enum PartialKind { + /// Fenced code block waiting for closing ``` + /// Stores the fence info (backticks count, language) + CodeFence { + fence_char: char, // ` or ~ + fence_len: usize, // typically 3 + language: Option<String>, + }, + + /// Inline code waiting for closing backtick(s) + InlineCode { backtick_count: usize }, + + /// Bold text waiting for closing ** or __ + Bold { + marker: char, // * or _ + }, + + /// Italic text waiting for closing * or _ + Italic { marker: char }, + + /// Bold+italic waiting for closing *** or ___ + BoldItalic { marker: char }, + + /// Strikethrough waiting for closing ~~ + Strikethrough, + + /// Link: seen [, waiting for ](url) + /// States: text, post-bracket, url + Link { state: LinkState, text: String }, + + /// Image: seen ![, waiting for ](url) + Image { state: LinkState, alt: String }, + + /// Heading started with # at line start, collecting content + Heading { level: u8 }, + + /// List item started, collecting content + ListItem { + ordered: bool, + number: Option<u32>, + indent: usize, + }, + + /// Blockquote started with >, collecting content + BlockQuote { depth: usize }, + + /// Paragraph being accumulated (waiting for double newline) + Paragraph, +} + +/// State machine for link/image parsing. +#[derive(Debug, Clone, PartialEq)] +pub enum LinkState { + /// Collecting text between [ and ] + Text, + /// Seen ], expecting ( + PostBracket, + /// Collecting URL between ( and ) + Url(String), +} diff --git a/crates/egui-md-stream/src/tests.rs b/crates/egui-md-stream/src/tests.rs @@ -0,0 +1,445 @@ +//! Tests for streaming parser behavior. + +use crate::partial::PartialKind; +use crate::{InlineElement, InlineStyle, MdElement, StreamParser}; + +#[test] +fn test_heading_complete() { + let mut parser = StreamParser::new(); + parser.push("# Hello World\n"); + + assert_eq!(parser.parsed().len(), 1); + assert_eq!( + parser.parsed()[0], + MdElement::Heading { + level: 1, + content: "Hello World".to_string() + } + ); +} + +#[test] +fn test_heading_streaming() { + let mut parser = StreamParser::new(); + + // Stream in chunks + parser.push("# Hel"); + assert_eq!(parser.parsed().len(), 0); + assert!(parser.partial().is_some()); + + parser.push("lo Wor"); + assert_eq!(parser.parsed().len(), 0); + + parser.push("ld\n"); + assert_eq!(parser.parsed().len(), 1); + assert_eq!( + parser.parsed()[0], + MdElement::Heading { + level: 1, + content: "Hello World".to_string() + } + ); +} + +#[test] +fn test_code_block_complete() { + let mut parser = StreamParser::new(); + parser.push("```rust\nfn main() {}\n```\n"); + + assert_eq!(parser.parsed().len(), 1); + match &parser.parsed()[0] { + MdElement::CodeBlock(cb) => { + assert_eq!(cb.language.as_deref(), Some("rust")); + assert_eq!(cb.content, "fn main() {}\n"); + } + _ => panic!("Expected code block"), + } +} + +#[test] +fn test_code_block_streaming() { + let mut parser = StreamParser::new(); + + parser.push("```py"); + assert!(parser.in_code_block() || parser.partial().is_some()); + + parser.push("thon\n"); + assert!(parser.in_code_block()); + + parser.push("print('hello')\n"); + assert!(parser.in_code_block()); + assert_eq!(parser.parsed().len(), 0); + + parser.push("```\n"); + assert!(!parser.in_code_block()); + assert_eq!(parser.parsed().len(), 1); +} + +#[test] +fn test_multiple_elements() { + let mut parser = StreamParser::new(); + parser.push("# Title\n\nSome paragraph text.\n\n## Subtitle\n"); + + assert!(parser.parsed().len() >= 2); +} + +#[test] +fn test_thematic_break() { + let mut parser = StreamParser::new(); + parser.push("---\n"); + + assert_eq!(parser.parsed().len(), 1); + assert_eq!(parser.parsed()[0], MdElement::ThematicBreak); +} + +#[test] +fn test_finalize_incomplete_code() { + let mut parser = StreamParser::new(); + parser.push("```\nunclosed code"); + + assert_eq!(parser.parsed().len(), 0); + + parser.finalize(); + + assert_eq!(parser.parsed().len(), 1); + match &parser.parsed()[0] { + MdElement::CodeBlock(cb) => { + assert!(cb.content.contains("unclosed code")); + } + _ => panic!("Expected code block"), + } +} + +#[test] +fn test_realistic_llm_stream() { + let mut parser = StreamParser::new(); + + // Simulate realistic LLM token chunks + let chunks = [ + "Here's", + " a ", + "simple", + " example:\n\n", + "```", + "rust", + "\n", + "fn ", + "main() {\n", + " println!(\"Hello\");\n", + "}", + "\n```", + "\n\nThat's", + " it!", + ]; + + for chunk in chunks { + parser.push(chunk); + } + + parser.finalize(); + + // Should have: paragraph, code block, paragraph + assert!( + parser.parsed().len() >= 2, + "Got {} elements", + parser.parsed().len() + ); +} + +#[test] +fn test_heading_levels() { + let mut parser = StreamParser::new(); + parser.push("# H1\n## H2\n### H3\n"); + + let headings: Vec<_> = parser + .parsed() + .iter() + .filter_map(|e| { + if let MdElement::Heading { level, .. } = e { + Some(*level) + } else { + None + } + }) + .collect(); + + assert!(headings.contains(&1)); + assert!(headings.contains(&2)); + assert!(headings.contains(&3)); +} + +#[test] +fn test_empty_push() { + let mut parser = StreamParser::new(); + parser.push(""); + parser.push(""); + parser.push("# Test\n"); + + assert_eq!(parser.parsed().len(), 1); +} + +#[test] +fn test_partial_content_visible() { + let mut parser = StreamParser::new(); + parser.push("```\nsome code"); + + // Should be able to see partial content for speculative rendering + let partial = parser.partial_content(); + assert!(partial.is_some()); + assert!(partial.unwrap().contains("some code")); +} + +// Inline formatting tests + +#[test] +fn test_inline_bold() { + let mut parser = StreamParser::new(); + parser.push("This has **bold** text.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!( + inlines.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Bold, content } if content == "bold" + )), + "Expected bold element, got: {:?}", + inlines + ); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_italic() { + let mut parser = StreamParser::new(); + parser.push("This has *italic* text.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!( + inlines.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Italic, content } if content == "italic" + )), + "Expected italic element, got: {:?}", + inlines + ); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_code() { + let mut parser = StreamParser::new(); + parser.push("Use `code` here.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!( + inlines.iter().any(|e| matches!( + e, + InlineElement::Code(s) if s == "code" + )), + "Expected code element, got: {:?}", + inlines + ); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_link() { + let mut parser = StreamParser::new(); + parser.push("Check [this link](https://example.com) out.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!(inlines.iter().any(|e| matches!( + e, + InlineElement::Link { text, url } if text == "this link" && url == "https://example.com" + )), "Expected link element, got: {:?}", inlines); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_image() { + let mut parser = StreamParser::new(); + parser.push("See ![alt text](image.png) here.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!( + inlines.iter().any(|e| matches!( + e, + InlineElement::Image { alt, url } if alt == "alt text" && url == "image.png" + )), + "Expected image element, got: {:?}", + inlines + ); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_strikethrough() { + let mut parser = StreamParser::new(); + parser.push("This is ~~deleted~~ text.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!(inlines.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Strikethrough, content } if content == "deleted" + )), "Expected strikethrough element, got: {:?}", inlines); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_mixed_formatting() { + let mut parser = StreamParser::new(); + parser.push("Some **bold**, *italic*, and `code` mixed.\n\n"); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + let has_bold = inlines.iter().any(|e| { + matches!( + e, + InlineElement::Styled { + style: InlineStyle::Bold, + .. + } + ) + }); + let has_italic = inlines.iter().any(|e| { + matches!( + e, + InlineElement::Styled { + style: InlineStyle::Italic, + .. + } + ) + }); + let has_code = inlines.iter().any(|e| matches!(e, InlineElement::Code(_))); + + assert!(has_bold, "Missing bold"); + assert!(has_italic, "Missing italic"); + assert!(has_code, "Missing code"); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_inline_finalize() { + let mut parser = StreamParser::new(); + parser.push("Text with **bold** formatting"); + + // Not complete yet (no paragraph break) + assert_eq!(parser.parsed().len(), 0); + + parser.finalize(); + + // Now should have parsed with inline formatting + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!(inlines.iter().any(|e| matches!( + e, + InlineElement::Styled { style: InlineStyle::Bold, content } if content == "bold" + ))); + } else { + panic!("Expected paragraph"); + } +} + +// Paragraph partial kind tests + +#[test] +fn test_paragraph_partial_kind() { + let mut parser = StreamParser::new(); + parser.push("Some text without"); + + // Should have a partial with Paragraph kind, not Heading with level 0 + let partial = parser.partial().expect("Should have partial"); + assert!( + matches!(partial.kind, PartialKind::Paragraph), + "Expected PartialKind::Paragraph, got {:?}", + partial.kind + ); +} + +#[test] +fn test_paragraph_streaming_with_newlines() { + let mut parser = StreamParser::new(); + + // Push text with single newline - should continue accumulating + parser.push("First line\n"); + assert!(parser.partial().is_some()); + assert!(matches!( + parser.partial().unwrap().kind, + PartialKind::Paragraph + )); + + parser.push("Second line"); + assert_eq!(parser.parsed().len(), 0); // Not complete yet + + // Finalize should emit the accumulated paragraph + parser.finalize(); + assert_eq!(parser.parsed().len(), 1); + assert!(matches!(parser.parsed()[0], MdElement::Paragraph(_))); +} + +#[test] +fn test_paragraph_double_newline_boundary() { + let mut parser = StreamParser::new(); + + // Test when double newline arrives all at once + parser.push("Complete paragraph\n\n"); + assert_eq!(parser.parsed().len(), 1); + assert!(matches!(parser.parsed()[0], MdElement::Paragraph(_))); +} + +#[test] +fn test_paragraph_finalize_emits_content() { + let mut parser = StreamParser::new(); + parser.push("Incomplete paragraph without double newline"); + + assert_eq!(parser.parsed().len(), 0); + assert!(matches!( + parser.partial().unwrap().kind, + PartialKind::Paragraph + )); + + parser.finalize(); + + assert_eq!(parser.parsed().len(), 1); + if let MdElement::Paragraph(inlines) = &parser.parsed()[0] { + assert!(inlines.iter().any(|e| matches!( + e, + InlineElement::Text(s) if s.contains("Incomplete paragraph") + ))); + } else { + panic!("Expected paragraph"); + } +} + +#[test] +fn test_heading_partial_kind_distinct_from_paragraph() { + let mut parser = StreamParser::new(); + parser.push("# Heading without newline"); + + let partial = parser.partial().expect("Should have partial"); + assert!( + matches!(partial.kind, PartialKind::Heading { level: 1 }), + "Expected PartialKind::Heading {{ level: 1 }}, got {:?}", + partial.kind + ); +} diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -27,6 +27,7 @@ futures = "0.3.31" dashmap = "6" #reqwest = "0.12.15" egui_extras = { workspace = true } +egui-md-stream = { workspace = true } similar = "2" dirs = "5" diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -98,7 +98,7 @@ impl ClaudeBackend { } Message::Assistant(content) => { prompt.push_str("Assistant: "); - prompt.push_str(content); + prompt.push_str(content.text()); prompt.push_str("\n\n"); } Message::ToolCalls(_) diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -33,8 +33,8 @@ use std::time::Instant; pub use avatar::DaveAvatar; pub use config::{AiMode, AiProvider, DaveSettings, ModelConfig}; pub use messages::{ - AskUserQuestionInput, DaveApiResponse, Message, PermissionResponse, PermissionResponseType, - QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, ToolResult, + AskUserQuestionInput, AssistantMessage, DaveApiResponse, Message, PermissionResponse, + PermissionResponseType, QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, ToolResult, }; pub use quaternion::Quaternion; pub use session::{ChatSession, SessionId, SessionManager}; @@ -265,8 +265,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), DaveApiResponse::Token(token) => match session.chat.last_mut() { - Some(Message::Assistant(msg)) => msg.push_str(&token), - Some(_) => session.chat.push(Message::Assistant(token)), + Some(Message::Assistant(msg)) => msg.push_token(&token), + Some(_) => { + let mut msg = messages::AssistantMessage::new(); + msg.push_token(&token); + session.chat.push(Message::Assistant(msg)); + } None => {} }, @@ -404,6 +408,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr Err(std::sync::mpsc::TryRecvError::Disconnected) => { // Stream ended, clear task state if let Some(session) = self.session_manager.get_mut(session_id) { + // Finalize any active assistant message to cache parsed elements + if let Some(Message::Assistant(msg)) = session.chat.last_mut() { + msg.finalize(); + } session.task_handle = None; // Don't restore incoming_tokens - leave it None } diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -1,5 +1,6 @@ use crate::tools::{ToolCall, ToolResponse}; use async_openai::types::*; +use egui_md_stream::{MdElement, Partial, StreamParser}; use nostrdb::{Ndb, Transaction}; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -148,12 +149,136 @@ pub struct SubagentInfo { pub max_output_size: usize, } +/// An assistant message with incremental markdown parsing support. +/// +/// During streaming, tokens are pushed to the parser incrementally. +/// After finalization (stream end), parsed elements are cached. +pub struct AssistantMessage { + /// Raw accumulated text (kept for API serialization) + text: String, + /// Incremental parser for this message (None after finalization) + parser: Option<StreamParser>, + /// Cached parsed elements (populated after finalization) + cached_elements: Option<Vec<MdElement>>, +} + +impl std::fmt::Debug for AssistantMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AssistantMessage") + .field("text", &self.text) + .field("is_streaming", &self.parser.is_some()) + .field( + "cached_elements", + &self.cached_elements.as_ref().map(|e| e.len()), + ) + .finish() + } +} + +impl Clone for AssistantMessage { + fn clone(&self) -> Self { + // StreamParser doesn't implement Clone, so we need special handling. + // For cloned messages (which are typically finalized), we just clone + // the text and cached elements. If there's an active parser, we + // re-parse from the raw text. + if let Some(cached) = &self.cached_elements { + Self { + text: self.text.clone(), + parser: None, + cached_elements: Some(cached.clone()), + } + } else { + // Active streaming - re-parse from text + let mut parser = StreamParser::new(); + parser.push(&self.text); + Self { + text: self.text.clone(), + parser: Some(parser), + cached_elements: None, + } + } + } +} + +impl AssistantMessage { + /// Create a new assistant message with a fresh parser. + pub fn new() -> Self { + Self { + text: String::new(), + parser: Some(StreamParser::new()), + cached_elements: None, + } + } + + /// Create from existing text (e.g., when loading from storage). + pub fn from_text(text: String) -> Self { + let mut parser = StreamParser::new(); + parser.push(&text); + parser.finalize(); + let cached = parser.parsed().to_vec(); + Self { + text, + parser: None, + cached_elements: Some(cached), + } + } + + /// Push a new token and update the parser. + pub fn push_token(&mut self, token: &str) { + self.text.push_str(token); + if let Some(parser) = &mut self.parser { + parser.push(token); + } + } + + /// Finalize the message (call when stream ends). + /// This caches the parsed elements and drops the parser. + pub fn finalize(&mut self) { + if let Some(mut parser) = self.parser.take() { + parser.finalize(); + self.cached_elements = Some(parser.parsed().to_vec()); + } + } + + /// Get the raw text content. + pub fn text(&self) -> &str { + &self.text + } + + /// Get parsed markdown elements. + pub fn parsed_elements(&self) -> &[MdElement] { + if let Some(cached) = &self.cached_elements { + cached + } else if let Some(parser) = &self.parser { + parser.parsed() + } else { + &[] + } + } + + /// Get the current partial (in-progress) element, if any. + pub fn partial(&self) -> Option<&Partial> { + self.parser.as_ref().and_then(|p| p.partial()) + } + + /// Check if the message is still being streamed. + pub fn is_streaming(&self) -> bool { + self.parser.is_some() + } +} + +impl Default for AssistantMessage { + fn default() -> Self { + Self::new() + } +} + #[derive(Debug, Clone)] pub enum Message { System(String), Error(String), User(String), - Assistant(String), + Assistant(AssistantMessage), ToolCalls(Vec<ToolCall>), ToolResponse(ToolResponse), /// A permission request from the AI that needs user response @@ -222,7 +347,7 @@ impl Message { Message::Assistant(msg) => Some(ChatCompletionRequestMessage::Assistant( ChatCompletionRequestAssistantMessage { content: Some(ChatCompletionRequestAssistantMessageContent::Text( - msg.clone(), + msg.text().to_string(), )), ..Default::default() }, diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -236,8 +236,9 @@ impl ChatSession { /// Update the session title from the last message (user or assistant) pub fn update_title_from_last_message(&mut self) { for msg in self.chat.iter().rev() { - let text = match msg { - Message::User(text) | Message::Assistant(text) => text, + let text: &str = match msg { + Message::User(text) => text, + Message::Assistant(msg) => msg.text(), _ => continue, }; // Use first ~30 chars of last message as title diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,6 +1,7 @@ use super::badge::{BadgeVariant, StatusBadge}; use super::diff; use super::git_status_ui; +use super::markdown_ui; use super::query_ui::query_call_ui; use super::top_buttons::top_buttons_ui; use crate::{ @@ -8,8 +9,9 @@ use crate::{ file_update::FileUpdate, git_status::GitStatusCache, messages::{ - AskUserQuestionInput, CompactionInfo, Message, PermissionRequest, PermissionResponse, - PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult, + AskUserQuestionInput, AssistantMessage, CompactionInfo, Message, PermissionRequest, + PermissionResponse, PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, + ToolResult, }, session::PermissionMessageState, tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse}, @@ -1117,13 +1119,9 @@ impl<'a> DaveUi<'a> { }); } - fn assistant_chat(&self, msg: &str, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - ui.add( - egui::Label::new(msg) - .wrap_mode(egui::TextWrapMode::Wrap) - .selectable(true), - ); - }); + fn assistant_chat(&self, msg: &AssistantMessage, ui: &mut egui::Ui) { + let elements = msg.parsed_elements(); + let partial = msg.partial(); + markdown_ui::render_assistant_message(elements, partial, ui); } } diff --git a/crates/notedeck_dave/src/ui/markdown_ui.rs b/crates/notedeck_dave/src/ui/markdown_ui.rs @@ -0,0 +1,229 @@ +//! Markdown rendering for assistant messages using egui. + +use egui::{Color32, RichText, Ui}; +use egui_md_stream::{ + parse_inline, CodeBlock, InlineElement, InlineStyle, ListItem, MdElement, Partial, PartialKind, +}; + +/// Theme for markdown rendering, derived from egui visuals. +pub struct MdTheme { + pub heading_sizes: [f32; 6], + pub code_bg: Color32, + pub code_text: Color32, + pub link_color: Color32, + pub blockquote_border: Color32, + pub blockquote_bg: Color32, +} + +impl MdTheme { + pub fn from_visuals(visuals: &egui::Visuals) -> Self { + Self { + heading_sizes: [24.0, 20.0, 18.0, 16.0, 14.0, 12.0], + code_bg: visuals.extreme_bg_color, + code_text: visuals.text_color(), + link_color: Color32::from_rgb(100, 149, 237), // Cornflower blue + blockquote_border: visuals.widgets.noninteractive.bg_stroke.color, + blockquote_bg: visuals.faint_bg_color, + } + } +} + +/// Render all parsed markdown elements plus any partial state. +pub fn render_assistant_message(elements: &[MdElement], partial: Option<&Partial>, ui: &mut Ui) { + let theme = MdTheme::from_visuals(ui.visuals()); + + ui.vertical(|ui| { + for element in elements { + render_element(element, &theme, ui); + } + + // Render partial (speculative) content for immediate feedback + if let Some(partial) = partial { + render_partial(partial, &theme, ui); + } + }); +} + +fn render_element(element: &MdElement, theme: &MdTheme, ui: &mut Ui) { + match element { + MdElement::Heading { level, content } => { + let size = theme.heading_sizes[(*level as usize).saturating_sub(1).min(5)]; + ui.add(egui::Label::new(RichText::new(content).size(size).strong()).wrap()); + ui.add_space(4.0); + } + + MdElement::Paragraph(inlines) => { + ui.horizontal_wrapped(|ui| { + render_inlines(inlines, theme, ui); + }); + ui.add_space(8.0); + } + + MdElement::CodeBlock(CodeBlock { language, content }) => { + render_code_block(language.as_deref(), content, theme, ui); + } + + MdElement::BlockQuote(nested) => { + egui::Frame::default() + .fill(theme.blockquote_bg) + .stroke(egui::Stroke::new(2.0, theme.blockquote_border)) + .inner_margin(egui::Margin::symmetric(8, 4)) + .show(ui, |ui| { + for elem in nested { + render_element(elem, theme, ui); + } + }); + ui.add_space(8.0); + } + + MdElement::UnorderedList(items) => { + for item in items { + render_list_item(item, "\u{2022}", theme, ui); + } + ui.add_space(8.0); + } + + MdElement::OrderedList { start, items } => { + for (i, item) in items.iter().enumerate() { + let marker = format!("{}.", start + i as u32); + render_list_item(item, &marker, theme, ui); + } + ui.add_space(8.0); + } + + MdElement::ThematicBreak => { + ui.separator(); + ui.add_space(8.0); + } + + MdElement::Text(text) => { + ui.label(text); + } + } +} + +fn render_inlines(inlines: &[InlineElement], theme: &MdTheme, ui: &mut Ui) { + for inline in inlines { + match inline { + InlineElement::Text(text) => { + ui.label(text); + } + + InlineElement::Styled { style, content } => { + let rt = match style { + InlineStyle::Bold => RichText::new(content).strong(), + InlineStyle::Italic => RichText::new(content).italics(), + InlineStyle::BoldItalic => RichText::new(content).strong().italics(), + InlineStyle::Strikethrough => RichText::new(content).strikethrough(), + }; + ui.label(rt); + } + + InlineElement::Code(code) => { + ui.label( + RichText::new(code) + .monospace() + .background_color(theme.code_bg), + ); + } + + InlineElement::Link { text, url } => { + ui.hyperlink_to(RichText::new(text).color(theme.link_color), url); + } + + InlineElement::Image { alt, url } => { + // Render as link for now; full image support can be added later + ui.hyperlink_to(format!("[Image: {}]", alt), url); + } + + InlineElement::LineBreak => { + ui.end_row(); + } + } + } +} + +fn render_code_block(language: Option<&str>, content: &str, theme: &MdTheme, ui: &mut Ui) { + egui::Frame::default() + .fill(theme.code_bg) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + // Language label if present + if let Some(lang) = language { + ui.label(RichText::new(lang).small().weak()); + } + + // Code content + ui.add( + egui::Label::new(RichText::new(content).monospace().color(theme.code_text)).wrap(), + ); + }); + ui.add_space(8.0); +} + +fn render_list_item(item: &ListItem, marker: &str, theme: &MdTheme, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label(RichText::new(marker).weak()); + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + render_inlines(&item.content, theme, ui); + }); + // Render nested list if present + if let Some(nested) = &item.nested { + ui.indent("nested", |ui| { + render_element(nested, theme, ui); + }); + } + }); + }); +} + +fn render_partial(partial: &Partial, theme: &MdTheme, ui: &mut Ui) { + let content = &partial.content; + if content.is_empty() { + return; + } + + match &partial.kind { + PartialKind::CodeFence { language, .. } => { + // Show incomplete code block + egui::Frame::default() + .fill(theme.code_bg) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + if let Some(lang) = language { + ui.label(RichText::new(lang).small().weak()); + } + ui.add( + egui::Label::new(RichText::new(content).monospace().color(theme.code_text)) + .wrap(), + ); + // Blinking cursor indicator would require animation; just show underscore + ui.label(RichText::new("_").weak()); + }); + } + + PartialKind::Heading { level } => { + let size = theme.heading_sizes[(*level as usize).saturating_sub(1).min(5)]; + ui.add(egui::Label::new(RichText::new(content).size(size).strong()).wrap()); + } + + PartialKind::Paragraph => { + // Parse inline elements from the partial content for proper formatting + let inlines = parse_inline(content); + ui.horizontal_wrapped(|ui| { + render_inlines(&inlines, theme, ui); + }); + } + + _ => { + // Other partial kinds - parse inline elements too + let inlines = parse_inline(content); + ui.horizontal_wrapped(|ui| { + render_inlines(&inlines, theme, ui); + }); + } + } +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -6,6 +6,7 @@ pub mod directory_picker; mod git_status_ui; pub mod keybind_hint; pub mod keybindings; +pub mod markdown_ui; pub mod path_utils; mod pill; mod query_ui;