Render Mermaid code blocks as diagrams in the TUI (#46054)

## What changed

- Render completed `mermaid` fences with `codex-mermaid`, using syntax-theme colors for nodes and edges.
- Keep source visible for unclosed, invalid, unsupported, or oversized diagrams, and preserve the original Mermaid for copying and raw mode.
- Keep streamed diagrams mutable until the next top-level block so closing fences and terminal resizing can update their display. Preserve scrollback progress across width and render-mode changes.

## Testing

Add renderer and streaming tests covering supported diagram families, nested fences, Unicode, theme colors, source fallbacks, resizing, raw-mode transitions, and original-source preservation. Verify that node connection ports retain node styling in all four flowchart directions.

GitOrigin-RevId: 9a759248110e6730d629d653061ff7e2ec5dc2c0
This commit is contained in:
Eric Traut
2026-09-16 23:11:13 +00:00
committed by copyberry
parent c56dda711c
commit f915e0de07
18 changed files with 682 additions and 42 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -4784,6 +4784,7 @@ dependencies = [
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-mermaid",
"codex-message-history",
"codex-model-provider",
"codex-model-provider-info",

View File

@@ -235,6 +235,7 @@ codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-mermaid = { path = "mermaid" }
codex-message-history = { path = "message-history" }
codex-memories-extension = { path = "ext/memories" }
codex-web-search-extension = { path = "ext/web-search" }

View File

@@ -168,8 +168,8 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result<Vec<Vec<Span>>,
canvas.set(x, y, Cell::edge(ch));
}
}
canvas.set(box_cross - 1, source, Cell::edge('├'));
canvas.set(box_cross - 1, target, Cell::edge('├'));
canvas.set(box_cross - 1, source, Cell::node('├'));
canvas.set(box_cross - 1, target, Cell::node('├'));
canvas.set(box_cross, source, Cell::edge(edge.source_tip));
canvas.set(box_cross, target, Cell::edge(edge.target_tip));
canvas.set(

View File

@@ -190,4 +190,22 @@ fn semantic_spans_distinguish_labels_from_matching_endpoint_glyphs() {
.role,
Role::Node
);
for direction in ["TD", "BT", "LR", "RL"] {
let lines = super::render_spans(
&format!("flowchart {direction}; A --> B"),
/*max_width*/ 100,
)
.unwrap();
let ports = lines
.iter()
.flatten()
.flat_map(|span| {
span.text
.chars()
.filter(|ch| matches!(ch, '├' | '┬'))
.map(|_| span.role)
})
.collect::<Vec<_>>();
assert_eq!(ports, vec![Role::Node, Role::Node]);
}
}

View File

@@ -47,6 +47,7 @@ codex-file-search = { workspace = true }
codex-git-utils = { workspace = true }
codex-http-client = { workspace = true }
codex-login = { workspace = true }
codex-mermaid = { workspace = true }
codex-message-history = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }

View File

@@ -143,6 +143,11 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
.last_top_level_block_start
.and_then(|boundary| markdown_source.strip_suffix(&normalized[boundary..]))
.map(str::len);
rendered.mermaid_start = rendered.mermaid_start.map(|boundary| {
markdown_source
.strip_suffix(&normalized[boundary..])
.map_or(0, str::len)
});
}
rendered
}

View File

