Skip to content

feat(tui): render responsive Markdown tables in TUI - #20252

Closed
fcoury-oai wants to merge 1 commit into
mainfrom
fcoury/md-table
Closed

feat(tui): render responsive Markdown tables in TUI#20252
fcoury-oai wants to merge 1 commit into
mainfrom
fcoury/md-table

Conversation

@fcoury-oai

@fcoury-oai fcoury-oai commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds responsive Markdown table rendering in the TUI and preserves the raw Markdown source needed to re-render streamed and finalized transcript content after terminal resizes.

The change keeps the final in-progress Markdown block as a live, width-sensitive tail while queuing only stable blocks for scrollback. This lets tables redraw during streaming, after terminal resize, and after turn finalization without losing scrollback ordering.

It also tightens table behavior discovered during validation:

  • renders tables with thin Unicode box borders
  • uses a less eager vertical fallback for constrained/dense tables, rendering each source row as a boxed key/value record with separators
  • keeps small and wide-enough tables boxed even when cells wrap
  • falls back for dense multi-column tables in narrow panes and emoji-heavy near-fit tables that are risky in xterm.js surfaces
  • keeps links and <br> content inside table cells instead of leaking URL text after the table
  • avoids table boundary normalization inside fenced or indented code blocks
  • prevents live stream tails from appearing ahead of queued stable blocks or duplicating headers

Testing

  • macOS and Windows with the prompt "Testing table markdown rendering. Send 5 tables with different range of markdown contents in cells: emojis, code, italic, and other markdowns, make each table between 10 and 30 rows."
  • resizing during streaming
  • resizing after turn ends
  • cargo test -p codex-tui table_readability_fallback --no-fail-fast
  • cargo test -p codex-tui markdown_render --no-fail-fast
  • cargo test -p codex-tui streaming::controller --no-fail-fast
  • cargo test -p codex-tui table_resize_lifecycle --no-fail-fast
  • just fix -p codex-tui
  • just argument-comment-lint
  • git diff --check
  • cargo insta pending-snapshots --manifest-path codex-rs/tui/Cargo.toml

Full cargo test -p codex-tui --no-fail-fast was also run, but the codex-tui --lib target still aborts on the pre-existing stack overflow in app::tests::attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads; the non-lib test targets and doctests completed successfully.

@fcoury-oai fcoury-oai changed the title [codex] Render responsive Markdown tables in TUI feat(tui): render responsive Markdown tables in TUI Apr 29, 2026
@fcoury-oai
fcoury-oai marked this pull request as ready for review May 1, 2026 20:27
@fcoury-oai

Copy link
Copy Markdown
Contributor Author

@codex review

fcoury-oai added a commit that referenced this pull request May 1, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 916e73853d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/tui/src/app/resize_reflow.rs Outdated
fcoury-oai added a commit that referenced this pull request May 1, 2026
@fcoury-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c32994513b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/tui/src/tui.rs
fcoury-oai added a commit that referenced this pull request May 1, 2026
@fcoury-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6cc4173121

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/tui/src/markdown_render.rs Outdated
fcoury-oai added a commit that referenced this pull request May 1, 2026
@fcoury-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@fcoury-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Eridanus117

Copy link
Copy Markdown

Thanks for pushing this forward. This would address a pretty painful readability gap tracked in #8259, especially for agent outputs that naturally use comparison tables or status matrices.

Is there anything still blocking this PR besides rebasing/resolving conflicts and final review? If external validation would help, I can test the branch in a real Codex CLI workflow with table-heavy responses and report any resize/streaming/rendering issues.

@Eridanus117

Eridanus117 commented May 6, 2026

Copy link
Copy Markdown

I took another pass over this branch and found one small concrete GFM behavior gap: column alignment markers are parsed by pulldown-cmark, but this branch currently drops the Tag::Table(...) alignments and renders every boxed cell left-aligned.

The existing complex markdown fixture already includes :---, :---:, and ---: markers, so this falls out pretty naturally from the current test surface. The patch below threads the parsed alignments into the table renderer, pads each rendered cell line accordingly, adds focused regression coverage, and updates the existing complex snapshot.

Validation run locally on this branch:

cargo test -p codex-tui table_alignment_markers_align --no-fail-fast
cargo test -p codex-tui markdown_render_complex_snapshot --no-fail-fast

