mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
2d1ad374a7
commit
6b4b15a1ed
@@ -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,
|
||||
|
||||
+7
-11
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 |"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('─'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 |"),
|
||||
|
||||
+3
-5
@@ -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>
|
||||
|
||||
+21
-22
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user