Explain Mermaid rendering fallbacks in the TUI (#47572)

## Why

Mermaid diagrams that cannot be rendered currently fall back to source without
explaining why the preview is unavailable.

## What changed

Show a dimmed notice above the preserved source when a completed Mermaid block
uses unsupported syntax, exceeds renderer size limits, or does not fit the
terminal width. Wrap the notice to the available width and preserve indentation
inside lists and blockquotes. Leave unfinished fences and disabled Mermaid
rendering without notices.

## Testing

Add coverage for fallback reasons, dimmed styling, source preservation, nested
indentation, and suppression of notices for unfinished fences and disabled
rendering. Update the streaming resize snapshot to include the width notice.

GitOrigin-RevId: 6ac7914b398c855e7aed3775ee0de70fb4484b78
This commit is contained in:
Eric Traut
2026-09-23 15:15:58 +00:00
committed by copyberry
parent acd5ed9207
commit cb1eea3e98
7 changed files with 173 additions and 39 deletions
+2 -2
View File
@@ -1018,11 +1018,11 @@ where
{
let indent =
Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
mermaid::render(
Some(mermaid::render(
&code,
self.wrap_width.map(|width| width.saturating_sub(indent)),
&current_syntax_theme(),
)
))
} else {
None
};
+46 -26
View File
@@ -1,11 +1,15 @@
//! 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.
//! overflow retain the original code block with a reason; diagrams never emit terminal control
//! sequences. Callers only render completed fences when Mermaid rendering is enabled.
use crate::render::highlight::foreground_style_for_scopes_with_theme;
use crate::render::highlight::highlight_code_to_lines;
use codex_mermaid::RenderError;
use codex_mermaid::Role;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::ops::Range;
@@ -32,12 +36,30 @@ pub(super) fn has_closing_fence(input: &str, range: Range<usize>, content_end: u
>= 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()?;
pub(super) fn render(source: &str, width: Option<usize>, theme: &Theme) -> Vec<Line<'static>> {
let width = width.unwrap_or(120);
let diagram = match codex_mermaid::render_spans(source, width) {
Ok(diagram) => diagram,
Err(error) => {
let notice = match error {
RenderError::Unsupported => {
"This Mermaid diagram uses features the terminal renderer doesn't support."
}
RenderError::TooWide => {
"This Mermaid diagram doesn't fit the current terminal width."
}
RenderError::Limit => {
"This Mermaid diagram exceeds the terminal renderer's size limits."
}
};
let mut lines = textwrap::wrap(notice, width.max(/*other*/ 1))
.into_iter()
.map(|line| Line::from(line.into_owned().dim()))
.collect::<Vec<_>>();
lines.extend(highlight_code_to_lines(source, "mermaid"));
return lines;
}
};
let node = foreground_style_for_scopes_with_theme(
theme,
&["entity.name.type", "support.type", "variable"],
@@ -45,25 +67,23 @@ pub(super) fn render(
.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(),
)
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)]
@@ -61,24 +61,20 @@ flowchart TD
let output = markdown_text(source, /*width*/ 100);
assert!(output.starts_with('╭'));
assert_snapshot!(output);
assert_eq!(
markdown_text(source, /*width*/ 40),
markdown_text(
assert!(
markdown_text(source, /*width*/ 40).ends_with(&markdown_text(
&source.replacen("mermaid", "unknown", /*count*/ 1),
/*width*/ 40,
)
))
);
}
#[test]
fn mermaid_unclosed_invalid_unsupported_and_wide_blocks_keep_source() {
fn mermaid_unclosed_blocks_keep_source_without_notice() {
for (source, width) in [
("```mermaid\nflowchart LR\nA --> B\n", 80),
("```mermaid\nflowchart TD\nA[unfinished\n", 80),
("````mermaid\nflowchart LR\nA --> B\n```\n", 80),
("```mermaid\nflowchart LR\nA[unfinished\n```", 80),
("```mermaid\nflowchart TD\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!(
@@ -89,6 +85,52 @@ fn mermaid_unclosed_invalid_unsupported_and_wide_blocks_keep_source() {
}
}
#[test]
fn mermaid_fallback_notices_preserve_source() {
let mut cases = Vec::new();
for (name, source, width) in [
("invalid", "```mermaid\nflowchart LR\nA[unfinished\n```", 80),
("unsupported", "```mermaid\npie\n\"Cats\": 2\n```", 80),
(
"too wide",
"```mermaid\nflowchart LR\nA[Request] --> B[Reply]\n```",
8,
),
(
"limit",
"```mermaid\nflowchart TD\nA[This label exceeds the forty column limit]\n```",
80,
),
] {
let rendered = render_markdown_text_with_width(source, Some(width));
assert!(
rendered.lines[0]
.spans
.iter()
.filter(|span| !span.content.is_empty())
.all(|span| {
span.style
.add_modifier
.contains(ratatui::style::Modifier::DIM)
}),
"{rendered:?}"
);
let output = rendered.to_string();
assert!(output.ends_with(&markdown_text(
&source.replacen("mermaid", "unknown", /*count*/ 1),
width,
)));
cases.push(format!("{name}\n{output}"));
}
assert_snapshot!(cases.join("\n\n---\n\n"));
}
#[test]
fn mermaid_fallback_notices_follow_nested_indentation() {
let source = "> ```mermaid\n> pie\n> \"Cats\": 2\n> ```\n\n- Diagram:\n\n ```mermaid\n pie\n \"Cats\": 2\n ```\n";
assert_snapshot!(markdown_text(source, /*width*/ 40));
}
#[test]
fn mermaid_styles_follow_the_supplied_theme() {
use two_face::theme::EmbeddedThemeName;
@@ -118,7 +160,6 @@ fn mermaid_styles_follow_the_supplied_theme() {
/*width*/ Some(40),
theme,
)
.unwrap()
});
let styled = lines
.into_iter()
@@ -53,6 +53,21 @@ fn disabled_renderers_preserve_source_independently() {
insta::assert_snapshot!(snapshot.join("\n\n---\n\n"));
}
#[test]
fn disabled_mermaid_keeps_unsupported_source_without_notice() {
init(TuiRendering {
mermaid: false,
..Default::default()
});
let source = "```mermaid\npie\n\"Cats\": 2\n```\n";
let rendered = render_markdown_text(source);
init(TuiRendering::default());
assert_eq!(
rendered.to_string(),
render_markdown_text(&source.replacen("mermaid", "unknown", /*count*/ 1)).to_string(),
);
}
#[test]
fn disabled_tables_keep_markdown_fences_and_cell_markup() {
init(TuiRendering {
@@ -0,0 +1,15 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
expression: "markdown_text(source, 40)"
---
> This Mermaid diagram uses features the
> terminal renderer doesn't support.
> pie
> "Cats": 2
• Diagram:
This Mermaid diagram uses features the
terminal renderer doesn't support.
pie
"Cats": 2
@@ -0,0 +1,36 @@
---
source: tui/src/markdown_render/mermaid_tests.rs
expression: "cases.join(\"\\n\\n---\\n\\n\")"
---
invalid
This Mermaid diagram uses features the terminal renderer doesn't support.
flowchart LR
A[unfinished
---
unsupported
This Mermaid diagram uses features the terminal renderer doesn't support.
pie
"Cats": 2
---
too wide
This
Mermaid
diagram
doesn't
fit the
current
terminal
width.
flowchart LR
A[Request] --> B[Reply]
---
limit
This Mermaid diagram exceeds the terminal renderer's size limits.
flowchart TD
A[This label exceeds the forty column limit]
@@ -1,6 +1,5 @@
---
source: tui/src/streaming/mermaid_tests.rs
assertion_line: 78
expression: "stages.join(\"\\n\\n---\\n\\n\")"
---
Before.
@@ -45,6 +44,14 @@ After.
Before.
This
Mermaid
diagram
doesn't
fit the
current
terminal
width.
flowchart LR
A[Request] --> B[Reply]