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:
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 
+ 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  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 
+ 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  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;