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.
1923 lines
70 KiB
Rust
1923 lines
70 KiB
Rust
//! Two-region streaming controllers for agent messages and proposed plans.
|
|
//!
|
|
//! Each stream partitions rendered markdown into a *stable region* (committed
|
|
//! to scrollback via the animation queue in `StreamState`) and a *tail region*
|
|
//! (mutable, displayed in the active-cell slot as a transient stream-tail cell).
|
|
//!
|
|
//! `StreamCore` owns the shared bookkeeping: source accumulation, re-rendering,
|
|
//! stable/tail partitioning, commit-animation queue management, and terminal
|
|
//! resize handling. `StreamController` and `PlanStreamController` are thin
|
|
//! wrappers that add only their `emit()` styling and finalize return types.
|
|
//!
|
|
//! ## Table holdback
|
|
//!
|
|
//! Table rendering is inherently non-incremental: adding a new row can change
|
|
//! every column's width and reshape all prior rows. The holdback mechanism
|
|
//! (`table_holdback_state`) detects pipe-table patterns (header + delimiter
|
|
//! pair) in the accumulated source and keeps content from the table header
|
|
//! onward as mutable tail until the stream finalizes. Holdback is enabled for
|
|
//! agent and proposed-plan streams. Lines in `Outside` and `Markdown` fence
|
|
//! contexts are scanned; lines inside non-markdown fences are skipped.
|
|
//!
|
|
//! ## Resize handling
|
|
//!
|
|
//! On terminal width change, `StreamCore::set_width` re-renders at the new
|
|
//! width and rebuilds the queued stable region from the current emitted line
|
|
//! count. This intentionally avoids byte-level remap complexity while the
|
|
//! stream is active; finalized content is canonicalized by transcript
|
|
//! consolidation into source-backed markdown cells.
|
|
//!
|
|
//! ## Invariants
|
|
//!
|
|
//! - `emitted_stable_len <= enqueued_stable_len <= rendered_lines.len()`.
|
|
//! - `raw_source` is append-only until `reset()`; never modified mid-stream.
|
|
//! - Tail starts exactly at `enqueued_stable_len`.
|
|
//! - During confirmed table streaming, only lines from the table header onward
|
|
//! are forced into tail; pre-table lines may remain stable.
|
|
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::HistoryRenderMode;
|
|
use crate::history_cell::raw_lines_from_source;
|
|
use crate::history_cell::{self};
|
|
use crate::markdown::render_markdown_agent_with_links_and_cwd;
|
|
use crate::style::proposed_plan_style;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
|
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
|
|
use ratatui::prelude::Stylize;
|
|
use ratatui::text::Line;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use super::StreamState;
|
|
use super::table_holdback::TableHoldbackScanner;
|
|
use super::table_holdback::TableHoldbackState;
|
|
#[cfg(test)]
|
|
use super::table_holdback::table_holdback_state;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StreamCore — shared bookkeeping for both stream controllers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Shared state and logic for the two-region streaming model.
|
|
///
|
|
/// Both [`StreamController`] (agent messages) and [`PlanStreamController`]
|
|
/// (proposed plans) delegate their core bookkeeping here: source
|
|
/// accumulation, re-rendering, stable/tail partitioning, commit-animation
|
|
/// queue management, and terminal resize handling.
|
|
///
|
|
/// The wrapping controllers add only their own `emit()` styling and
|
|
/// finalize return types.
|
|
struct StreamCore {
|
|
state: StreamState,
|
|
/// Current rendering width (columns available for markdown content).
|
|
width: Option<usize>,
|
|
/// Accumulated raw markdown source for the current stream.
|
|
raw_source: String,
|
|
/// Full re-render of `raw_source` at `width`. Rebuilt on every committed delta.
|
|
rendered_lines: Vec<HyperlinkLine>,
|
|
/// Lines enqueued into the commit-animation queue.
|
|
enqueued_stable_len: usize,
|
|
/// Lines actually emitted to scrollback.
|
|
emitted_stable_len: usize,
|
|
/// Session cwd used to keep local file-link display stable during stream re-renders.
|
|
cwd: PathBuf,
|
|
render_mode: HistoryRenderMode,
|
|
/// Cached rendered line count for prefix-before-table keyed by source start and width.
|
|
stable_prefix_len_cache: Option<StablePrefixLenCache>,
|
|
/// Incremental holdback scanner state for append-only source updates.
|
|
holdback_scanner: TableHoldbackScanner,
|
|
}
|
|
|
|
struct StablePrefixLenCache {
|
|
/// Byte offset of the candidate table/header start in `raw_source`.
|
|
source_start: usize,
|
|
/// Width that produced `stable_prefix_len`.
|
|
width: Option<usize>,
|
|
/// Rendered line count for `raw_source[..source_start]` at `width`.
|
|
///
|
|
/// The streaming controller uses this to avoid repeatedly re-rendering the
|
|
/// same stable prefix while a live table tail is still mutating.
|
|
stable_prefix_len: usize,
|
|
}
|
|
|
|
impl StreamCore {
|
|
fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
|
Self {
|
|
state: StreamState::new(width, cwd),
|
|
width,
|
|
raw_source: String::with_capacity(1024),
|
|
rendered_lines: Vec::with_capacity(64),
|
|
enqueued_stable_len: 0,
|
|
emitted_stable_len: 0,
|
|
cwd: cwd.to_path_buf(),
|
|
render_mode,
|
|
stable_prefix_len_cache: None,
|
|
holdback_scanner: TableHoldbackScanner::new(),
|
|
}
|
|
}
|
|
|
|
/// Push a streaming delta and enqueue any newly-stable rendered lines.
|
|
///
|
|
/// Only newline-terminated source is committed into `raw_source`. This is
|
|
/// important for tables because an unterminated partial row must stay out
|
|
/// of both the stable queue and the live tail until its structure is
|
|
/// unambiguous; otherwise the user can briefly see malformed columns that
|
|
/// immediately disappear on the next delta.
|
|
fn push_delta(&mut self, delta: &str) -> bool {
|
|
if !delta.is_empty() {
|
|
self.state.has_seen_delta = true;
|
|
}
|
|
self.state.collector.push_delta(delta);
|
|
|
|
let mut enqueued = false;
|
|
if delta.contains('\n')
|
|
&& let Some(committed_source) = self.state.collector.commit_complete_source()
|
|
{
|
|
self.raw_source.push_str(&committed_source);
|
|
self.holdback_scanner.push_source_chunk(&committed_source);
|
|
self.recompute_streaming_render();
|
|
enqueued = self.sync_stable_queue();
|
|
}
|
|
enqueued
|
|
}
|
|
|
|
/// Drain the collector, render the final source snapshot, and return lines not yet emitted.
|
|
///
|
|
/// This intentionally re-renders from the full raw source instead of
|
|
/// trying to stitch together queued stable lines and the current tail. The
|
|
/// final render is the canonical transcript representation used for
|
|
/// consolidation, so callers that skip `reset()` can accidentally replay a
|
|
/// finished stream into the next answer.
|
|
fn finalize_remaining(&mut self) -> Vec<HyperlinkLine> {
|
|
let remainder_source = self.state.collector.finalize_and_drain_source();
|
|
if !remainder_source.is_empty() {
|
|
self.raw_source.push_str(&remainder_source);
|
|
self.holdback_scanner.push_source_chunk(&remainder_source);
|
|
}
|
|
let rendered = self.render_source(&self.raw_source);
|
|
if self.emitted_stable_len >= rendered.len() {
|
|
Vec::new()
|
|
} else {
|
|
rendered[self.emitted_stable_len..].to_vec()
|
|
}
|
|
}
|
|
|
|
/// Step animation: dequeue one line, update the emitted count.
|
|
fn tick(&mut self) -> Vec<HyperlinkLine> {
|
|
let step = self.state.step();
|
|
self.emitted_stable_len += step.len();
|
|
step
|
|
}
|
|
|
|
/// Batch drain: dequeue up to `max_lines`, update the emitted count.
|
|
fn tick_batch(&mut self, max_lines: usize) -> Vec<HyperlinkLine> {
|
|
if max_lines == 0 {
|
|
return Vec::new();
|
|
}
|
|
let step = self.state.drain_n(max_lines);
|
|
if step.is_empty() {
|
|
return step;
|
|
}
|
|
self.emitted_stable_len += step.len();
|
|
step
|
|
}
|
|
|
|
// Trivial StreamCore accessors inlined — called on every animation tick
|
|
// and render frame during active streaming.
|
|
|
|
#[inline]
|
|
fn is_idle(&self) -> bool {
|
|
self.state.is_idle()
|
|
}
|
|
|
|
#[inline]
|
|
fn queued_lines(&self) -> usize {
|
|
self.state.queued_len()
|
|
}
|
|
|
|
#[inline]
|
|
fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
|
self.state.oldest_queued_age(now)
|
|
}
|
|
|
|
/// Lines that belong to the mutable tail, not yet queued for stable commit.
|
|
///
|
|
/// The tail starts at `enqueued_stable_len`, so this returns the portion
|
|
/// of the current render snapshot that is still allowed to change without
|
|
/// violating scrollback ordering. If callers were to derive the tail from
|
|
/// `emitted_stable_len` instead, queued-but-not-yet-emitted lines could
|
|
/// reappear in the active cell and duplicate content on screen.
|
|
#[inline]
|
|
fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
|
let start = self.enqueued_stable_len.min(self.rendered_lines.len());
|
|
self.rendered_lines[start..].to_vec()
|
|
}
|
|
|
|
#[inline]
|
|
fn has_tail(&self) -> bool {
|
|
self.enqueued_stable_len < self.rendered_lines.len()
|
|
}
|
|
|
|
/// Update rendering width and rebuild queued stable lines for the new layout.
|
|
///
|
|
/// Re-renders once at the new width and rebuilds queue state from the
|
|
/// current emitted line count.
|
|
///
|
|
/// Resize is the point where source-backed rendering matters most:
|
|
/// previously emitted prose must stay in scrollback order, while any live
|
|
/// table tail is free to reshape at the new width. This method preserves
|
|
/// that split without attempting byte-for-byte line remapping.
|
|
fn set_width(&mut self, width: Option<usize>) {
|
|
if self.width == width {
|
|
return;
|
|
}
|
|
let had_pending_queue = self.state.queued_len() > 0;
|
|
let had_live_tail = self.has_tail();
|
|
self.width = width;
|
|
self.state.collector.set_width(width);
|
|
if self.raw_source.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.recompute_streaming_render();
|
|
self.emitted_stable_len = self.emitted_stable_len.min(self.rendered_lines.len());
|
|
if had_pending_queue
|
|
&& self.emitted_stable_len == self.rendered_lines.len()
|
|
&& self.emitted_stable_len > 0
|
|
{
|
|
// If wrapped remainder compresses into fewer lines at the new width,
|
|
// keep at least one line un-emitted so pre-resize pending content is
|
|
// not skipped permanently.
|
|
self.emitted_stable_len -= 1;
|
|
}
|
|
self.state.clear_queue();
|
|
if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail {
|
|
// Avoid replaying already-emitted content after resize when no
|
|
// stable lines were waiting in the queue and there was no mutable
|
|
// tail to preserve.
|
|
self.enqueued_stable_len = self.rendered_lines.len();
|
|
return;
|
|
}
|
|
self.rebuild_stable_queue_from_render();
|
|
}
|
|
|
|
/// Clear all accumulated state for current stream.
|
|
fn reset(&mut self) {
|
|
self.state.clear();
|
|
self.raw_source.clear();
|
|
self.rendered_lines.clear();
|
|
self.enqueued_stable_len = 0;
|
|
self.emitted_stable_len = 0;
|
|
self.stable_prefix_len_cache = None;
|
|
self.holdback_scanner.reset();
|
|
}
|
|
|
|
fn render_source(&self, source: &str) -> Vec<HyperlinkLine> {
|
|
match self.render_mode {
|
|
HistoryRenderMode::Rich => render_markdown_agent_with_links_and_cwd(
|
|
source,
|
|
self.width,
|
|
Some(self.cwd.as_path()),
|
|
),
|
|
HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)),
|
|
}
|
|
}
|
|
|
|
fn recompute_streaming_render(&mut self) {
|
|
self.rendered_lines = self.render_source(&self.raw_source);
|
|
}
|
|
|
|
fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
|
if self.render_mode == render_mode {
|
|
return;
|
|
}
|
|
|
|
let had_pending_queue = self.state.queued_len() > 0;
|
|
let had_live_tail = self.has_tail();
|
|
self.render_mode = render_mode;
|
|
if self.raw_source.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.recompute_streaming_render();
|
|
self.emitted_stable_len = self.emitted_stable_len.min(self.rendered_lines.len());
|
|
if had_pending_queue
|
|
&& self.emitted_stable_len == self.rendered_lines.len()
|
|
&& self.emitted_stable_len > 0
|
|
{
|
|
self.emitted_stable_len -= 1;
|
|
}
|
|
self.state.clear_queue();
|
|
if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail {
|
|
self.enqueued_stable_len = self.rendered_lines.len();
|
|
return;
|
|
}
|
|
self.rebuild_stable_queue_from_render();
|
|
}
|
|
|
|
/// Compute how many rendered lines should be in the stable region.
|
|
fn compute_target_stable_len(&mut self) -> usize {
|
|
let tail_budget = self.active_tail_budget_lines();
|
|
self.rendered_lines
|
|
.len()
|
|
.saturating_sub(tail_budget)
|
|
.max(self.emitted_stable_len)
|
|
}
|
|
|
|
/// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any
|
|
/// newly-stable lines. Returns `true` if new lines were enqueued.
|
|
fn sync_stable_queue(&mut self) -> bool {
|
|
let target_stable_len = self.compute_target_stable_len();
|
|
|
|
// A structural rewrite moved the stable boundary backward into enqueue-but-unemitted
|
|
// lines. Rebuild queue from the latest snapshot.
|
|
if target_stable_len < self.enqueued_stable_len {
|
|
self.state.clear_queue();
|
|
if self.emitted_stable_len < target_stable_len {
|
|
self.state.enqueue(
|
|
self.rendered_lines[self.emitted_stable_len..target_stable_len].to_vec(),
|
|
);
|
|
}
|
|
self.enqueued_stable_len = target_stable_len;
|
|
return self.state.queued_len() > 0;
|
|
}
|
|
|
|
if target_stable_len == self.enqueued_stable_len {
|
|
return false;
|
|
}
|
|
|
|
self.state
|
|
.enqueue(self.rendered_lines[self.enqueued_stable_len..target_stable_len].to_vec());
|
|
self.enqueued_stable_len = target_stable_len;
|
|
true
|
|
}
|
|
|
|
/// Rebuild the stable queue from the current render snapshot.
|
|
///
|
|
/// This is used after `set_width()`, where any queued lines were computed
|
|
/// against the old width and can no longer be trusted to line up with the
|
|
/// current render.
|
|
fn rebuild_stable_queue_from_render(&mut self) {
|
|
let target_stable_len = self.compute_target_stable_len();
|
|
self.state.clear_queue();
|
|
if self.emitted_stable_len < target_stable_len {
|
|
self.state
|
|
.enqueue(self.rendered_lines[self.emitted_stable_len..target_stable_len].to_vec());
|
|
}
|
|
self.enqueued_stable_len = target_stable_len;
|
|
}
|
|
|
|
/// How many rendered lines to withhold as mutable tail.
|
|
///
|
|
/// When a table is detected (`Confirmed` or `PendingHeader`), the entire
|
|
/// table region is held as tail because adding a row can reshape table
|
|
/// column widths. For `PendingHeader`, only content from the speculative
|
|
/// header line onward is kept mutable so earlier prose can continue
|
|
/// streaming. When no table is detected, everything flows directly to
|
|
/// stable. This is the core decision point for the holdback mechanism.
|
|
fn active_tail_budget_lines(&mut self) -> usize {
|
|
if self.render_mode == HistoryRenderMode::Raw {
|
|
return 0;
|
|
}
|
|
let scan_start = Instant::now();
|
|
let holdback_state = self.holdback_scanner.state();
|
|
let tail_budget = match holdback_state {
|
|
TableHoldbackState::Confirmed { table_start: start }
|
|
| TableHoldbackState::PendingHeader {
|
|
header_start: start,
|
|
} => self.tail_budget_from_source_start(start),
|
|
TableHoldbackState::None => 0,
|
|
};
|
|
tracing::trace!(
|
|
state = ?holdback_state,
|
|
tail_budget,
|
|
elapsed_us = scan_start.elapsed().as_micros(),
|
|
"table holdback decision",
|
|
);
|
|
tail_budget
|
|
}
|
|
|
|
/// Convert a raw-source boundary into the number of rendered tail lines.
|
|
///
|
|
/// The important contract here is that the holdback scanner reasons in
|
|
/// byte offsets while the queue operates in rendered lines. This helper is
|
|
/// the only place where those coordinate systems are bridged.
|
|
fn tail_budget_from_source_start(&mut self, source_start: usize) -> usize {
|
|
if source_start == 0 {
|
|
return self.rendered_lines.len();
|
|
}
|
|
let source_start = source_start.min(self.raw_source.len());
|
|
let stable_prefix_len = self.stable_prefix_len_for_source_start(source_start);
|
|
self.rendered_lines.len().saturating_sub(stable_prefix_len)
|
|
}
|
|
|
|
/// Render the stable prefix before `source_start` and return its line count.
|
|
///
|
|
/// This value is cached because dense table streams can call into this path
|
|
/// for every committed line while the header/delimiter/body are still
|
|
/// arriving incrementally.
|
|
fn stable_prefix_len_for_source_start(&mut self, source_start: usize) -> usize {
|
|
if let Some(cache) = &self.stable_prefix_len_cache
|
|
&& cache.source_start == source_start
|
|
&& cache.width == self.width
|
|
{
|
|
tracing::trace!(
|
|
source_start,
|
|
width = ?self.width,
|
|
stable_prefix_len = cache.stable_prefix_len,
|
|
"table holdback stable-prefix cache hit",
|
|
);
|
|
return cache.stable_prefix_len;
|
|
}
|
|
|
|
let render_start = Instant::now();
|
|
let stable_prefix_render = render_markdown_agent_with_links_and_cwd(
|
|
&self.raw_source[..source_start.min(self.raw_source.len())],
|
|
self.width,
|
|
Some(self.cwd.as_path()),
|
|
);
|
|
let stable_prefix_len = stable_prefix_render.len();
|
|
tracing::trace!(
|
|
source_start,
|
|
width = ?self.width,
|
|
stable_prefix_len,
|
|
elapsed_us = render_start.elapsed().as_micros(),
|
|
"table holdback stable-prefix render",
|
|
);
|
|
self.stable_prefix_len_cache = Some(StablePrefixLenCache {
|
|
source_start,
|
|
width: self.width,
|
|
stable_prefix_len,
|
|
});
|
|
stable_prefix_len
|
|
}
|
|
}
|
|
|
|
/// Controller for streaming agent message content with table-aware holdback.
|
|
///
|
|
/// Wraps [`StreamCore`] and adds `AgentMessageCell` emission styling.
|
|
pub(crate) struct StreamController {
|
|
core: StreamCore,
|
|
header_emitted: bool,
|
|
}
|
|
|
|
impl StreamController {
|
|
/// Create a controller whose markdown renderer shortens local file links relative to `cwd`.
|
|
///
|
|
/// `width` is the content width available to markdown rendering, not necessarily the full
|
|
/// terminal width. Passing a stale width after resize will keep queued live output wrapped for
|
|
/// the old viewport until app-level reflow repairs the finalized transcript.
|
|
pub(crate) fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
|
Self {
|
|
core: StreamCore::new(width, cwd, render_mode),
|
|
header_emitted: false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn push(&mut self, delta: &str) -> bool {
|
|
self.core.push_delta(delta)
|
|
}
|
|
|
|
/// Finalize the active stream. Returns the final cell (if any remaining lines) and the raw
|
|
/// markdown source for consolidation.
|
|
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
|
|
let remaining = self.core.finalize_remaining();
|
|
if self.core.raw_source.is_empty() {
|
|
self.core.reset();
|
|
return (None, None);
|
|
}
|
|
|
|
// Move ownership — source is consumed before reset() clears it.
|
|
let source = std::mem::take(&mut self.core.raw_source);
|
|
let out = self.emit(remaining);
|
|
self.core.reset();
|
|
(out, Some(source))
|
|
}
|
|
|
|
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
|
|
let step = self.core.tick();
|
|
(self.emit(step), self.core.is_idle())
|
|
}
|
|
|
|
pub(crate) fn on_commit_tick_batch(
|
|
&mut self,
|
|
max_lines: usize,
|
|
) -> (Option<Box<dyn HistoryCell>>, bool) {
|
|
let step = self.core.tick_batch(max_lines);
|
|
(self.emit(step), self.core.is_idle())
|
|
}
|
|
|
|
// Thin StreamController accessors inlined — one-liner delegates called
|
|
// on every render frame and animation tick.
|
|
|
|
#[inline]
|
|
pub(crate) fn queued_lines(&self) -> usize {
|
|
self.core.queued_lines()
|
|
}
|
|
|
|
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
|
self.core.oldest_queued_age(now)
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
|
self.core.current_tail_lines()
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn tail_starts_stream(&self) -> bool {
|
|
!self.header_emitted && self.core.enqueued_stable_len == 0
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn has_live_tail(&self) -> bool {
|
|
self.core.has_tail()
|
|
}
|
|
|
|
pub(crate) fn clear_queue(&mut self) {
|
|
self.core.state.clear_queue();
|
|
self.core.enqueued_stable_len = self.core.emitted_stable_len;
|
|
}
|
|
|
|
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
|
self.core.set_width(width);
|
|
}
|
|
|
|
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
|
self.core.set_render_mode(render_mode);
|
|
}
|
|
|
|
fn emit(&mut self, lines: Vec<HyperlinkLine>) -> Option<Box<dyn HistoryCell>> {
|
|
if lines.is_empty() {
|
|
return None;
|
|
}
|
|
Some(Box::new(
|
|
history_cell::AgentMessageCell::new_hyperlink_lines(lines, {
|
|
let header_emitted = self.header_emitted;
|
|
self.header_emitted = true;
|
|
!header_emitted
|
|
}),
|
|
))
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// PlanStreamController — proposed plan streams
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Controller that streams proposed plan markdown into a styled plan block.
|
|
///
|
|
/// Wraps [`StreamCore`] and adds plan-specific header, indentation, and
|
|
/// background styling.
|
|
pub(crate) struct PlanStreamController {
|
|
core: StreamCore,
|
|
header_emitted: bool,
|
|
top_padding_emitted: bool,
|
|
}
|
|
|
|
impl PlanStreamController {
|
|
/// Create a plan-stream controller whose markdown renderer shortens local file links relative
|
|
/// to `cwd`.
|
|
///
|
|
/// The width has the same meaning as in `StreamController`: it is the markdown body width, and
|
|
/// callers must update it when the terminal width changes.
|
|
pub(crate) fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
|
|
Self {
|
|
core: StreamCore::new(width, cwd, render_mode),
|
|
header_emitted: false,
|
|
top_padding_emitted: false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn push(&mut self, delta: &str) -> bool {
|
|
self.core.push_delta(delta)
|
|
}
|
|
|
|
/// Finalize the active stream. Returns the final cell (if any remaining
|
|
/// lines) plus raw markdown source for consolidation.
|
|
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
|
|
let remaining = self.core.finalize_remaining();
|
|
if self.core.raw_source.is_empty() {
|
|
self.core.reset();
|
|
return (None, None);
|
|
}
|
|
|
|
// Move ownership — source is consumed before reset() clears it.
|
|
let source = std::mem::take(&mut self.core.raw_source);
|
|
let out = self.emit(remaining, /*include_bottom_padding*/ true);
|
|
self.core.reset();
|
|
(out, Some(source))
|
|
}
|
|
|
|
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
|
|
let step = self.core.tick();
|
|
(
|
|
self.emit(step, /*include_bottom_padding*/ false),
|
|
self.core.is_idle(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn on_commit_tick_batch(
|
|
&mut self,
|
|
max_lines: usize,
|
|
) -> (Option<Box<dyn HistoryCell>>, bool) {
|
|
let step = self.core.tick_batch(max_lines);
|
|
(
|
|
self.emit(step, /*include_bottom_padding*/ false),
|
|
self.core.is_idle(),
|
|
)
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn queued_lines(&self) -> usize {
|
|
self.core.queued_lines()
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn has_live_tail(&self) -> bool {
|
|
self.core.has_tail()
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
|
self.core.current_tail_lines()
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn tail_starts_stream(&self) -> bool {
|
|
!self.header_emitted && self.core.enqueued_stable_len == 0
|
|
}
|
|
|
|
pub(crate) fn current_tail_display_lines(&self) -> Vec<HyperlinkLine> {
|
|
let lines = self.current_tail_lines();
|
|
if lines.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
self.render_display_lines(lines, /*include_bottom_padding*/ false)
|
|
}
|
|
|
|
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
|
self.core.oldest_queued_age(now)
|
|
}
|
|
|
|
pub(crate) fn clear_queue(&mut self) {
|
|
self.core.state.clear_queue();
|
|
self.core.enqueued_stable_len = self.core.emitted_stable_len;
|
|
}
|
|
|
|
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
|
self.core.set_width(width);
|
|
}
|
|
|
|
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
|
self.core.set_render_mode(render_mode);
|
|
}
|
|
|
|
fn emit(
|
|
&mut self,
|
|
lines: Vec<HyperlinkLine>,
|
|
include_bottom_padding: bool,
|
|
) -> Option<Box<dyn HistoryCell>> {
|
|
if lines.is_empty() && !include_bottom_padding {
|
|
return None;
|
|
}
|
|
|
|
let is_stream_continuation = self.header_emitted;
|
|
let out_lines = self.render_display_lines(lines, include_bottom_padding);
|
|
self.header_emitted = true;
|
|
self.top_padding_emitted = true;
|
|
|
|
Some(Box::new(history_cell::new_proposed_plan_stream(
|
|
out_lines,
|
|
is_stream_continuation,
|
|
)))
|
|
}
|
|
|
|
fn render_display_lines(
|
|
&self,
|
|
lines: Vec<HyperlinkLine>,
|
|
include_bottom_padding: bool,
|
|
) -> Vec<HyperlinkLine> {
|
|
let mut out_lines: Vec<HyperlinkLine> = Vec::with_capacity(/*capacity*/ 4);
|
|
if !self.header_emitted {
|
|
out_lines.push(HyperlinkLine::new(
|
|
vec!["• ".dim(), "Proposed Plan".bold()].into(),
|
|
));
|
|
out_lines.push(HyperlinkLine::new(Line::from(" ")));
|
|
}
|
|
|
|
let mut plan_lines: Vec<HyperlinkLine> = Vec::with_capacity(/*capacity*/ 4);
|
|
if !self.top_padding_emitted {
|
|
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
|
|
}
|
|
plan_lines.extend(lines);
|
|
if include_bottom_padding {
|
|
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
|
|
}
|
|
|
|
let plan_style = proposed_plan_style();
|
|
let plan_lines = prefix_hyperlink_lines(plan_lines, " ".into(), " ".into())
|
|
.into_iter()
|
|
.map(|line| line.style(plan_style))
|
|
.collect::<Vec<_>>();
|
|
out_lines.extend(plan_lines);
|
|
out_lines
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal_hyperlinks::visible_lines;
|
|
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()
|
|
}
|
|
|
|
fn stream_controller(width: Option<usize>) -> StreamController {
|
|
StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
|
|
}
|
|
|
|
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
|
|
PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
|
|
}
|
|
|
|
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
|
|
lines
|
|
.iter()
|
|
.map(|l| {
|
|
l.spans
|
|
.iter()
|
|
.map(|s| s.content.clone())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn hyperlink_lines_to_plain_strings(lines: &[HyperlinkLine]) -> Vec<String> {
|
|
lines_to_plain_strings(&visible_lines(lines.to_vec()))
|
|
}
|
|
|
|
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
|
|
let mut ctrl = stream_controller(width);
|
|
let mut lines = Vec::new();
|
|
for d in deltas {
|
|
ctrl.push(d);
|
|
while let (Some(cell), idle) = ctrl.on_commit_tick() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
if idle {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let (Some(cell), _source) = ctrl.finalize() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
}
|
|
lines_to_plain_strings(&lines)
|
|
.into_iter()
|
|
.map(|s| s.chars().skip(2).collect::<String>())
|
|
.collect()
|
|
}
|
|
|
|
fn collect_plan_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
|
|
let mut ctrl = plan_stream_controller(width);
|
|
let mut lines = Vec::new();
|
|
for d in deltas {
|
|
ctrl.push(d);
|
|
while let (Some(cell), idle) = ctrl.on_commit_tick() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
if idle {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let (Some(cell), _source) = ctrl.finalize() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
}
|
|
lines_to_plain_strings(&lines)
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_rebuilds_queued_lines() {
|
|
let mut ctrl = stream_controller(Some(120));
|
|
let delta = "This is a long line that should wrap into multiple rows when resized.\n";
|
|
assert!(ctrl.push(delta));
|
|
assert_eq!(ctrl.queued_lines(), 1);
|
|
|
|
ctrl.set_width(Some(24));
|
|
let (cell, idle) = ctrl.on_commit_tick_batch(usize::MAX);
|
|
let rendered = lines_to_plain_strings(
|
|
&cell
|
|
.expect("expected resized queued lines")
|
|
.transcript_lines(u16::MAX),
|
|
);
|
|
|
|
assert!(idle);
|
|
assert!(
|
|
rendered.len() > 1,
|
|
"expected resized content to occupy multiple lines, got {rendered:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_no_duplicate_after_emit() {
|
|
let mut ctrl = stream_controller(Some(120));
|
|
let line =
|
|
"This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n";
|
|
ctrl.push(line);
|
|
let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX);
|
|
assert!(cell.is_some(), "expected emitted cell");
|
|
assert_eq!(ctrl.queued_lines(), 0);
|
|
|
|
ctrl.set_width(Some(24));
|
|
|
|
assert_eq!(
|
|
ctrl.queued_lines(),
|
|
0,
|
|
"already-emitted content must not be re-queued after resize",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_tick_batch_zero_is_noop() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
assert!(ctrl.push("line one\n"));
|
|
assert_eq!(ctrl.queued_lines(), 1);
|
|
|
|
let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0);
|
|
assert!(cell.is_none(), "batch size 0 should not emit lines");
|
|
assert!(!idle, "batch size 0 should not drain queued lines");
|
|
assert_eq!(
|
|
ctrl.queued_lines(),
|
|
1,
|
|
"queue depth should remain unchanged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_has_live_tail_reflects_tail_presence() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
assert!(!ctrl.has_live_tail());
|
|
|
|
ctrl.core.rendered_lines = vec![Line::from("tail line").into()];
|
|
ctrl.core.enqueued_stable_len = 0;
|
|
assert!(ctrl.has_live_tail());
|
|
|
|
ctrl.core.enqueued_stable_len = 1;
|
|
assert!(!ctrl.has_live_tail());
|
|
}
|
|
|
|
#[test]
|
|
fn plan_controller_has_live_tail_reflects_tail_presence() {
|
|
let mut ctrl = plan_stream_controller(Some(80));
|
|
assert!(!ctrl.has_live_tail());
|
|
|
|
ctrl.core.rendered_lines = vec![Line::from("tail line").into()];
|
|
ctrl.core.enqueued_stable_len = 0;
|
|
assert!(ctrl.has_live_tail());
|
|
|
|
ctrl.core.enqueued_stable_len = 1;
|
|
assert!(!ctrl.has_live_tail());
|
|
}
|
|
|
|
#[test]
|
|
fn controller_live_tail_keeps_uncommitted_table_cell_newline_gated() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
ctrl.push("| A | B |\n");
|
|
ctrl.push("| --- | --- |\n");
|
|
ctrl.push("| partial");
|
|
|
|
let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines()).join("\n");
|
|
assert!(
|
|
!tail.contains("partial"),
|
|
"expected live tail to remain newline-gated: {tail:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_live_tail_requires_table_holdback_state() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
ctrl.push("plain text without newline");
|
|
|
|
assert!(
|
|
ctrl.current_tail_lines().is_empty(),
|
|
"expected no live tail outside table holdback state",
|
|
);
|
|
assert!(!ctrl.has_live_tail());
|
|
}
|
|
|
|
#[test]
|
|
fn controller_live_tail_rerenders_table_tail_after_resize() {
|
|
let mut ctrl = stream_controller(Some(96));
|
|
ctrl.push("| # | Feature | Details | Link |\n");
|
|
ctrl.push("| --- | --- | --- | --- |\n");
|
|
ctrl.push(
|
|
"| 1 | RESIZE_REPRO_SENTINEL | long wrapped content that should be reflowed | https://example.com/resize |\n",
|
|
);
|
|
|
|
for width in [48, 104, 56] {
|
|
ctrl.set_width(Some(width));
|
|
let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines());
|
|
|
|
let mut expected = Vec::new();
|
|
crate::markdown::append_markdown_agent(
|
|
&ctrl.core.raw_source,
|
|
Some(width),
|
|
&mut expected,
|
|
);
|
|
let expected = lines_to_plain_strings(&expected);
|
|
|
|
assert_eq!(
|
|
tail, expected,
|
|
"expected live table tail to be rerendered at width {width}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_partial_drain_no_lost_lines() {
|
|
let mut ctrl = stream_controller(Some(40));
|
|
ctrl.push("AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ\n");
|
|
ctrl.push("second line\n");
|
|
|
|
let (cell, idle) = ctrl.on_commit_tick();
|
|
assert!(cell.is_some(), "expected 1 emitted line");
|
|
assert!(!idle, "queue should still have lines");
|
|
let remaining_before = ctrl.queued_lines();
|
|
assert!(remaining_before > 0, "should have queued lines left");
|
|
|
|
ctrl.set_width(Some(20));
|
|
|
|
let (cell, source) = ctrl.finalize();
|
|
let final_lines = cell
|
|
.map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default();
|
|
|
|
assert!(
|
|
final_lines.iter().any(|l| l.contains("second line")),
|
|
"un-emitted 'second line' was lost after resize; got: {final_lines:?}",
|
|
);
|
|
assert!(source.is_some(), "expected source from finalize");
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_partial_drain_keeps_pending_queue() {
|
|
let mut ctrl = stream_controller(Some(40));
|
|
ctrl.push("AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ\n");
|
|
ctrl.push("second line\n");
|
|
|
|
let (cell, idle) = ctrl.on_commit_tick();
|
|
assert!(cell.is_some(), "expected 1 emitted line");
|
|
assert!(!idle, "queue should still have lines");
|
|
assert!(ctrl.queued_lines() > 0, "expected pending queued lines");
|
|
|
|
ctrl.set_width(Some(20));
|
|
|
|
assert!(
|
|
ctrl.queued_lines() > 0,
|
|
"resize must preserve pending queued lines"
|
|
);
|
|
|
|
let mut drained = Vec::new();
|
|
for _ in 0..64 {
|
|
let (cell, is_idle) = ctrl.on_commit_tick();
|
|
if let Some(cell) = cell {
|
|
drained.extend(lines_to_plain_strings(&cell.transcript_lines(u16::MAX)));
|
|
}
|
|
if is_idle {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
drained.iter().any(|l| l.contains("second line")),
|
|
"pending lines should continue draining after resize; got {drained:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_preserves_in_flight_tail() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
ctrl.push("tail without newline");
|
|
ctrl.set_width(Some(24));
|
|
|
|
let (cell, _source) = ctrl.finalize();
|
|
let rendered = lines_to_plain_strings(
|
|
&cell
|
|
.expect("expected finalized tail")
|
|
.transcript_lines(u16::MAX),
|
|
);
|
|
|
|
assert_eq!(rendered, vec!["• tail without newline".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_preserves_table_tail_when_queue_is_empty() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
ctrl.push("intro line\n");
|
|
|
|
let (_cell, idle) = ctrl.on_commit_tick();
|
|
assert!(idle, "intro line should fully drain");
|
|
assert_eq!(ctrl.queued_lines(), 0, "expected empty queue before table");
|
|
|
|
ctrl.push("| A | B |\n");
|
|
assert_eq!(
|
|
ctrl.queued_lines(),
|
|
0,
|
|
"pending table header should remain mutable tail, not queued",
|
|
);
|
|
assert!(ctrl.has_live_tail(), "expected live tail before resize");
|
|
|
|
ctrl.set_width(Some(24));
|
|
|
|
let tail_after = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines());
|
|
assert!(
|
|
!tail_after.is_empty(),
|
|
"resize must keep mutable tail when queue is empty",
|
|
);
|
|
let joined = tail_after.join(" ");
|
|
assert!(
|
|
joined.contains('A') && joined.contains('B'),
|
|
"expected table header content to remain in tail after resize: {tail_after:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_controller_set_width_preserves_in_flight_tail() {
|
|
let mut ctrl = plan_stream_controller(Some(80));
|
|
ctrl.push("1. Item without newline");
|
|
ctrl.set_width(Some(24));
|
|
|
|
let rendered = lines_to_plain_strings(
|
|
&(ctrl
|
|
.finalize()
|
|
.0
|
|
.expect("expected finalized tail")
|
|
.transcript_lines(u16::MAX)),
|
|
);
|
|
|
|
assert!(
|
|
rendered
|
|
.iter()
|
|
.any(|line| line.contains("Item without newline")),
|
|
"expected finalized plan content after resize, got {rendered:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_controller_holds_table_header_as_live_tail() {
|
|
let mut ctrl = plan_stream_controller(Some(80));
|
|
assert!(ctrl.push("Intro\n"));
|
|
let (_cell, idle) = ctrl.on_commit_tick_batch(usize::MAX);
|
|
assert!(idle, "intro line should fully drain");
|
|
|
|
assert!(!ctrl.push("| Step | Owner |\n"));
|
|
assert!(
|
|
ctrl.has_live_tail(),
|
|
"expected plan table header to be held"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
|
let mut ctrl = stream_controller(/*width*/ None);
|
|
let mut lines = Vec::new();
|
|
|
|
let deltas = vec![
|
|
"\n\n",
|
|
"Loose",
|
|
" vs",
|
|
".",
|
|
" tight",
|
|
" list",
|
|
" items",
|
|
":\n",
|
|
"1",
|
|
".",
|
|
" Tight",
|
|
" item",
|
|
"\n",
|
|
"2",
|
|
".",
|
|
" Another",
|
|
" tight",
|
|
" item",
|
|
"\n\n",
|
|
"1",
|
|
".",
|
|
" Loose",
|
|
" item",
|
|
" with",
|
|
" its",
|
|
" own",
|
|
" paragraph",
|
|
".\n\n",
|
|
" ",
|
|
" This",
|
|
" paragraph",
|
|
" belongs",
|
|
" to",
|
|
" the",
|
|
" same",
|
|
" list",
|
|
" item",
|
|
".\n\n",
|
|
"2",
|
|
".",
|
|
" Second",
|
|
" loose",
|
|
" item",
|
|
" with",
|
|
" a",
|
|
" nested",
|
|
" list",
|
|
" after",
|
|
" a",
|
|
" blank",
|
|
" line",
|
|
".\n\n",
|
|
" ",
|
|
" -",
|
|
" Nested",
|
|
" bullet",
|
|
" under",
|
|
" a",
|
|
" loose",
|
|
" item",
|
|
"\n",
|
|
" ",
|
|
" -",
|
|
" Another",
|
|
" nested",
|
|
" bullet",
|
|
"\n\n",
|
|
];
|
|
|
|
for d in deltas.iter() {
|
|
ctrl.push(d);
|
|
while let (Some(cell), idle) = ctrl.on_commit_tick() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
if idle {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let (Some(cell), _source) = ctrl.finalize() {
|
|
lines.extend(cell.transcript_lines(u16::MAX));
|
|
}
|
|
|
|
let streamed: Vec<_> = lines_to_plain_strings(&lines)
|
|
.into_iter()
|
|
.map(|s| s.chars().skip(2).collect::<String>())
|
|
.collect();
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ None, &mut rendered);
|
|
let rendered_strs = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, rendered_strs);
|
|
|
|
let expected = vec![
|
|
"Loose vs. tight list items:".to_string(),
|
|
"".to_string(),
|
|
"1. Tight item".to_string(),
|
|
"2. Another tight item".to_string(),
|
|
"3. Loose item with its own paragraph.".to_string(),
|
|
"".to_string(),
|
|
" This paragraph belongs to the same list item.".to_string(),
|
|
"".to_string(),
|
|
"4. Second loose item with a nested list after a blank line.".to_string(),
|
|
" - Nested bullet under a loose item".to_string(),
|
|
" - Another nested bullet".to_string(),
|
|
];
|
|
assert_eq!(
|
|
streamed, expected,
|
|
"expected exact rendered lines for loose/tight section"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_streamed_table_matches_full_render_widths() {
|
|
let deltas = vec![
|
|
"| Key | Description |\n",
|
|
"| --- | --- |\n",
|
|
"| -v | Enable very verbose logging output for debugging |\n",
|
|
"\n",
|
|
];
|
|
|
|
let streamed = collect_streamed_lines(&deltas, Some(80));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_holds_blockquoted_table_tail_until_stable() {
|
|
let deltas = vec![
|
|
"> | A | B |\n",
|
|
"> | --- | --- |\n",
|
|
"> | longvalue | ok |\n",
|
|
"\n",
|
|
];
|
|
|
|
let streamed = collect_streamed_lines(&deltas, Some(80));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_keeps_pre_table_lines_queued_when_table_is_confirmed() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
|
|
ctrl.push("Intro line before table.\n");
|
|
assert_eq!(ctrl.queued_lines(), 1);
|
|
|
|
ctrl.push("| Key | Value |\n");
|
|
ctrl.push("| --- | --- |\n");
|
|
assert_eq!(
|
|
ctrl.queued_lines(),
|
|
1,
|
|
"pre-table line should remain queued after table confirmation",
|
|
);
|
|
|
|
let (cell, idle) = ctrl.on_commit_tick();
|
|
let committed = cell
|
|
.map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default();
|
|
assert!(
|
|
committed
|
|
.iter()
|
|
.any(|line| line.contains("Intro line before table.")),
|
|
"expected pre-table line to commit independently: {committed:?}",
|
|
);
|
|
assert!(idle, "only pre-table content should have been queued");
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_during_confirmed_table_stream_matches_finalize_render() {
|
|
let mut ctrl = stream_controller(Some(120));
|
|
let deltas = [
|
|
"| Key | Description |\n",
|
|
"| --- | --- |\n",
|
|
"| one | value that should wrap after resize |\n",
|
|
];
|
|
for delta in deltas {
|
|
ctrl.push(delta);
|
|
}
|
|
assert_eq!(
|
|
ctrl.queued_lines(),
|
|
0,
|
|
"confirmed table should remain mutable"
|
|
);
|
|
|
|
ctrl.set_width(Some(32));
|
|
|
|
let (cell, source) = ctrl.finalize();
|
|
let source = source.expect("expected finalized source");
|
|
let streamed = lines_to_plain_strings(
|
|
&cell
|
|
.expect("expected finalized table")
|
|
.transcript_lines(u16::MAX),
|
|
)
|
|
.into_iter()
|
|
.map(|line| line.chars().skip(2).collect::<String>())
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(32), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
assert_eq!(streamed, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_does_not_hold_back_pipe_prose_without_table_delimiter() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
|
|
ctrl.push("status | owner | note\n");
|
|
let (_first_commit, first_idle) = ctrl.on_commit_tick();
|
|
assert!(first_idle);
|
|
|
|
ctrl.push("next line\n");
|
|
let (second_commit, _second_idle) = ctrl.on_commit_tick();
|
|
assert!(
|
|
second_commit.is_some(),
|
|
"expected prose lines to be released once no table delimiter follows"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_does_not_stall_repeated_pipe_prose_paragraphs() {
|
|
let mut ctrl = stream_controller(Some(80));
|
|
|
|
ctrl.push("alpha | beta\n\n");
|
|
let (_first_commit, first_idle) = ctrl.on_commit_tick();
|
|
assert!(first_idle);
|
|
|
|
ctrl.push("gamma | delta\n\n");
|
|
let (second_commit, _second_idle) = ctrl.on_commit_tick();
|
|
let second_lines = second_commit
|
|
.map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default();
|
|
|
|
assert!(
|
|
second_lines
|
|
.iter()
|
|
.any(|line| line.contains("alpha | beta")),
|
|
"expected the first pipe-prose paragraph to stream before finalize; got {second_lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_handles_table_immediately_after_heading() {
|
|
let deltas = vec![
|
|
"### 1) Basic table\n",
|
|
"| Name | Role | Status |\n",
|
|
"|---|---|---|\n",
|
|
"| Alice | Admin | Active |\n",
|
|
"| Bob | Editor | Pending |\n",
|
|
"\n",
|
|
];
|
|
|
|
let streamed = collect_streamed_lines(&deltas, Some(100));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(100), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
}
|
|
|
|
#[test]
|
|
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
|
|
| 19.50 | Yes |\n| Monitor | 1 | 219.0 | No |\n\n| Field | Example | Notes
|
|
|\n|---------------|----------------------------------|--------------------------|\n| Escaped pipe | `foo \\| bar` | Should stay
|
|
in one cell |\n| Inline code | `let x = value;` | Monospace inline content |\n| Link | [OpenAI](https://openai.com) |
|
|
Standard markdown link |\n";
|
|
|
|
let chunked = source
|
|
.split_inclusive('\n')
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
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 table separator in streamed output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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";
|
|
|
|
let chunked = source
|
|
.split_inclusive('\n')
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
let deltas = chunked.iter().map(String::as_str).collect::<Vec<_>>();
|
|
let streamed = collect_streamed_lines(&deltas, Some(100));
|
|
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(source, /*width*/ Some(100), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
let has_raw_no_outer_header = streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "Col A | Col B | Col C");
|
|
assert!(
|
|
!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]
|
|
fn controller_stabilizes_first_no_outer_pipes_table_in_response() {
|
|
let deltas = vec![
|
|
"### No outer pipes first\n\n",
|
|
"Col A | Col B | Col C\n",
|
|
"--- | --- | ---\n",
|
|
"x | y | z\n",
|
|
"10 | 20 | 30\n",
|
|
"\n",
|
|
"After table paragraph.\n",
|
|
];
|
|
let streamed = collect_streamed_lines(&deltas, Some(100));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(100), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
assert!(
|
|
streamed.iter().any(|line| line.contains('━')),
|
|
"expected table separator for no-outer-pipes streaming: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "Col A | Col B | Col C"),
|
|
"did not expect raw no-outer-pipes header in final streamed output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_stabilizes_two_column_no_outer_table_in_response() {
|
|
let deltas = vec![
|
|
"A | B\n",
|
|
"--- | ---\n",
|
|
"left | right\n",
|
|
"\n",
|
|
"After table paragraph.\n",
|
|
];
|
|
let streamed = collect_streamed_lines(&deltas, Some(80));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(&source, /*width*/ Some(80), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
assert!(
|
|
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"),
|
|
"did not expect raw two-column no-outer header in final streamed output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_converts_no_outer_table_between_preboxed_sections() {
|
|
let source = " ┌───────┬──────────┬────────┐\n │ Name │ Role │ Active │\n ├───────┼──────────┼────────┤\n │ Alice │ Engineer │ Yes │\n │ Bob │ Designer │ No │\n │ Cara │ PM │ Yes │\n └───────┴──────────┴────────┘\n\n ### 3) No outer pipes\n\n Col A | Col B | Col C\n --- | --- | ---\n x | y | z\n 10 | 20 | 30\n\n ┌─────────────────┬────────┬────────────────────────┐\n │ Example │ Output │ Notes │\n ├─────────────────┼────────┼────────────────────────┤\n │ a | b │ `a │ b` │\n │ npm run test │ ok │ Inline code formatting │\n │ SELECT * FROM t │ 3 rows │ SQL snippet │\n └─────────────────┴────────┴────────────────────────┘\n";
|
|
|
|
let deltas = source
|
|
.split_inclusive('\n')
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
let streamed = collect_streamed_lines(
|
|
&deltas.iter().map(String::as_str).collect::<Vec<_>>(),
|
|
Some(100),
|
|
);
|
|
|
|
let has_raw_no_outer_header = streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "Col A | Col B | Col C");
|
|
assert!(
|
|
!has_raw_no_outer_header,
|
|
"no-outer table header remained raw in streamed output: {streamed:?}"
|
|
);
|
|
assert!(
|
|
streamed
|
|
.iter()
|
|
.any(|line| line.contains(" Col A Col B Col C")),
|
|
"expected converted no-outer table header in streamed output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_keeps_markdown_fenced_tables_mutable_until_finalize() {
|
|
let source = "```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
|
|
let deltas = vec![
|
|
"```md\n",
|
|
"| A | B |\n",
|
|
"|---|---|\n",
|
|
"| 1 | 2 |\n",
|
|
"```\n",
|
|
];
|
|
let streamed = collect_streamed_lines(&deltas, Some(80));
|
|
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(source, /*width*/ Some(80), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
assert!(
|
|
streamed.iter().any(|line| line.contains('━')),
|
|
"expected table separator in streamed output: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed.iter().any(|line| line.trim() == "| A | B |"),
|
|
"did not expect raw table header line after finalize: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_keeps_markdown_fenced_no_outer_tables_mutable_until_finalize() {
|
|
let source =
|
|
"```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n";
|
|
let deltas = vec![
|
|
"```md\n",
|
|
"Col A | Col B | Col C\n",
|
|
"--- | --- | ---\n",
|
|
"x | y | z\n",
|
|
"10 | 20 | 30\n",
|
|
"```\n",
|
|
];
|
|
let streamed = collect_streamed_lines(&deltas, Some(100));
|
|
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(source, /*width*/ Some(100), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
assert!(
|
|
streamed.iter().any(|line| line.contains('━')),
|
|
"expected table separator in streamed output: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "Col A | Col B | Col C"),
|
|
"did not expect raw no-outer-pipes header line after finalize: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_live_view_matches_render_during_interleaved_table_streaming() {
|
|
let source = "Project updates are easier to scan when narrative and structured data alternate.\n\n| Focus Area | Owner | Priority | Status |\n|---|---|---|---|\n| Authentication cleanup | Maya | High | 80% |\n| CLI error messages | Jordan | Medium | 55% |\n| Docs refresh | Lee | Low | 30% |\n\nThe first checkpoint shows progress, but we still have open risks.\n\n| Task | Command / Artifact | Due | State |\n|---|---|---|---|\n| Run unit tests | `cargo test -p codex-core` | Today | ✅ |\n| Snapshot review | `cargo insta pending-snapshots -p codex-tui` | Today | ⏳ |\n| Changelog draft | Release template (https://replacechangelog.com/) | Tomorrow | 📝 |\n\nFinal sign-off criteria are summarized below.\n";
|
|
let width = Some(72usize);
|
|
let mut ctrl = stream_controller(width);
|
|
let mut emitted_lines: Vec<Line<'static>> = Vec::new();
|
|
|
|
for delta in source.split_inclusive('\n') {
|
|
ctrl.push(delta);
|
|
loop {
|
|
let (cell, idle) = ctrl.on_commit_tick();
|
|
if let Some(cell) = cell {
|
|
emitted_lines.extend(cell.transcript_lines(u16::MAX).into_iter().map(|line| {
|
|
let plain: String = line
|
|
.spans
|
|
.iter()
|
|
.map(|s| s.content.clone())
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
Line::from(plain.chars().skip(2).collect::<String>())
|
|
}));
|
|
}
|
|
if idle {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let mut visible = emitted_lines.clone();
|
|
visible.extend(visible_lines(ctrl.current_tail_lines()));
|
|
let visible_plain = lines_to_plain_strings(&visible);
|
|
|
|
let mut expected = Vec::new();
|
|
crate::markdown::append_markdown_agent(
|
|
&ctrl.core.raw_source,
|
|
/*width*/ width,
|
|
&mut expected,
|
|
);
|
|
let expected_plain = lines_to_plain_strings(&expected);
|
|
|
|
assert_eq!(
|
|
visible_plain, expected_plain,
|
|
"live view diverged after delta: {delta:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn finalized_stream_table_preserves_semantic_url_fragments() {
|
|
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
|
let source = format!("| Item | URL |\n| --- | --- |\n| report | {destination} |\n");
|
|
let mut ctrl = stream_controller(/*width*/ Some(32));
|
|
ctrl.push(&source);
|
|
|
|
let (cell, _) = ctrl.finalize();
|
|
let lines = cell
|
|
.expect("final stream table cell")
|
|
.display_hyperlink_lines(/*width*/ 32);
|
|
let linked_rows = lines
|
|
.iter()
|
|
.filter(|line| !line.hyperlinks.is_empty())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(linked_rows.len() > 1);
|
|
assert!(linked_rows.iter().all(|line| {
|
|
line.hyperlinks
|
|
.iter()
|
|
.all(|link| link.destination == destination)
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn controller_keeps_non_markdown_fenced_tables_as_code() {
|
|
let source = "```sh\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
|
|
let deltas = vec![
|
|
"```sh\n",
|
|
"| A | B |\n",
|
|
"|---|---|\n",
|
|
"| 1 | 2 |\n",
|
|
"```\n",
|
|
];
|
|
let streamed = collect_streamed_lines(&deltas, Some(80));
|
|
|
|
let mut rendered = Vec::new();
|
|
crate::markdown::append_markdown_agent(source, /*width*/ Some(80), &mut rendered);
|
|
let expected = lines_to_plain_strings(&rendered);
|
|
|
|
assert_eq!(streamed, expected);
|
|
assert!(
|
|
streamed.iter().any(|line| line.trim() == "| A | B |"),
|
|
"expected code-fenced pipe line to remain raw: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed
|
|
.iter()
|
|
.any(|line| line.contains('━') || line.contains('─')),
|
|
"did not expect a table separator for non-markdown fence: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_controller_streamed_table_matches_final_render() {
|
|
let deltas = vec![
|
|
"## Build plan\n\n",
|
|
"| Step | Owner |\n",
|
|
"|---|---|\n",
|
|
"| Write tests | Agent |\n",
|
|
"| Verify output | User |\n",
|
|
"\n",
|
|
];
|
|
let streamed = collect_plan_streamed_lines(&deltas, Some(80));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let baseline = collect_plan_streamed_lines(&[source.as_str()], Some(80));
|
|
|
|
assert_eq!(streamed, baseline);
|
|
assert!(
|
|
streamed.iter().any(|line| line.contains('━')),
|
|
"expected table separators in plan streamed output: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "| Step | Owner |"),
|
|
"did not expect raw table header line in plan output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn finalized_plan_stream_preserves_semantic_url_fragments() {
|
|
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
|
let source = format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n");
|
|
let mut ctrl = PlanStreamController::new(
|
|
/*width*/ Some(32),
|
|
&test_cwd(),
|
|
HistoryRenderMode::Rich,
|
|
);
|
|
ctrl.push(&source);
|
|
|
|
let (cell, _) = ctrl.finalize();
|
|
let lines = cell
|
|
.expect("final plan stream table cell")
|
|
.display_hyperlink_lines(/*width*/ 32);
|
|
let linked_rows = lines
|
|
.iter()
|
|
.filter(|line| !line.hyperlinks.is_empty())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(linked_rows.len() > 1);
|
|
assert!(linked_rows.iter().all(|line| {
|
|
line.hyperlinks
|
|
.iter()
|
|
.all(|link| link.destination == destination)
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_controller_streamed_markdown_fenced_table_matches_final_render() {
|
|
let deltas = vec![
|
|
"## Build plan\n\n",
|
|
"```md\n",
|
|
"| Step | Owner |\n",
|
|
"|---|---|\n",
|
|
"| Write tests | Agent |\n",
|
|
"| Verify output | User |\n",
|
|
"```\n",
|
|
"\n",
|
|
];
|
|
let streamed = collect_plan_streamed_lines(&deltas, Some(80));
|
|
|
|
let source: String = deltas.iter().copied().collect();
|
|
let baseline = collect_plan_streamed_lines(&[source.as_str()], Some(80));
|
|
|
|
assert_eq!(streamed, baseline);
|
|
assert!(
|
|
streamed.iter().any(|line| line.contains('━')),
|
|
"expected table separators in fenced plan output: {streamed:?}"
|
|
);
|
|
assert!(
|
|
!streamed
|
|
.iter()
|
|
.any(|line| line.trim() == "| Step | Owner |"),
|
|
"did not expect raw table header line in fenced plan output: {streamed:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn table_holdback_state_detects_header_plus_delimiter() {
|
|
let source = "| Key | Description |\n| --- | --- |\n";
|
|
assert!(matches!(
|
|
table_holdback_state(source),
|
|
TableHoldbackState::Confirmed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn table_holdback_state_detects_single_column_header_plus_delimiter() {
|
|
let source = "| Only |\n| --- |\n";
|
|
assert!(matches!(
|
|
table_holdback_state(source),
|
|
TableHoldbackState::Confirmed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn table_holdback_state_ignores_table_like_lines_inside_unclosed_long_fence() {
|
|
let source = "````sh\n```cmd\n| Key | Description |\n| --- | --- |\n````\n";
|
|
assert!(
|
|
matches!(table_holdback_state(source), TableHoldbackState::None),
|
|
"table holdback should ignore pipe lines inside an open non-markdown fence",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn table_holdback_state_treats_indented_fence_text_as_plain_content() {
|
|
let source = " ```sh\n| Key | Description |\n| --- | --- |\n";
|
|
assert!(
|
|
matches!(
|
|
table_holdback_state(source),
|
|
TableHoldbackState::Confirmed { .. }
|
|
),
|
|
"indented fence-like text should not open a fence and should not block table detection",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn table_holdback_state_ignores_table_like_lines_inside_blockquoted_other_fence() {
|
|
let source = "> ```sh\n> | Key | Value |\n> | --- | --- |\n> ```\n";
|
|
assert!(
|
|
matches!(table_holdback_state(source), TableHoldbackState::None),
|
|
"table holdback should ignore pipe lines inside non-markdown blockquoted fences",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_holdback_matches_stateless_scan_per_chunk() {
|
|
let chunks = [
|
|
"status | owner\n",
|
|
"\n",
|
|
"> ```sh\n",
|
|
"> | A | B |\n",
|
|
"> | --- | --- |\n",
|
|
"> ```\n",
|
|
"> | Key | Value |\n",
|
|
"> | --- | --- |\n",
|
|
];
|
|
|
|
let mut scanner = TableHoldbackScanner::new();
|
|
let mut source = String::new();
|
|
for chunk in chunks {
|
|
source.push_str(chunk);
|
|
scanner.push_source_chunk(chunk);
|
|
assert_eq!(
|
|
scanner.state(),
|
|
table_holdback_state(&source),
|
|
"scanner mismatch after chunk: {chunk:?}\nsource:\n{source}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_holdback_detects_header_delimiter_across_chunk_boundary() {
|
|
let mut scanner = TableHoldbackScanner::new();
|
|
scanner.push_source_chunk("| A | B |\n");
|
|
assert_eq!(
|
|
scanner.state(),
|
|
TableHoldbackState::PendingHeader { header_start: 0 }
|
|
);
|
|
scanner.push_source_chunk("| --- | --- |\n");
|
|
assert_eq!(
|
|
scanner.state(),
|
|
TableHoldbackState::Confirmed { table_start: 0 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_after_first_line_emit_does_not_requeue_first_line() {
|
|
let mut ctrl = stream_controller(Some(120));
|
|
ctrl.push(
|
|
"FIRSTTOKEN contains enough words to wrap once the width is reduced dramatically.\n",
|
|
);
|
|
ctrl.push("second line remains pending\n");
|
|
|
|
let (first_emit, _) = ctrl.on_commit_tick();
|
|
assert!(first_emit.is_some(), "expected first line emission");
|
|
|
|
ctrl.set_width(Some(20));
|
|
|
|
let (cell, _source) = ctrl.finalize();
|
|
let remaining = cell
|
|
.map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|line| line.chars().skip(2).collect::<String>())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
!remaining.iter().any(|line| line.contains("FIRSTTOKEN")),
|
|
"first line should not be re-queued after resize: {remaining:?}",
|
|
);
|
|
assert!(
|
|
remaining.iter().any(|line| line.contains("second line")),
|
|
"expected pending second line after resize: {remaining:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_partial_wrapped_emit_preserves_remaining_content() {
|
|
let mut ctrl = stream_controller(Some(20));
|
|
ctrl.push("The quick brown fox jumps over the lazy dog near the riverbank.\n");
|
|
ctrl.push("tail line\n");
|
|
|
|
let (first_emit, idle) = ctrl.on_commit_tick();
|
|
assert!(first_emit.is_some(), "expected first wrapped line emission");
|
|
assert!(!idle, "expected remaining queued content after one tick");
|
|
assert!(
|
|
ctrl.queued_lines() > 0,
|
|
"expected non-empty queue before resize"
|
|
);
|
|
|
|
ctrl.set_width(Some(120));
|
|
|
|
let (cell, _source) = ctrl.finalize();
|
|
let remaining = cell
|
|
.map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|line| line.chars().skip(2).collect::<String>())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
remaining.iter().any(|line| line.contains("tail line")),
|
|
"un-emitted content should remain after resize remap: {remaining:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn controller_set_width_partial_wrapped_emit_keeps_wrapped_remainder() {
|
|
let mut ctrl = stream_controller(Some(18));
|
|
ctrl.push("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\n");
|
|
|
|
let (first_emit, idle) = ctrl.on_commit_tick();
|
|
assert!(first_emit.is_some(), "expected first wrapped line emission");
|
|
assert!(!idle, "expected remaining wrapped content after one tick");
|
|
assert!(
|
|
ctrl.queued_lines() > 0,
|
|
"expected queued wrapped remainder before resize"
|
|
);
|
|
|
|
ctrl.set_width(Some(80));
|
|
|
|
let (cell, _source) = ctrl.finalize();
|
|
let remaining = cell
|
|
.map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX)))
|
|
.unwrap_or_default();
|
|
let joined = remaining.join(" ");
|
|
assert!(
|
|
joined.contains("kappa") || joined.contains("lambda") || joined.contains("mu"),
|
|
"wrapped remainder from partially emitted source line was lost after resize: {remaining:?}",
|
|
);
|
|
}
|
|
}
|