//! 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:?}", ); }}