mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5248e3da2b
## Why The TUI currently treats Markdown tables as ordinary wrapped text, which makes table-heavy responses hard to read and brittle across narrow panes and terminal resizes. This change teaches the TUI to render Markdown tables responsively while preserving the raw Markdown source needed to re-render streamed and finalized transcript content after width changes. The goal is to keep tables legible during streaming, after resize, and once a turn has finished, without corrupting scrollback ordering. ## What Changed - add table detection and responsive table rendering in the Markdown renderer - render standard tables with Unicode box-drawing borders when the pane is wide enough - add a vertical readability fallback for constrained or dense tables so narrow panes still show each row clearly - keep links and `<br>` content inside table cells instead of leaking text outside the table - avoid table normalization inside fenced or indented code blocks - preserve raw streamed Markdown source and keep the active table as a mutable tail until finalization - consolidate finalized streamed content into source-backed transcript cells so post-resize re-rendering stays correct - add snapshot and targeted streaming/resize regression coverage for the new table behavior ## How to Test 1. Start Codex TUI from this branch. 2. Paste this exact prompt: `This is a session to test codex, no need to do any thinking, just end different markdown tables, with columns exploring different markdown contents, like links, bold italic, code, etc. Make them different sizes, some 30+ rows, some not and intertwine them with some paragraphs with complex formatting as well.` 3. Confirm the response includes several Markdown tables mixed with richly formatted paragraphs. 4. Confirm wide-enough tables render with box-drawing borders instead of plain wrapped pipe text. 5. Resize the terminal narrower while the answer is still streaming and confirm the in-progress table stays coherent instead of duplicating headers or leaving broken scrollback behind. 6. Resize again after the turn finishes and confirm the finalized transcript re-renders cleanly at the new width. 7. In a narrow pane, verify dense tables fall back to the vertical per-row layout instead of producing unreadable wrapped columns. 8. Also verify pipe-heavy fenced code blocks still render as code, not as tables. Targeted tests: - `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` ## Docs No developer docs update appears necessary.
243 lines
8.5 KiB
Rust
243 lines
8.5 KiB
Rust
//! Pipe-table holdback scanner for source-backed agent streams.
|
|
//!
|
|
//! Agent streams with markdown tables keep the active table as mutable tail so
|
|
//! adding a row can reflow earlier table rows instead of committing a stale
|
|
//! render to scrollback.
|
|
//!
|
|
//! The scanner is intentionally conservative: it only looks for enough
|
|
//! structure to decide where the mutable tail should start. It does not try to
|
|
//! validate an entire table or predict final layout. Rendering remains the job
|
|
//! of the markdown renderer.
|
|
|
|
use std::time::Instant;
|
|
|
|
use crate::table_detect::FenceKind;
|
|
use crate::table_detect::FenceTracker;
|
|
use crate::table_detect::is_table_delimiter_line;
|
|
use crate::table_detect::is_table_header_line;
|
|
use crate::table_detect::parse_table_segments;
|
|
use crate::table_detect::strip_blockquote_prefix;
|
|
|
|
/// Result of scanning accumulated raw source for pipe-table patterns.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(super) enum TableHoldbackState {
|
|
/// No table detected -- all rendered lines can flow into the stable queue.
|
|
None,
|
|
/// The last non-blank line looks like a table header row but no delimiter
|
|
/// row has followed yet. Hold back in case the next delta is a delimiter.
|
|
PendingHeader { header_start: usize },
|
|
/// A header + delimiter pair was found -- the source contains a confirmed
|
|
/// table. Content from the table header onward stays mutable.
|
|
Confirmed { table_start: usize },
|
|
}
|
|
|
|
/// Facts remembered about the previous committed source line.
|
|
///
|
|
/// The scanner only needs one-line lookbehind because a table is confirmed by
|
|
/// a header row immediately followed by a delimiter row.
|
|
#[derive(Clone, Copy)]
|
|
struct PreviousLineState {
|
|
source_start: usize,
|
|
fence_kind: FenceKind,
|
|
is_header: bool,
|
|
}
|
|
|
|
/// Incremental scanner for table holdback state on append-only source streams.
|
|
///
|
|
/// `push_source_chunk` must receive source in the same order it will be
|
|
/// appended to the stream. The scanner stores byte offsets into that logical
|
|
/// source buffer, so feeding chunks out of order would make later tail
|
|
/// boundaries point at the wrong rendered region.
|
|
pub(super) struct TableHoldbackScanner {
|
|
source_offset: usize,
|
|
fence_tracker: FenceTracker,
|
|
previous_line: Option<PreviousLineState>,
|
|
pending_header_start: Option<usize>,
|
|
confirmed_table_start: Option<usize>,
|
|
}
|
|
|
|
impl TableHoldbackScanner {
|
|
pub(super) fn new() -> Self {
|
|
Self {
|
|
source_offset: 0,
|
|
fence_tracker: FenceTracker::new(),
|
|
previous_line: None,
|
|
pending_header_start: None,
|
|
confirmed_table_start: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn reset(&mut self) {
|
|
*self = Self::new();
|
|
}
|
|
|
|
/// Return the current holdback decision for the committed source prefix.
|
|
///
|
|
/// `PendingHeader` means the newest non-blank line looks like a table
|
|
/// header but the delimiter row has not arrived yet, so callers should
|
|
/// optimistically keep that region mutable. `Confirmed` means the header
|
|
/// and delimiter pair has been seen and all subsequent body rows remain in
|
|
/// the live tail until finalization.
|
|
pub(super) fn state(&self) -> TableHoldbackState {
|
|
if let Some(table_start) = self.confirmed_table_start {
|
|
TableHoldbackState::Confirmed { table_start }
|
|
} else if let Some(header_start) = self.pending_header_start {
|
|
TableHoldbackState::PendingHeader { header_start }
|
|
} else {
|
|
TableHoldbackState::None
|
|
}
|
|
}
|
|
|
|
/// Advance the scanner with newly committed source.
|
|
///
|
|
/// Chunks are expected to contain only source that is now safe to commit
|
|
/// into `raw_source`, typically newline-terminated lines from the
|
|
/// streaming collector. Partial rows are intentionally excluded so the
|
|
/// scanner never treats an unfinished table row as a stable structural
|
|
/// signal.
|
|
pub(super) fn push_source_chunk(&mut self, source_chunk: &str) {
|
|
if source_chunk.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let scan_start = Instant::now();
|
|
let mut lines = 0usize;
|
|
for source_line in source_chunk.split_inclusive('\n') {
|
|
lines += 1;
|
|
self.push_line(source_line);
|
|
}
|
|
tracing::trace!(
|
|
bytes = source_chunk.len(),
|
|
lines,
|
|
state = ?self.state(),
|
|
elapsed_us = scan_start.elapsed().as_micros(),
|
|
"table holdback incremental scan",
|
|
);
|
|
}
|
|
|
|
/// Fold one committed source line into the scanner state machine.
|
|
fn push_line(&mut self, source_line: &str) {
|
|
let line = source_line.strip_suffix('\n').unwrap_or(source_line);
|
|
let source_start = self.source_offset;
|
|
let fence_kind = self.fence_tracker.kind();
|
|
|
|
let candidate_text = if fence_kind == FenceKind::Other {
|
|
None
|
|
} else {
|
|
table_candidate_text(line)
|
|
};
|
|
let is_header = candidate_text.is_some_and(is_table_header_line);
|
|
let is_delimiter = candidate_text.is_some_and(is_table_delimiter_line);
|
|
|
|
if self.confirmed_table_start.is_none()
|
|
&& let Some(previous_line) = self.previous_line
|
|
&& previous_line.fence_kind != FenceKind::Other
|
|
&& fence_kind != FenceKind::Other
|
|
&& previous_line.is_header
|
|
&& is_delimiter
|
|
{
|
|
self.confirmed_table_start = Some(previous_line.source_start);
|
|
self.pending_header_start = None;
|
|
}
|
|
|
|
if self.confirmed_table_start.is_none() && !line.trim().is_empty() {
|
|
if fence_kind != FenceKind::Other && is_header {
|
|
self.pending_header_start = Some(source_start);
|
|
} else {
|
|
self.pending_header_start = None;
|
|
}
|
|
}
|
|
|
|
self.previous_line = Some(PreviousLineState {
|
|
source_start,
|
|
fence_kind,
|
|
is_header,
|
|
});
|
|
|
|
self.fence_tracker.advance(line);
|
|
self.source_offset = self.source_offset.saturating_add(source_line.len());
|
|
}
|
|
}
|
|
|
|
/// Strip blockquote prefixes and return the trimmed text if it contains
|
|
/// pipe-table segments, or `None` otherwise.
|
|
///
|
|
/// Table holdback treats quoted tables as real tables, but it still requires a
|
|
/// pipe-table shape after the quote markers are removed.
|
|
fn table_candidate_text(line: &str) -> Option<&str> {
|
|
let stripped = strip_blockquote_prefix(line).trim();
|
|
parse_table_segments(stripped).map(|_| stripped)
|
|
}
|
|
|
|
/// A source line annotated with whether it falls inside a fenced code block.
|
|
#[cfg(test)]
|
|
struct ParsedLine<'a> {
|
|
text: &'a str,
|
|
fence_context: FenceKind,
|
|
source_start: usize,
|
|
}
|
|
|
|
/// Parse source into lines tagged with fenced-code context for table scanning.
|
|
#[cfg(test)]
|
|
fn parse_lines_with_fence_state(source: &str) -> Vec<ParsedLine<'_>> {
|
|
let mut tracker = FenceTracker::new();
|
|
let mut lines = Vec::new();
|
|
let mut source_start = 0usize;
|
|
|
|
for raw_line in source.split('\n') {
|
|
lines.push(ParsedLine {
|
|
text: raw_line,
|
|
fence_context: tracker.kind(),
|
|
source_start,
|
|
});
|
|
|
|
tracker.advance(raw_line);
|
|
source_start = source_start
|
|
.saturating_add(raw_line.len())
|
|
.saturating_add(1);
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
/// Scan `source` for pipe-table patterns outside of non-markdown fenced code
|
|
/// blocks.
|
|
#[cfg(test)]
|
|
pub(super) fn table_holdback_state(source: &str) -> TableHoldbackState {
|
|
let lines = parse_lines_with_fence_state(source);
|
|
for pair in lines.windows(2) {
|
|
let [header_line, delimiter_line] = pair else {
|
|
continue;
|
|
};
|
|
if header_line.fence_context == FenceKind::Other
|
|
|| delimiter_line.fence_context == FenceKind::Other
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let Some(header_text) = table_candidate_text(header_line.text) else {
|
|
continue;
|
|
};
|
|
let Some(delimiter_text) = table_candidate_text(delimiter_line.text) else {
|
|
continue;
|
|
};
|
|
|
|
if is_table_header_line(header_text) && is_table_delimiter_line(delimiter_text) {
|
|
return TableHoldbackState::Confirmed {
|
|
table_start: header_line.source_start,
|
|
};
|
|
}
|
|
}
|
|
|
|
let pending_header = lines.iter().rev().find(|line| !line.text.trim().is_empty());
|
|
if let Some(line) = pending_header
|
|
&& line.fence_context != FenceKind::Other
|
|
&& table_candidate_text(line.text).is_some_and(is_table_header_line)
|
|
{
|
|
return TableHoldbackState::PendingHeader {
|
|
header_start: line.source_start,
|
|
};
|
|
}
|
|
TableHoldbackState::None
|
|
}
|