From f915e0de0794db24c0ff1419933f81028887069e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 16 Sep 2026 23:11:13 +0000 Subject: [PATCH] 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 --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/mermaid/src/draw.rs | 4 +- codex-rs/mermaid/src/tests.rs | 18 ++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/markdown.rs | 5 + codex-rs/tui/src/markdown_render.rs | 42 +++- codex-rs/tui/src/markdown_render/mermaid.rs | 71 ++++++ .../tui/src/markdown_render/mermaid_tests.rs | 89 +++++++ ...ts__mermaid_nested_fences_and_unicode.snap | 26 +++ ...maid_styles_follow_the_supplied_theme.snap | 34 +++ codex-rs/tui/src/markdown_render/streaming.rs | 11 + codex-rs/tui/src/render/highlight.rs | 5 +- codex-rs/tui/src/streaming/controller.rs | 102 +++++--- codex-rs/tui/src/streaming/mermaid_tests.rs | 218 ++++++++++++++++++ codex-rs/tui/src/streaming/mod.rs | 4 + codex-rs/tui/src/streaming/render.rs | 15 +- ...oses_resizes_and_preserves_raw_source.snap | 77 +++++++ 18 files changed, 682 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/markdown_render/mermaid.rs create mode 100644 codex-rs/tui/src/markdown_render/mermaid_tests.rs create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_nested_fences_and_unicode.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_styles_follow_the_supplied_theme.snap create mode 100644 codex-rs/tui/src/streaming/mermaid_tests.rs create mode 100644 codex-rs/tui/src/streaming/snapshots/codex_tui__streaming__mermaid_tests__mermaid_stream_closes_resizes_and_preserves_raw_source.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ecfafd99c2..4cf1efabc7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4784,6 +4784,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-mermaid", "codex-message-history", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5963eaba18..538ee5298f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" } diff --git a/codex-rs/mermaid/src/draw.rs b/codex-rs/mermaid/src/draw.rs index 0068b047a7..9f2400d786 100644 --- a/codex-rs/mermaid/src/draw.rs +++ b/codex-rs/mermaid/src/draw.rs @@ -168,8 +168,8 @@ pub(super) fn render(graph: &Graph, max_width: usize) -> Result>, 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( diff --git a/codex-rs/mermaid/src/tests.rs b/codex-rs/mermaid/src/tests.rs index 7daf9935ca..84131680b9 100644 --- a/codex-rs/mermaid/src/tests.rs +++ b/codex-rs/mermaid/src/tests.rs @@ -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::>(); + assert_eq!(ports, vec![Role::Node, Role::Node]); + } } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 0bbdcd44e8..2ed86100bb 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -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 } diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 0f9b7345ba..995f57f917 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -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 } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 91ea4370b1..759fd9c3f4 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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, code_block_buffer: String, + code_block_content_end: usize, wrap_width: Option, cwd: Option, 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) { 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) { + // 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 { diff --git a/codex-rs/tui/src/markdown_render/mermaid.rs b/codex-rs/tui/src/markdown_render/mermaid.rs new file mode 100644 index 0000000000..56442718de --- /dev/null +++ b/codex-rs/tui/src/markdown_render/mermaid.rs @@ -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, 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, + theme: &Theme, +) -> Option>> { + 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::>(), + ) + }) + .collect(), + ) +} + +#[cfg(test)] +#[path = "mermaid_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/markdown_render/mermaid_tests.rs b/codex-rs/tui/src/markdown_render/mermaid_tests.rs new file mode 100644 index 0000000000..9609a63077 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/mermaid_tests.rs @@ -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::() + }) + .collect::>() + .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::>() + .join(" | ") + }) + .collect::>() + .join("\n"); + cases.push(format!("{name}\n{styled}")); + } + assert_snapshot!(cases.join("\n\n")); +} diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_nested_fences_and_unicode.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_nested_fences_and_unicode.snap new file mode 100644 index 0000000000..9d9ad250a0 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_nested_fences_and_unicode.snap @@ -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 │ + └┬──┘ └┬──┘ + │ ▲ + │ │ + │ │ + │ │ + └──────┘ diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_styles_follow_the_supplied_theme.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_styles_follow_the_supplied_theme.snap new file mode 100644 index 0000000000..906b21bf3f --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__mermaid__tests__mermaid_styles_follow_the_supplied_theme.snap @@ -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() " └─────────┘" diff --git a/codex-rs/tui/src/markdown_render/streaming.rs b/codex-rs/tui/src/markdown_render/streaming.rs index 001fced6be..c6978e34ee 100644 --- a/codex-rs/tui/src/markdown_render/streaming.rs +++ b/codex-rs/tui/src/markdown_render/streaming.rs @@ -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, } /// 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 { block_count: usize, last_start: usize, first_is_html: bool, + mermaid_start: Option, } impl<'a, I> Iterator for TopLevelBlockTracker @@ -90,6 +95,7 @@ where fn next(&mut self) -> Option { 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), diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index 8d186712c2..d9eaaf6491 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -321,7 +321,10 @@ pub(crate) fn foreground_style_for_scopes(scope_names: &[&str]) -> Option