//! 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 //! display. It is the final rendering stage used by higher-level helpers in //! `markdown.rs`. //! //! This renderer intentionally treats local file links differently from normal web links. For //! local paths, the displayed text comes from the destination, not the markdown label, so //! transcripts show the real file target (including normalized location suffixes) and can shorten //! absolute paths relative to a known working directory. //! //! ## Table rendering pipeline //! //! When the parser emits `Tag::Table` .. `TagEnd::Table`, the writer //! accumulates header and body rows into a `TableState`, then hands it to //! `render_table_lines` which runs this pipeline: //! //! 1. **Filter spillover rows** -- heuristic extraction of rows that are //! artifacts of pulldown-cmark's lenient parsing. //! 2. **Normalize column counts** -- pad or truncate so every row matches the //! alignment count. //! 3. **Compute column widths** -- allocate widths with content-aware //! priority and iterative shrinking. //! 4. **Choose presentation** -- render theme-accented row-separated columns //! while values remain scannable, otherwise transpose body rows //! into key/value records separated by muted rules. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! //! ## Width allocation //! //! Columns are classified as Narrative (long prose), TokenHeavy (paths, URLs, //! or hashes), or Compact (short values such as counts and status labels). //! Token-heavy columns give up excess width before narrative columns so an //! oversized path does not collapse readable prose; compact values are //! preserved last. When compact values split, token-heavy values collapse into //! unusably short chunks, expansive cells form tall narrow strips across enough //! body rows, or even 3-char-wide columns cannot fit, body rows render as //! key/value records. use crate::markdown_text_merge::DecodedTextMerge; use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::style::table_separator_style; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::annotate_web_urls_in_line; use crate::terminal_hyperlinks::remap_wrapped_line; use crate::terminal_hyperlinks::visible_lines; use crate::terminal_hyperlinks::web_destination; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; use codex_utils_string::normalize_markdown_hash_location_suffix; use dirs::home_dir; use pulldown_cmark::Alignment; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; use pulldown_cmark::HeadingLevel; use pulldown_cmark::Options; use pulldown_cmark::Parser; use pulldown_cmark::Tag; use pulldown_cmark::TagEnd; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; use regex_lite::Regex; use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use url::Url; mod table_key_value; const TABLE_COLUMN_GAP: usize = 2; const TABLE_CELL_PADDING: usize = 1; const TABLE_HEADER_SEPARATOR_CHAR: char = '━'; const TABLE_BODY_SEPARATOR_CHAR: char = '─'; struct MarkdownStyles { h1: Style, h2: Style, h3: Style, h4: Style, h5: Style, h6: Style, code: Style, emphasis: Style, strong: Style, strikethrough: Style, ordered_list_marker: Style, unordered_list_marker: Style, link: Style, blockquote: Style, } impl Default for MarkdownStyles { fn default() -> Self { Self { h1: Style::new().bold().underlined(), h2: Style::new().bold(), h3: Style::new().bold().italic(), h4: Style::new().italic(), h5: Style::new().italic(), h6: Style::new().italic(), code: Style::new().cyan(), emphasis: Style::new().italic(), strong: Style::new().bold(), strikethrough: Style::new().crossed_out(), ordered_list_marker: Style::new().light_blue(), unordered_list_marker: Style::new(), link: Style::new().cyan().underlined(), blockquote: Style::new().green(), } } } #[derive(Clone, Debug)] struct IndentContext { prefix: Vec>, marker: Option>>, is_list: bool, } impl IndentContext { fn new(prefix: Vec>, marker: Option>>, is_list: bool) -> Self { Self { prefix, marker, is_list, } } } /// Styled content of a single cell in the table being parsed. /// /// A cell can contain multiple lines (hard breaks inside the cell) and rich inline spans (bold, /// code, links). The `plain_text()` projection is used for column-width measurement; the styled /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { lines: Vec, } // TableCell mutators inlined — called per-span during table event parsing. impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { self.lines.push(HyperlinkLine::new(Line::default())); } } #[inline] fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { line.line.push_span(span); } } fn push_annotated(&mut self, mut appended: HyperlinkLine) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { let shift = line.width(); line.line.spans.append(&mut appended.line.spans); line.hyperlinks .extend(appended.hyperlinks.into_iter().map(|mut link| { link.columns = link.columns.start + shift..link.columns.end + shift; link })); } } #[inline] fn hard_break(&mut self) { self.lines.push(HyperlinkLine::new(Line::default())); } fn plain_text(&self) -> String { use std::fmt::Write; let mut buf = String::new(); for (i, line) in self.lines.iter().enumerate() { if i > 0 { buf.push(' '); } for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } buf } } /// Accumulates pulldown-cmark table events into a structured representation. /// /// `TableState` is created on `Tag::Table` and consumed on `TagEnd::Table`. Between those events, /// the Writer delegates cell content (text, code, html, breaks) into the `current_cell`, which is /// flushed into `current_row` on `TagEnd::TableCell`, then into `header`/`rows` on row/head end /// events. #[derive(Debug)] struct TableBodyRow { cells: Vec, has_table_pipe_syntax: bool, } #[derive(Debug)] struct TableState { alignments: Vec, header: Option>, rows: Vec, current_row: Option>, current_row_has_table_pipe_syntax: bool, current_cell: Option, in_header: bool, } impl TableState { fn new(alignments: Vec) -> Self { Self { alignments, header: None, rows: Vec::new(), current_row: None, current_row_has_table_pipe_syntax: false, current_cell: None, in_header: false, } } } /// Rendered table output split by wrapping behavior. /// /// `table_lines` are prewrapped aligned rows or key/value records, except /// header-only tables may retain pipe fallback rows for normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { table_lines: Vec, table_lines_prewrapped: bool, spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. /// /// Token-heavy columns such as paths and URLs are allowed to wrap before prose becomes unreadable. /// Compact columns such as counts or status words resist wrapping so their values stay scannable. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TableColumnKind { /// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width). Narrative, /// Content dominated by long tokens, such as paths, URLs, and hashes. TokenHeavy, /// Short values, such as counts and status labels, that should resist wrapping. Compact, } /// Per-column statistics used to drive the width-allocation algorithm. /// /// Collected in a single pass over the header and body rows before any /// shrinking decisions are made. #[derive(Clone, Debug)] struct TableColumnMetrics { /// Widest cell content (display width) across header and all body rows. max_width: usize, /// Display width of the longest whitespace-delimited token in the header. header_token_width: usize, /// Display width of the longest whitespace-delimited token across body rows. body_token_width: usize, /// Classification derived from body token density and average cell content. kind: TableColumnKind, } /// Render markdown with default wrapping behavior. /// /// Use this when the caller does not have a concrete render width yet (for /// example, snapshot tests or contexts that intentionally defer wrapping). If /// a viewport width is known, prefer [`render_markdown_text_with_width`] so /// table fallback and line wrapping decisions match the visible terminal. pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, /*width*/ None) } /// Render markdown constrained to a known terminal width. /// /// The renderer preserves columnar table structure while values remain /// scannable and falls back to key/value records when body rows cannot fit /// readably. Passing `None` keeps intrinsic line widths and disables /// width-driven wrapping in the markdown writer. Local file links render /// relative to the current process working directory. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let cwd = std::env::current_dir().ok(); render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref()) } /// Render markdown with an explicit working directory for local file links. /// /// The `cwd` parameter controls how absolute local targets are shortened before display. Passing /// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even /// when rendering happens away from the process cwd. pub(crate) fn render_markdown_text_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, ) -> Text<'static> { Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( input, width, cwd, ))) } pub(crate) fn render_markdown_lines_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, ) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter()); let mut w = Writer::new(input, parser, width, cwd); w.run(); w.text } #[derive(Clone, Debug)] struct LinkState { destination: String, show_destination: bool, /// Pre-rendered display text for local file links. /// /// When this is present, the markdown label is intentionally suppressed so the rendered /// transcript always reflects the real target path. local_target_display: Option, } fn should_render_link_destination(dest_url: &str) -> bool { !is_local_path_like_link(dest_url) } static COLON_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new( || match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") { Ok(regex) => regex, Err(error) => panic!("invalid location suffix regex: {error}"), }, ); // Covered by load_location_suffix_regexes. static HASH_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") { Ok(regex) => regex, Err(error) => panic!("invalid hash location regex: {error}"), }); /// Stateful pulldown-cmark event consumer that builds styled `ratatui` output. /// /// Tracks inline style nesting, indent/blockquote context, list numbering, /// and an optional `TableState` for accumulating table events. The /// `wrap_width` field enables width-aware line wrapping and table column /// allocation; when `None`, lines keep their intrinsic width. struct Writer<'a, I> where I: Iterator, Range)>, { input: &'a str, iter: I, text: Vec, styles: MarkdownStyles, inline_styles: Vec