mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
tui: use source-mapped boundaries for stream resize remap
This commit is contained in:
committed by
Felipe Coury
parent
3c9f8d1eaf
commit
f1ee30daa3
+184
-24
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user