diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 44d45146f8..d76a8459af 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -744,31 +744,7 @@ impl StreamCore { return source_start + boundary; } - let mut boundaries = Vec::new(); - boundaries.push(source_start); - boundaries.extend( - self.raw_source[source_start..source_end] - .char_indices() - .map(|(idx, ch)| source_start + idx + ch.len_utf8()), - ); - - for boundary in boundaries { - let rendered = self.render_source(&self.raw_source[..boundary]); - if rendered.len() < emitted_prefix.len() { - continue; - } - let rendered_text = rendered[..emitted_prefix.len()] - .iter() - .map(hyperlink_line_text) - .collect::>(); - if rendered_text == emitted_text { - return boundary; - } - } - - self.source_boundary_after_rendered_len(emitted_prefix.len()) - .min(source_end) - .max(source_start) + source_start } fn default_tail_budget_lines(&mut self) -> usize { @@ -805,20 +781,34 @@ fn visible_text_source_boundary(source: &str, visible_prefix: &str) -> Option Option Option { visible_chars.next(); + at_line_start = ch == '\n'; if visible_chars.peek().is_none() { return Some(idx + ch.len_utf8()); } } + Some(_) if ch.is_ascii_punctuation() => continue, _ => return None, } } @@ -865,14 +858,70 @@ fn visible_text_source_boundary(source: &str, visible_prefix: &str) -> Option>, + expected: Option, +) -> bool { + if expected == Some(ch) { + return false; + } + + if matches!(ch, '#' | '>' | '-' | '+' | '*') { + while let Some((_, next_ch)) = iter.peek().copied() { + if next_ch.is_whitespace() { + iter.next(); + } else { + break; + } + } + return true; + } + + if ch.is_ascii_digit() { + let mut clone = iter.clone(); + while let Some((_, next_ch)) = clone.peek().copied() { + if next_ch.is_ascii_digit() { + clone.next(); + } else { + break; + } + } + if let Some((_, '.' | ')')) = clone.next() { + while let Some((_, next_ch)) = clone.peek().copied() { + if next_ch.is_whitespace() { + clone.next(); + } else { + break; + } + } + *iter = clone; + return true; + } + } + + false +} + fn skip_link_destination(source: &str, label_end: usize) -> Option { let after_label = source.get(label_end..)?; if !after_label.starts_with('(') { return None; } - after_label - .find(')') - .map(|destination_end| label_end + destination_end + ')'.len_utf8()) + let mut depth = 0usize; + for (idx, ch) in after_label.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(label_end + idx + ch.len_utf8()); + } + } + _ => {} + } + } + None } fn trim_rendered_prefix( @@ -2867,6 +2916,98 @@ mod tests { ); } + #[test] + fn controller_set_render_mode_raw_suffix_skips_balanced_link_destination() { + let mut ctrl = stream_controller(Some(/*width*/ 10)); + ctrl.push( + "[alpha beta](https://example.com/a(foo)/bar) gamma delta epsilon zeta eta theta\n", + ); + ctrl.push("tail line\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + let first_emit = first_emit + .expect("expected first rich wrapped line emission") + .transcript_lines(u16::MAX); + assert!(!idle, "expected remaining rich content after one tick"); + + ctrl.set_render_mode(HistoryRenderMode::Raw); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + let first_emit = lines_to_plain_strings(&first_emit).join("\n"); + let joined = remaining.join("\n"); + assert!( + !joined.contains("foo") && !joined.contains("/bar"), + "raw suffix must skip the full hidden destination with balanced parens; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + assert!( + joined.contains("gamma") && joined.contains("tail line"), + "raw suffix should resume after the link destination; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + } + + #[test] + fn controller_set_render_mode_raw_suffix_handles_nested_link_label() { + let mut ctrl = stream_controller(Some(/*width*/ 14)); + ctrl.push("[alpha [beta] rest](https://example.com) gamma delta epsilon zeta\n"); + ctrl.push("tail line\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + let first_emit = first_emit + .expect("expected first rich wrapped line emission") + .transcript_lines(u16::MAX); + assert!(!idle, "expected remaining rich content after one tick"); + + ctrl.set_render_mode(HistoryRenderMode::Raw); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + let first_emit = lines_to_plain_strings(&first_emit).join("\n"); + let joined = remaining.join("\n"); + assert!( + !joined.contains("](https://example.com)") && !joined.contains("https://example.com"), + "raw suffix must not resume inside nested link markup; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + assert!( + joined.contains("gamma") && joined.contains("tail line"), + "raw suffix should preserve text after the nested link label; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + } + + #[test] + fn controller_set_render_mode_raw_suffix_offsets_heading_marker() { + let mut ctrl = stream_controller(Some(/*width*/ 12)); + ctrl.push("## alpha beta gamma delta epsilon zeta eta theta\n"); + ctrl.push("tail line\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + let first_emit = first_emit + .expect("expected first rich wrapped heading emission") + .transcript_lines(u16::MAX); + assert!(!idle, "expected remaining rich content after one tick"); + + ctrl.set_render_mode(HistoryRenderMode::Raw); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + let first_emit = lines_to_plain_strings(&first_emit).join("\n"); + let joined = remaining.join("\n"); + assert!( + !joined.contains("## alpha beta"), + "raw suffix must not replay the already-visible heading prefix; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + assert!( + joined.contains("delta") && joined.contains("tail line"), + "raw suffix should preserve un-emitted heading text; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}", + ); + } + #[test] fn controller_clear_queue_advances_synthetic_accounting_before_finalize() { let mut ctrl = stream_controller(Some(/*width*/ 18));