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]