//! Incremental markdown rendering for in-flight transcript streams. //! //! Completed top-level blocks are retained while the final block stays mutable, avoiding repeated //! rendering of the stable prefix as newline-bearing deltas arrive. use super::code_fence::OpenCodeFence; use crate::history_cell::HistoryRenderMode; use crate::history_cell::raw_lines_from_source; use crate::inline_visualization::InlineVisualizationContext; use crate::inline_visualization::contains_inline_visualization; use crate::markdown::render_markdown_agent_with_list_spacing; use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; use crate::markdown_render::ListSpacing; use crate::render::highlight::syntax_theme_revision; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::plain_hyperlink_lines; use ratatui::text::Line; use std::path::Path; /// Incremental render state split at source and rendered-line boundaries. /// /// The prefix before both boundaries is immutable; only the final top-level Markdown block is /// re-rendered as committed source arrives. pub(super) struct StreamingRender { pub(super) list_spacing: ListSpacing, pub(super) lines: Vec, pub(super) pending_math_start: Option, pub(super) mutable_fence_start: Option, /// Source boundary before the final top-level block, including after full re-renders. pub(super) completed_source_len: usize, /// Source prefix containing only completed top-level markdown blocks. stable_source_len: usize, /// Rendered-line boundary corresponding to `stable_source_len`. stable_rendered_len: usize, /// Reference-style link definitions can affect any earlier or later markdown block. pub(super) has_reference_link_definition: bool, /// Inline visualization directives require source-wide rewriting once one is committed. has_inline_visualization_directive: bool, /// Parser state for a directly appendable, open top-level code fence. open_code_fence: Option, } impl StreamingRender { pub(super) fn new() -> Self { Self { list_spacing: ListSpacing::AfterMultiline, lines: Vec::with_capacity(64), pending_math_start: None, mutable_fence_start: None, completed_source_len: 0, stable_source_len: 0, stable_rendered_len: 0, has_reference_link_definition: false, has_inline_visualization_directive: false, open_code_fence: None, } } pub(super) fn clear(&mut self) { self.lines.clear(); self.pending_math_start = None; self.mutable_fence_start = None; self.completed_source_len = 0; self.stable_source_len = 0; self.stable_rendered_len = 0; self.has_reference_link_definition = false; self.has_inline_visualization_directive = false; self.open_code_fence = None; } /// Re-render the full source and reset both stable-prefix boundaries. /// /// This is used when width or render mode changes, and whenever source-wide rendering state /// makes retaining previously rendered blocks unsafe. pub(super) fn recompute( &mut self, source: &str, width: Option, cwd: &Path, render_mode: HistoryRenderMode, inline_visualization_context: Option<&InlineVisualizationContext>, ) { self.open_code_fence = None; self.pending_math_start = None; self.mutable_fence_start = None; self.completed_source_len = 0; self.has_inline_visualization_directive = contains_inline_visualization(source); self.lines = match (render_mode, inline_visualization_context) { (HistoryRenderMode::Rich, None) if !self.has_inline_visualization_directive => { let rendered = render_streaming_markdown_agent_with_links_and_cwd( source, width, Some(cwd), self.list_spacing, ); self.has_reference_link_definition = rendered.has_reference_link_definition; self.completed_source_len = rendered.last_top_level_block_start.unwrap_or(0); self.pending_math_start = rendered.pending_math_start; self.mutable_fence_start = rendered.mutable_fence_start; rendered.lines } _ => { self.has_reference_link_definition = false; if render_mode == HistoryRenderMode::Rich { let rendered = render_streaming_markdown_agent_with_links_and_cwd( source, width, Some(cwd), self.list_spacing, ); self.has_reference_link_definition = rendered.has_reference_link_definition; self.completed_source_len = rendered.last_top_level_block_start.unwrap_or(0); self.pending_math_start = rendered.pending_math_start; self.mutable_fence_start = rendered.mutable_fence_start; } render_source_with_list_spacing( source, width, cwd, render_mode, inline_visualization_context, self.list_spacing, ) } }; self.stable_source_len = 0; self.stable_rendered_len = 0; } /// Append newly committed source while retaining only the final markdown block as mutable. /// /// The final top-level block can still change meaning when another line arrives (for example, /// list tightness, a setext heading, a fenced block, or a table). Earlier top-level blocks are /// rendered once and retained. Reference-style link definitions and inline visualization /// rewriting fall back to a full render because they can affect source-wide rendering state. pub(super) fn append( &mut self, raw_source: &str, committed_source: &str, width: Option, cwd: &Path, render_mode: HistoryRenderMode, inline_visualization_context: Option<&InlineVisualizationContext>, ) { if render_mode == HistoryRenderMode::Raw { self.lines .extend(plain_hyperlink_lines(raw_lines_from_source( committed_source, ))); return; } self.has_inline_visualization_directive |= contains_inline_visualization(committed_source); if self.has_inline_visualization_directive { self.recompute( raw_source, width, cwd, render_mode, inline_visualization_context, ); return; } if self.has_reference_link_definition { self.recompute( raw_source, width, cwd, render_mode, inline_visualization_context, ); return; } if let Some(fence) = self.open_code_fence.take() && let Some((fence, lines)) = fence.append(raw_source, committed_source) { self.lines.extend(lines); self.open_code_fence = Some(fence); return; } let pending_source = &raw_source[self.stable_source_len..]; let theme_revision = syntax_theme_revision(); let pending = render_streaming_markdown_agent_with_links_and_cwd( pending_source, width, Some(cwd), self.list_spacing, ); self.pending_math_start = pending .pending_math_start .map(|start| self.stable_source_len + start); self.mutable_fence_start = pending .mutable_fence_start .map(|start| self.stable_source_len + start); if pending.has_reference_link_definition { self.has_reference_link_definition = true; self.recompute( raw_source, width, cwd, render_mode, inline_visualization_context, ); return; } let final_block_start = pending.last_top_level_block_start.unwrap_or(/*default*/ 0); self.completed_source_len = self.stable_source_len + final_block_start; self.open_code_fence = OpenCodeFence::detect( &pending_source[final_block_start..], raw_source.len(), theme_revision, ); let mut newly_stable_rendered_len = None; if let Some(boundary) = pending.last_top_level_block_start { let newly_stable_source = &pending_source[..boundary]; let newly_stable = render_source_with_list_spacing( newly_stable_source, width, cwd, render_mode, inline_visualization_context, self.list_spacing, ); self.stable_source_len += boundary; newly_stable_rendered_len = Some(newly_stable.len()); } self.lines.truncate(self.stable_rendered_len); if !self.lines.is_empty() && (!pending.lines.is_empty() || !pending_source.trim().is_empty()) && !pending.first_top_level_block_is_html { self.lines.push(HyperlinkLine::new(Line::default())); } let pending_render_start = self.lines.len(); self.lines.extend(pending.lines); if let Some(newly_stable_rendered_len) = newly_stable_rendered_len { self.stable_rendered_len = pending_render_start + newly_stable_rendered_len; } } } pub(super) fn render_source( source: &str, width: Option, cwd: &Path, render_mode: HistoryRenderMode, inline_visualization_context: Option<&InlineVisualizationContext>, ) -> Vec { render_source_with_list_spacing( source, width, cwd, render_mode, inline_visualization_context, ListSpacing::AfterMultiline, ) } pub(super) fn render_source_with_list_spacing( source: &str, width: Option, cwd: &Path, render_mode: HistoryRenderMode, inline_visualization_context: Option<&InlineVisualizationContext>, list_spacing: ListSpacing, ) -> Vec { match render_mode { HistoryRenderMode::Rich => render_markdown_agent_with_list_spacing( source, width, Some(cwd), inline_visualization_context, list_spacing, ), HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)), } } #[cfg(test)] #[path = "render_tests.rs"] mod tests; #[cfg(test)] #[path = "inline_math_tests.rs"] mod inline_math_tests;