@@ -1,7 +1,7 @@
//! Low-level markdown event renderer for the TUI transcript.
//!
//! This module consumes `pulldown-cmark` events and emits styled `ratatui`
//! lines, including table layout, width-aware wrapping, and local file-link
//! lines, including table layout, Mermaid previews, width-aware wrapping, and local file-link
//! display. It is the final rendering stage used by higher-level helpers in
//! `markdown.rs`.
//!
@@ -37,6 +37,7 @@
//! key/value records.
use crate::markdown_text_merge::DecodedTextMerge;
use crate::render::highlight::current_syntax_theme;
use crate::render::highlight::foreground_style_for_scopes;
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
@@ -71,6 +72,7 @@ use std::path::PathBuf;
mod file_citations;
mod local_links;
mod math;
mod mermaid;
mod streaming;
mod table_key_value;
mod web_links;
@@ -392,6 +394,7 @@ where
in_code_block: bool,
code_block_lang: Option<String>,
code_block_buffer: String,
code_block_content_end: usize,
wrap_width: Option<usize>,
cwd: Option<PathBuf>,
is_hidden_link_destination: &'policy dyn Fn(&str) -> bool,
@@ -433,6 +436,7 @@ where
in_code_block: false,
code_block_lang: None,
code_block_buffer: String::new(),
code_block_content_end: 0,
wrap_width,
cwd: cwd.map(Path::to_path_buf),
is_hidden_link_destination,
@@ -458,8 +462,13 @@ where
self.prepare_for_event(&event);
match event {
Event::Start(tag) => self.start_tag(tag, range),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => self.text(text),
Event::End(tag) => self.end_tag(tag, range),
Event::Text(text) => {
if self.in_code_block {
self.code_block_content_end = range.end;
}
self.text(text);
}
Event::Code(code) => self.code(code),
Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(),
@@ -501,6 +510,7 @@ where
Tag::Heading { level, .. } => self.start_heading(level),
Tag::BlockQuote => self.start_blockquote(),
Tag::CodeBlock(kind) => {
self.code_block_content_end = range.end;
let indent = match kind {
CodeBlockKind::Fenced(_) => None,
CodeBlockKind::Indented => Some(Span::from(" ".repeat(4))),
@@ -528,12 +538,12 @@ where
}
}
fn end_tag(&mut self, tag: TagEnd) {
fn end_tag(&mut self, tag: TagEnd, range: Range<usize>) {
match tag {
TagEnd::Paragraph => self.end_paragraph(),
TagEnd::Heading(_) => self.end_heading(),
TagEnd::BlockQuote => self.end_blockquote(),
TagEnd::CodeBlock => self.end_codeblock(),
TagEnd::CodeBlock => self.end_codeblock(range),
TagEnd::List(_) => self.end_list(),
TagEnd::Item => {
self.flush_current_line();
@@ -879,12 +889,28 @@ where
self.needs_newline = true;
}
fn end_codeblock(&mut self) {
// If we buffered code for a known language, syntax-highlight it now.
fn end_codeblock(&mut self, range: Range<usize>) {
// Completed Mermaid fences can replace source with a diagram; other blocks keep highlighting.
if let Some(lang) = self.code_block_lang.take() {
let code = std::mem::take(&mut self.code_block_buffer);
if !code.is_empty() {
let highlighted = highlight_code_to_lines(&code, &lang);
let diagram = if lang == "mermaid"
&& mermaid::has_closing_fence(self.input, range, self.code_block_content_end)
{
let indent =
Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
mermaid::render(
&code,
self.wrap_width.map(|width| width.saturating_sub(indent)),
&current_syntax_theme(),
)
} else {
None
};
let highlighted = match diagram {
Some(diagram) => diagram,
None => highlight_code_to_lines(&code, &lang),
};
for hl_line in highlighted {
self.push_line(Line::default());
for span in hl_line.spans {

View File

@@ -0,0 +1,71 @@
//! Bounded, theme-aware Mermaid previews for completed Markdown fences.
//!
//! Source remains owned by the transcript. Unsupported syntax, resource limits, and terminal
//! overflow retain the original code block; diagrams never emit terminal control sequences.
use crate::render::highlight::foreground_style_for_scopes_with_theme;
use codex_mermaid::Role;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use std::ops::Range;
use syntect::highlighting::Theme;
/// CommonMark emits an End event even at EOF. A real closer is outside the last Text event.
pub(super) fn has_closing_fence(input: &str, range: Range<usize>, content_end: usize) -> bool {
let Some(block) = input.get(range.clone()) else {
return false;
};
let Some(marker @ (b'`' | b'~')) = block.as_bytes().first().copied() else {
return false;
};
let opening_len = block.bytes().take_while(|byte| *byte == marker).count();
let Some(suffix) = input.get(content_end..range.end) else {
return false;
};
suffix
.trim_end_matches([' ', '\t', '\r', '\n'])
.bytes()
.rev()
.take_while(|byte| *byte == marker)
.count()
>= opening_len
}
pub(super) fn render(
source: &str,
width: Option<usize>,
theme: &Theme,
) -> Option<Vec<Line<'static>>> {
let diagram = codex_mermaid::render_spans(source, width.unwrap_or(120)).ok()?;
let node = foreground_style_for_scopes_with_theme(
theme,
&["entity.name.type", "support.type", "variable"],
)
.unwrap_or_else(|| Style::default().cyan());
let edge = foreground_style_for_scopes_with_theme(theme, &["comment"])
.unwrap_or_else(|| Style::default().dim());
Some(
diagram
.into_iter()
.map(|line| {
Line::from(
line.into_iter()
.map(|span| {
let style = match span.role {
Role::Node => node,
Role::Edge => edge,
Role::Text => Style::default(),
};
Span::styled(span.text, style)
})
.collect::<Vec<_>>(),
)
})
.collect(),
)
}
#[cfg(test)]
#[path = "mermaid_tests.rs"]
mod tests;

View File

@@ -0,0 +1,89 @@
use crate::markdown_render::render_markdown_text_with_width;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
fn markdown_text(source: &str, width: usize) -> String {
render_markdown_text_with_width(source, Some(width))
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn mermaid_fences_use_native_renderer_for_every_family() {
for source in [
"%% heading\nflowchart TD; A --> B",
"graph LR; A --> B",
"sequenceDiagram; A->>B: request; B-->>A: response",
"stateDiagram-v2; [*] --> Active; Active --> [*]",
"stateDiagram; [*] --> Active; Active --> [*]",
"classDiagram; Order \"1\" *-- \"many\" Item : contains",
"erDiagram; CUSTOMER ||--o{ ORDER : places",
] {
let markdown = format!("```mermaid title=example\n{source}\n```\n");
assert_eq!(
markdown_text(&markdown, /*width*/ 100),
codex_mermaid::render(source, /*max_width*/ 100).unwrap()
);
}
}
#[test]
fn mermaid_nested_fences_and_unicode() {
let source = "> ~~~~mermaid\n> flowchart TD\n> A[请求] --> B[Réponse]\n> ~~~~~\n\n- Diagram:\n\n ```mermaid\n flowchart LR\n A --> B\n ```\n";
assert_snapshot!(markdown_text(source, /*width*/ 60));
}
#[test]
fn mermaid_unclosed_invalid_unsupported_and_wide_blocks_keep_source() {
for (source, width) in [
("```mermaid\nflowchart LR\nA --> B\n", 80),
("````mermaid\nflowchart LR\nA --> B\n```\n", 80),
("```mermaid\nflowchart LR\nA[unfinished\n```", 80),
("```mermaid\npie\n\"Cats\": 2\n```", 80),
("```mermaid\nflowchart LR\nA[Request] --> B[Reply]\n```", 8),
("> ```mermaid\n> flowchart LR\n> A --> B\n", 80),
] {
assert_eq!(
markdown_text(source, width),
markdown_text(&source.replacen("mermaid", "unknown", /*count*/ 1), width),
"source: {source:?}",
);
}
}
#[test]
fn mermaid_styles_follow_the_supplied_theme() {
use two_face::theme::EmbeddedThemeName;
let themes = two_face::theme::extra();
let fallback = syntect::highlighting::Theme::default();
let mut cases = Vec::new();
for (name, theme) in [
("dark", themes.get(EmbeddedThemeName::Dracula)),
("light", themes.get(EmbeddedThemeName::SolarizedLight)),
("fallback", &fallback),
] {
let lines = super::render("flowchart LR; A[请求] --> B[Reply]", Some(40), theme).unwrap();
let styled = lines
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| format!("{:?} {:?}", span.style, span.content))
.collect::<Vec<_>>()
.join(" | ")
})
.collect::<Vec<_>>()
.join("\n");
cases.push(format!("{name}\n{styled}"));
}
assert_snapshot!(cases.join("\n\n"));
}

View File

@@ -0,0 +1,26 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
assertion_line: 38
expression: "markdown_text(source, 60)"
---
> ┌─────────┐
> │ 请求 │
> │ ├─────┐
> └─────────┘ │
> │
> │
> ┌─────────┐ │
> │ Réponse │ │
> │ ├◄────┘
> └─────────┘
- Diagram:
┌───┐ ┌───┐
│ A │ │ B │
└┬──┘ └┬──┘
│ ▲
│ │
│ │
│ │
└──────┘

View File

@@ -0,0 +1,34 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
assertion_line: 88
expression: "cases.join(\"\\n\\n\")"
---
dark
Style::new().fg(Color::Rgb(102, 217, 239)) "┌──────┐" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new().fg(Color::Rgb(102, 217, 239)) "┌───────┐"
Style::new().fg(Color::Rgb(102, 217, 239)) "│" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new() "请求" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new().fg(Color::Rgb(102, 217, 239)) "│" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new().fg(Color::Rgb(102, 217, 239)) "│" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new() "Reply" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new().fg(Color::Rgb(102, 217, 239)) "│"
Style::new().fg(Color::Rgb(102, 217, 239)) "└┬─────┘" | Style::new().fg(Color::Rgb(98, 114, 164)) " " | Style::new().fg(Color::Rgb(102, 217, 239)) "└┬──────┘"
Style::new().fg(Color::Rgb(98, 114, 164)) " │ ▲"
Style::new().fg(Color::Rgb(98, 114, 164)) " │ │"
Style::new().fg(Color::Rgb(98, 114, 164)) " │ │"
Style::new().fg(Color::Rgb(98, 114, 164)) " │ │"
Style::new().fg(Color::Rgb(98, 114, 164)) " └─────────┘"
light
Style::new().fg(Color::Rgb(181, 137, 0)) "┌──────┐" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new().fg(Color::Rgb(181, 137, 0)) "┌───────┐"
Style::new().fg(Color::Rgb(181, 137, 0)) "│" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new() "请求" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new().fg(Color::Rgb(181, 137, 0)) "│" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new().fg(Color::Rgb(181, 137, 0)) "│" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new() "Reply" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new().fg(Color::Rgb(181, 137, 0)) "│"
Style::new().fg(Color::Rgb(181, 137, 0)) "└┬─────┘" | Style::new().fg(Color::Rgb(147, 161, 161)) " " | Style::new().fg(Color::Rgb(181, 137, 0)) "└┬──────┘"
Style::new().fg(Color::Rgb(147, 161, 161)) " │ ▲"
Style::new().fg(Color::Rgb(147, 161, 161)) " │ │"
Style::new().fg(Color::Rgb(147, 161, 161)) " │ │"
Style::new().fg(Color::Rgb(147, 161, 161)) " │ │"
Style::new().fg(Color::Rgb(147, 161, 161)) " └─────────┘"
fallback
Style::new().cyan() "┌──────┐" | Style::new().dim() " " | Style::new().cyan() "┌───────┐"
Style::new().cyan() "│" | Style::new().dim() " " | Style::new() "请求" | Style::new().dim() " " | Style::new().cyan() "│" | Style::new().dim() " " | Style::new().cyan() "│" | Style::new().dim() " " | Style::new() "Reply" | Style::new().dim() " " | Style::new().cyan() "│"
Style::new().cyan() "└┬─────┘" | Style::new().dim() " " | Style::new().cyan() "└┬──────┘"
Style::new().dim() " │ ▲"
Style::new().dim() " │ │"
Style::new().dim() " │ │"
Style::new().dim() " │ │"
Style::new().dim() " └─────────┘"

View File

@@ -27,6 +27,8 @@ pub(crate) struct StreamingMarkdownRender {
pub(crate) has_reference_link_definition: bool,
/// Whether the first block is raw HTML, which joins a retained prefix without a separator.
pub(crate) first_top_level_block_is_html: bool,
/// Mermaid in the final top-level block stays mutable, including within a list or quote.
pub(crate) mermaid_start: Option<usize>,
}
/// Render `input` while tracking the final mutable top-level block.
@@ -52,6 +54,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
block_count: 0,
last_start: 0,
first_is_html: false,
mermaid_start: None,
};
let mut writer = Writer::new(input, parser, width, cwd, is_hidden_link_destination);
writer.run();
@@ -69,6 +72,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
}),
has_reference_link_definition,
first_top_level_block_is_html: writer.iter.first_is_html,
mermaid_start: writer.iter.mermaid_start,
}
}
@@ -79,6 +83,7 @@ struct TopLevelBlockTracker<I> {
block_count: usize,
last_start: usize,
first_is_html: bool,
mermaid_start: Option<usize>,
}
impl<'a, I> Iterator for TopLevelBlockTracker<I>
@@ -90,6 +95,7 @@ where
fn next(&mut self) -> Option<Self::Item> {
let (event, range) = self.iter.next()?;
if self.depth == 0 && matches!(&event, Event::Start(_) | Event::Rule | Event::Html(_)) {
self.mermaid_start = None;
self.block_count += 1;
self.last_start = range.start;
if self.block_count == 1 {
@@ -97,6 +103,11 @@ where
matches!(&event, Event::Start(Tag::HtmlBlock) | Event::Html(_));
}
}
if let Event::Start(Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(info))) = &event
&& info.split([',', ' ', '\t']).next() == Some("mermaid")
{
self.mermaid_start.get_or_insert(self.last_start);
}
match event {
Event::Start(_) => self.depth += 1,
Event::End(_) => self.depth = self.depth.saturating_sub(1),

View File

@@ -321,7 +321,10 @@ pub(crate) fn foreground_style_for_scopes(scope_names: &[&str]) -> Option<Style>
foreground_style_for_scopes_with_theme(&theme, scope_names)
}
fn foreground_style_for_scopes_with_theme(theme: &Theme, scope_names: &[&str]) -> Option<Style> {
pub(crate) fn foreground_style_for_scopes_with_theme(
theme: &Theme,
scope_names: &[&str],
) -> Option<Style> {
let highlighter = Highlighter::new(theme);
scope_names.iter().find_map(|scope_name| {
let scope = Scope::new(scope_name).ok()?;

View File

@@ -19,6 +19,10 @@
//! agent and proposed-plan streams. Lines in `Outside` and `Markdown` fence
//! contexts are scanned; lines inside non-markdown fences are skipped.
//!
//! Mermaid stays mutable while its containing top-level block is last. The closing fence replaces
//! source with a diagram, and resizing can replace a diagram that no longer fits with its source.
//! Once another block starts, the diagram enters scrollback so later prose does not grow the tail.
//!
//! ## Resize handling
//!
//! On terminal width change, `StreamCore::set_width` re-renders at the new
@@ -286,6 +290,7 @@ impl StreamCore {
}
let had_pending_queue = self.state.queued_len() > 0;
let had_live_tail = self.has_tail();
let previous_width = self.width;
self.width = width;
self.state.collector.set_width(width);
let source = self.state.collector.committed_source();
@@ -294,15 +299,8 @@ impl StreamCore {
return;
}
self.render.recompute(
source,
self.width,
self.cwd.as_path(),
self.render_mode,
self.inline_visualization_context.as_ref(),
);
self.recompute_render(previous_width, self.render_mode);
self.refresh_preview();
self.emitted_stable_len = self.emitted_stable_len.min(self.render.lines.len());
if had_pending_queue
&& self.emitted_stable_len == self.render.lines.len()
&& self.emitted_stable_len > 0
@@ -317,6 +315,7 @@ impl StreamCore {
// Avoid replaying already-emitted content after resize when no
// stable lines were waiting in the queue and there was no mutable
// tail to preserve.
self.emitted_stable_len = self.render.lines.len();
self.enqueued_stable_len = self.render.lines.len();
return;
}
@@ -341,6 +340,7 @@ impl StreamCore {
let had_pending_queue = self.state.queued_len() > 0;
let had_live_tail = self.has_tail();
let previous_render_mode = self.render_mode;
self.render_mode = render_mode;
let source = self.state.collector.committed_source();
if source.is_empty() {
@@ -348,15 +348,8 @@ impl StreamCore {
return;
}
self.render.recompute(
source,
self.width,
self.cwd.as_path(),
self.render_mode,
self.inline_visualization_context.as_ref(),
);
self.recompute_render(self.width, previous_render_mode);
self.refresh_preview();
self.emitted_stable_len = self.emitted_stable_len.min(self.render.lines.len());
if had_pending_queue
&& self.emitted_stable_len == self.render.lines.len()
&& self.emitted_stable_len > 0
@@ -365,12 +358,73 @@ impl StreamCore {
}
self.state.clear_queue();
if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail {
self.emitted_stable_len = self.render.lines.len();
self.enqueued_stable_len = self.render.lines.len();
return;
}
self.rebuild_stable_queue_from_render();
}
/// Preserve an emitted source prefix when resizing changes earlier diagrams' heights.
fn recompute_render(
&mut self,
previous_width: Option<usize>,
previous_render_mode: HistoryRenderMode,
) {
let previous_tail_start = self.active_tail_source_start(previous_render_mode);
let source = self.state.collector.committed_source();
self.render.recompute(
source,
self.width,
self.cwd.as_path(),
self.render_mode,
self.inline_visualization_context.as_ref(),
);
if let Some(start) = previous_tail_start.or(self.active_tail_source_start(self.render_mode))
{
let prefix_len = |width, mode| {
render_source(
&source[..start],
width,
self.cwd.as_path(),
mode,
self.inline_visualization_context.as_ref(),
)
.len()
};
let previous_prefix_len = prefix_len(previous_width, previous_render_mode);
let prefix_len = prefix_len(self.width, self.render_mode);
if self.emitted_stable_len >= previous_prefix_len {
self.emitted_stable_len =
prefix_len.saturating_add(self.emitted_stable_len - previous_prefix_len);
} else {
self.emitted_stable_len = self.emitted_stable_len.min(prefix_len);
}
}
self.emitted_stable_len = self.emitted_stable_len.min(self.render.lines.len());
}
fn active_tail_source_start(&self, render_mode: HistoryRenderMode) -> Option<usize> {
if render_mode == HistoryRenderMode::Raw {
return None;
}
let table_start = match self.holdback_scanner.state() {
TableHoldbackState::Confirmed { table_start }
| TableHoldbackState::PendingHeader {
header_start: table_start,
} => Some(table_start),
TableHoldbackState::None => None,
};
[
table_start,
self.render.mermaid_start,
self.render.pending_math_start,
]
.into_iter()
.flatten()
.min()
}
/// Compute how many rendered lines should be in the stable region.
fn compute_target_stable_len(&mut self) -> usize {
let tail_budget = self.active_tail_budget_lines();
@@ -438,24 +492,16 @@ impl StreamCore {
}
let scan_start = Instant::now();
let holdback_state = self.holdback_scanner.state();
let tail_budget = match holdback_state {
TableHoldbackState::Confirmed { table_start: start }
| TableHoldbackState::PendingHeader {
header_start: start,
} => self.tail_budget_from_source_start(start),
TableHoldbackState::None => 0,
};
let tail_budget = self
.active_tail_source_start(self.render_mode)
.map_or(0, |start| self.tail_budget_from_source_start(start));
tracing::trace!(
state = ?holdback_state,
tail_budget,
elapsed_us = scan_start.elapsed().as_micros(),
"table holdback decision",
);
let math_budget = self
.render
.pending_math_start
.map_or(0, |start| self.tail_budget_from_source_start(start));
tail_budget.max(math_budget)
tail_budget
}
/// Convert a raw-source boundary into the number of rendered tail lines.

View File

@@ -0,0 +1,218 @@
use super::controller::StreamController;
use super::render::StreamingRender;
use crate::history_cell::HistoryRenderMode;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
#[test]
fn mermaid_stream_closes_resizes_and_preserves_raw_source() {
let cwd = std::env::temp_dir();
let mut render = StreamingRender::new();
let mut source = String::new();
let mut stages = Vec::new();
for chunk in [
"Before.\n\n```mermaid\n",
"flowchart LR\nA[Request] --> B[Reply]\n",
"```\n",
"\nAfter.\n",
] {
source.push_str(chunk);
render.append(
&source,
chunk,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
assert_eq!(
render.lines,
super::render::render_source(
&source,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None
)
);
stages.push(
render
.lines
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>()
.join("\n"),
);
}
for (width, mode) in [
(8, HistoryRenderMode::Rich),
(80, HistoryRenderMode::Raw),
(80, HistoryRenderMode::Rich),
] {
render.recompute(
&source,
Some(width),
&cwd,
mode,
/*inline_visualization_context*/ None,
);
assert_eq!(
render.lines,
super::render::render_source(
&source,
Some(width),
&cwd,
mode,
/*inline_visualization_context*/ None
)
);
stages.push(
render
.lines
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>()
.join("\n"),
);
}
assert_snapshot!(stages.join("\n\n---\n\n"));
}
#[test]
fn mermaid_holdback_tracks_nested_blocks_after_normalized_markdown() {
let cwd = std::env::temp_dir();
let prefix = "```md\n| Name | Value |\n| --- | --- |\n| A | B |\n```\n\n";
for block in [
"> ```mermaid\n> flowchart LR\n> A --> B\n> ```\n",
"- Diagram:\n\n ```mermaid\n flowchart LR\n A --> B\n ```\n",
] {
let mut render = StreamingRender::new();
let mut source = prefix.to_owned();
render.append(
&source,
prefix,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
for chunk in block.split_inclusive('\n') {
source.push_str(chunk);
render.append(
&source,
chunk,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
}
assert_eq!(render.mermaid_start, Some(prefix.len()));
render.recompute(
&source,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
assert_eq!(render.mermaid_start, Some(prefix.len()));
let continuation = "\nAfter.\n\n";
source.push_str(continuation);
render.append(
&source,
continuation,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
assert_eq!(render.mermaid_start, None);
render.recompute(
&source,
Some(80),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
assert_eq!(render.mermaid_start, None);
}
}
#[test]
fn mermaid_controller_keeps_diagram_mutable_and_returns_original_source() {
let mut controller =
StreamController::new(Some(80), &std::env::temp_dir(), HistoryRenderMode::Rich);
let source = "```mermaid\nflowchart LR\nA[Request] --> B[Reply]\n```\n";
for chunk in source.split_inclusive('\n') {
controller.push(chunk);
assert!(
controller.on_commit_tick().0.is_none(),
"Mermaid escaped into scrollback"
);
}
let diagram = controller.current_tail_lines();
assert!(
diagram
.iter()
.any(|line| line.line.to_string().contains('┌'))
);
controller.set_width(Some(8));
assert!(
controller
.current_tail_lines()
.iter()
.any(|line| line.line.to_string().contains("A[Request]"))
);
controller.set_width(Some(80));
assert_eq!(controller.current_tail_lines(), diagram);
assert_eq!(
crate::markdown::extract_copy_targets(source),
vec![crate::markdown::CopyTarget::Code {
language: Some("mermaid".to_owned()),
content: std::sync::Arc::from("flowchart LR\nA[Request] --> B[Reply]\n"),
}],
);
let mut full_source = source.to_owned();
for i in 0..256 {
let continuation = format!("\nStep {i}\n");
full_source.push_str(&continuation);
controller.push(&continuation);
assert!(!controller.has_live_tail());
assert!(controller.current_tail_lines().is_empty());
assert!(controller.on_commit_tick_batch(usize::MAX).0.is_some());
}
controller.set_width(Some(8));
controller.set_width(Some(80));
full_source.push('\n');
controller.push("\n");
controller.on_commit_tick_batch(usize::MAX);
for chunk in source.split_inclusive('\n') {
full_source.push_str(chunk);
controller.push(chunk);
assert!(controller.on_commit_tick().0.is_none());
}
assert!(controller.has_live_tail());
let second_diagram = controller.current_tail_lines();
assert!(second_diagram.len() <= diagram.len() + 1);
controller.set_width(Some(8));
assert!(
controller
.current_tail_lines()
.iter()
.any(|line| line.line.to_string().contains("A[Request]"))
);
controller.set_width(Some(80));
assert_eq!(controller.current_tail_lines(), second_diagram);
controller.set_render_mode(HistoryRenderMode::Raw);
assert_eq!(controller.queued_lines(), source.lines().count());
controller.set_render_mode(HistoryRenderMode::Rich);
assert_eq!(controller.current_tail_lines(), second_diagram);
controller.set_render_mode(HistoryRenderMode::Raw);
assert!(controller.on_commit_tick_batch(/*max_lines*/ 1).0.is_some());
controller.set_render_mode(HistoryRenderMode::Rich);
assert_eq!(controller.current_tail_lines(), second_diagram[1..]);
assert_eq!(
controller.finalize().1.as_deref(),
Some(full_source.as_str())
);
}

View File

@@ -24,6 +24,10 @@ mod prose_preview;
mod render;
mod table_holdback;
#[cfg(test)]
#[path = "mermaid_tests.rs"]
mod mermaid_tests;
struct QueuedLine {
line: HyperlinkLine,
enqueued_at: Instant,

View File

@@ -23,6 +23,7 @@ use std::path::Path;
pub(super) struct StreamingRender {
pub(super) lines: Vec<HyperlinkLine>,
pub(super) pending_math_start: Option<usize>,
pub(super) mermaid_start: Option<usize>,
/// Source prefix containing only completed top-level markdown blocks.
stable_source_len: usize,
/// Rendered-line boundary corresponding to `stable_source_len`.
@@ -40,6 +41,7 @@ impl StreamingRender {
Self {
lines: Vec::with_capacity(64),
pending_math_start: None,
mermaid_start: None,
stable_source_len: 0,
stable_rendered_len: 0,
has_reference_link_definition: false,
@@ -51,6 +53,7 @@ impl StreamingRender {
pub(super) fn clear(&mut self) {
self.lines.clear();
self.pending_math_start = None;
self.mermaid_start = None;
self.stable_source_len = 0;
self.stable_rendered_len = 0;
self.has_reference_link_definition = false;
@@ -72,6 +75,7 @@ impl StreamingRender {
) {
self.open_code_fence = None;
self.pending_math_start = None;
self.mermaid_start = None;
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 => {
@@ -79,17 +83,19 @@ impl StreamingRender {
render_streaming_markdown_agent_with_links_and_cwd(source, width, Some(cwd));
self.has_reference_link_definition = rendered.has_reference_link_definition;
self.pending_math_start = rendered.pending_math_start;
self.mermaid_start = rendered.mermaid_start;
rendered.lines
}
_ => {
self.has_reference_link_definition = false;
if render_mode == HistoryRenderMode::Rich {
self.pending_math_start = render_streaming_markdown_agent_with_links_and_cwd(
let rendered = render_streaming_markdown_agent_with_links_and_cwd(
source,
width,
Some(cwd),
)
.pending_math_start;
);
self.pending_math_start = rendered.pending_math_start;
self.mermaid_start = rendered.mermaid_start;
}
render_source(
source,
@@ -165,6 +171,9 @@ impl StreamingRender {
self.pending_math_start = pending
.pending_math_start
.map(|start| self.stable_source_len + start);
self.mermaid_start = pending
.mermaid_start
.map(|start| self.stable_source_len + start);
if pending.has_reference_link_definition {
self.has_reference_link_definition = true;
self.recompute(

View File

@@ -0,0 +1,77 @@
---
source: tui/src/streaming/mermaid_tests.rs
assertion_line: 78
expression: "stages.join(\"\\n\\n---\\n\\n\")"
---
Before.
---
Before.
flowchart LR
A[Request] --> B[Reply]
---
Before.
┌─────────┐ ┌───────┐
│ Request │ │ Reply │
└┬────────┘ └┬──────┘
│ ▲
│ │
│ │
│ │
└────────────┘
---
Before.
┌─────────┐ ┌───────┐
│ Request │ │ Reply │
└┬────────┘ └┬──────┘
│ ▲
│ │
│ │
│ │
└────────────┘
After.
---
Before.
flowchart LR
A[Request] --> B[Reply]
After.
---
Before.
```mermaid
flowchart LR
A[Request] --> B[Reply]
```
After.
---
Before.
┌─────────┐ ┌───────┐
│ Request │ │ Reply │
└┬────────┘ └┬──────┘
│ ▲
│ │
│ │
│ │
└────────────┘
After.