mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(tui): reflow scrollback on terminal resize (#18575)
Fixes multiple scrollback and terminal resize issues: #5538, #5576, #8352, #12223, #16165, and #15380. ## Why Codex writes finalized transcript output into terminal scrollback after wrapping it for the current viewport width. A later terminal resize could leave that scrollback shaped for the old width, so wider windows kept narrow output and narrower windows could show stale wrapping artifacts until enough new output replaced the visible area. This is also the foundation PR for responsive markdown tables. Table rendering needs finalized transcript content to be width-sensitive after insertion, not only while content is first streaming. Markdown table rendering itself stays in #18576. ## Stack - PR1: resize backlog reflow and interrupt cleanup - #18576: markdown table support ## What Changed - Rebuild source-backed transcript history when the terminal width changes. `terminal_resize_reflow` is introduced through the experimental feature system, but is enabled by default for this rollout so we can validate behavior across real terminals. - Preserve assistant and plan stream source so finalized streaming output can participate in resize reflow after consolidation. - Debounce resize work, but force a final source-backed reflow when a resize happened during active or unconsolidated streaming output. - Clear stale pending history lines on resize so old-width wrapped output is not emitted just before rebuilt scrollback. - Bound replay work with `[tui.terminal_resize_reflow].max_rows`: omitted uses terminal-specific defaults, `0` keeps all rendered rows, and a positive value sets an explicit cap. The cap applies both while initially replaying a resumed transcript into scrollback and when rebuilding scrollback after terminal resize. - Consolidate interrupted assistant streams before cleanup, then clear pending stream output and active-tail state consistently. - Move resize reflow and thread event buffering helpers out of `app.rs` into dedicated TUI modules. - Add focused coverage for resize reflow, feature-gated behavior, streaming source preservation, interrupted output cleanup, unicode-neutral text, terminal-specific row caps, and composer/layout stability. ## Runtime Bounds Resize reflow keeps only the most recent rendered rows when a row cap is active. The default is `auto`, which maps to the detected terminal's default scrollback size where Codex can identify it: VS Code `1000`, Windows Terminal `9001`, WezTerm `3500`, and Alacritty `10000`. Terminals without a dedicated mapping use the conservative fallback of `1000` rows. Users can override this with `[tui.terminal_resize_reflow] max_rows = N`, or set `max_rows = 0` to disable row limiting. ## Validation - `just fmt` - `git diff --check` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui reflow` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui transcript_reflow` - `just fix -p codex-tui` - PR CI in progress on the squashed branch
This commit is contained in:
@@ -1,103 +1,292 @@
|
||||
//! Streams markdown deltas while retaining source for later transcript reflow.
|
||||
//!
|
||||
//! Streaming has two outputs with different lifetimes. The live viewport needs incremental
|
||||
//! `HistoryCell`s so the user sees progress, while finalized transcript history needs raw markdown
|
||||
//! source so it can be rendered again after a terminal resize. These controllers keep those outputs
|
||||
//! tied together: newline-complete source is rendered into queued live cells, and finalization
|
||||
//! returns the accumulated source to the app for consolidation.
|
||||
//!
|
||||
//! Width changes are handled by re-rendering from source and rebuilding only the not-yet-emitted
|
||||
//! queue. Already emitted rows stay emitted until the app-level transcript reflow rebuilds the full
|
||||
//! scrollback from finalized cells.
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::{self};
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::style::proposed_plan_style;
|
||||
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;
|
||||
|
||||
/// Controller that manages newline-gated streaming, header emission, and
|
||||
/// commit animation across streams.
|
||||
pub(crate) struct StreamController {
|
||||
/// Shared source-retaining stream state for assistant and plan output.
|
||||
///
|
||||
/// `raw_source` is the markdown source that has crossed a newline boundary and can be rendered
|
||||
/// deterministically. `rendered_lines` is the current-width render of that source. `enqueued_len`
|
||||
/// tracks how much of that render has been offered to the commit queue, while `emitted_len` tracks
|
||||
/// how much has actually reached history cells. Keeping those counters separate lets width changes
|
||||
/// rebuild pending output without duplicating lines that are already visible.
|
||||
struct StreamCore {
|
||||
state: StreamState,
|
||||
finishing_after_drain: bool,
|
||||
width: Option<usize>,
|
||||
raw_source: String,
|
||||
rendered_lines: Vec<Line<'static>>,
|
||||
enqueued_len: usize,
|
||||
emitted_len: usize,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl StreamCore {
|
||||
fn new(width: Option<usize>, cwd: &Path) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width, cwd),
|
||||
width,
|
||||
raw_source: String::with_capacity(1024),
|
||||
rendered_lines: Vec::with_capacity(64),
|
||||
enqueued_len: 0,
|
||||
emitted_len: 0,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delta(&mut self, delta: &str) -> bool {
|
||||
if !delta.is_empty() {
|
||||
self.state.has_seen_delta = true;
|
||||
}
|
||||
self.state.collector.push_delta(delta);
|
||||
|
||||
if delta.contains('\n')
|
||||
&& let Some(committed_source) = self.state.collector.commit_complete_source()
|
||||
{
|
||||
self.raw_source.push_str(&committed_source);
|
||||
self.recompute_render();
|
||||
return self.sync_queue_to_render();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn finalize_remaining(&mut self) -> Vec<Line<'static>> {
|
||||
let remainder_source = self.state.collector.finalize_and_drain_source();
|
||||
if !remainder_source.is_empty() {
|
||||
self.raw_source.push_str(&remainder_source);
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
append_markdown(
|
||||
&self.raw_source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut rendered,
|
||||
);
|
||||
if self.emitted_len >= rendered.len() {
|
||||
Vec::new()
|
||||
} else {
|
||||
rendered[self.emitted_len..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self) -> Vec<Line<'static>> {
|
||||
let step = self.state.step();
|
||||
self.emitted_len += step.len();
|
||||
step
|
||||
}
|
||||
|
||||
fn tick_batch(&mut self, max_lines: usize) -> Vec<Line<'static>> {
|
||||
if max_lines == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let step = self.state.drain_n(max_lines);
|
||||
self.emitted_len += step.len();
|
||||
step
|
||||
}
|
||||
|
||||
fn queued_lines(&self) -> usize {
|
||||
self.state.queued_len()
|
||||
}
|
||||
|
||||
fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
||||
self.state.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
fn is_idle(&self) -> bool {
|
||||
self.state.is_idle()
|
||||
}
|
||||
|
||||
fn set_width(&mut self, width: Option<usize>) {
|
||||
if self.width == width {
|
||||
return;
|
||||
}
|
||||
|
||||
let had_pending_queue = self.state.queued_len() > 0;
|
||||
self.width = width;
|
||||
self.state.collector.set_width(width);
|
||||
if self.raw_source.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.recompute_render();
|
||||
self.emitted_len = self.emitted_len.min(self.rendered_lines.len());
|
||||
if had_pending_queue
|
||||
&& self.emitted_len == self.rendered_lines.len()
|
||||
&& self.emitted_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_len -= 1;
|
||||
}
|
||||
|
||||
self.state.clear_queue();
|
||||
if self.emitted_len > 0 && !had_pending_queue {
|
||||
self.enqueued_len = self.rendered_lines.len();
|
||||
return;
|
||||
}
|
||||
self.rebuild_queue_from_render();
|
||||
}
|
||||
|
||||
fn clear_queue(&mut self) {
|
||||
self.state.clear_queue();
|
||||
self.enqueued_len = self.emitted_len;
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.state.clear();
|
||||
self.raw_source.clear();
|
||||
self.rendered_lines.clear();
|
||||
self.enqueued_len = 0;
|
||||
self.emitted_len = 0;
|
||||
}
|
||||
|
||||
fn recompute_render(&mut self) {
|
||||
self.rendered_lines.clear();
|
||||
append_markdown(
|
||||
&self.raw_source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut self.rendered_lines,
|
||||
);
|
||||
}
|
||||
|
||||
/// Append newly rendered lines to the live queue without replaying already queued rows.
|
||||
///
|
||||
/// Width changes can make the rendered line count smaller than the previous queue boundary; in
|
||||
/// that case the only safe option is rebuilding the queue from `emitted_len`, because slicing
|
||||
/// from the stale `enqueued_len` would skip pending source.
|
||||
fn sync_queue_to_render(&mut self) -> bool {
|
||||
let target_len = self.rendered_lines.len().max(self.emitted_len);
|
||||
if target_len < self.enqueued_len {
|
||||
self.rebuild_queue_from_render();
|
||||
return self.state.queued_len() > 0;
|
||||
}
|
||||
|
||||
if target_len == self.enqueued_len {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.state
|
||||
.enqueue(self.rendered_lines[self.enqueued_len..target_len].to_vec());
|
||||
self.enqueued_len = target_len;
|
||||
true
|
||||
}
|
||||
|
||||
/// Rebuild the pending live queue from the current render and current emitted position.
|
||||
///
|
||||
/// This is used when resize invalidates queued wrapping. It must never enqueue rows before
|
||||
/// `emitted_len`, because those rows have already been inserted into terminal history.
|
||||
fn rebuild_queue_from_render(&mut self) {
|
||||
self.state.clear_queue();
|
||||
let target_len = self.rendered_lines.len().max(self.emitted_len);
|
||||
if self.emitted_len < target_len {
|
||||
self.state
|
||||
.enqueue(self.rendered_lines[self.emitted_len..target_len].to_vec());
|
||||
}
|
||||
self.enqueued_len = target_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls newline-gated streaming for assistant messages.
|
||||
///
|
||||
/// The controller emits transient `AgentMessageCell`s for live display and returns raw markdown
|
||||
/// source on `finalize` so the app can replace those transient cells with a source-backed
|
||||
/// `AgentMarkdownCell`. Callers should use `set_width` on terminal resize; rebuilding the queue
|
||||
/// from already emitted cells would duplicate output instead of preserving the stream position.
|
||||
pub(crate) struct StreamController {
|
||||
core: StreamCore,
|
||||
header_emitted: bool,
|
||||
}
|
||||
|
||||
impl StreamController {
|
||||
/// Create a controller whose markdown renderer shortens local file links relative to `cwd`.
|
||||
/// Create a stream controller that renders markdown relative to the given width and cwd.
|
||||
///
|
||||
/// The controller snapshots the path into stream state so later commit ticks and finalization
|
||||
/// render against the same session cwd that was active when streaming started.
|
||||
/// `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) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width, cwd),
|
||||
finishing_after_drain: false,
|
||||
core: StreamCore::new(width, cwd),
|
||||
header_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a delta; if it contains a newline, commit completed lines and start animation.
|
||||
pub(crate) fn push(&mut self, delta: &str) -> bool {
|
||||
let state = &mut self.state;
|
||||
if !delta.is_empty() {
|
||||
state.has_seen_delta = true;
|
||||
}
|
||||
state.collector.push_delta(delta);
|
||||
if delta.contains('\n') {
|
||||
let newly_completed = state.collector.commit_complete_lines();
|
||||
if !newly_completed.is_empty() {
|
||||
state.enqueue(newly_completed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Finalize the active stream. Drain and emit now.
|
||||
pub(crate) fn finalize(&mut self) -> Option<Box<dyn HistoryCell>> {
|
||||
// Finalize collector first.
|
||||
let remaining = {
|
||||
let state = &mut self.state;
|
||||
state.collector.finalize_and_drain()
|
||||
};
|
||||
// Collect all output first to avoid emitting headers when there is no content.
|
||||
let mut out_lines = Vec::new();
|
||||
{
|
||||
let state = &mut self.state;
|
||||
if !remaining.is_empty() {
|
||||
state.enqueue(remaining);
|
||||
}
|
||||
let step = state.drain_all();
|
||||
out_lines.extend(step);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
self.state.clear();
|
||||
self.finishing_after_drain = false;
|
||||
self.emit(out_lines)
|
||||
}
|
||||
|
||||
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
|
||||
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
|
||||
let step = self.state.step();
|
||||
(self.emit(step), self.state.is_idle())
|
||||
}
|
||||
|
||||
/// Step animation: commit at most `max_lines` queued lines.
|
||||
/// Push a raw model delta and return whether it produced queued complete lines.
|
||||
///
|
||||
/// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a
|
||||
/// very large value can collapse perceived animation into a single jump.
|
||||
/// Deltas are committed only through newline boundaries. A `false` return can still mean source
|
||||
/// was buffered; it only means no newly renderable complete line is ready for live emission.
|
||||
pub(crate) fn push(&mut self, delta: &str) -> bool {
|
||||
self.core.push_delta(delta)
|
||||
}
|
||||
|
||||
/// Finish the stream and return the final transient cell plus accumulated markdown source.
|
||||
///
|
||||
/// The source is `None` only when the stream never accumulated content. Callers that discard the
|
||||
/// returned source cannot later consolidate the transcript into a width-sensitive finalized
|
||||
/// cell.
|
||||
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);
|
||||
}
|
||||
|
||||
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.state.drain_n(max_lines.max(1));
|
||||
(self.emit(step), self.state.is_idle())
|
||||
let step = self.core.tick_batch(max_lines);
|
||||
(self.emit(step), self.core.is_idle())
|
||||
}
|
||||
|
||||
/// Returns the current number of queued lines waiting to be displayed.
|
||||
pub(crate) fn queued_lines(&self) -> usize {
|
||||
self.state.queued_len()
|
||||
self.core.queued_lines()
|
||||
}
|
||||
|
||||
/// Returns the age of the oldest queued line.
|
||||
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
||||
self.state.oldest_queued_age(now)
|
||||
self.core.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_queue(&mut self) {
|
||||
self.core.clear_queue();
|
||||
}
|
||||
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
|
||||
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
|
||||
@@ -112,96 +301,88 @@ impl StreamController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller that streams proposed plan markdown into a styled plan block.
|
||||
/// Controls newline-gated streaming for proposed plan markdown.
|
||||
///
|
||||
/// This follows the same source-retention contract as `StreamController`, but wraps emitted lines
|
||||
/// in the proposed-plan header, padding, and style. Finalization must return source for
|
||||
/// `ProposedPlanCell`; otherwise a resized finalized plan would keep the transient stream shape.
|
||||
pub(crate) struct PlanStreamController {
|
||||
state: StreamState,
|
||||
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`.
|
||||
/// Create a proposed-plan stream controller that renders markdown relative to the given cwd.
|
||||
///
|
||||
/// The controller snapshots the path into stream state so later commit ticks and finalization
|
||||
/// render against the same session cwd that was active when streaming started.
|
||||
/// 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) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width, cwd),
|
||||
core: StreamCore::new(width, cwd),
|
||||
header_emitted: false,
|
||||
top_padding_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a delta; if it contains a newline, commit completed lines and start animation.
|
||||
/// Push a raw proposed-plan delta and return whether it produced queued complete lines.
|
||||
///
|
||||
/// Source may be buffered even when this returns `false`; callers should continue ticking only
|
||||
/// when queued lines exist.
|
||||
pub(crate) fn push(&mut self, delta: &str) -> bool {
|
||||
let state = &mut self.state;
|
||||
if !delta.is_empty() {
|
||||
state.has_seen_delta = true;
|
||||
}
|
||||
state.collector.push_delta(delta);
|
||||
if delta.contains('\n') {
|
||||
let newly_completed = state.collector.commit_complete_lines();
|
||||
if !newly_completed.is_empty() {
|
||||
state.enqueue(newly_completed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
self.core.push_delta(delta)
|
||||
}
|
||||
|
||||
/// Finalize the active stream. Drain and emit now.
|
||||
pub(crate) fn finalize(&mut self) -> Option<Box<dyn HistoryCell>> {
|
||||
let remaining = {
|
||||
let state = &mut self.state;
|
||||
state.collector.finalize_and_drain()
|
||||
};
|
||||
let mut out_lines = Vec::new();
|
||||
{
|
||||
let state = &mut self.state;
|
||||
if !remaining.is_empty() {
|
||||
state.enqueue(remaining);
|
||||
}
|
||||
let step = state.drain_all();
|
||||
out_lines.extend(step);
|
||||
/// Finish the plan stream and return the final transient cell plus accumulated markdown source.
|
||||
///
|
||||
/// The returned source is consumed by app-level consolidation to create the source-backed
|
||||
/// `ProposedPlanCell` used for later resize reflow.
|
||||
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);
|
||||
}
|
||||
|
||||
self.state.clear();
|
||||
self.emit(out_lines, /*include_bottom_padding*/ true)
|
||||
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))
|
||||
}
|
||||
|
||||
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
|
||||
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
|
||||
let step = self.state.step();
|
||||
let step = self.core.tick();
|
||||
(
|
||||
self.emit(step, /*include_bottom_padding*/ false),
|
||||
self.state.is_idle(),
|
||||
self.core.is_idle(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Step animation: commit at most `max_lines` queued lines.
|
||||
///
|
||||
/// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a
|
||||
/// very large value can collapse perceived animation into a single jump.
|
||||
pub(crate) fn on_commit_tick_batch(
|
||||
&mut self,
|
||||
max_lines: usize,
|
||||
) -> (Option<Box<dyn HistoryCell>>, bool) {
|
||||
let step = self.state.drain_n(max_lines.max(1));
|
||||
let step = self.core.tick_batch(max_lines);
|
||||
(
|
||||
self.emit(step, /*include_bottom_padding*/ false),
|
||||
self.state.is_idle(),
|
||||
self.core.is_idle(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the current number of queued plan lines waiting to be displayed.
|
||||
pub(crate) fn queued_lines(&self) -> usize {
|
||||
self.state.queued_len()
|
||||
self.core.queued_lines()
|
||||
}
|
||||
|
||||
/// Returns the age of the oldest queued plan line.
|
||||
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
|
||||
self.state.oldest_queued_age(now)
|
||||
self.core.oldest_queued_age(now)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_queue(&mut self) {
|
||||
self.core.clear_queue();
|
||||
}
|
||||
|
||||
pub(crate) fn set_width(&mut self, width: Option<usize>) {
|
||||
self.core.set_width(width);
|
||||
}
|
||||
|
||||
fn emit(
|
||||
@@ -213,7 +394,7 @@ impl PlanStreamController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out_lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(4);
|
||||
let is_stream_continuation = self.header_emitted;
|
||||
if !self.header_emitted {
|
||||
out_lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
|
||||
@@ -221,7 +402,7 @@ impl PlanStreamController {
|
||||
self.header_emitted = true;
|
||||
}
|
||||
|
||||
let mut plan_lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut plan_lines: Vec<Line<'static>> = Vec::with_capacity(4);
|
||||
if !self.top_padding_emitted {
|
||||
plan_lines.push(Line::from(" "));
|
||||
self.top_padding_emitted = true;
|
||||
@@ -248,106 +429,37 @@ impl PlanStreamController {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
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 lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
|
||||
fn stream_controller(width: Option<usize>) -> StreamController {
|
||||
StreamController::new(width, &test_cwd())
|
||||
}
|
||||
|
||||
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
|
||||
PlanStreamController::new(width, &test_cwd())
|
||||
}
|
||||
|
||||
fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
.map(|span| span.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||
let mut ctrl = StreamController::new(/*width*/ None, &test_cwd());
|
||||
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
|
||||
let mut ctrl = stream_controller(width);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Exact deltas from the session log (section: Loose vs. tight list items)
|
||||
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",
|
||||
];
|
||||
|
||||
// Simulate streaming with a commit tick attempt after each delta.
|
||||
for d in deltas.iter() {
|
||||
ctrl.push(d);
|
||||
for delta in deltas {
|
||||
ctrl.push(delta);
|
||||
while let (Some(cell), idle) = ctrl.on_commit_tick() {
|
||||
lines.extend(cell.transcript_lines(u16::MAX));
|
||||
if idle {
|
||||
@@ -355,47 +467,122 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Finalize and flush remaining lines now.
|
||||
if let Some(cell) = ctrl.finalize() {
|
||||
if let (Some(cell), _source) = ctrl.finalize() {
|
||||
lines.extend(cell.transcript_lines(u16::MAX));
|
||||
}
|
||||
|
||||
let streamed: Vec<_> = lines_to_plain_strings(&lines)
|
||||
lines_to_plain_strings(&lines)
|
||||
.into_iter()
|
||||
// skip • and 2-space indentation
|
||||
.map(|s| s.chars().skip(2).collect::<String>())
|
||||
.collect();
|
||||
.map(|line| line.chars().skip(2).collect::<String>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Full render of the same source
|
||||
let source: String = deltas.iter().copied().collect();
|
||||
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
|
||||
let test_cwd = test_cwd();
|
||||
crate::markdown::append_markdown(
|
||||
&source,
|
||||
/*width*/ None,
|
||||
Some(test_cwd.as_path()),
|
||||
&mut rendered,
|
||||
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 delta in deltas {
|
||||
ctrl.push(delta);
|
||||
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),
|
||||
);
|
||||
let rendered_strs = lines_to_plain_strings(&rendered);
|
||||
|
||||
assert_eq!(streamed, rendered_strs);
|
||||
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));
|
||||
|
||||
// Also assert exact expected plain strings for clarity.
|
||||
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(),
|
||||
"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"
|
||||
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_finalize_returns_raw_source_for_consolidation() {
|
||||
let mut ctrl = stream_controller(Some(80));
|
||||
assert!(ctrl.push("hello\n"));
|
||||
let (_cell, source) = ctrl.finalize();
|
||||
assert_eq!(source, Some("hello\n".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_controller_finalize_returns_raw_source_for_consolidation() {
|
||||
let mut ctrl = plan_stream_controller(Some(80));
|
||||
assert!(ctrl.push("- step\n"));
|
||||
let (_cell, source) = ctrl.finalize();
|
||||
assert_eq!(source, Some("- step\n".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_lines_stream_in_order() {
|
||||
let actual = collect_streamed_lines(&["hello\n", "world\n"], Some(80));
|
||||
assert_eq!(actual, vec!["hello".to_string(), "world".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_lines_stream_in_order() {
|
||||
let actual = collect_plan_streamed_lines(&["- one\n", "- two\n"], Some(80));
|
||||
assert!(
|
||||
actual.iter().any(|line| line.contains("Proposed Plan")),
|
||||
"expected plan header in streamed plan: {actual:?}",
|
||||
);
|
||||
assert!(
|
||||
actual.iter().any(|line| line.contains("one")),
|
||||
"expected plan body in streamed plan: {actual:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,9 @@ impl StreamState {
|
||||
.map(|queued| queued.line)
|
||||
.collect()
|
||||
}
|
||||
/// Drains all queued lines from the front of the queue.
|
||||
pub(crate) fn drain_all(&mut self) -> Vec<Line<'static>> {
|
||||
self.queued_lines
|
||||
.drain(..)
|
||||
.map(|queued| queued.line)
|
||||
.collect()
|
||||
/// Clears queued lines while keeping collector/turn lifecycle state intact.
|
||||
pub(crate) fn clear_queue(&mut self) {
|
||||
self.queued_lines.clear();
|
||||
}
|
||||
/// Returns whether no lines are queued for commit.
|
||||
pub(crate) fn is_idle(&self) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user