tui: use source-mapped boundaries for stream resize remap

This commit is contained in:
Felipe Coury
2026-04-06 13:26:28 -03:00
committed by Felipe Coury
parent 3c9f8d1eaf
commit f1ee30daa3
3 changed files with 400 additions and 175 deletions
+184 -24
View File
@@ -20,10 +20,16 @@
//! info string is `md` or `markdown` AND whose body contains a
//! header+delimiter pair, and degrades gracefully on unclosed fences.
use ratatui::text::Line;
use std::ops::Range;
use std::path::Path;
use crate::table_detect;
pub(crate) struct AgentRenderedWithSourceMap {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) line_end_source_bytes: Vec<usize>,
}
/// Render markdown into `lines` while resolving local file-link display relative to `cwd`.
///
/// Callers that already know the session working directory should pass it here so streamed and
@@ -64,8 +70,39 @@ pub(crate) fn append_markdown_agent_with_cwd(
cwd: Option<&Path>,
lines: &mut Vec<Line<'static>>,
) {
let normalized = unwrap_markdown_fences(markdown_source);
append_markdown(&normalized, width, cwd, lines);
let rendered = render_markdown_agent_with_source_map_with_cwd(markdown_source, width, cwd);
lines.extend(rendered.lines);
}
#[cfg(test)]
pub(crate) fn render_markdown_agent_with_source_map(
markdown_source: &str,
width: Option<usize>,
) -> AgentRenderedWithSourceMap {
render_markdown_agent_with_source_map_with_cwd(markdown_source, width, /*cwd*/ None)
}
pub(crate) fn render_markdown_agent_with_source_map_with_cwd(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> AgentRenderedWithSourceMap {
let normalized = unwrap_markdown_fences_with_mapping(markdown_source);
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd_and_source_map(
&normalized.text,
width,
cwd,
);
let lines = rendered.text.lines;
let line_end_source_bytes = rendered
.line_end_input_bytes
.into_iter()
.map(|offset| normalized.raw_source_offset(offset))
.collect();
AgentRenderedWithSourceMap {
lines,
line_end_source_bytes,
}
}
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
@@ -79,7 +116,64 @@ pub(crate) fn append_markdown_agent_with_cwd(
/// The fence unwrapping is intentionally conservative: it buffers the entire fence body before
/// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial
/// streams degrade to code display.
#[cfg(test)]
fn unwrap_markdown_fences(markdown_source: &str) -> String {
unwrap_markdown_fences_with_mapping(markdown_source).text
}
#[derive(Clone, Debug)]
struct NormalizedToRawSegment {
normalized_start: usize,
raw_start: usize,
}
struct NormalizedMarkdown {
text: String,
raw_source_len: usize,
segments: Vec<NormalizedToRawSegment>,
}
impl NormalizedMarkdown {
fn new(raw_source_len: usize, capacity: usize) -> Self {
Self {
text: String::with_capacity(capacity),
raw_source_len,
segments: Vec::new(),
}
}
fn push_source_range(&mut self, source: &str, range: Range<usize>) {
if range.is_empty() {
return;
}
let out_start = self.text.len();
let out_text = &source[range.clone()];
self.text.push_str(out_text);
self.segments.push(NormalizedToRawSegment {
normalized_start: out_start,
raw_start: range.start,
});
}
fn raw_source_offset(&self, normalized_offset: usize) -> usize {
if normalized_offset == 0 {
return 0;
}
if normalized_offset >= self.text.len() {
return self.raw_source_len;
}
let idx = self
.segments
.partition_point(|segment| segment.normalized_start < normalized_offset);
if idx == 0 {
return 0;
}
let segment = &self.segments[idx - 1];
segment.raw_start + normalized_offset.saturating_sub(segment.normalized_start)
}
}
fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkdown {
#[derive(Clone, Copy)]
struct Fence {
marker: char,
@@ -159,46 +253,66 @@ fn unwrap_markdown_fences(markdown_source: &str) -> String {
false
}
fn content_from_ranges(source: &str, ranges: &[Range<usize>]) -> String {
let mut content = String::new();
for range in ranges {
content.push_str(&source[range.clone()]);
}
content
}
enum ActiveFence {
Passthrough(Fence),
MarkdownCandidate {
fence: Fence,
opening_line: String,
content: String,
opening_range: Range<usize>,
content_ranges: Vec<Range<usize>>,
},
}
let mut out = String::with_capacity(markdown_source.len());
let mut out = NormalizedMarkdown::new(markdown_source.len(), markdown_source.len());
let mut active_fence: Option<ActiveFence> = None;
let mut source_offset = 0usize;
for line in markdown_source.split_inclusive('\n') {
let line_start = source_offset;
source_offset += line.len();
let line_range = line_start..source_offset;
if let Some(active) = active_fence.take() {
match active {
ActiveFence::Passthrough(fence) => {
out.push_str(line);
out.push_source_range(markdown_source, line_range);
if !is_close_fence(line, fence) {
active_fence = Some(ActiveFence::Passthrough(fence));
}
}
ActiveFence::MarkdownCandidate {
fence,
opening_line,
mut content,
opening_range,
mut content_ranges,
} => {
if is_close_fence(line, fence) {
if markdown_fence_contains_table(&content) {
out.push_str(&content);
if markdown_fence_contains_table(&content_from_ranges(
markdown_source,
&content_ranges,
)) {
for range in content_ranges {
out.push_source_range(markdown_source, range);
}
} else {
out.push_str(&opening_line);
out.push_str(&content);
out.push_str(line);
out.push_source_range(markdown_source, opening_range);
for range in content_ranges {
out.push_source_range(markdown_source, range);
}
out.push_source_range(markdown_source, line_range);
}
} else {
content.push_str(line);
content_ranges.push(line_range);
active_fence = Some(ActiveFence::MarkdownCandidate {
fence,
opening_line,
content,
opening_range,
content_ranges,
});
}
}
@@ -210,29 +324,31 @@ fn unwrap_markdown_fences(markdown_source: &str) -> String {
if fence.is_markdown {
active_fence = Some(ActiveFence::MarkdownCandidate {
fence,
opening_line: line.to_string(),
content: String::new(),
opening_range: line_range,
content_ranges: Vec::new(),
});
} else {
out.push_str(line);
out.push_source_range(markdown_source, line_range);
active_fence = Some(ActiveFence::Passthrough(fence));
}
continue;
}
out.push_str(line);
out.push_source_range(markdown_source, line_range);
}
if let Some(active) = active_fence {
match active {
ActiveFence::Passthrough(_) => {}
ActiveFence::MarkdownCandidate {
opening_line,
content,
opening_range,
content_ranges,
..
} => {
out.push_str(&opening_line);
out.push_str(&content);
out.push_source_range(markdown_source, opening_range);
for range in content_ranges {
out.push_source_range(markdown_source, range);
}
}
}
}
@@ -421,4 +537,48 @@ mod tests {
let normalized = unwrap_markdown_fences(src);
assert_eq!(normalized, src);
}
#[test]
fn render_markdown_agent_with_source_map_offsets_align_with_line_count() {
let src = "alpha\n\n```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\nomega\n";
let rendered = render_markdown_agent_with_source_map(src, Some(80));
assert_eq!(rendered.lines.len(), rendered.line_end_source_bytes.len());
let mut prev = 0usize;
for offset in rendered.line_end_source_bytes {
assert!(
offset <= src.len(),
"line-end offset must stay within source bounds: {offset} > {}",
src.len()
);
assert!(
offset >= prev,
"line-end offsets must be non-decreasing: {offset} < {prev}"
);
prev = offset;
}
}
#[test]
fn unwrap_markdown_fences_mapping_preserves_raw_offsets_across_removed_fence_lines() {
let src = "before\n```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\nafter\n";
let normalized = unwrap_markdown_fences_with_mapping(src);
assert!(
!normalized.text.contains("```"),
"markdown table fence should be removed in normalized text"
);
let after_in_normalized = normalized
.text
.find("after")
.expect("expected 'after' in normalized text");
let after_in_raw = src.find("after").expect("expected 'after' in raw source");
assert_eq!(
normalized.raw_source_offset(after_in_normalized + 1),
after_in_raw + 1
);
assert_eq!(
normalized.raw_source_offset(normalized.text.len()),
src.len()
);
}
}
+55 -11
View File
@@ -59,6 +59,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use regex_lite::Regex;
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
@@ -251,6 +252,11 @@ pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, /*width*/ None)
}
pub(crate) struct RenderedMarkdownWithSourceMap {
pub(crate) text: Text<'static>,
pub(crate) line_end_input_bytes: Vec<usize>,
}
/// Render markdown constrained to a known terminal width.
///
/// The renderer preserves table structure when possible and falls back to
@@ -273,13 +279,33 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
width: Option<usize>,
cwd: Option<&Path>,
) -> Text<'static> {
render_markdown_text_with_width_and_cwd_and_source_map(input, width, cwd).text
}
#[cfg(test)]
pub(crate) fn render_markdown_text_with_width_and_source_map(
input: &str,
width: Option<usize>,
) -> RenderedMarkdownWithSourceMap {
let cwd = std::env::current_dir().ok();
render_markdown_text_with_width_and_cwd_and_source_map(input, width, cwd.as_deref())
}
pub(crate) fn render_markdown_text_with_width_and_cwd_and_source_map(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> RenderedMarkdownWithSourceMap {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width, cwd);
let parser = Parser::new_ext(input, options).into_offset_iter();
let mut w = Writer::new(parser, width, input.len(), cwd);
w.run();
w.text
RenderedMarkdownWithSourceMap {
text: w.text,
line_end_input_bytes: w.line_end_input_bytes,
}
}
#[derive(Clone, Debug)]
@@ -320,10 +346,13 @@ static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
/// allocation; when `None`, lines keep their intrinsic width.
struct Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
iter: I,
source_len: usize,
text: Text<'static>,
line_end_input_bytes: Vec<usize>,
current_event_end: usize,
styles: MarkdownStyles,
inline_styles: Vec<Style>,
indent_stack: Vec<IndentContext>,
@@ -349,12 +378,20 @@ where
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
fn new(
iter: I,
wrap_width: Option<usize>,
source_len: usize,
cwd: Option<&Path>,
) -> Self {
Self {
iter,
source_len,
text: Text::default(),
line_end_input_bytes: Vec::new(),
current_event_end: 0,
styles: MarkdownStyles::default(),
inline_styles: Vec::new(),
indent_stack: Vec::new(),
@@ -380,9 +417,11 @@ where
}
fn run(&mut self) {
while let Some(ev) = self.iter.next() {
while let Some((ev, range)) = self.iter.next() {
self.current_event_end = range.end;
self.handle_event(ev);
}
self.current_event_end = self.source_len;
self.flush_current_line();
}
@@ -1569,13 +1608,13 @@ where
.subsequent_indent(self.current_subsequent_indent.clone().into());
for wrapped in adaptive_wrap_line(&line, opts) {
let owned = line_to_static(&wrapped).style(style);
self.text.lines.push(owned);
self.push_output_line(owned);
}
} else {
let mut spans = self.current_initial_indent.clone();
let mut line = line;
spans.append(&mut line.spans);
self.text.lines.push(Line::from_iter(spans).style(style));
self.push_output_line(Line::from_iter(spans).style(style));
}
self.current_initial_indent.clear();
self.current_subsequent_indent.clear();
@@ -1605,7 +1644,7 @@ where
let mut spans = self.prefix_spans(pending_marker_line);
spans.extend(line.spans);
self.text.lines.push(Line::from(spans).style(style));
self.push_output_line(Line::from(spans).style(style));
}
fn push_line(&mut self, line: Line<'static>) {
@@ -1642,13 +1681,18 @@ where
fn push_blank_line(&mut self) {
self.flush_current_line();
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
self.text.lines.push(Line::default());
self.push_output_line(Line::default());
} else {
self.push_line(Line::default());
self.flush_current_line();
}
}
fn push_output_line(&mut self, line: Line<'static>) {
self.text.lines.push(line);
self.line_end_input_bytes.push(self.current_event_end);
}
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
let mut prefix: Vec<Span<'static>> = Vec::new();
let last_marker_index = if pending_marker_line {
+161 -140
View File
@@ -21,14 +21,15 @@
//! ## Resize handling
//!
//! On terminal width change, `StreamCore::set_width` remaps the
//! already-emitted line count to the new width using
//! `source_bytes_for_rendered_count`, then rebuilds the stable queue. The
//! already-emitted stable boundary to the new width from source-byte anchors
//! and line-end source maps, then rebuilds the stable queue. The
//! caller (`App`) schedules a debounced full-transcript reflow so committed
//! `AgentMarkdownCell`s re-render from source at the new width.
//!
//! ## Invariants
//!
//! - `emitted_stable_len <= enqueued_stable_len <= rendered_lines.len()`.
//! - `rendered_line_end_source_bytes.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
@@ -37,6 +38,7 @@
use crate::history_cell::HistoryCell;
use crate::history_cell::{self};
use crate::markdown::append_markdown_agent_with_cwd;
use crate::markdown::render_markdown_agent_with_source_map_with_cwd;
use crate::render::line_utils::prefix_lines;
use crate::style::proposed_plan_style;
use ratatui::prelude::Stylize;
@@ -73,12 +75,20 @@ struct StreamCore {
raw_source: String,
/// Full re-render of `raw_source` at `width`. Rebuilt on every committed delta.
rendered_lines: Vec<Line<'static>>,
/// Per-rendered-line source byte offsets (raw source coordinates).
rendered_line_end_source_bytes: Vec<usize>,
/// 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,
/// Raw source boundary reached by emitted stable lines.
emitted_stable_source_bytes: usize,
/// How many emitted stable lines end exactly at `emitted_stable_source_bytes`.
emitted_stable_lines_at_source_byte: usize,
/// Total rendered lines ending at `emitted_stable_source_bytes` in current render.
emitted_stable_lines_at_source_byte_total: usize,
}
impl StreamCore {
@@ -88,9 +98,13 @@ impl StreamCore {
width,
raw_source: String::new(),
rendered_lines: Vec::new(),
rendered_line_end_source_bytes: Vec::new(),
enqueued_stable_len: 0,
emitted_stable_len: 0,
cwd: cwd.to_path_buf(),
emitted_stable_source_bytes: 0,
emitted_stable_lines_at_source_byte: 0,
emitted_stable_lines_at_source_byte_total: 0,
}
}
@@ -120,13 +134,12 @@ impl StreamCore {
if !remainder_source.is_empty() {
self.raw_source.push_str(&remainder_source);
}
let mut rendered = Vec::new();
append_markdown_agent_with_cwd(
let rendered = render_markdown_agent_with_source_map_with_cwd(
&self.raw_source,
self.width,
Some(self.cwd.as_path()),
&mut rendered,
);
let rendered = rendered.lines;
if self.emitted_stable_len >= rendered.len() {
Vec::new()
} else {
@@ -137,14 +150,18 @@ impl StreamCore {
/// Step animation: dequeue one line, update the emitted count.
fn tick(&mut self) -> Vec<Line<'static>> {
let step = self.state.step();
let emitted_start = self.emitted_stable_len;
self.emitted_stable_len += step.len();
self.advance_emitted_source_boundary(emitted_start, self.emitted_stable_len);
step
}
/// Batch drain: dequeue up to `max_lines`, update the emitted count.
fn tick_batch(&mut self, max_lines: usize) -> Vec<Line<'static>> {
let step = self.state.drain_n(max_lines.max(1));
let emitted_start = self.emitted_stable_len;
self.emitted_stable_len += step.len();
self.advance_emitted_source_boundary(emitted_start, self.emitted_stable_len);
step
}
@@ -171,41 +188,22 @@ impl StreamCore {
/// Update rendering width and rebuild queued stable lines for the new layout.
///
/// When `emitted_stable_len > 0`, uses `source_bytes_for_rendered_count` to
/// find the byte offset in `raw_source` that corresponds to the old emitted
/// count, then re-renders that prefix at the new width to recalculate
/// `emitted_stable_len`. The stable queue is rebuilt from the new render.
/// Re-renders once at the new width and remaps already-emitted progress by
/// source boundary (`emitted_stable_source_bytes`) plus tie-break count for
/// wrapped lines that share the same line-end source offset.
fn set_width(&mut self, width: Option<usize>) {
if self.width == width {
return;
}
let old_width = self.width;
self.width = width;
self.state.collector.set_width(width);
if self.raw_source.is_empty() {
return;
}
// Recalculate emitted_stabe_len for the new width so we don't re-queue lines that were
// already emitted at the old width.
if self.emitted_stable_len > 0 {
let emitted_bytes = source_bytes_for_rendered_count(
&self.raw_source,
old_width,
self.cwd.as_path(),
self.emitted_stable_len,
);
let mut emitted_at_new = Vec::new();
append_markdown_agent_with_cwd(
&self.raw_source[..emitted_bytes],
self.width,
Some(self.cwd.as_path()),
&mut emitted_at_new,
);
self.emitted_stable_len = emitted_at_new.len();
}
self.recompute_streaming_render();
self.emitted_stable_len = self.remap_emitted_len_from_source_boundary();
self.sync_emitted_source_counters_from_render();
self.rebuild_stable_queue_from_render();
}
@@ -214,20 +212,27 @@ impl StreamCore {
self.state.clear();
self.raw_source.clear();
self.rendered_lines.clear();
self.rendered_line_end_source_bytes.clear();
self.enqueued_stable_len = 0;
self.emitted_stable_len = 0;
self.emitted_stable_source_bytes = 0;
self.emitted_stable_lines_at_source_byte = 0;
self.emitted_stable_lines_at_source_byte_total = 0;
}
/// Re-render the full `raw_source` at current `width`.
fn recompute_streaming_render(&mut self) {
let mut rendered = Vec::new();
append_markdown_agent_with_cwd(
let rendered = render_markdown_agent_with_source_map_with_cwd(
&self.raw_source,
self.width,
Some(self.cwd.as_path()),
&mut rendered,
);
self.rendered_lines = rendered;
self.rendered_lines = rendered.lines;
self.rendered_line_end_source_bytes = rendered.line_end_source_bytes;
debug_assert_eq!(
self.rendered_line_end_source_bytes.len(),
self.rendered_lines.len()
);
}
/// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any
@@ -316,6 +321,87 @@ impl StreamCore {
.len()
.saturating_sub(stable_prefix_render.len())
}
fn advance_emitted_source_boundary(&mut self, emitted_start: usize, emitted_end: usize) {
if emitted_start >= emitted_end || self.rendered_line_end_source_bytes.is_empty() {
return;
}
let end = emitted_end.min(self.rendered_line_end_source_bytes.len());
for line_end_source in &self.rendered_line_end_source_bytes[emitted_start..end] {
if *line_end_source > self.emitted_stable_source_bytes {
self.emitted_stable_source_bytes = *line_end_source;
self.emitted_stable_lines_at_source_byte = 1;
self.emitted_stable_lines_at_source_byte_total = self
.rendered_line_end_source_bytes
.iter()
.filter(|source| **source == *line_end_source)
.count()
.max(1);
} else if *line_end_source == self.emitted_stable_source_bytes {
self.emitted_stable_lines_at_source_byte += 1;
}
}
}
fn remap_emitted_len_from_source_boundary(&self) -> usize {
if self.emitted_stable_len == 0 {
return 0;
}
if self.emitted_stable_source_bytes >= self.raw_source.len() {
return self.rendered_lines.len();
}
let mut lines_before = 0usize;
let mut lines_at = 0usize;
for line_end_source in &self.rendered_line_end_source_bytes {
if *line_end_source < self.emitted_stable_source_bytes {
lines_before += 1;
} else if *line_end_source == self.emitted_stable_source_bytes {
lines_at += 1;
}
}
let emitted_at_boundary = if self.emitted_stable_lines_at_source_byte
>= self.emitted_stable_lines_at_source_byte_total
{
lines_at
} else if lines_at >= self.emitted_stable_lines_at_source_byte {
self.emitted_stable_lines_at_source_byte
} else {
0
};
lines_before
+ emitted_at_boundary.min(self.rendered_lines.len().saturating_sub(lines_before))
}
fn sync_emitted_source_counters_from_render(&mut self) {
let emitted_len = self
.emitted_stable_len
.min(self.rendered_line_end_source_bytes.len());
if emitted_len == 0 {
self.emitted_stable_source_bytes = 0;
self.emitted_stable_lines_at_source_byte = 0;
self.emitted_stable_lines_at_source_byte_total = 0;
return;
}
let boundary = self.rendered_line_end_source_bytes[emitted_len - 1];
let emitted_at_boundary = self.rendered_line_end_source_bytes[..emitted_len]
.iter()
.rev()
.take_while(|line_end| **line_end == boundary)
.count();
let total_at_boundary = self
.rendered_line_end_source_bytes
.iter()
.filter(|line_end| **line_end == boundary)
.count()
.max(1);
self.emitted_stable_source_bytes = boundary;
self.emitted_stable_lines_at_source_byte = emitted_at_boundary;
self.emitted_stable_lines_at_source_byte_total = total_at_boundary;
}
}
/// Controller for streaming agent message content with table-aware holdback.
@@ -512,53 +598,6 @@ impl PlanStreamController {
}
}
// ---------------------------------------------------------------------------
// source_bytes_for_rendered_count — resize remapping helper
// ---------------------------------------------------------------------------
/// Map a rendered-line count back to a source-byte offset for resize remapping.
///
/// Iterates through each newline in `raw_source`, rendering the prefix up to
/// that newline at `width`, and returns the largest byte offset whose rendering
/// produces at most `target_count` lines.
///
/// When the target falls exactly on a source-line boundary, the returned offset
/// covers that line. When it falls in the middle of a wrapped source line
/// (partial drain), the offset stops at the *previous* newline to avoid
/// overshooting -- this may re-queue a few already-emitted wrapped lines as
/// duplicates, but never drops un-emitted content.
///
/// This function is only called during `set_width` when `emitted_stable_len > 0`
/// (i.e. non-table content has already been committed to scrollback). For
/// non-table content, rendering a newline-terminated prefix produces a prefix of
/// the full rendering, so this converges correctly.
///
/// Performance: O(n) in source lines, each incurring a full markdown render of
/// the prefix. Acceptable for typical agent message lengths (hundreds of lines).
fn source_bytes_for_rendered_count(
raw_source: &str,
width: Option<usize>,
cwd: &Path,
target_count: usize,
) -> usize {
if target_count == 0 {
return 0;
}
let mut best_offset = 0;
for (i, _) in raw_source.match_indices('\n') {
let prefix = &raw_source[..=i];
let mut lines = Vec::new();
append_markdown_agent_with_cwd(prefix, width, Some(cwd), &mut lines);
if lines.len() <= target_count {
best_offset = i + 1;
}
if lines.len() >= target_count {
break;
}
}
best_offset
}
// ---------------------------------------------------------------------------
// Table holdback infrastructure
// ---------------------------------------------------------------------------
@@ -769,6 +808,7 @@ fn table_holdback_state(source: &str) -> TableHoldbackState {
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn test_cwd() -> PathBuf {
@@ -1541,81 +1581,62 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// source_bytes_for_rendered_count — prefix-stability tests
// -----------------------------------------------------------------------
#[test]
fn source_bytes_plain_paragraphs() {
let src = "Alpha\n\nBravo\n\nCharlie\n";
let width = Some(80);
let cwd = test_cwd();
let mut full = Vec::new();
crate::markdown::append_markdown_agent(src, width, &mut full);
let total = full.len();
assert!(
total >= 3,
"expected at least 3 rendered lines, got {total}"
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");
assert_eq!(source_bytes_for_rendered_count(src, width, &cwd, 0), 0);
assert_eq!(
source_bytes_for_rendered_count(src, width, &cwd, total),
src.len()
);
let (first_emit, _) = ctrl.on_commit_tick();
assert!(first_emit.is_some(), "expected first line emission");
let off = source_bytes_for_rendered_count(src, width, &cwd, 1);
assert!(off > 0 && off <= src.len());
let mut partial = Vec::new();
crate::markdown::append_markdown_agent(&src[..off], width, &mut partial);
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!(
partial.len() <= 1,
"expected <=1 line, got {}",
partial.len()
!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 source_bytes_wrapped_lines() {
let src = "The quick brown fox jumps over the lazy dog near the riverbank.\n";
let width = Some(20);
let cwd = test_cwd();
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 mut full = Vec::new();
crate::markdown::append_markdown_agent(src, width, &mut full);
let total = full.len();
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!(
total > 1,
"expected wrapping at width 20, got {total} lines"
ctrl.queued_lines() > 0,
"expected non-empty queue before resize"
);
let off = source_bytes_for_rendered_count(src, width, &cwd, 1);
assert_eq!(off, 0, "single wrapped source line should not be split");
}
ctrl.set_width(Some(120));
#[test]
fn source_bytes_list_items() {
let src = "- First item\n- Second item\n- Third item\n";
let width = Some(80);
let cwd = test_cwd();
let mut full = Vec::new();
crate::markdown::append_markdown_agent(src, width, &mut full);
let total = full.len();
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!(
total >= 3,
"expected at least 3 rendered lines, got {total}"
);
let off = source_bytes_for_rendered_count(src, width, &cwd, 2);
assert!(off > 0);
let mut partial = Vec::new();
crate::markdown::append_markdown_agent(&src[..off], width, &mut partial);
assert!(
partial.len() <= 2,
"expected <=2 lines from prefix, got {}",
partial.len()
remaining.iter().any(|line| line.contains("tail line")),
"un-emitted content should remain after resize remap: {remaining:?}",
);
}
}