Both passed.

Primary patch for the alignment issue:

diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs
index 87c70d2..2a70159 100644
--- a/codex-rs/tui/src/markdown_render.rs
+++ b/codex-rs/tui/src/markdown_render.rs
@@ -11,6 +11,7 @@ use crate::wrapping::RtOptions;
 use crate::wrapping::adaptive_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;
@@ -289,7 +290,7 @@ 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(_) => self.start_table(),
+            Tag::Table(alignments) => self.start_table(alignments),
             Tag::TableHead | Tag::TableRow => self.start_table_row(),
             Tag::TableCell => self.start_table_cell(),
             Tag::HtmlBlock
@@ -389,13 +390,13 @@ where
         self.needs_newline = true;
     }
 
-    fn start_table(&mut self) {
+    fn start_table(&mut self, alignments: Vec<Alignment>) {
         if self.needs_newline {
             self.push_blank_line();
             self.needs_newline = false;
         }
         self.flush_current_line();
-        self.table = Some(TableState::default());
+        self.table = Some(TableState::new(alignments));
     }
 
     fn end_table(&mut self) {
@@ -410,7 +411,7 @@ where
         let table_width = self
             .wrap_width
             .map(|width| width.saturating_sub(prefix_width));
-        for line in render_table_lines(&table.rows, table_width) {
+        for line in render_table_lines(&table.rows, &table.alignments, table_width) {
             self.push_line(line);
             self.flush_current_line();
         }
diff --git a/codex-rs/tui/src/markdown_render/table.rs b/codex-rs/tui/src/markdown_render/table.rs
index 8b67e02..cfc13dd 100644
--- a/codex-rs/tui/src/markdown_render/table.rs
+++ b/codex-rs/tui/src/markdown_render/table.rs
@@ -5,6 +5,7 @@ use std::ops::Range;
 use crate::render::line_utils::line_to_static;
 use crate::wrapping::RtOptions;
 use crate::wrapping::word_wrap_line;
+use pulldown_cmark::Alignment;
 use ratatui::text::Line;
 use ratatui::text::Span;
 use unicode_width::UnicodeWidthStr;
@@ -25,6 +26,7 @@ struct TableMetrics {
 
 pub(super) fn render_table_lines(
     rows: &[Vec<TableCell>],
+    alignments: &[Alignment],
     width: Option<usize>,
 ) -> Vec<Line<'static>> {
     if rows.is_empty() {
@@ -49,6 +51,7 @@ pub(super) fn render_table_lines(
     match choose_table_layout(&normalized_rows, &widths, available_width, column_count) {
         Some(candidate) => render_box_table(
             &normalized_rows,
+            alignments,
             &candidate.column_widths,
             candidate.padding,
             candidate.hard_wrap,
@@ -465,6 +468,7 @@ fn grow_columns_to_targets(widths: &mut [usize], remaining: &mut usize, targets:
 
 fn render_box_table(
     rows: &[Vec<TableCell>],
+    alignments: &[Alignment],
     column_widths: &[usize],
     padding: usize,
     hard_wrap: bool,
@@ -479,7 +483,13 @@ fn render_box_table(
     )));
 
     for (index, row) in rows.iter().enumerate() {
-        out.extend(render_table_row(row, column_widths, padding, hard_wrap));
+        out.extend(render_table_row(
+            row,
+            alignments,
+            column_widths,
+            padding,
+            hard_wrap,
+        ));
         if index == 0 {
             out.push(Line::from(border_line(
                 "├",
@@ -503,6 +513,7 @@ fn render_box_table(
 
 fn render_table_row(
     row: &[TableCell],
+    alignments: &[Alignment],
     column_widths: &[usize],
     padding: usize,
     hard_wrap: bool,
@@ -517,12 +528,21 @@ fn render_table_row(
 
     for line_index in 0..row_height {
         let mut spans = vec![Span::from("│")];
-        for (cell_lines, width) in wrapped_cells.iter().zip(column_widths) {
+        for (column_index, (cell_lines, width)) in
+            wrapped_cells.iter().zip(column_widths).enumerate()
+        {
             let content = cell_lines.get(line_index);
             push_padding(&mut spans, padding);
             if let Some(content) = content {
-                spans.extend(content.spans.iter().cloned());
-                push_padding(&mut spans, width.saturating_sub(content.width()));
+                push_aligned_content(
+                    &mut spans,
+                    content,
+                    *width,
+                    alignments
+                        .get(column_index)
+                        .copied()
+                        .unwrap_or(Alignment::None),
+                );
             } else {
                 push_padding(&mut spans, *width);
             }
@@ -535,6 +555,25 @@ fn render_table_row(
     out
 }
 
+fn push_aligned_content(
+    spans: &mut Vec<Span<'static>>,
+    content: &Line<'static>,
+    width: usize,
+    alignment: Alignment,
+) {
+    let content_width = content.width();
+    let padding = width.saturating_sub(content_width);
+    let (left_padding, right_padding) = match alignment {
+        Alignment::Center => (padding / 2, padding - padding / 2),
+        Alignment::Right => (padding, 0),
+        Alignment::None | Alignment::Left => (0, padding),
+    };
+
+    push_padding(spans, left_padding);
+    spans.extend(content.spans.iter().cloned());
+    push_padding(spans, right_padding);
+}
+
 fn push_padding(spans: &mut Vec<Span<'static>>, width: usize) {
     if width > 0 {
         spans.push(Span::from(" ".repeat(width)));
diff --git a/codex-rs/tui/src/markdown_render/table_state.rs b/codex-rs/tui/src/markdown_render/table_state.rs
index 5cedcec..1781e23 100644
--- a/codex-rs/tui/src/markdown_render/table_state.rs
+++ b/codex-rs/tui/src/markdown_render/table_state.rs
@@ -6,12 +6,20 @@ use super::table_cell::TableCell;
 #[derive(Debug, Default)]
 pub(super) struct TableState {
     pub(super) rows: Vec<Vec<TableCell>>,
+    pub(super) alignments: Vec<pulldown_cmark::Alignment>,
     current_row: Vec<TableCell>,
     current_cell: TableCell,
     in_cell: bool,
 }
 
 impl TableState {
+    pub(super) fn new(alignments: Vec<pulldown_cmark::Alignment>) -> Self {
+        Self {
+            alignments,
+            ..Self::default()
+        }
+    }
+
     pub(super) fn start_row(&mut self) {
         self.current_row.clear();
     }
diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs
index f084640..e25276b 100644
--- a/codex-rs/tui/src/markdown_render_tests.rs
+++ b/codex-rs/tui/src/markdown_render_tests.rs
@@ -403,6 +403,44 @@ fn table_inline_links_and_html_breaks_stay_inside_table() {
     );
 }
 
+#[test]
+fn table_alignment_markers_align_cell_content() {
+    let markdown =
+        "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n| alpha | beta | gamma |\n";
+    let rendered =
+        render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(80), /*cwd*/ None);
+    let lines = plain_lines(&rendered);
+
+    assert!(
+        lines.iter().any(|line| line.contains("│ a     │   b    │     c │")),
+        "left, center, and right alignment should be reflected in short cells: {lines:?}"
+    );
+    assert!(
+        lines
+            .iter()
+            .any(|line| line.contains("│ alpha │  beta  │ gamma │")),
+        "alignment should preserve full-width content without truncation: {lines:?}"
+    );
+}
+
+#[test]
+fn table_alignment_markers_align_multiline_cell_content() {
+    let markdown =
+        "| Left | Centered | Righted |\n|:-----|:--------:|--------:|\n| x | a<br>bb | c<br>dd |\n";
+    let rendered =
+        render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(80), /*cwd*/ None);
+    let lines = plain_lines(&rendered);
+
+    assert!(
+        lines.iter().any(|line| line.contains("│ x    │    a     │       c │")),
+        "alignment should apply to the first rendered line of multiline cells: {lines:?}"
+    );
+    assert!(
+        lines.iter().any(|line| line.contains("│      │    bb    │      dd │")),
+        "alignment should apply to continuation lines of multiline cells: {lines:?}"
+    );
+}
+
 #[test]
 fn table_raw_pipes_inside_inline_code_stay_inside_cell() {
     let markdown = "| Scenario | ✅ Pass Case | ⚠️ Edge Case | ❌ Fail Case |\n|---|---|---|---|\n| Pipes | `a \\| b` | escaped pipe inside code | raw `a | b` can split |\n";
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 9c7d2d4..a838e09 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
@@ -32,7 +32,7 @@ Table below (alignment test):
 ┌──────┬────────┬───────┐
 │ Left │ Center │ Right │
 ├──────┼────────┼───────┤
-│ a    │ b      │ c     │
+│ a    │   b    │     c │
 └──────┴────────┴───────┘
 
 Inline HTML: <sup>sup</sup> and <sub>sub</sub>.

Separately, here is a small optional robustness guard that is independent from the alignment fix. After choosing and rendering the boxed layout, it re-checks the actual rendered Line::width() values and falls back to the vertical layout if the final output would still exceed the available table width. This is meant as a defensive last check for edge cases where width heuristics, prefixes, or terminal-width changes disagree with the initial layout decision.

diff --git a/codex-rs/tui/src/markdown_render/table.rs b/codex-rs/tui/src/markdown_render/table.rs
--- a/codex-rs/tui/src/markdown_render/table.rs
+++ b/codex-rs/tui/src/markdown_render/table.rs
@@ -49,13 +49,20 @@ pub(super) fn render_table_lines(
     let widths = desired_column_widths(&normalized_rows, column_count);
 
     match choose_table_layout(&normalized_rows, &widths, available_width, column_count) {
-        Some(candidate) => render_box_table(
-            &normalized_rows,
-            alignments,
-            &candidate.column_widths,
-            candidate.padding,
-            candidate.hard_wrap,
-        ),
+        Some(candidate) => {
+            let boxed = render_box_table(
+                &normalized_rows,
+                alignments,
+                &candidate.column_widths,
+                candidate.padding,
+                candidate.hard_wrap,
+            );
+            if table_lines_fit(&boxed, available_width) {
+                boxed
+            } else {
+                render_vertical_table(&normalized_rows, available_width)
+            }
+        }
         None => render_vertical_table(&normalized_rows, available_width),
     }
 }
@@ -724,6 +731,10 @@ fn table_total_width(column_widths: &[usize], padding: usize) -> usize {
         + padding * 2 * column_widths.len()
 }
 
+fn table_lines_fit(lines: &[Line<'static>], available_width: usize) -> bool {
+    lines.iter().all(|line| line.width() <= available_width)
+}
+
 fn is_index_column(rows: &[Vec<TableCell>], index: usize) -> bool {
     let header = rows
         .first()

@Don-Yin

Don-Yin commented May 7, 2026

Copy link
Copy Markdown

Tested this locally on macOS arm64 from 887947d7a837120566e3aa5dcbc31b2218898479 with the focused TUI table test filter:

RUSTUP_TOOLCHAIN=stable cargo test -p codex-tui table --lib -- --nocapture

Result: 51 passed; 0 failed; 2011 filtered out.

This also covers the cases I care about for the table-rendering issue: streaming/reflow behavior, scrollback persistence, narrow-width fallback, inline code containing pipes, nested blockquote/list tables, links, emoji width handling, and local file link display. From a user perspective, this fixes a real readability gap in Codex TUI and looks substantially more complete than the smaller table-rendering alternative. I would strongly support getting this merged once maintainers are satisfied with the review details.

fcoury-oai added a commit that referenced this pull request May 9, 2026
fcoury-oai added a commit that referenced this pull request May 9, 2026
fcoury-oai added a commit that referenced this pull request May 9, 2026
fcoury-oai added a commit that referenced this pull request May 9, 2026
fcoury-oai added a commit that referenced this pull request May 9, 2026
@xiaodream551-a11y

Copy link
Copy Markdown

Tested locally on fcoury/md-table.

I applied and verified the main alignment fix from @Eridanus117's comment. GFM alignment markers (:---, :---:, ---:) now render left/center/right aligned cells correctly, including multiline cells. I also added CJK/Chinese table coverage to verify wide characters stay inside boxed table output and respect width safety.

Validation:

  • cargo test -p codex-tui table --lib --no-fail-fast: 61 passed
  • cargo test -p codex-tui markdown_render_complex_snapshot --lib --no-fail-fast: passed
  • cargo fmt --check: passed, with existing nightly-only rustfmt warnings

I did not apply the optional post-render width guard, only the primary alignment fix plus CJK coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants