From cb1eea3e98ebc433ab5f9c12ce043e979d1902df Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 23 Sep 2026 15:03:51 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/markdown_render.rs | 4 +- codex-rs/tui/src/markdown_render/mermaid.rs | 72 ++++++++++++------- .../tui/src/markdown_render/mermaid_tests.rs | 61 +++++++++++++--- .../src/markdown_render/preferences_tests.rs | 15 ++++ ...ack_notices_follow_nested_indentation.snap | 15 ++++ ...maid_fallback_notices_preserve_source.snap | 36 ++++++++++ ...oses_resizes_and_preserves_raw_source.snap | 9 ++- 7 files changed, 173 insertions(+), 39 deletions(-) create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_follow_nested_indentation.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_preserve_source.snap diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 20b622617b..2063b11aec 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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)), ¤t_syntax_theme(), - ) + )) } else { None }; diff --git a/codex-rs/tui/src/markdown_render/mermaid.rs b/codex-rs/tui/src/markdown_render/mermaid.rs index 56442718de..446208fbb9 100644 --- a/codex-rs/tui/src/markdown_render/mermaid.rs +++ b/codex-rs/tui/src/markdown_render/mermaid.rs @@ -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, content_end: u >= opening_len } -pub(super) fn render( - source: &str, - width: Option, - theme: &Theme, -) -> Option>> { - let diagram = codex_mermaid::render_spans(source, width.unwrap_or(120)).ok()?; +pub(super) fn render(source: &str, width: Option, theme: &Theme) -> Vec> { + 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::>(); + 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::>(), - ) - }) - .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::>(), + ) + }) + .collect() } #[cfg(test)] diff --git a/codex-rs/tui/src/markdown_render/mermaid_tests.rs b/codex-rs/tui/src/markdown_render/mermaid_tests.rs index 368fff2792..29b438edfc 100644 --- a/codex-rs/tui/src/markdown_render/mermaid_tests.rs +++ b/codex-rs/tui/src/markdown_render/mermaid_tests.rs @@ -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() diff --git a/codex-rs/tui/src/markdown_render/preferences_tests.rs b/codex-rs/tui/src/markdown_render/preferences_tests.rs index 1973d844b9..e543c4f0be 100644 --- a/codex-rs/tui/src/markdown_render/preferences_tests.rs +++ b/codex-rs/tui/src/markdown_render/preferences_tests.rs @@ -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 { diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_follow_nested_indentation.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_follow_nested_indentation.snap new file mode 100644 index 0000000000..7aa6fdbfd2 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_follow_nested_indentation.snap @@ -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 diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_preserve_source.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_preserve_source.snap new file mode 100644 index 0000000000..e7eaf91720 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_fallback_notices_preserve_source.snap @@ -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] diff --git a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__mermaid_tests__mermaid_stream_closes_resizes_and_preserves_raw_source.snap b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__mermaid_tests__mermaid_stream_closes_resizes_and_preserves_raw_source.snap index 4ddc098e97..d19f7ec28e 100644 --- a/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__mermaid_tests__mermaid_stream_closes_resizes_and_preserves_raw_source.snap +++ b/codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__mermaid_tests__mermaid_stream_closes_resizes_and_preserves_raw_source.snap @@ -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]