mirror of
https://github.com/openai/codex.git
synced 2026-05-28 06:55:01 +00:00
## 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.
125 lines
4.3 KiB
Rust
125 lines
4.3 KiB
Rust
//! Streaming primitives used by the TUI transcript pipeline.
|
|
//!
|
|
//! `StreamState` owns newline-gated markdown collection and a FIFO queue of committed render lines.
|
|
//! Higher-level modules build on top of this state:
|
|
//! - `controller` adapts queued lines into `HistoryCell` emission rules for message and plan streams.
|
|
//! - `chunking` computes adaptive drain plans from queue pressure.
|
|
//! - `commit_tick` binds policy decisions to concrete controller drains.
|
|
//!
|
|
//! The key invariant is queue ordering. All drains pop from the front, and enqueue records an
|
|
//! arrival timestamp so policy code can reason about oldest queued age without peeking into text.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use ratatui::text::Line;
|
|
|
|
use crate::markdown_stream::MarkdownStreamCollector;
|
|
pub(crate) mod chunking;
|
|
pub(crate) mod commit_tick;
|
|
pub(crate) mod controller;
|
|
mod table_holdback;
|
|
|
|
struct QueuedLine {
|
|
line: Line<'static>,
|
|
enqueued_at: Instant,
|
|
}
|
|
|
|
/// Holds in-flight markdown stream state and queued committed lines.
|
|
pub(crate) struct StreamState {
|
|
pub(crate) collector: MarkdownStreamCollector,
|
|
queued_lines: VecDeque<QueuedLine>,
|
|
pub(crate) has_seen_delta: bool,
|
|
}
|
|
|
|
impl StreamState {
|
|
/// Create stream state whose markdown collector renders local file links relative to `cwd`.
|
|
///
|
|
/// Controllers are expected to pass the session cwd here once and keep it stable for the
|
|
/// lifetime of the active stream.
|
|
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
|
|
Self {
|
|
collector: MarkdownStreamCollector::new(width, cwd),
|
|
queued_lines: VecDeque::new(),
|
|
has_seen_delta: false,
|
|
}
|
|
}
|
|
/// Resets collector and queue state for the next stream lifecycle.
|
|
pub(crate) fn clear(&mut self) {
|
|
self.collector.clear();
|
|
self.queued_lines.clear();
|
|
self.has_seen_delta = false;
|
|
}
|
|
/// Drains one queued line from the front of the queue.
|
|
pub(crate) fn step(&mut self) -> Vec<Line<'static>> {
|
|
self.queued_lines
|
|
.pop_front()
|
|
.map(|queued| queued.line)
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
/// Drains up to `max_lines` queued lines from the front of the queue.
|
|
///
|
|
/// Callers that pass very large values still get bounded behavior because this method clamps to
|
|
/// the currently available queue length.
|
|
pub(crate) fn drain_n(&mut self, max_lines: usize) -> Vec<Line<'static>> {
|
|
let end = max_lines.min(self.queued_lines.len());
|
|
self.queued_lines
|
|
.drain(..end)
|
|
.map(|queued| queued.line)
|
|
.collect()
|
|
}
|
|
/// Clears queued lines while keeping collector/turn lifecycle state intact.
|
|
pub(crate) fn clear_queue(&mut self) {
|
|
self.queued_lines.clear();
|
|
}
|
|
/// Returns whether no lines are queued for commit.
|
|
pub(crate) fn is_idle(&self) -> bool {
|
|
self.queued_lines.is_empty()
|
|
}
|
|
/// Returns the current queue depth.
|
|
pub(crate) fn queued_len(&self) -> usize {
|
|
self.queued_lines.len()
|
|
}
|
|
/// Returns the age of the oldest queued line.
|
|
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
|
self.queued_lines
|
|
.front()
|
|
.map(|queued| now.saturating_duration_since(queued.enqueued_at))
|
|
}
|
|
/// Appends committed lines to the queue with a shared enqueue timestamp.
|
|
pub(crate) fn enqueue(&mut self, lines: Vec<Line<'static>>) {
|
|
let now = Instant::now();
|
|
self.queued_lines
|
|
.extend(lines.into_iter().map(|line| QueuedLine {
|
|
line,
|
|
enqueued_at: now,
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
|
|
fn test_cwd() -> PathBuf {
|
|
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
|
|
// Windows-specific root semantics into the fixtures.
|
|
std::env::temp_dir()
|
|
}
|
|
|
|
#[test]
|
|
fn drain_n_clamps_to_available_lines() {
|
|
let mut state = StreamState::new(/*width*/ None, &test_cwd());
|
|
state.enqueue(vec![Line::from("one")]);
|
|
|
|
let drained = state.drain_n(/*max_lines*/ 8);
|
|
assert_eq!(drained, vec![Line::from("one")]);
|
|
assert!(state.is_idle());
|
|
}
|
|
}
|