diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 5690db4ab07..acb1df6c5ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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, diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap index 1d89a4bd6a0..f1c04cec859 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -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 diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 7f846d39fb2..703a37653c4 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -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"), diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 6d0daf89b2c..0cabee08052 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -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 |")); } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 75125a1ac1a..87861989c0c 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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_table_separator( + &column_widths, + TABLE_HEADER_SEPARATOR_CHAR, + separator_style, )); - out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style)); - for row in &rows { + 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 { 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::>() + .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> { let wrapped_cells: Vec>> = 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 { diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index d02fef76ddf..68e19616992 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -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 = 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 = 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 = 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('─')) + ); } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index ad8cc68e7d1..c3f2474b5bd 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -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 |"), 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 1e32d7c61d5..30c768ab6b4 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 @@ -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 and sub. HTML block:
inline block
diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap index 400b4020237..95ad94f6474 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap @@ -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 diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index a653f1b210f..0cdf956087c 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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::>(); 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 diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 10c269174f9..0284df91427 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -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 + ); + } }