mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
7a26497836
## Why Wrapped URLs in rich TUI output, especially URLs rendered inside Markdown tables, are split across terminal rows. In terminals that support OSC 8 hyperlinks, treating each visible fragment as part of the complete destination enables reliable open-link and copy-link actions even after table layout wraps the URL. This addresses the semantic-link portion of #12200 and the behavior described in https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It does not change ordinary drag-selection across bordered table rows. ## What Changed - Added shared TUI OSC 8 support that validates `http://` and `https://` destinations, sanitizes terminal payloads, and applies metadata separately from visible line width/layout. - Added semantic web-link annotations to assistant and proposed-plan Markdown, including explicit web links and bare web URLs in prose and table cells while excluding code and non-web Markdown destinations. - Preserved complete URL targets through table wrapping, narrow pipe fallback, streaming, transcript overlay rendering, history insertion, and resize replay. - Routed intentional Codex-owned links in notices, status/setup/app-link, feedback, onboarding, MCP/plugin help, memories, and update surfaces through the shared hyperlink handling. ## How to Test 1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and request an assistant response containing a Markdown table whose last column contains a long `https://` URL. 2. Make the terminal narrow enough for the URL to wrap across multiple bordered table rows. 3. Use the terminal's open-link or copy-link action on more than one wrapped URL fragment and confirm each fragment resolves to the complete original URL. 4. Resize the terminal after the table is rendered and repeat the link action to confirm the destination survives scrollback replay. 5. Open the transcript overlay while rich output is present and confirm web links remain interactive there. 6. As a regression check, render inline/fenced code containing URL text and a Markdown link such as `[https://example.com](mailto:support@example.com)`; confirm these do not acquire a web OSC 8 destination. Targeted automated coverage exercised Markdown links and exclusions, wrapped and pipe-fallback tables, streaming/transcript overlay propagation, status-link truncation, and rendered word-wrapping cell alignment. `just test -p codex-tui` was also run; it passed the hyperlink coverage and reproduced two unrelated existing guardian feature-flag test failures.
125 lines
4.4 KiB
Rust
125 lines
4.4 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 crate::markdown_stream::MarkdownStreamCollector;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
pub(crate) mod chunking;
|
|
pub(crate) mod commit_tick;
|
|
pub(crate) mod controller;
|
|
mod table_holdback;
|
|
|
|
struct QueuedLine {
|
|
line: HyperlinkLine,
|
|
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<HyperlinkLine> {
|
|
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<HyperlinkLine> {
|
|
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<HyperlinkLine>) {
|
|
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 ratatui::text::Line;
|
|
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![HyperlinkLine::new(Line::from("one"))]);
|
|
|
|
let drained = state.drain_n(/*max_lines*/ 8);
|
|
assert_eq!(drained, vec![HyperlinkLine::new(Line::from("one"))]);
|
|
assert!(state.is_idle());
|
|
}
|
|
}
|