Codex Handbook
tui/src/markdown.rs 502 lines
//! Markdown-to-ratatui rendering entry points.//!//! This module provides the public API surface that the rest of the TUI uses//! to turn markdown source into `Vec<Line<'static>>`.  Two variants exist://!//! - [`append_markdown`] -- general-purpose, used for plan blocks and history//!   cells that already hold pre-processed markdown (no fence unwrapping).//! - [`append_markdown_agent`] -- for agent responses.  Runs//!   [`unwrap_markdown_fences`] first so that `` ```md ``/`` ```markdown ``//!   fences containing tables are stripped and `pulldown-cmark` sees raw//!   table syntax instead of fenced code.//!//! ## Why fence unwrapping exists//!//! LLM agents frequently wrap tables in `` ```markdown `` fences, treating//! them as code.  Without unwrapping, `pulldown-cmark` parses those lines//! as a fenced code block and renders them as monospace code rather than a//! structured table.  The unwrapper is intentionally conservative: it//! buffers the entire fence body before deciding, only unwraps fences whose//! info string is `md` or `markdown` AND whose body contains a//! header+delimiter pair, and degrades gracefully on unclosed fences.use ratatui::text::Line;use std::borrow::Cow;use std::ops::Range;use std::path::Path;use crate::table_detect;use crate::terminal_hyperlinks::HyperlinkLine;/// Render markdown source to styled ratatui lines and append them to `lines`.////// Callers that already know the session working directory should pass it here so streamed and/// non-streamed rendering show the same relative path text even if the process cwd differs.pub(crate) fn append_markdown(    markdown_source: &str,    width: Option<usize>,    cwd: Option<&Path>,    lines: &mut Vec<Line<'static>>,) {    let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(        markdown_source,        width,        cwd,    );    crate::render::line_utils::push_owned_lines(&rendered.lines, lines);}/// Render an agent message to styled ratatui lines.////// Before rendering, the source is passed through [`unwrap_markdown_fences`] so that tables/// wrapped in `` ```md `` fences are rendered as native tables rather than code blocks./// Non-markdown fences (e.g. `rust`, `sh`) are left/// intact.#[cfg(test)]pub(crate) fn append_markdown_agent(    markdown_source: &str,    width: Option<usize>,    lines: &mut Vec<Line<'static>>,) {    let normalized = unwrap_markdown_fences(markdown_source);    let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(        &normalized,        width,        /*cwd*/ None,    );    crate::render::line_utils::push_owned_lines(&rendered.lines, lines);}pub(crate) fn render_markdown_agent_with_links_and_cwd(    markdown_source: &str,    width: Option<usize>,    cwd: Option<&Path>,) -> Vec<HyperlinkLine> {    let normalized = unwrap_markdown_fences(markdown_source);    crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd)}/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare/// markdown so `pulldown-cmark` parses the tables natively.////// Fences whose info string is not `md` or `markdown` are passed through unchanged.  Markdown/// fences that do *not* contain a table (detected by checking for a header row + delimiter row)/// are also passed through so that non-table markdown inside a fence still renders as a code/// block.////// The fence unwrapping is intentionally conservative: it buffers the entire fence body before/// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial/// streams degrade to code display.fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> {    // Zero-copy fast path: most messages contain no fences at all.    if !markdown_source.contains("```") && !markdown_source.contains("~~~") {        return Cow::Borrowed(markdown_source);    }    #[derive(Clone, Copy)]    struct Fence {        marker: u8,        len: usize,        is_blockquoted: bool,    }    // Strip a trailing newline and up to 3 leading spaces, returning the    // trimmed slice.  Returns `None` when the line has 4+ leading spaces    // (which makes it an indented code line per CommonMark).    fn strip_line_indent(line: &str) -> Option<&str> {        let without_newline = line.strip_suffix('\n').unwrap_or(line);        let mut byte_idx = 0usize;        let mut column = 0usize;        for b in without_newline.as_bytes() {            match b {                b' ' => {                    byte_idx += 1;                    column += 1;                }                b'\t' => {                    byte_idx += 1;                    column += 4;                }                _ => break,            }            if column >= 4 {                return None;            }        }        Some(&without_newline[byte_idx..])    }    // Parse an opening fence line, returning the fence metadata and whether    // the fence info string indicates markdown content.    fn parse_open_fence(line: &str) -> Option<(Fence, bool)> {        let trimmed = strip_line_indent(line)?;        let is_blockquoted = trimmed.trim_start().starts_with('>');        let fence_scan_text = table_detect::strip_blockquote_prefix(trimmed);        let (marker, len) = table_detect::parse_fence_marker(fence_scan_text)?;        let is_markdown = table_detect::is_markdown_fence_info(fence_scan_text, len);        Some((            Fence {                marker: marker as u8,                len,                is_blockquoted,            },            is_markdown,        ))    }    fn is_close_fence(line: &str, fence: Fence) -> bool {        let Some(trimmed) = strip_line_indent(line) else {            return false;        };        let fence_scan_text = if fence.is_blockquoted {            if !trimmed.trim_start().starts_with('>') {                return false;            }            table_detect::strip_blockquote_prefix(trimmed)        } else {            trimmed        };        if let Some((marker, len)) = table_detect::parse_fence_marker(fence_scan_text) {            marker as u8 == fence.marker                && len >= fence.len                && fence_scan_text[len..].trim().is_empty()        } else {            false        }    }    fn markdown_fence_contains_table(content: &str, is_blockquoted_fence: bool) -> bool {        let mut previous_line: Option<&str> = None;        for line in content.lines() {            let text = if is_blockquoted_fence {                table_detect::strip_blockquote_prefix(line)            } else {                line            };            let trimmed = text.trim();            if trimmed.is_empty() {                previous_line = None;                continue;            }            if let Some(previous) = previous_line                && table_detect::is_table_header_line(previous)                && !table_detect::is_table_delimiter_line(previous)                && table_detect::is_table_delimiter_line(trimmed)            {                return true;            }            previous_line = Some(trimmed);        }        false    }    fn content_from_ranges(source: &str, ranges: &[Range<usize>]) -> String {        let total_len: usize = ranges.iter().map(ExactSizeIterator::len).sum();        let mut content = String::with_capacity(total_len);        for range in ranges {            content.push_str(&source[range.start..range.end]);        }        content    }    struct MarkdownCandidateData {        fence: Fence,        opening_range: Range<usize>,        content_ranges: Vec<Range<usize>>,    }    // Box the large variant to keep ActiveFence small (~pointer-sized).    enum ActiveFence {        Passthrough(Fence),        MarkdownCandidate(Box<MarkdownCandidateData>),    }    let mut out = String::with_capacity(markdown_source.len());    let mut active_fence: Option<ActiveFence> = None;    let mut source_offset = 0usize;    let mut push_source_range = |range: Range<usize>| {        if !range.is_empty() {            out.push_str(&markdown_source[range]);        }    };    for line in markdown_source.split_inclusive('\n') {        let line_start = source_offset;        source_offset += line.len();        let line_range = line_start..source_offset;        if let Some(active) = active_fence.take() {            match active {                ActiveFence::Passthrough(fence) => {                    push_source_range(line_range);                    if !is_close_fence(line, fence) {                        active_fence = Some(ActiveFence::Passthrough(fence));                    }                }                ActiveFence::MarkdownCandidate(mut data) => {                    if is_close_fence(line, data.fence) {                        if markdown_fence_contains_table(                            &content_from_ranges(markdown_source, &data.content_ranges),                            data.fence.is_blockquoted,                        ) {                            for range in data.content_ranges {                                push_source_range(range);                            }                        } else {                            push_source_range(data.opening_range);                            for range in data.content_ranges {                                push_source_range(range);                            }                            push_source_range(line_range);                        }                    } else {                        data.content_ranges.push(line_range);                        active_fence = Some(ActiveFence::MarkdownCandidate(data));                    }                }            }            continue;        }        if let Some((fence, is_markdown)) = parse_open_fence(line) {            if is_markdown {                active_fence = Some(ActiveFence::MarkdownCandidate(Box::new(                    MarkdownCandidateData {                        fence,                        opening_range: line_range,                        content_ranges: Vec::new(),                    },                )));            } else {                push_source_range(line_range);                active_fence = Some(ActiveFence::Passthrough(fence));            }            continue;        }        push_source_range(line_range);    }    if let Some(active) = active_fence {        match active {            ActiveFence::Passthrough(_) => {}            ActiveFence::MarkdownCandidate(data) => {                push_source_range(data.opening_range);                for range in data.content_ranges {                    push_source_range(range);                }            }        }    }    Cow::Owned(out)}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    use ratatui::text::Line;    fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {        lines            .iter()            .map(|l| {                l.spans                    .iter()                    .map(|s| s.content.clone())                    .collect::<String>()            })            .collect()    }    #[test]    fn citations_render_as_plain_text() {        let src = "Before 【F:/x.rs†L1】\nAfter 【F:/x.rs†L3】\n";        let mut out = Vec::new();        append_markdown(src, /*width*/ None, /*cwd*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert_eq!(            rendered,            vec![                "Before 【F:/x.rs†L1】".to_string(),                "After 【F:/x.rs†L3】".to_string()            ]        );    }    #[test]    fn indented_code_blocks_preserve_leading_whitespace() {        // Basic sanity: indented code with surrounding blank lines should produce the indented line.        let src = "Before\n\n    code 1\n\nAfter\n";        let mut out = Vec::new();        append_markdown(src, /*width*/ None, /*cwd*/ None, &mut out);        let lines = lines_to_strings(&out);        assert_eq!(lines, vec!["Before", "", "    code 1", "", "After"]);    }    #[test]    fn append_markdown_preserves_full_text_line() {        let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";        let mut out = Vec::new();        append_markdown(src, /*width*/ None, /*cwd*/ None, &mut out);        assert_eq!(            out.len(),            1,            "expected a single rendered line for plain text"        );        let rendered: String = out            .iter()            .flat_map(|l| l.spans.iter())            .map(|s| s.content.clone())            .collect::<Vec<_>>()            .join("");        assert_eq!(            rendered,            "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?"        );    }    #[test]    fn append_markdown_matches_tui_markdown_for_ordered_item() {        let mut out = Vec::new();        append_markdown(            "1. Tight item\n",            /*width*/ None,            /*cwd*/ None,            &mut out,        );        let lines = lines_to_strings(&out);        assert_eq!(lines, vec!["1. Tight item".to_string()]);    }    #[test]    fn append_markdown_keeps_ordered_list_line_unsplit_in_context() {        let src = "Loose vs. tight list items:\n1. Tight item\n";        let mut out = Vec::new();        append_markdown(src, /*width*/ None, /*cwd*/ None, &mut out);        let lines = lines_to_strings(&out);        // Expect to find the ordered list line rendered as a single line,        // not split into a marker-only line followed by the text.        assert!(            lines.iter().any(|s| s == "1. Tight item"),            "expected '1. Tight item' rendered as a single line; got: {lines:?}"        );        assert!(            !lines                .windows(2)                .any(|w| w[0].trim_end() == "1." && w[1] == "Tight item"),            "did not expect a split into ['1.', 'Tight item']; got: {lines:?}"        );    }    #[test]    fn append_markdown_agent_unwraps_markdown_fences_for_table_rendering() {        let src = "```markdown\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert!(rendered.iter().any(|line| line.contains('━')));        assert!(rendered.iter().any(|line| line.contains(" 1      2")));    }    #[test]    fn append_markdown_agent_unwraps_markdown_fences_for_no_outer_table_rendering() {        let src = "```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert!(rendered.iter().any(|line| line.contains('━')));        assert!(            rendered                .iter()                .any(|line| line.contains(" Col A    Col B    Col C"))        );        assert!(            !rendered                .iter()                .any(|line| line.trim() == "Col A | Col B | Col C")        );    }    #[test]    fn append_markdown_agent_unwraps_markdown_fences_for_two_column_no_outer_table() {        let src = "```md\nA | B\n--- | ---\nleft | right\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert!(rendered.iter().any(|line| line.contains('━')));        assert!(rendered.iter().any(|line| line.contains(" left    right")));        assert!(!rendered.iter().any(|line| line.trim() == "A | B"));    }    #[test]    fn append_markdown_agent_unwraps_markdown_fences_for_single_column_table() {        let src = "```md\n| Only |\n|---|\n| value |\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert!(rendered.iter().any(|line| line.contains('━')));        assert!(!rendered.iter().any(|line| line.trim() == "| Only |"));    }    #[test]    fn append_markdown_agent_keeps_non_markdown_fences_as_code() {        let src = "```rust\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert_eq!(            rendered,            vec![                "| A | B |".to_string(),                "|---|---|".to_string(),                "| 1 | 2 |".to_string(),            ]        );    }    #[test]    fn append_markdown_agent_unwraps_blockquoted_markdown_fence_table() {        let src = "> ```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n> ```\n";        let rendered = unwrap_markdown_fences(src);        assert!(            !rendered.contains("```"),            "expected markdown fence markers to be removed: {rendered:?}"        );    }    #[test]    fn append_markdown_agent_keeps_non_blockquoted_markdown_fence_with_blockquote_table_example() {        let src = "```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n```\n";        let normalized = unwrap_markdown_fences(src);        assert_eq!(normalized, src);    }    #[test]    fn append_markdown_agent_keeps_markdown_fence_when_content_is_not_table() {        let src = "```markdown\n**bold**\n```\n";        let mut out = Vec::new();        append_markdown_agent(src, /*width*/ None, &mut out);        let rendered = lines_to_strings(&out);        assert_eq!(rendered, vec!["**bold**".to_string()]);    }    #[test]    fn unwrap_markdown_fences_repro_keeps_fence_without_header_delimiter_pair() {        let src = "```markdown\n| A | B |\nnot a delimiter row\n| --- | --- |\n# Heading\n```\n";        let normalized = unwrap_markdown_fences(src);        assert_eq!(normalized, src);    }    #[test]    fn append_markdown_agent_keeps_markdown_fence_with_blank_line_between_header_and_delimiter() {        let src = "```markdown\n| A | B |\n\n|---|---|\n| 1 | 2 |\n```\n";        let rendered = unwrap_markdown_fences(src);        assert_eq!(rendered, src);    }}