diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c550..5a9a2e06ff 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -211,6 +211,7 @@ mod voice { mod wrapping; +mod table_detect; #[cfg(test)] pub mod test_backend; #[cfg(test)] diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index ae680f5db3..0d337b7353 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -7,10 +7,13 @@ use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; +use crate::render::line_utils::push_owned_lines; 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; @@ -20,6 +23,7 @@ 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; @@ -27,6 +31,7 @@ use regex_lite::Regex; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; +use unicode_width::UnicodeWidthStr; use url::Url; struct MarkdownStyles { @@ -86,6 +91,113 @@ impl IndentContext { } } +/// 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>, +} + +impl TableCell { + fn ensure_line(&mut self) { + if self.lines.is_empty() { + self.lines.push(Line::default()); + } + } + + fn push_span(&mut self, span: Span<'static>) { + self.ensure_line(); + if let Some(line) = self.lines.last_mut() { + line.push_span(span); + } + } + + fn hard_break(&mut self) { + self.lines.push(Line::default()); + } + + fn plain_text(&self) -> String { + self.lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.clone()) + .collect::() + }) + .collect::>() + .join(" ") + } +} + +/// 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 TableState { + alignments: Vec, + header: Option>, + rows: Vec>, + current_row: Option>, + current_cell: Option, + in_header: bool, +} + +impl TableState { + fn new(alignments: Vec) -> Self { + Self { + alignments, + header: None, + rows: Vec::new(), + current_row: None, + current_cell: None, + in_header: false, + } + } +} + +/// Classification of a table column for width-allocation priority. +/// +/// Narrative columns (long prose, many words per cell) are shrunk first when the table exceeds +/// available width. Structured columns (short tokens like dates, status words, numbers) are +/// preserved as long as possible to keep their content on a single line. +/// +/// The heuristic is simple: >= 4 average words per cell OR >= 28 average character width → +/// Narrative. Everything else → Structured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TableColumnKind { + /// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width). + Narrative, + /// Short, token-like content that should resist wrapping. + Structured, +} + +/// 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, + /// Average number of whitespace-delimited words per non-empty body cell. + avg_words_per_cell: f64, + /// Average display width of non-empty body cells. + avg_cell_width: f64, + /// Classification derived from `avg_words_per_cell` and `avg_cell_width`. + kind: TableColumnKind, +} + pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, /*width*/ None) } @@ -108,6 +220,7 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( ) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); let parser = Parser::new_ext(input, options); let mut w = Writer::new(parser, width, cwd); w.run(); @@ -170,6 +283,7 @@ where current_subsequent_indent: Vec>, current_line_style: Style, current_line_in_code_block: bool, + table_state: Option, } impl<'a, I> Writer<'a, I> @@ -200,6 +314,7 @@ where current_subsequent_indent: Vec::new(), current_line_style: Style::default(), current_line_in_code_block: false, + table_state: None, } } @@ -273,12 +388,12 @@ where Tag::Strong => self.push_inline_style(self.styles.strong), Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough), Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()), + Tag::Table(alignments) => self.start_table(alignments), + Tag::TableHead => self.start_table_head(), + Tag::TableRow => self.start_table_row(), + Tag::TableCell => self.start_table_cell(), Tag::HtmlBlock | Tag::FootnoteDefinition(_) - | Tag::Table(_) - | Tag::TableHead - | Tag::TableRow - | Tag::TableCell | Tag::Image { .. } | Tag::MetadataBlock(_) => {} } @@ -297,18 +412,21 @@ where } TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(), TagEnd::Link => self.pop_link(), + TagEnd::Table => self.end_table(), + TagEnd::TableHead => self.end_table_head(), + TagEnd::TableRow => self.end_table_row(), + TagEnd::TableCell => self.end_table_cell(), TagEnd::HtmlBlock | TagEnd::FootnoteDefinition - | TagEnd::Table - | TagEnd::TableHead - | TagEnd::TableRow - | TagEnd::TableCell | TagEnd::Image | TagEnd::MetadataBlock(_) => {} } } fn start_paragraph(&mut self) { + if self.in_table_cell() { + return; + } if self.needs_newline { self.push_blank_line(); } @@ -318,12 +436,18 @@ where } fn end_paragraph(&mut self) { + if self.in_table_cell() { + return; + } self.needs_newline = true; self.in_paragraph = false; self.pending_marker_line = false; } fn start_heading(&mut self, level: HeadingLevel) { + if self.in_table_cell() { + return; + } if self.needs_newline { self.push_line(Line::default()); self.needs_newline = false; @@ -343,11 +467,17 @@ where } fn end_heading(&mut self) { + if self.in_table_cell() { + return; + } self.needs_newline = true; self.pop_inline_style(); } fn start_blockquote(&mut self) { + if self.in_table_cell() { + return; + } if self.needs_newline { self.push_blank_line(); self.needs_newline = false; @@ -360,6 +490,9 @@ where } fn end_blockquote(&mut self) { + if self.in_table_cell() { + return; + } self.indent_stack.pop(); self.needs_newline = true; } @@ -369,6 +502,11 @@ where return; } self.line_ends_with_local_link_target = false; + if self.in_table_cell() { + self.push_text_to_table_cell(&text); + return; + } + if self.pending_marker_line { self.push_line(Line::default()); } @@ -422,6 +560,11 @@ where return; } self.line_ends_with_local_link_target = false; + if self.in_table_cell() { + self.push_span_to_table_cell(Span::from(code.into_string()).style(self.styles.code)); + return; + } + if self.pending_marker_line { self.push_line(Line::default()); self.pending_marker_line = false; @@ -435,6 +578,19 @@ where return; } self.line_ends_with_local_link_target = false; + if self.in_table_cell() { + let style = self.inline_styles.last().copied().unwrap_or_default(); + for (i, line) in html.lines().enumerate() { + if i > 0 { + self.push_table_cell_hard_break(); + } + self.push_span_to_table_cell(Span::styled(line.to_string(), style)); + } + if !inline { + self.push_table_cell_hard_break(); + } + return; + } self.pending_marker_line = false; for (i, line) in html.lines().enumerate() { if self.needs_newline { @@ -455,6 +611,10 @@ where return; } self.line_ends_with_local_link_target = false; + if self.in_table_cell() { + self.push_table_cell_hard_break(); + return; + } self.push_line(Line::default()); } @@ -462,6 +622,11 @@ where if self.suppressing_local_link_label() { return; } + if self.in_table_cell() { + let style = self.inline_styles.last().copied().unwrap_or_default(); + self.push_span_to_table_cell(Span::styled(" ".to_string(), style)); + return; + } if self.line_ends_with_local_link_target { self.pending_local_link_soft_break = true; self.line_ends_with_local_link_target = false; @@ -570,6 +735,593 @@ where self.indent_stack.pop(); } + fn start_table(&mut self, alignments: Vec) { + self.flush_current_line(); + if self.needs_newline { + self.push_blank_line(); + self.needs_newline = false; + } + self.table_state = Some(TableState::new(alignments)); + } + + fn end_table(&mut self) { + let Some(table_state) = self.table_state.take() else { + return; + }; + + let table_lines = self.render_table_lines(table_state); + let mut pending_marker_line = self.pending_marker_line; + for line in table_lines { + self.push_prewrapped_line(line, pending_marker_line); + pending_marker_line = false; + } + self.pending_marker_line = false; + self.needs_newline = true; + } + + fn start_table_head(&mut self) { + if let Some(table_state) = self.table_state.as_mut() { + table_state.in_header = true; + table_state.current_row = Some(Vec::new()); + } + } + + fn end_table_head(&mut self) { + let Some(table_state) = self.table_state.as_mut() else { + return; + }; + if let Some(current_cell) = table_state.current_cell.take() { + table_state + .current_row + .get_or_insert_with(Vec::new) + .push(current_cell); + } + if let Some(row) = table_state.current_row.take() { + table_state.header = Some(row); + } + table_state.in_header = false; + } + + fn start_table_row(&mut self) { + if let Some(table_state) = self.table_state.as_mut() { + table_state.current_row = Some(Vec::new()); + } + } + + fn end_table_row(&mut self) { + let Some(table_state) = self.table_state.as_mut() else { + return; + }; + + if let Some(current_cell) = table_state.current_cell.take() { + table_state + .current_row + .get_or_insert_with(Vec::new) + .push(current_cell); + } + + let Some(row) = table_state.current_row.take() else { + return; + }; + + if table_state.in_header { + table_state.header = Some(row); + } else { + table_state.rows.push(row); + } + } + + fn start_table_cell(&mut self) { + if let Some(table_state) = self.table_state.as_mut() { + table_state.current_cell = Some(TableCell::default()); + } + } + + fn end_table_cell(&mut self) { + let Some(table_state) = self.table_state.as_mut() else { + return; + }; + + if let Some(cell) = table_state.current_cell.take() { + table_state + .current_row + .get_or_insert_with(Vec::new) + .push(cell); + } + } + + fn in_table_cell(&self) -> bool { + self.table_state + .as_ref() + .and_then(|table_state| table_state.current_cell.as_ref()) + .is_some() + } + + fn push_span_to_table_cell(&mut self, span: Span<'static>) { + if let Some(table_state) = self.table_state.as_mut() + && let Some(cell) = table_state.current_cell.as_mut() + { + cell.push_span(span); + } + } + + fn push_table_cell_hard_break(&mut self) { + if let Some(table_state) = self.table_state.as_mut() + && let Some(cell) = table_state.current_cell.as_mut() + { + cell.hard_break(); + } + } + + fn push_text_to_table_cell(&mut self, text: &str) { + let style = self.inline_styles.last().copied().unwrap_or_default(); + for (i, line) in text.lines().enumerate() { + if i > 0 { + self.push_table_cell_hard_break(); + } + self.push_span_to_table_cell(Span::styled(line.to_string(), style)); + } + } + + /// Convert a completed `TableState` into styled `Line`s with Unicode box-drawing borders. + /// + /// The pipeline is: filter spillover rows -> normalize column counts -> compute column widths + /// -> render box grid (or fall back to pipe format if widths can't fit). Spillover rows are + /// appended after the table grid. + fn render_table_lines(&self, mut table_state: TableState) -> Vec> { + let column_count = table_state.alignments.len(); + if column_count == 0 { + return Vec::new(); + } + + let mut spillover_rows: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + for (row_idx, row) in table_state.rows.iter().enumerate() { + let next_row = table_state.rows.get(row_idx + 1); + // pulldown-cmark accepts body rows without pipes, which can turn a following paragraph + // into a one-cell table row. For multi-column tables, treat those as spillover text + // rendered after the table. + if column_count > 1 && Self::is_spillover_row(row, next_row) { + if let Some(cell) = row.first().cloned() { + spillover_rows.push(cell); + } + } else { + rows.push(row.clone()); + } + } + + let mut header = table_state + .header + .take() + .unwrap_or_else(|| vec![TableCell::default(); column_count]); + Self::normalize_row(&mut header, column_count); + for row in &mut rows { + Self::normalize_row(row, column_count); + } + + let available_width = self.available_table_width(column_count); + let widths = + self.compute_column_widths(&header, &rows, &table_state.alignments, available_width); + + let Some(column_widths) = widths else { + let mut fallback = + self.render_table_pipe_fallback(&header, &rows, &table_state.alignments); + for spillover in spillover_rows { + fallback.extend(spillover.lines); + } + return fallback; + }; + + let border_style = Style::new().dim(); + let mut out = Vec::new(); + out.push(self.render_border_line('┌', '┬', '┐', &column_widths, border_style)); + out.extend(self.render_table_row( + &header, + &column_widths, + &table_state.alignments, + border_style, + )); + out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style)); + for row in &rows { + out.extend(self.render_table_row( + row, + &column_widths, + &table_state.alignments, + border_style, + )); + } + out.push(self.render_border_line('└', '┴', '┘', &column_widths, border_style)); + for spillover in spillover_rows { + out.extend(spillover.lines); + } + out + } + + fn normalize_row(row: &mut Vec, column_count: usize) { + if row.len() > column_count { + row.truncate(column_count); + } + if row.len() < column_count { + row.resize(column_count, TableCell::default()); + } + } + + /// subtracts the space eaten by border characters + fn available_table_width(&self, column_count: usize) -> Option { + self.wrap_width.map(|wrap_width| { + let prefix_width = + Self::spans_display_width(&self.prefix_spans(self.pending_marker_line)); + let reserved = prefix_width + 1 + (column_count * 3); + wrap_width.saturating_sub(reserved) + }) + } + + fn compute_column_widths( + &self, + header: &[TableCell], + rows: &[Vec], + alignments: &[Alignment], + available_width: Option, + ) -> Option> { + let min_column_width = 3usize; + let metrics = Self::collect_table_column_metrics(header, rows, alignments.len()); + let mut widths: Vec = metrics + .iter() + .map(|col| col.max_width.max(min_column_width)) + .collect(); + + let Some(max_width) = available_width else { + return Some(widths); + }; + let minimum_total = alignments.len() * min_column_width; + if max_width < minimum_total { + return None; + } + + let mut floors: Vec = metrics + .iter() + .map(|col| Self::preferred_column_floor(col, min_column_width)) + .collect(); + let mut floor_total: usize = floors.iter().sum(); + if floor_total > max_width { + // Relax preferred floors (starting with narrative columns) until we can satisfy the + // width budget. We still keep hard minimums. + while floor_total > max_width { + let Some((idx, _)) = floors + .iter() + .enumerate() + .filter(|(_, floor)| **floor > min_column_width) + .min_by_key(|(idx, floor)| { + let kind_priority = match metrics[*idx].kind { + TableColumnKind::Narrative => 0, + TableColumnKind::Structured => 1, + }; + (kind_priority, *floor) + }) + else { + break; + }; + + floors[idx] -= 1; + floor_total -= 1; + } + } + + let mut total_width: usize = widths.iter().sum(); + + while total_width > max_width { + let Some(idx) = Self::next_column_to_shrink(&widths, &floors, &metrics) else { + break; + }; + widths[idx] -= 1; + total_width -= 1; + } + + if total_width > max_width { + return None; + } + + Some(widths) + } + + fn collect_table_column_metrics( + header: &[TableCell], + rows: &[Vec], + column_count: usize, + ) -> Vec { + let mut metrics = Vec::with_capacity(column_count); + for column in 0..column_count { + let header_cell = &header[column]; + let header_plain = header_cell.plain_text(); + let header_token_width = Self::longest_token_width(&header_plain); + let mut max_width = Self::cell_display_width(header_cell); + let mut body_token_width = 0usize; + let mut total_words = 0usize; + let mut total_cells = 0usize; + let mut total_cell_width = 0usize; + + for row in rows { + let cell = &row[column]; + max_width = max_width.max(Self::cell_display_width(cell)); + let plain = cell.plain_text(); + body_token_width = body_token_width.max(Self::longest_token_width(&plain)); + let word_count = plain.split_whitespace().count(); + if word_count > 0 { + total_words += word_count; + total_cells += 1; + total_cell_width += plain.width(); + } + } + + let avg_words_per_cell = if total_cells == 0 { + header_plain.split_whitespace().count() as f64 + } else { + total_words as f64 / total_cells as f64 + }; + let avg_cell_width = if total_cells == 0 { + header_plain.width() as f64 + } else { + total_cell_width as f64 / total_cells as f64 + }; + let kind = if avg_words_per_cell >= 4.0 || avg_cell_width >= 28.0 { + TableColumnKind::Narrative + } else { + TableColumnKind::Structured + }; + + metrics.push(TableColumnMetrics { + max_width, + header_token_width, + body_token_width, + avg_words_per_cell, + avg_cell_width, + kind, + }); + } + + metrics + } + + fn preferred_column_floor(metrics: &TableColumnMetrics, min_column_width: usize) -> usize { + let token_target = match metrics.kind { + TableColumnKind::Narrative => metrics.header_token_width.min(10), + TableColumnKind::Structured => metrics + .header_token_width + .max(metrics.body_token_width.min(16)), + }; + token_target.max(min_column_width).min(metrics.max_width) + } + + fn next_column_to_shrink( + widths: &[usize], + floors: &[usize], + metrics: &[TableColumnMetrics], + ) -> Option { + widths + .iter() + .enumerate() + .filter(|(idx, width)| **width > floors[*idx]) + .min_by_key(|(idx, width)| { + let slack = width.saturating_sub(floors[*idx]); + let kind_cost = match metrics[*idx].kind { + TableColumnKind::Narrative => 0i32, + TableColumnKind::Structured => 2i32, + }; + let header_guard = if **width <= metrics[*idx].header_token_width { + 3i32 + } else { + 0i32 + }; + let density_guard = if metrics[*idx].avg_words_per_cell >= 4.0 + || metrics[*idx].avg_cell_width >= 24.0 + { + 0i32 + } else { + 1i32 + }; + ( + kind_cost + header_guard + density_guard, + usize::MAX.saturating_sub(slack), + ) + }) + .map(|(idx, _)| idx) + } + + fn render_border_line( + &self, + left: char, + sep: char, + right: char, + column_widths: &[usize], + style: Style, + ) -> Line<'static> { + let mut spans = Vec::with_capacity(column_widths.len() * 2 + 1); + spans.push(Span::styled(left.to_string(), style)); + for (idx, width) in column_widths.iter().enumerate() { + spans.push(Span::styled("─".repeat(*width + 2), style)); + if idx + 1 == column_widths.len() { + spans.push(Span::styled(right.to_string(), style)); + } else { + spans.push(Span::styled(sep.to_string(), style)); + } + } + Line::from(spans) + } + + fn render_table_row( + &self, + row: &[TableCell], + column_widths: &[usize], + alignments: &[Alignment], + border_style: Style, + ) -> Vec> { + let wrapped_cells: Vec>> = row + .iter() + .zip(column_widths) + .map(|(cell, width)| self.wrap_cell(cell, *width)) + .collect(); + let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1).max(1); + + let mut out = Vec::with_capacity(row_height); + for row_line in 0..row_height { + let mut spans = Vec::new(); + spans.push(Span::styled("│".to_string(), border_style)); + for (column, width) in column_widths.iter().enumerate() { + spans.push(Span::raw(" ")); + let line = wrapped_cells[column] + .get(row_line) + .cloned() + .unwrap_or_default(); + let line_width = Self::line_display_width(&line); + let remaining = width.saturating_sub(line_width); + let (left_padding, right_padding) = match alignments[column] { + Alignment::Left | Alignment::None => (0, remaining), + Alignment::Center => (remaining / 2, remaining - (remaining / 2)), + Alignment::Right => (remaining, 0), + }; + if left_padding > 0 { + spans.push(Span::raw(" ".repeat(left_padding))); + } + spans.extend(line.spans); + if right_padding > 0 { + spans.push(Span::raw(" ".repeat(right_padding))); + } + spans.push(Span::raw(" ")); + spans.push(Span::styled("│".to_string(), border_style)); + } + out.push(Line::from(spans)); + } + out + } + + fn render_table_pipe_fallback( + &self, + header: &[TableCell], + rows: &[Vec], + alignments: &[Alignment], + ) -> Vec> { + let mut out = Vec::new(); + out.push(Line::from(Self::row_to_pipe_string(header))); + out.push(Line::from(Self::alignments_to_pipe_delimiter(alignments))); + out.extend( + rows.iter() + .map(|row| Line::from(Self::row_to_pipe_string(row))), + ); + out + } + + fn row_to_pipe_string(row: &[TableCell]) -> String { + let mut out = String::new(); + out.push('|'); + for cell in row { + out.push(' '); + out.push_str(&cell.plain_text()); + out.push(' '); + out.push('|'); + } + out + } + + fn alignments_to_pipe_delimiter(alignments: &[Alignment]) -> String { + let mut out = String::new(); + out.push('|'); + for alignment in alignments { + let segment = match alignment { + Alignment::Left => ":---", + Alignment::Center => ":---:", + Alignment::Right => "---:", + Alignment::None => "---", + }; + out.push_str(segment); + out.push('|'); + } + out + } + + fn wrap_cell(&self, cell: &TableCell, width: usize) -> Vec> { + if cell.lines.is_empty() { + return vec![Line::default()]; + } + let mut wrapped = Vec::new(); + for source_line in &cell.lines { + let rendered = word_wrap_line(source_line, RtOptions::new(width.max(1))); + if rendered.is_empty() { + wrapped.push(Line::default()); + } else { + push_owned_lines(&rendered, &mut wrapped); + }; + } + if wrapped.is_empty() { + wrapped.push(Line::default()); + } + wrapped + } + + /// Detect rows that are artifacts of pulldown-cmark's lenient table parsing rather than real + /// table data. These "spillover" rows -- typically trailing paragraphs absorbed into the table + /// because they lack leading pipes -- are extracted and rendered as plain text after the table + /// grid. + fn is_spillover_row(row: &[TableCell], next_row: Option<&Vec>) -> bool { + let Some(first_text) = Self::first_non_empty_only_text(row) else { + return false; + }; + + if row.len() == 1 { + return true; + } + + if Self::looks_like_html_content(&first_text) { + return true; + } + + // Keep common intro + html-block spillover together: + // "HTML block:" followed by "
". + first_text.trim_end().ends_with(':') + && (next_row + .and_then(|row| Self::first_non_empty_only_text(row)) + .is_some_and(|text| Self::looks_like_html_content(&text)) + || Self::looks_like_label_line(&first_text)) + } + + fn first_non_empty_only_text(row: &[TableCell]) -> Option { + let first = row.first()?.plain_text(); + if first.trim().is_empty() { + return None; + } + let rest_empty = row[1..] + .iter() + .all(|cell| cell.plain_text().trim().is_empty()); + rest_empty.then_some(first) + } + + fn looks_like_html_content(text: &str) -> bool { + text.contains('<') && text.contains('>') + } + + fn looks_like_label_line(text: &str) -> bool { + text.trim_end().ends_with(':') && text.split_whitespace().count() <= 3 + } + + fn spans_display_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(|span| span.content.width()).sum() + } + + fn line_display_width(line: &Line<'_>) -> usize { + line.spans.iter().map(|span| span.content.width()).sum() + } + + fn cell_display_width(cell: &TableCell) -> usize { + cell.lines + .iter() + .map(Self::line_display_width) + .max() + .unwrap_or(0) + } + + fn longest_token_width(text: &str) -> usize { + text.split_whitespace().map(str::width).max().unwrap_or(0) + } + fn push_inline_style(&mut self, style: Style) { let current = self.inline_styles.last().copied().unwrap_or_default(); let merged = current.patch(style); @@ -596,13 +1348,16 @@ where fn pop_link(&mut self) { if let Some(link) = self.link.take() { if link.show_destination { - self.push_span(" (".into()); - self.push_span(Span::styled(link.destination, self.styles.link)); - self.push_span(")".into()); - } else if let Some(local_target_display) = link.local_target_display { - if self.pending_marker_line { - self.push_line(Line::default()); + if self.in_table_cell() { + self.push_span_to_table_cell(" (".into()); + self.push_span_to_table_cell(Span::styled(link.destination, self.styles.link)); + self.push_span_to_table_cell(")".into()); + } else { + self.push_span(" (".into()); + self.push_span(Span::styled(link.destination, self.styles.link)); + self.push_span(")".into()); } + } else if let Some(local_target_display) = link.local_target_display { // Local file links are rendered as code-like path text so the transcript shows the // resolved target instead of arbitrary caller-provided label text. let style = self @@ -611,8 +1366,16 @@ where .copied() .unwrap_or_default() .patch(self.styles.code); - self.push_span(Span::styled(local_target_display, style)); - self.line_ends_with_local_link_target = true; + let span = Span::styled(local_target_display, style); + if self.in_table_cell() { + self.push_span_to_table_cell(span); + } else { + if self.pending_marker_line { + self.push_line(Line::default()); + } + self.push_span(span); + self.line_ends_with_local_link_target = true; + } } } } @@ -651,6 +1414,30 @@ where } } + // Like push_line, but skips word wrapping. + // + // It just prepends the indent prefix in case blockquote is active and pushed directly to the + // output. + // + // Table lines are already laid out with the exact column widths. We don't want word wrapping + // to break the box-drawing borders. + fn push_prewrapped_line(&mut self, line: Line<'static>, pending_marker_line: bool) { + self.flush_current_line(); + let blockquote_active = self + .indent_stack + .iter() + .any(|ctx| ctx.prefix.iter().any(|p| p.content.contains('>'))); + let style = if blockquote_active { + self.styles.blockquote.patch(line.style) + } else { + line.style + }; + + let mut spans = self.prefix_spans(pending_marker_line); + spans.extend(line.spans); + self.text.lines.push(Line::from(spans).style(style)); + } + fn push_line(&mut self, line: Line<'static>) { self.flush_current_line(); let blockquote_active = self diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 850d343853..ebfedff446 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -1368,3 +1368,222 @@ fn code_block_preserves_trailing_blank_lines() { "trailing blank line inside code fence was lost: {content:?}" ); } + +#[test] +fn table_renders_unicode_box() { + let md = "| A | B |\n|---|---|\n| 1 | 2 |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert_eq!( + lines, + vec![ + "┌─────┬─────┐".to_string(), + "│ A │ B │".to_string(), + "├─────┼─────┤".to_string(), + "│ 1 │ 2 │".to_string(), + "└─────┴─────┘".to_string(), + ] + ); +} + +#[test] +fn table_alignment_respects_markers() { + let md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert_eq!(lines[1], "│ Left │ Center │ Right │"); + assert_eq!(lines[3], "│ a │ b │ c │"); +} + +#[test] +fn table_wraps_cell_content_when_width_is_narrow() { + let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n"; + let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30)); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert!(lines[0].starts_with('┌') && lines[0].ends_with('┐')); + assert!( + lines + .iter() + .any(|line| line.contains("Enable very verbose")) + && lines.iter().any(|line| line.contains("logging output")), + "expected wrapped row content: {lines:?}" + ); +} + +#[test] +fn table_inside_blockquote_has_quote_prefix() { + let md = "> | A | B |\n> |---|---|\n> | 1 | 2 |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert!(lines.iter().all(|line| line.starts_with("> "))); + assert!(lines.iter().any(|line| line.contains("┌─────┬─────┐"))); +} + +#[test] +fn escaped_pipes_render_in_table_cells() { + let md = "| Col |\n| --- |\n| a \\| b |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert!(lines.iter().any(|line| line.contains("a | b"))); +} + +#[test] +fn table_falls_back_to_pipe_rendering_if_it_cannot_fit() { + let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | + 10 |\n"; + let text = crate::markdown_render::render_markdown_text_with_width(md, Some(20)); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + assert!(lines.first().is_some_and(|line| line.starts_with('|'))); + assert!(!lines.iter().any(|line| line.contains('┌'))); +} + +#[test] +fn table_keeps_sparse_rows_with_empty_trailing_cells() { + let md = "| A | B | C |\n|---|---|---|\n| a | | |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + + assert!( + lines + .iter() + .any(|line| line.contains("│ a") && line.ends_with('│')), + "expected sparse row to remain inside table grid: {lines:?}" + ); + assert!( + !lines.iter().any(|line| line == "a"), + "did not expect sparse row content to spill outside the table: {lines:?}" + ); +} + +#[test] +fn table_keeps_sparse_sentence_row_inside_grid() { + let md = "| A | B | C |\n|---|---|---|\n| This is done. | | |\n"; + let text = render_markdown_text(md); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + + assert!( + lines + .iter() + .any(|line| line.contains("│ This is done.") && line.ends_with('│')), + "expected sparse sentence row to remain inside table grid: {lines:?}" + ); + assert!( + !lines.iter().any(|line| line.trim() == "This is done."), + "did not expect sparse sentence row to spill outside table: {lines:?}" + ); +} + +#[test] +fn table_preserves_structured_leading_columns_when_last_column_is_long() { + let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary + traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95 + returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible + downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load. + |\n"; + let text = crate::markdown_render::render_markdown_text_with_width(md, Some(160)); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + + assert!( + lines.iter().any(|line| line.contains("Milestone")), + "expected first structured header to remain readable: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.contains("Planned Date")), + "expected date header to remain readable: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.contains("2026-01-10")), + "expected date values to avoid forced mid-token wraps: {lines:?}" + ); +} + +#[test] +fn table_preserves_status_column_with_long_notes() { + let md = "| Service | Status | Notes |\n|---|---|---|\n| Auth API | Stable | Handles login and token refresh with no major incidents in the last + 30 days. |\n| Billing Worker | Monitoring | Throughput is good, but we still see occasional retry storms when upstream settlement providers return + partial failures. |\n| Search Indexer | Tuning | Performance improved after shard balancing, yet memory usage remains elevated during full rebuild + windows. |\n"; + let text = crate::markdown_render::render_markdown_text_with_width(md, Some(150)); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + + assert!( + lines.iter().any(|line| line.contains("Status")), + "expected status header to remain readable: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.contains("Monitoring")), + "expected status values to avoid mid-word wraps: {lines:?}" + ); +} + +#[test] +fn table_keeps_long_body_rows_inside_grid_instead_of_spilling_raw_pipe_rows() { + let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary + traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95 + returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible + downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load. + |\n| Legacy decommission | 2026-02-07 | In progress | Most workloads have been drained, but final decommission is blocked by one compliance export + task that still depends on a deprecated storage path and requires legal sign-off before removal. |\n"; + let text = crate::markdown_render::render_markdown_text_with_width(md, Some(200)); + let lines: Vec = text + .lines + .iter() + .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) + .collect(); + + assert!( + lines.iter().any(|line| line.starts_with('┌')) + && lines.iter().any(|line| line.starts_with('└')), + "expected boxed table output: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.contains("│ Canary rollout")), + "expected first body row to stay inside table grid: {lines:?}" + ); + assert!( + !lines + .iter() + .any(|line| line.trim_start().starts_with("| Canary rollout |")), + "did not expect raw pipe-form body rows outside table: {lines:?}" + ); +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap index cc752dd66d..34cbd1c63e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap @@ -28,9 +28,12 @@ Image: alt text ——— Table below (alignment test): -| Left | Center | Right | -|:-----|:------:|------:| -| a | b | c | + +┌──────┬────────┬───────┐ +│ Left │ Center │ Right │ +├──────┼────────┼───────┤ +│ a │ b │ c │ +└──────┴────────┴───────┘ Inline HTML: sup and sub. HTML block:
inline block
diff --git a/codex-rs/tui/src/table_detect.rs b/codex-rs/tui/src/table_detect.rs new file mode 100644 index 0000000000..b4d8da8808 --- /dev/null +++ b/codex-rs/tui/src/table_detect.rs @@ -0,0 +1,139 @@ +//! Shared pipe-table detection helpers. +//! +//! Both the streaming controller (`streaming/controller.rs`) and the +//! markdown-fence unwrapper (`markdown.rs`) need to identify pipe-table +//! structure in raw markdown source. This module provides the canonical +//! implementations so fixes only need to happen in one place. + +/// Split a pipe-delimited line into trimmed segments. +/// +/// Returns `None` if the line is empty or has fewer than two segments. +/// Leading/trailing pipes are stripped before splitting. +pub(crate) fn parse_table_segments(line: &str) -> Option> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let mut content = trimmed; + if let Some(without_leading) = content.strip_prefix('|') { + content = without_leading; + } + if let Some(without_trailing) = content.strip_suffix('|') { + content = without_trailing; + } + + let segments: Vec<&str> = content.split('|').map(str::trim).collect(); + (segments.len() >= 2).then_some(segments) +} + +/// Whether `line` looks like a table header row (has pipe-separated +/// segments with at least one non-empty cell). +pub(crate) fn is_table_header_line(line: &str) -> bool { + parse_table_segments(line).is_some_and(|segments| segments.iter().any(|s| !s.is_empty())) +} + +/// Whether a single segment matches the `---`, `:---`, `---:`, or `:---:` +/// alignment-colon syntax used in markdown table delimiter rows. +pub(crate) fn is_table_delimiter_segment(segment: &str) -> bool { + let trimmed = segment.trim(); + if trimmed.is_empty() { + return false; + } + let without_leading = trimmed.strip_prefix(':').unwrap_or(trimmed); + let without_ends = without_leading.strip_suffix(':').unwrap_or(without_leading); + without_ends.len() >= 3 && without_ends.chars().all(|c| c == '-') +} + +/// Whether `line` is a valid table delimiter row (every segment passes +/// [`is_table_delimiter_segment`]). +pub(crate) fn is_table_delimiter_line(line: &str) -> bool { + parse_table_segments(line) + .is_some_and(|segments| segments.into_iter().all(is_table_delimiter_segment)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_table_segments_basic() { + assert_eq!( + parse_table_segments("| A | B | C |"), + Some(vec!["A", "B", "C"]) + ); + } + + #[test] + fn parse_table_segments_no_outer_pipes() { + assert_eq!(parse_table_segments("A | B | C"), Some(vec!["A", "B", "C"])); + } + + #[test] + fn parse_table_segments_no_leading_pipe() { + assert_eq!( + parse_table_segments("A | B | C |"), + Some(vec!["A", "B", "C"]) + ); + } + + #[test] + fn parse_table_segments_no_trailing_pipe() { + assert_eq!( + parse_table_segments("| A | B | C"), + Some(vec!["A", "B", "C"]) + ); + } + + #[test] + fn parse_table_segments_single_segment_returns_none() { + assert_eq!(parse_table_segments("| only |"), None); + } + + #[test] + fn parse_table_segments_empty_returns_none() { + assert_eq!(parse_table_segments(""), None); + assert_eq!(parse_table_segments(" "), None); + } + + #[test] + fn is_table_delimiter_segment_valid() { + assert!(is_table_delimiter_segment("---")); + assert!(is_table_delimiter_segment(":---")); + assert!(is_table_delimiter_segment("---:")); + assert!(is_table_delimiter_segment(":---:")); + assert!(is_table_delimiter_segment(":-------:")); + } + + #[test] + fn is_table_delimiter_segment_invalid() { + assert!(!is_table_delimiter_segment("")); + assert!(!is_table_delimiter_segment("--")); + assert!(!is_table_delimiter_segment("abc")); + assert!(!is_table_delimiter_segment(":--")); + } + + #[test] + fn is_table_delimiter_line_valid() { + assert!(is_table_delimiter_line("| --- | --- |")); + assert!(is_table_delimiter_line("|:---:|---:|")); + assert!(is_table_delimiter_line("--- | --- | ---")); + } + + #[test] + fn is_table_delimiter_line_invalid() { + assert!(!is_table_delimiter_line("| A | B |")); + assert!(!is_table_delimiter_line("| -- | -- |")); + } + + #[test] + fn is_table_header_line_valid() { + assert!(is_table_header_line("| A | B |")); + assert!(is_table_header_line("Name | Value")); + } + + #[test] + fn is_table_header_line_all_empty_segments() { + assert!(!is_table_header_line("| | |")); + } +}