diff --git a/codex-rs/mermaid/README.md b/codex-rs/mermaid/README.md index 65c20e817c6a..c5a6eca66338 100644 --- a/codex-rs/mermaid/README.md +++ b/codex-rs/mermaid/README.md @@ -8,7 +8,7 @@ production dependency is needed. | Family | Supported syntax | | --- | --- | -| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle and decision labels; directed and labeled `-->` edges; chains, branches, merges, loops | +| `flowchart`, `graph` | TD/TB, BT, LR, RL; rectangle `[label]`, decision `{label}`, and stadium `([label])` nodes; directed and labeled `-->` edges; chains, branches, merges, loops | | `sequenceDiagram` | Implicit participants, `participant`/`actor`, aliases, `->`, `->>`, `-->`, `-->>`, `-x`, `--x`, self-messages, `Note over A[,B]`, nested `loop`/`alt`/`opt`/`critical`/`break`, one labeled `else` per `alt` | | `stateDiagram-v2`, `stateDiagram` | Flat states, `state "label" as ID`, descriptions, directed transitions with optional labels, initial/final `[*]`, direction declarations | | `classDiagram` | `class ID`, multiline member bodies, `ID : member`, solid/dashed links, association, inheritance, composition, aggregation, dependency, realization, quoted endpoint cardinalities, relationship labels, direction declarations | @@ -32,7 +32,7 @@ on any error; the library never returns a partial diagram. Graph nodes appear in declaration/first-reference order, in the requested direction. Each edge gets its own lane and endpoint positions. Crossings use `╪` and never -join routes. Decisions use `◇` inside a box. Horizontal layouts +join routes. Decisions use `◇` inside a box; stadiums use rounded box corners. Horizontal layouts reserve a text gutter for every endpoint, which can make connected graphs wide; the caller receives `TooWide` if the complete output does not fit. diff --git a/codex-rs/mermaid/src/draw.rs b/codex-rs/mermaid/src/draw.rs index 9f2400d786d9..eb79905ca019 100644 --- a/codex-rs/mermaid/src/draw.rs +++ b/codex-rs/mermaid/src/draw.rs @@ -6,6 +6,7 @@ use super::Direction; use super::Graph; use super::RenderError; use super::Role; +use super::Shape; use super::Span; use super::output::Cell; use super::output::finish; @@ -18,7 +19,7 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result>, .nodes .iter() .map(|node| { - let mut lines = vec![if node.decision { + let mut lines = vec![if node.shape == Shape::Decision { format!("◇ {}", node.label) } else { node.label.clone() @@ -105,10 +106,14 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result>, for (i, lines) in labels.iter().enumerate() { let start = starts[i]; let end = start + sizes[i] - 1; - canvas.set(/*across*/ 0, start, Cell::node('┌')); - canvas.set(box_cross - 1, start, Cell::node('┐')); - canvas.set(/*across*/ 0, end, Cell::node('└')); - canvas.set(box_cross - 1, end, Cell::node('┘')); + let [top_left, top_right, bottom_left, bottom_right] = match graph.nodes[i].shape { + Shape::Stadium => ['╭', '╮', '╰', '╯'], + Shape::Rectangle | Shape::Decision => ['┌', '┐', '└', '┘'], + }; + canvas.set(/*across*/ 0, start, Cell::node(top_left)); + canvas.set(box_cross - 1, start, Cell::node(top_right)); + canvas.set(/*across*/ 0, end, Cell::node(bottom_left)); + canvas.set(box_cross - 1, end, Cell::node(bottom_right)); for x in 1..box_cross - 1 { canvas.set(x, start, Cell::node('─')); canvas.set(x, end, Cell::node('─')); @@ -226,6 +231,8 @@ fn transpose(ch: char) -> char { '┆' => '┄', '┐' => '└', '└' => '┐', + '╮' => '╰', + '╰' => '╮', '├' => '┬', '┬' => '├', '◄' => '▲', diff --git a/codex-rs/mermaid/src/lib.rs b/codex-rs/mermaid/src/lib.rs index a6475a3a35b9..e381dc72851c 100644 --- a/codex-rs/mermaid/src/lib.rs +++ b/codex-rs/mermaid/src/lib.rs @@ -107,11 +107,22 @@ impl Direction { } } +/// Node shapes supported by the terminal renderer. +#[derive(Debug, PartialEq, Eq)] +enum Shape { + /// A rectangular node, such as `A[Work]`. + Rectangle, + /// A decision node (`A{Done?}`), rendered with a `◇` marker inside a box. + Decision, + /// A stadium node (`A([Start])`), rendered as a box with rounded corners. + Stadium, +} + #[derive(Debug, PartialEq, Eq)] struct Node { id: String, label: String, - decision: bool, + shape: Shape, declared: bool, members: Vec, } @@ -159,7 +170,7 @@ impl Graph { self.nodes.push(Node { id: id.to_owned(), label: id.to_owned(), - decision: false, + shape: Shape::Rectangle, declared: false, members: Vec::new(), }); diff --git a/codex-rs/mermaid/src/parse.rs b/codex-rs/mermaid/src/parse.rs index 157e69fbbf06..b9cbaf7e2ccc 100644 --- a/codex-rs/mermaid/src/parse.rs +++ b/codex-rs/mermaid/src/parse.rs @@ -6,6 +6,7 @@ use super::Graph; use super::MAX_EDGES; use super::MAX_LABEL; use super::RenderError; +use super::Shape; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; @@ -59,25 +60,24 @@ fn node(rest: &mut &str, graph: &mut Graph) -> Result { return Err(RenderError::Unsupported); } let declaration = match rest.chars().next() { - Some(open @ ('[' | '{')) => { - let close = if open == '[' { ']' } else { '}' }; - let (label, remaining) = rest[1..] - .split_once(close) - .ok_or(RenderError::Unsupported)?; - check_label(label)?; - *rest = remaining; - Some((label, open == '{')) - } + Some('[') => Some(("[", "]", Shape::Rectangle)), + Some('{') => Some(("{", "}", Shape::Decision)), + Some('(') if rest.starts_with("([") => Some(("([", "])", Shape::Stadium)), _ => None, }; let index = graph.node(id)?; - if let Some((label, decision)) = declaration { + if let Some((open, close, shape)) = declaration { + let (label, remaining) = rest[open.len()..] + .split_once(close) + .ok_or(RenderError::Unsupported)?; + check_label(label)?; + *rest = remaining; let node = &mut graph.nodes[index]; - if node.declared && (node.label != label || node.decision != decision) { + if node.declared && (node.label != label || node.shape != shape) { return Err(RenderError::Unsupported); } node.label = label.to_owned(); - node.decision = decision; + node.shape = shape; node.declared = true; } Ok(index) diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__stadiums_with_other_shapes_in_every_direction.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__stadiums_with_other_shapes_in_every_direction.snap new file mode 100644 index 000000000000..e9e3d4da2b6e --- /dev/null +++ b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__stadiums_with_other_shapes_in_every_direction.snap @@ -0,0 +1,73 @@ +--- +source: mermaid/src/tests.rs +expression: "cases.join(\"\\n\\n\")" +--- +TD +╭─────────╮ +│ 请求 │ +│ ├─────┐ +│ ├◄────╪───┐ +╰─────────╯ │ │ + │ │ + │ │ +┌─────────┐ │ │ +│ Work │ │ │ +│ ├◄────┘ │ +│ ├───────┐ │ +└─────────┘ │ │ + │ │ + │ │ +┌─────────┐ │ │ +│ ◇ Done? │ │ │ +│ ├◄──────┘ │ +│ ├─────────┘ +└─────────┘ + +BT +┌─────────┐ +│ ◇ Done? │ +│ ├◄──────┐ +│ ├───────╪─┐ +└─────────┘ │ │ + │ │ + │ │ +┌─────────┐ │ │ +│ Work │ │ │ +│ ├◄────┐ │ │ +│ ├─────╪─┘ │ +└─────────┘ │ │ + │ │ + │ │ +╭─────────╮ │ │ +│ 请求 │ │ │ +│ ├─────┘ │ +│ ├◄────────┘ +╰─────────╯ + +LR +╭──────╮ ┌──────┐ ┌─────────┐ +│ 请求 │ │ Work │ │ ◇ Done? │ +╰┬─┬───╯ └┬─┬───┘ └┬─┬──────┘ + │ ▲ ▲ │ ▲ │ + │ │ │ │ │ │ + │ │ │ │ │ │ + │ │ │ │ │ │ + └─╪───────┘ │ │ │ + │ │ │ │ + │ └───────┘ │ + │ │ + └───────────────────┘ + +RL +┌─────────┐ ┌──────┐ ╭──────╮ +│ ◇ Done? │ │ Work │ │ 请求 │ +└┬─┬──────┘ └┬─┬───┘ ╰┬─┬───╯ + ▲ │ ▲ │ │ ▲ + │ │ │ │ │ │ + │ │ │ │ │ │ + │ │ │ │ │ │ + │ │ └─╪───────┘ │ + │ │ │ │ + └─╪────────────┘ │ + │ │ + └──────────────────────┘ diff --git a/codex-rs/mermaid/src/tests.rs b/codex-rs/mermaid/src/tests.rs index 84131680b9c5..6ea97ff84634 100644 --- a/codex-rs/mermaid/src/tests.rs +++ b/codex-rs/mermaid/src/tests.rs @@ -4,6 +4,37 @@ use insta::assert_snapshot; use pretty_assertions::assert_eq; use unicode_width::UnicodeWidthStr; +#[test] +fn stadium_declarations_and_references() { + let graph = + super::parse::parse("flowchart TD", &["A", "A([Ready?])", "A", "A([Ready?])"]).unwrap(); + assert_eq!( + graph.nodes, + vec![super::Node { + id: "A".to_owned(), + label: "Ready?".to_owned(), + shape: super::Shape::Stadium, + declared: true, + members: Vec::new(), + }] + ); +} + +#[test] +fn stadiums_with_other_shapes_in_every_direction() { + let mut cases = Vec::new(); + for direction in ["TD", "BT", "LR", "RL"] { + let source = + format!("flowchart {direction}; A([请求]) --> B[Work] --> C{{Done?}}; C --> A"); + let output = render(&source, /*max_width*/ 100).unwrap(); + let width = output.lines().map(UnicodeWidthStr::width).max().unwrap(); + assert_eq!(render(&source, width), Ok(output.clone())); + assert_eq!(render(&source, width - 1), Err(RenderError::TooWide)); + cases.push(format!("{direction}\n{output}")); + } + assert_snapshot!(cases.join("\n\n")); +} + #[test] fn branches_merges_and_retry_loop() { let source = "flowchart TD\nA[Checkout] --> B{In stock?}\nB -->|yes| C[Reserve]\nB -->|no| D[Waitlist]\nC --> E{Paid?}\nE -->|yes| F[Ship]\nE -->|no| G[Retry payment]\nG --> E\nD --> H[Notify buyer]\nF --> H"; @@ -31,6 +62,15 @@ fn rejects_partial_or_unsupported_input() { "flowchart TD; A[unclosed", "flowchart TD; click A", "flowchart TD; A((circle))", + "flowchart TD; A(rounded)", + "flowchart TD; A([unclosed]", + "flowchart TD; A([unclosed)", + "flowchart TD; A([label]) trailing", + "flowchart TD; A([])", + "flowchart TD; A([nested[label]])", + "flowchart TD; A([one]); A([two])", + "flowchart TD; A([same]); A[same]", + "flowchart TD; A{same}; A([same])", "flowchart TD; A -->|unclosed B", "flowchart TD; A[\"quoted\"]", "flowchart TD; A[foo;bar]", @@ -50,6 +90,7 @@ fn source_graph_and_width_limits() { for source in [ " ".repeat(16 * 1024 + 1), format!("graph TD; A[{}]", "x".repeat(41)), + format!("graph TD; A([{}])", "x".repeat(41)), format!( "graph TD; {}", (0..17).map(|n| format!("N{n};")).collect::() diff --git a/codex-rs/tui/src/markdown_render/mermaid_tests.rs b/codex-rs/tui/src/markdown_render/mermaid_tests.rs index fc6ee5e0d5c3..368fff2792c4 100644 --- a/codex-rs/tui/src/markdown_render/mermaid_tests.rs +++ b/codex-rs/tui/src/markdown_render/mermaid_tests.rs @@ -41,12 +41,42 @@ fn mermaid_nested_fences_and_unicode() { assert_snapshot!(markdown_text(source, /*width*/ 60)); } +#[test] +fn mermaid_stadium_flowchart() { + let source = "```mermaid +flowchart TD + A([What should I work on?]) --> B{Anything urgent?} + B -->|Yes| C[Handle the urgent task] + B -->|No| D{Have a clear goal?} + D -->|No| E[Pick one useful outcome] + E --> F[Choose the smallest next step] + D -->|Yes| F + F --> G[Focus for 25 minutes] + C --> H{Done?} + G --> H + H -->|No| I[Take a short break] + I --> F + H -->|Yes| J([Celebrate. Stretch. Repeat.]) +```"; + let output = markdown_text(source, /*width*/ 100); + assert!(output.starts_with('╭')); + assert_snapshot!(output); + assert_eq!( + markdown_text(source, /*width*/ 40), + markdown_text( + &source.replacen("mermaid", "unknown", /*count*/ 1), + /*width*/ 40, + ) + ); +} + #[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\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), diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_stadium_flowchart.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_stadium_flowchart.snap new file mode 100644 index 000000000000..508885a16c0e --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_stadium_flowchart.snap @@ -0,0 +1,76 @@ +--- +source: tui/src/markdown_render/mermaid_tests.rs +expression: output +--- +╭───────────────────────────────╮ +│ What should I work on? │ +│ ├────────┐ +╰───────────────────────────────╯ │ + │ + │ +┌───────────────────────────────┐ │ +│ ◇ Anything urgent? │ │ +│ ├◄───────┘ +│ ├──Yes─────┐ +│ ├──No──────╪─┐ +└───────────────────────────────┘ │ │ + │ │ + │ │ +┌───────────────────────────────┐ │ │ +│ Handle the urgent task │ │ │ +│ ├◄─────────┘ │ +│ ├────────────╪─────────┐ +└───────────────────────────────┘ │ │ + │ │ + │ │ +┌───────────────────────────────┐ │ │ +│ ◇ Have a clear goal? │ │ │ +│ ├◄───────────┘ │ +│ ├──No──────────┐ │ +│ ├──Yes─────────╪───┐ │ +└───────────────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌───────────────────────────────┐ │ │ │ +│ Pick one useful outcome │ │ │ │ +│ ├◄─────────────┘ │ │ +│ ├────────────────┐ │ │ +└───────────────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌───────────────────────────────┐ │ │ │ +│ Choose the smallest next step │ │ │ │ +│ ├◄───────────────┘ │ │ +│ ├◄─────────────────┘ │ +│ ├────────────────────┐ │ +│ ├◄───────────────────╪─╪─────┐ +└───────────────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌───────────────────────────────┐ │ │ │ +│ Focus for 25 minutes │ │ │ │ +│ ├◄───────────────────┘ │ │ +│ ├──────────────────────╪─┐ │ +└───────────────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌───────────────────────────────┐ │ │ │ +│ ◇ Done? │ │ │ │ +│ ├◄─────────────────────┘ │ │ +│ ├◄───────────────────────┘ │ +│ ├──No──────────────────────┐ │ +│ ├──Yes─────────────────────╪─╪─┐ +└───────────────────────────────┘ │ │ │ + │ │ │ + │ │ │ +┌───────────────────────────────┐ │ │ │ +│ Take a short break │ │ │ │ +│ ├◄─────────────────────────┘ │ │ +│ ├────────────────────────────┘ │ +└───────────────────────────────┘ │ + │ + │ +╭───────────────────────────────╮ │ +│ Celebrate. Stretch. Repeat. │ │ +│ ├◄─────────────────────────────┘ +╰───────────────────────────────╯