mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
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:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -4784,6 +4784,7 @@ dependencies = [
|
||||
"codex-install-context",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
"codex-mermaid",
|
||||
"codex-message-history",
|
||||
"codex-model-provider",
|
||||
"codex-model-provider-info",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
¤t_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 {
|
||||
|
||||
71
codex-rs/tui/src/markdown_render/mermaid.rs
Normal file
71
codex-rs/tui/src/markdown_render/mermaid.rs
Normal 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;
|
||||
89
codex-rs/tui/src/markdown_render/mermaid_tests.rs
Normal file
89
codex-rs/tui/src/markdown_render/mermaid_tests.rs
Normal 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"));
|
||||
}
|
||||
@@ -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 │
|
||||
└┬──┘ └┬──┘
|
||||
│ ▲
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└──────┘
|
||||
@@ -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() " └─────────┘"
|
||||
@@ -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),
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
218
codex-rs/tui/src/streaming/mermaid_tests.rs
Normal file
218
codex-rs/tui/src/streaming/mermaid_tests.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user