feat(tui): render markdown tables in app style [1 of 2] (#24489)

## Stack

- **Current: #24489 [1 of 2]** - render markdown tables in app style.
- **Stacked follow-up: #24636 [2 of 2]** - render cramped markdown
tables as key/value records.

## Why

Markdown tables currently render as boxed terminal grids, which gives
ordinary assistant output a heavier visual treatment than surrounding
rich text. This row-separated layout is the best match for how the App
renders tables, while accented headers remain distinguishable even when
a terminal font renders bold subtly.

<table>
<tr><td>
<p align="center">Codex CLI - Before</p>
<img width="1722" height="742" alt="CleanShot 2026-05-25 at 18 46 17"
src="https://github.com/user-attachments/assets/f673d92a-ebd8-46e2-b414-3d985e41b6a4"
/>
</td></tr>
<tr><td>
<p align="center">Codex CLI - After</p>
<img width="1720" height="957" alt="image"
src="https://github.com/user-attachments/assets/36a3d331-bea1-439b-b5be-e97b0731bd6f"
/>
</td></tr>
<tr><td>
<p align="center">Codex App</p>
<img width="979" height="1293" alt="CleanShot 2026-05-25 at 18 45 04"
src="https://github.com/user-attachments/assets/7d97cae0-9256-4f6e-a4b3-8b8f22b0d901"
/>
</td></tr>
</table>

## What Changed

- Render markdown tables as padded, aligned rows without an enclosing
box.
- Style table headers with the active syntax-theme accent plus bold
text, while keeping separators low contrast and theme-aware.
- Use a segmented heavy header rule and thin body-row rules, preserving
wrapping, narrow-width fallback, streaming parity, and rich-history
rendering.
- Update focused assertions and snapshots for the final table layout.

## How to Test

1. Render a markdown table in the TUI with several rows and columns.
2. Confirm the header uses the active theme accent, rows use
one-character interior padding, and the table has no enclosing box.
3. Confirm the header is followed by segmented `━` rules and multiple
body rows are separated by muted segmented `─` rules.
4. Render the same table while streaming and in history/raw-mode
toggles; the final rich layout should remain stable.
5. Render a narrow table with long content and verify wrapping or pipe
fallback does not overflow.

## Validation

- `just test -p codex-tui table`
- `just test -p codex-tui streaming::controller::tests`
- `just argument-comment-lint-from-source -p codex-tui -- --all-targets`
- `just fix -p codex-tui`
- `just fmt`

`just test -p codex-tui` was also run after accepting the snapshots; it
fails only in the unrelated existing guardian app tests
`update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
and
`update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`.
This commit is contained in:
Felipe Coury
2026-05-27 16:18:24 -03:00
committed by GitHub
Unverified
parent 2d1ad374a7
commit 6b4b15a1ed
11 changed files with 277 additions and 139 deletions
@@ -393,8 +393,8 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() {
assert!(saw_source_backed_plan, "expected source-backed plan insert");
assert!(
rendered_plan.contains('│') || rendered_plan.contains('┌'),
"expected completed plan table to render as a boxed table, got: {rendered_plan:?}"
rendered_plan.contains(''),
"expected completed plan table to render with separators, got: {rendered_plan:?}"
);
assert!(
!saw_stream_plan,
@@ -1,5 +1,5 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
rich before:
@@ -10,11 +10,9 @@ rich before:
• - first item
- second item
┌──────┬───────┐
│ Col │ Value │
├──────┼───────┤
│ code │ x = 1 │
└──────┴───────┘
Col Value
━━━━━━ ━━━━━━━
code x = 1
copy me
• Called
@@ -48,11 +46,9 @@ rich after:
• - first item
- second item
┌──────┬───────┐
│ Col │ Value │
├──────┼───────┤
│ code │ x = 1 │
└──────┴───────┘
Col Value
━━━━━━ ━━━━━━━
code x = 1
copy me
• Called
+4 -8
View File
@@ -278,10 +278,8 @@ fn proposed_plan_cell_renders_markdown_table() {
let rendered = render_lines(&plan.display_lines(/*width*/ 80));
assert!(
rendered
.iter()
.any(|line| line.contains('│') || line.contains('┌')),
"expected boxed table in proposed plan output: {rendered:?}"
rendered.iter().any(|line| line.contains('━')),
"expected separated table in proposed plan output: {rendered:?}"
);
assert!(
!rendered
@@ -308,10 +306,8 @@ fn proposed_plan_cell_unwraps_markdown_fenced_table() {
let rendered = render_lines(&plan.display_lines(/*width*/ 80));
assert!(
rendered
.iter()
.any(|line| line.contains('│') || line.contains('┌')),
"expected boxed table for markdown-fenced proposed plan output: {rendered:?}"
rendered.iter().any(|line| line.contains('━')),
"expected separated table for markdown-fenced proposed plan output: {rendered:?}"
);
assert!(
!rendered.iter().any(|line| line.trim() == "```markdown"),
+7 -7
View File
@@ -397,8 +397,8 @@ mod tests {
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 │")));
assert!(rendered.iter().any(|line| line.contains('━')));
assert!(rendered.iter().any(|line| line.contains(" 1 2")));
}
#[test]
@@ -407,11 +407,11 @@ mod tests {
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('━')));
assert!(
rendered
.iter()
.any(|line| line.contains(" Col A Col B Col C"))
.any(|line| line.contains(" Col A Col B Col C"))
);
assert!(
!rendered
@@ -426,8 +426,8 @@ mod tests {
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("│ A")));
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"));
}
@@ -437,7 +437,7 @@ mod tests {
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('━')));
assert!(!rendered.iter().any(|line| line.trim() == "| Only |"));
}
+77 -45
View File
@@ -22,8 +22,9 @@
//! alignment count.
//! 3. **Compute column widths** -- allocate widths with content-aware
//! priority and iterative shrinking.
//! 4. **Render box grid** -- Unicode borders (`┌───┬───┐`) or fallback to pipe
//! format when the minimum cannot fit.
//! 4. **Render row-separated layout** -- theme-accented bold headers, a
//! heavier segmented header rule, and low-contrast segmented body
//! separators, or fallback to pipe format when the minimum cannot fit.
//! 5. **Append spillover** -- extracted spillover rows rendered as plain text
//! after the table.
//!
@@ -36,9 +37,11 @@
//! preserved last. When even 3-char-wide columns cannot fit, the table falls
//! back to pipe-delimited format.
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::render::line_utils::push_owned_lines;
use crate::style::table_separator_style;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::word_wrap_line;
@@ -66,6 +69,11 @@ use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use url::Url;
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,
@@ -207,7 +215,7 @@ impl TableState {
/// Rendered table output split by wrapping behavior.
///
/// `table_lines` are either prewrapped grid rows (box rendering) or pipe
/// `table_lines` are either prewrapped aligned rows or pipe
/// fallback rows that should still pass through normal wrapping.
/// `spillover_lines` are prose rows extracted from parser artifacts and should
/// be routed through normal wrapping.
@@ -260,7 +268,7 @@ pub fn render_markdown_text(input: &str) -> Text<'static> {
/// Render markdown constrained to a known terminal width.
///
/// The renderer preserves table structure when possible and falls back to
/// pipe-table output when a box table cannot fit the available width. Passing
/// pipe-table output when an aligned table cannot fit the available width. 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.
@@ -982,13 +990,12 @@ where
}
}
/// Convert a completed `TableState` into styled `Line`s with Unicode
/// box-drawing borders.
/// Convert a completed `TableState` into styled, row-separated `Line`s.
///
/// Pipeline: filter spillover rows -> normalize column counts -> compute
/// column widths -> render box grid (or fall back to pipe format if the
/// column widths -> render aligned rows (or fall back to pipe format if the
/// minimum column widths exceed available terminal width). Spillover rows
/// are appended as plain text after the table grid.
/// are appended as plain text after the table.
///
/// Falls back to `render_table_pipe_fallback` (raw `| A | B |` format)
/// when `compute_column_widths` returns `None` (terminal too narrow for
@@ -1048,25 +1055,38 @@ where
};
};
let border_style = Style::new().dim();
let mut out = Vec::with_capacity(3 + rows.len() * 2);
out.push(self.render_border_line('┌', '┬', '┐', &column_widths, border_style));
let header_style =
foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"])
.unwrap_or(self.styles.strong)
.bold();
let separator_style = table_separator_style();
let mut out = Vec::with_capacity(2 + rows.len() * 2);
out.extend(self.render_table_row(
&header,
&column_widths,
&table_state.alignments,
border_style,
header_style,
));
out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style));
for row in &rows {
out.push(Self::render_table_separator(
&column_widths,
TABLE_HEADER_SEPARATOR_CHAR,
separator_style,
));
for (row_idx, row) in rows.iter().enumerate() {
out.extend(self.render_table_row(
row,
&column_widths,
&table_state.alignments,
border_style,
Style::default(),
));
if row_idx + 1 < rows.len() {
out.push(Self::render_table_separator(
&column_widths,
TABLE_BODY_SEPARATOR_CHAR,
separator_style,
));
}
}
out.push(self.render_border_line('└', '┴', '┘', &column_widths, border_style));
RenderedTableLines {
table_lines: out,
table_lines_prewrapped: true,
@@ -1079,17 +1099,19 @@ where
row.resize(column_count, TableCell::default());
}
/// subtracts the space eaten by border characters
/// Subtract horizontal gutters and per-cell padding from the content budget.
fn available_table_width(&self, column_count: usize) -> Option<usize> {
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);
let reserved = prefix_width
+ (column_count.saturating_sub(1) * TABLE_COLUMN_GAP)
+ (column_count * TABLE_CELL_PADDING * 2);
wrap_width.saturating_sub(reserved)
})
}
/// Allocate column widths for box-drawing table rendering.
/// Allocate column widths for aligned, row-separated table rendering.
///
/// Each column starts at its natural (max cell content) width, then columns
/// are iteratively shrunk one character at a time until the total fits within
@@ -1277,25 +1299,19 @@ where
}
}
fn render_border_line(
&self,
left: char,
sep: char,
right: char,
fn render_table_separator(
column_widths: &[usize],
separator_char: char,
style: Style,
) -> Line<'static> {
let mut spans = Vec::with_capacity(column_widths.len() * 2 + 1);
spans.push(Span::styled(String::from(left), 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(String::from(right), style));
} else {
spans.push(Span::styled(String::from(sep), style));
}
}
Line::from(spans)
let segment_char = separator_char.to_string();
let gap = " ".repeat(TABLE_COLUMN_GAP);
let text = column_widths
.iter()
.map(|width| segment_char.repeat(*width + (TABLE_CELL_PADDING * 2)))
.collect::<Vec<_>>()
.join(&gap);
Line::from(Span::styled(text, style))
}
fn render_table_row(
@@ -1303,7 +1319,7 @@ where
row: &[TableCell],
column_widths: &[usize],
alignments: &[Alignment],
border_style: Style,
row_style: Style,
) -> Vec<Line<'static>> {
let wrapped_cells: Vec<Vec<Line<'static>>> = row
.iter()
@@ -1314,10 +1330,21 @@ where
let mut out = Vec::with_capacity(row_height);
for row_line in 0..row_height {
let Some(last_visible_column) = wrapped_cells.iter().rposition(|lines| {
lines
.get(row_line)
.is_some_and(|line| Self::line_display_width(line) > 0)
}) else {
out.push(Line::default().style(row_style));
continue;
};
let mut spans = Vec::new();
spans.push(Span::styled("", border_style));
for (column, width) in column_widths.iter().enumerate() {
spans.push(Span::raw(" "));
for (column, width) in column_widths
.iter()
.enumerate()
.take(last_visible_column + 1)
{
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
let line = wrapped_cells[column]
.get(row_line)
.cloned()
@@ -1333,13 +1360,18 @@ where
spans.push(Span::raw(" ".repeat(left_padding)));
}
spans.extend(line.spans);
if right_padding > 0 {
let is_last_column = column == last_visible_column;
if right_padding > 0 && !is_last_column {
spans.push(Span::raw(" ".repeat(right_padding)));
}
spans.push(Span::raw(" "));
spans.push(Span::styled("", border_style));
if !is_last_column {
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
}
if !is_last_column {
spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP)));
}
}
out.push(Line::from(spans));
out.push(Line::from(spans).style(row_style));
}
out
}
@@ -1635,8 +1667,8 @@ where
/// Push a line that has already been laid out at the correct width, skipping
/// word wrapping.
///
/// Table lines are pre-formatted with exact column widths and box-drawing
/// borders. Passing them through `word_wrap_line` would break the grid at
/// Table lines are pre-formatted with exact column widths and separators.
/// Passing them through `word_wrap_line` would break the layout at
/// arbitrary positions. This method prepends the indent/blockquote prefix
/// and pushes directly to `self.text.lines`.
fn is_blockquote_active(&self) -> bool {
+57 -13
View File
@@ -1,4 +1,5 @@
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -1471,7 +1472,7 @@ fn code_block_preserves_trailing_blank_lines() {
}
#[test]
fn table_renders_unicode_box() {
fn table_renders_app_style_rows_with_themed_bold_header() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
@@ -1483,13 +1484,33 @@ fn table_renders_unicode_box() {
assert_eq!(
lines,
vec![
"┌─────┬─────┐".to_string(),
"│ A │ B │".to_string(),
"├─────┼─────┤".to_string(),
"│ 1 │ 2 │".to_string(),
"└─────┴─────┘".to_string(),
" A B".to_string(),
"━━━━━ ━━━━━".to_string(),
" 1 2".to_string(),
]
);
assert!(
text.lines[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert!(
text.lines[0].style.fg.is_some(),
"expected the syntax theme to provide a table header accent"
);
assert!(
text.lines[1].spans[0]
.style
.add_modifier
.contains(Modifier::DIM)
);
assert!(
!text.lines[2]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
@@ -1502,13 +1523,13 @@ fn table_alignment_respects_markers() {
.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");
assert_eq!(lines[0], " Left Center Right");
assert_eq!(lines[2], " 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";
fn table_separates_logical_rows_after_wrapped_content() {
let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n| -q | Quiet output |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30));
let lines: Vec<String> = text
.lines
@@ -1516,7 +1537,6 @@ fn table_wraps_cell_content_when_width_is_narrow() {
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines[0].starts_with('┌') && lines[0].ends_with('┐'));
assert!(
lines
.iter()
@@ -1524,6 +1544,26 @@ fn table_wraps_cell_content_when_width_is_narrow() {
&& lines.iter().any(|line| line.contains("logging output")),
"expected wrapped row content: {lines:?}"
);
let separator_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(idx, line)| {
((line.contains('━') || line.contains('─'))
&& line.chars().all(|ch| matches!(ch, '━' | '─' | ' ')))
.then_some(idx)
})
.collect();
let wrapped_row_end = lines
.iter()
.position(|line| line.contains("logging output"))
.expect("expected final wrapped line");
assert_eq!(separator_indices.len(), 2);
assert!(separator_indices[1] > wrapped_row_end);
assert!(
!lines
.last()
.is_some_and(|line| line.contains('━') || line.contains('─'))
);
}
#[test]
@@ -1553,7 +1593,7 @@ fn table_inside_blockquote_has_quote_prefix() {
.collect();
assert!(lines.iter().all(|line| line.starts_with("> ")));
assert!(lines.iter().any(|line| line.contains("┌─────┬─────┐")));
assert!(lines.iter().any(|line| line.contains("━━━━━ ━━━━━")));
}
#[test]
@@ -1580,5 +1620,9 @@ fn table_falls_back_to_pipe_rendering_if_it_cannot_fit() {
.collect();
assert!(lines.first().is_some_and(|line| line.starts_with('|')));
assert!(!lines.iter().any(|line| line.contains('┌')));
assert!(
!lines
.iter()
.any(|line| line.contains('━') || line.contains('─'))
);
}
+2 -2
View File
@@ -878,8 +878,8 @@ mod tests {
let rendered_strs = lines_to_plain_strings(&rendered);
assert!(
rendered_strs.iter().any(|line| line.contains('')),
"expected markdown-fenced table to render as boxed table: {rendered_strs:?}"
rendered_strs.iter().any(|line| line.contains('')),
"expected markdown-fenced table to render with a separator: {rendered_strs:?}"
);
assert!(
!rendered_strs.iter().any(|line| line.trim() == "| A | B |"),
@@ -30,11 +30,9 @@ Image: alt text
Table below (alignment test):
┌──────┬────────┬───────┐
│ Left │ Center │ Right │
├──────┼────────┼───────┤
│ a │ b │ c │
└──────┴────────┴───────┘
Left Center Right
━━━━━━ ━━━━━━━━ ━━━━━━━
a b c
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
HTML block:
<div style="border:1px solid #ccc;padding:2px">inline block</div>
@@ -2,25 +2,24 @@
source: tui/src/markdown_render_tests.rs
expression: "plain_lines(&text).join(\"\\n\")"
---
┌────────────────────────────────────────┬──────────────────┬──────┬─────────┬─────────────────────────────────────────┐
│ Unit │ Files │ Adds │ Removes │ What It Adds │
├────────────────────────────────────────┼──────────────────┼──────┼─────────┼─────────────────────────────────────────┤
│ Suggestion engine and unit coverage codex-rs/core/ 704 │ 0 │ Sampling workflow, stable-history │
│ │ src/ │ │ │ checks, tool-flow suppression, fast │
│ │ next_prompt_sugg │ │ │ reasoning profile, filtering rules, │
│ │ estion.rs:1, │ │ │ cancellation and timeout. │
│ │ codex-rs/core/ │ │ │ │
│ │ src/ │ │ │ │
│ │ next_prompt_sugg │ │ │ │
│ │ estion_tests.rs: │ │ │ │
│ │ 1 │ │ │ │
Model instruction fragment and codex-rs/core/ 54 0 Synthetic suggestion prompt and an
contextual isolation src/context/ │ │ isolation test for ordinary user text.
next_prompt_sugg │ │ │ │
estion.rs:1, │ │ │ │
codex-rs/core/ │ │ │ │
src/context/ │ │ │ │
contextual_user_ │ │ │ │
message_tests.rs │ │ │ │
:1 │ │ │ │
└────────────────────────────────────────┴──────────────────┴──────┴─────────┴─────────────────────────────────────────┘
Unit Files Adds Removes What It Adds
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━ ━━━━━━ ━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Suggestion engine and unit coverage codex-rs/core/ 704 0 Sampling workflow, stable-history
src/ checks, tool-flow suppression, fast
next_prompt_sugg reasoning profile, filtering rules,
estion.rs:1, cancellation and timeout.
codex-rs/core/
src/
next_prompt_sugg
estion_tests.rs:
1
─────────────────────────────────────── ────────────────── ────── ───────── ────────────────────────────────────────
Model instruction fragment and codex-rs/core/ 54 0 Synthetic suggestion prompt and an
contextual isolation src/context/ isolation test for ordinary user text.
next_prompt_sugg
estion.rs:1,
codex-rs/core/
src/context/
contextual_user_
message_tests.rs
:1
+26 -24
View File
@@ -1362,7 +1362,7 @@ mod tests {
}
#[test]
fn controller_renders_unicode_for_multi_table_response_shape() {
fn controller_renders_separators_for_multi_table_response_shape() {
let source = "Absolutely. Here are several different Markdown table patterns you can use for rendering tests.\n\n| Name | Role |
Location |\n|-------|-----------|----------|\n| Ava | Engineer | NYC |\n| Malik | Designer | Berlin |\n| Priya | PM | Remote
|\n\n| Item | Qty | Price | In Stock |\n|:------------|----:|------:|:--------:|\n| Keyboard | 2 | 49.99 | Yes |\n| Mouse | 10
@@ -1378,13 +1378,13 @@ mod tests {
let deltas = chunked.iter().map(String::as_str).collect::<Vec<_>>();
let streamed = collect_streamed_lines(&deltas, Some(120));
assert!(
streamed.iter().any(|line| line.contains('')),
"expected unicode table border in streamed output: {streamed:?}"
streamed.iter().any(|line| line.contains('')),
"expected table separator in streamed output: {streamed:?}"
);
}
#[test]
fn controller_renders_unicode_for_no_outer_pipes_table_shape() {
fn controller_renders_separators_for_no_outer_pipes_table_shape() {
let source = "### 1) Basic\n\n| Name | Role | Active |\n|---|---|---|\n| Alice | Engineer | Yes |\n| Bob | Designer | No |\n\n### 2) No outer
pipes\n\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n\n### 3) Another table\n\n| Key | Value |\n|---|---|\n| a | b |\n";
@@ -1407,6 +1407,10 @@ mod tests {
!has_raw_no_outer_header,
"no-outer-pipes header should not remain raw in final streamed output: {streamed:?}"
);
assert!(
streamed.iter().any(|line| line.contains('━')),
"expected table separator in final streamed output: {streamed:?}"
);
}
#[test]
@@ -1429,8 +1433,8 @@ mod tests {
assert_eq!(streamed, expected);
assert!(
streamed.iter().any(|line| line.contains('')),
"expected unicode table border for no-outer-pipes streaming: {streamed:?}"
streamed.iter().any(|line| line.contains('')),
"expected table separator for no-outer-pipes streaming: {streamed:?}"
);
assert!(
!streamed
@@ -1458,8 +1462,8 @@ mod tests {
assert_eq!(streamed, expected);
assert!(
streamed.iter().any(|line| line.contains('')),
"expected unicode table border for two-column no-outer table: {streamed:?}"
streamed.iter().any(|line| line.contains('')),
"expected table separator for two-column no-outer table: {streamed:?}"
);
assert!(
!streamed.iter().any(|line| line.trim() == "A | B"),
@@ -1490,8 +1494,8 @@ mod tests {
assert!(
streamed
.iter()
.any(|line| line.contains("┌───────┬───────┬───────┐")),
"expected converted no-outer table border in streamed output: {streamed:?}"
.any(|line| line.contains(" Col A Col B Col C")),
"expected converted no-outer table header in streamed output: {streamed:?}"
);
}
@@ -1513,8 +1517,8 @@ mod tests {
assert_eq!(streamed, expected);
assert!(
streamed.iter().any(|line| line.contains('')),
"expected unicode table border in streamed output: {streamed:?}"
streamed.iter().any(|line| line.contains('')),
"expected table separator in streamed output: {streamed:?}"
);
assert!(
!streamed.iter().any(|line| line.trim() == "| A | B |"),
@@ -1542,8 +1546,8 @@ mod tests {
assert_eq!(streamed, expected);
assert!(
streamed.iter().any(|line| line.contains('')),
"expected unicode table border in streamed output: {streamed:?}"
streamed.iter().any(|line| line.contains('')),
"expected table separator in streamed output: {streamed:?}"
);
assert!(
!streamed
@@ -1621,8 +1625,10 @@ mod tests {
"expected code-fenced pipe line to remain raw: {streamed:?}"
);
assert!(
!streamed.iter().any(|line| line.contains('┌')),
"did not expect unicode table border for non-markdown fence: {streamed:?}"
!streamed
.iter()
.any(|line| line.contains('━') || line.contains('─')),
"did not expect a table separator for non-markdown fence: {streamed:?}"
);
}
@@ -1643,10 +1649,8 @@ mod tests {
assert_eq!(streamed, baseline);
assert!(
streamed
.iter()
.any(|line| line.contains('│') || line.contains('└') || line.contains('┌')),
"expected unicode table box drawing chars in plan streamed output: {streamed:?}"
streamed.iter().any(|line| line.contains('━')),
"expected table separators in plan streamed output: {streamed:?}"
);
assert!(
!streamed
@@ -1675,10 +1679,8 @@ mod tests {
assert_eq!(streamed, baseline);
assert!(
streamed
.iter()
.any(|line| line.contains('│') || line.contains('└') || line.contains('┌')),
"expected unicode table box drawing chars in fenced plan output: {streamed:?}"
streamed.iter().any(|line| line.contains('━')),
"expected table separators in fenced plan output: {streamed:?}"
);
assert!(
!streamed
+71
View File
@@ -1,12 +1,18 @@
use crate::color::blend;
use crate::color::is_light;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use crate::terminal_palette::rgb_color;
use crate::terminal_palette::stdout_color_level;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
const LIGHT_BG_ACCENT_RGB: (u8, u8, u8) = (0, 95, 135);
// Decorative table rules should remain visible without competing with cell content.
const TABLE_SEPARATOR_FG_ALPHA: f32 = 0.20;
pub fn user_message_style() -> Style {
user_message_style_for(default_bg())
@@ -16,6 +22,11 @@ pub fn proposed_plan_style() -> Style {
proposed_plan_style_for(default_bg())
}
/// Returns a low-contrast rule style for separators within markdown tables.
pub(crate) fn table_separator_style() -> Style {
table_separator_style_for(default_fg(), default_bg(), stdout_color_level())
}
/// Returns the shared accent style for active or selected TUI controls.
pub(crate) fn accent_style() -> Style {
accent_style_for(default_bg())
@@ -45,6 +56,22 @@ pub(crate) fn accent_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
}
}
fn table_separator_style_for(
terminal_fg: Option<(u8, u8, u8)>,
terminal_bg: Option<(u8, u8, u8)>,
color_level: StdoutColorLevel,
) -> Style {
let (Some(fg), Some(bg)) = (terminal_fg, terminal_bg) else {
return Style::default().dim();
};
let separator_rgb = blend(fg, bg, TABLE_SEPARATOR_FG_ALPHA);
match color_level {
StdoutColorLevel::TrueColor => Style::default().fg(rgb_color(separator_rgb)),
StdoutColorLevel::Ansi256 => Style::default().fg(best_color(separator_rgb)),
StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Style::default().dim(),
}
}
#[allow(clippy::disallowed_methods)]
pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
let (top, alpha) = if is_light(terminal_bg) {
@@ -81,4 +108,48 @@ mod tests {
assert_eq!(accent_style_for(Some((0, 0, 0))), expected);
assert_eq!(accent_style_for(/*terminal_bg*/ None), expected);
}
#[test]
fn table_separator_blends_toward_dark_background() {
let style = table_separator_style_for(
Some((255, 255, 255)),
Some((0, 0, 0)),
StdoutColorLevel::TrueColor,
);
assert_eq!(style.fg, Some(rgb_color((51, 51, 51))));
}
#[test]
fn table_separator_blends_toward_light_background() {
let style = table_separator_style_for(
Some((0, 0, 0)),
Some((255, 255, 255)),
StdoutColorLevel::TrueColor,
);
assert_eq!(style.fg, Some(rgb_color((204, 204, 204))));
}
#[test]
fn table_separator_dims_when_palette_aware_color_is_unavailable() {
let expected = Style::default().dim();
assert_eq!(
table_separator_style_for(
Some((255, 255, 255)),
Some((0, 0, 0)),
StdoutColorLevel::Ansi16,
),
expected
);
assert_eq!(
table_separator_style_for(
/*terminal_fg*/ None,
Some((0, 0, 0)),
StdoutColorLevel::TrueColor,
),
expected
);
}
}