From 2fd2259286b0ee435415f60bb8eae264eb6a0bd9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 17 Feb 2026 19:32:48 -0300 Subject: [PATCH] fix(tui): streaming remap and resize --- codex-rs/tui/src/chatwidget.rs | 6 ++ .../src/chatwidget/tests/status_and_layout.rs | 15 ++++ codex-rs/tui/src/markdown.rs | 20 +++-- codex-rs/tui/src/markdown_render.rs | 11 +-- codex-rs/tui/src/pager_overlay.rs | 70 +++++++++++++++++ codex-rs/tui/src/streaming/controller.rs | 76 ++++++++++++++++--- 6 files changed, 176 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 303475482a..84bff7ce91 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4244,12 +4244,15 @@ impl ChatWidget { if w == 0 { None } else { + // Keep a 1-column minimum for active stream controllers so they can + // continue accepting deltas on ultra-narrow layouts. Some(crate::width::usable_content_width(w, reserved_cols).unwrap_or(1)) } }) } pub(crate) fn on_terminal_resize(&mut self, width: u16) { + let had_rendered_width = self.last_rendered_width.get().is_some(); self.last_rendered_width.set(Some(width as usize)); let stream_width = self.current_stream_width(2); let plan_stream_width = self.current_stream_width(4); @@ -4260,6 +4263,9 @@ impl ChatWidget { controller.set_width(plan_stream_width); } self.sync_active_stream_tail(); + if !had_rendered_width { + self.request_redraw(); + } } fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 { diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c484a43e00..3e2a52a9d4 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -126,6 +126,21 @@ async fn current_stream_width_clamps_to_minimum_when_reserved_columns_exhaust_wi assert_eq!(chat.current_stream_width(4), Some(1)); } +#[tokio::test] +async fn on_terminal_resize_initial_width_requests_redraw() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (draw_tx, mut draw_rx) = tokio::sync::broadcast::channel(8); + chat.frame_requester = FrameRequester::new(draw_tx); + chat.last_rendered_width.set(None); + + chat.on_terminal_resize(120); + + let draw = tokio::time::timeout(std::time::Duration::from_millis(200), draw_rx.recv()) + .await + .expect("timed out waiting for redraw request"); + assert!(draw.is_ok(), "expected redraw notification to be sent"); +} + #[tokio::test] async fn add_to_history_does_not_commit_transient_stream_tail_after_controller_clear() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 5392bad25a..3bd40b3874 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -182,7 +182,7 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd #[derive(Clone, Copy)] struct Fence { - marker: char, + marker: u8, len: usize, is_markdown: bool, } @@ -198,11 +198,15 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd return None; } let trimmed = &without_newline[leading_ws..]; - let marker = trimmed.chars().next()?; - if marker != '`' && marker != '~' { + let marker = *trimmed.as_bytes().first()?; + if marker != b'`' && marker != b'~' { return None; } - let len = trimmed.chars().take_while(|ch| *ch == marker).count(); + let len = trimmed + .as_bytes() + .iter() + .take_while(|byte| **byte == marker) + .count(); if len < 3 { return None; } @@ -227,10 +231,14 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd return false; } let trimmed = &without_newline[leading_ws..]; - if !trimmed.starts_with(fence.marker) { + if !trimmed.as_bytes().starts_with(&[fence.marker]) { return false; } - let len = trimmed.chars().take_while(|ch| *ch == fence.marker).count(); + let len = trimmed + .as_bytes() + .iter() + .take_while(|byte| **byte == fence.marker) + .count(); if len < fence.len { return false; } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index c5d71894dd..53afbf59a5 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -85,8 +85,6 @@ struct MarkdownStyles { impl Default for MarkdownStyles { fn default() -> Self { - use ratatui::style::Stylize; - Self { h1: Style::new().bold().underlined(), h2: Style::new().bold(), @@ -1487,9 +1485,12 @@ where tag_start += 1; } - if tag_start < bytes.len() - && bytes[tag_start].is_ascii_alphabetic() - && bytes[tag_start + 1..].contains(&b'>') + if bytes + .get(tag_start) + .is_some_and(u8::is_ascii_alphabetic) + && bytes + .get(tag_start + 1..) + .is_some_and(|suffix| suffix.contains(&b'>')) { return true; } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 6e5e017f99..e00e45c5c0 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -584,8 +584,24 @@ impl TranscriptOverlay { let clamped_end = range.end.min(self.cells.len()); let clamped_start = range.start.min(clamped_end); if clamped_start < clamped_end { + let removed = clamped_end - clamped_start; + if let Some(highlight_cell) = self.highlight_cell.as_mut() + && *highlight_cell >= clamped_start + { + if *highlight_cell < clamped_end { + *highlight_cell = clamped_start; + } else { + *highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1)); + } + } self.cells .splice(clamped_start..clamped_end, std::iter::once(consolidated)); + if self + .highlight_cell + .is_some_and(|highlight_cell| highlight_cell >= self.cells.len()) + { + self.highlight_cell = None; + } self.rebuild_renderables(); } if follow_bottom { @@ -1117,6 +1133,60 @@ mod tests { assert_eq!(overlay.view.scroll_offset, 0); } + #[test] + fn transcript_overlay_consolidation_remaps_highlight_inside_range() { + let mut overlay = TranscriptOverlay::new( + (0..6) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(3)); + + overlay.consolidate_cells( + 2..5, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(2), + "highlight inside consolidated range should point to replacement cell", + ); + } + + #[test] + fn transcript_overlay_consolidation_remaps_highlight_after_range() { + let mut overlay = TranscriptOverlay::new( + (0..7) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(6)); + + overlay.consolidate_cells( + 2..5, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(4), + "highlight after consolidated range should shift left by removed cells", + ); + } + #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 0bd7231594..b98c65ffc1 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -30,6 +30,7 @@ //! //! - `emitted_stable_len <= enqueued_stable_len <= rendered_lines.len()`. //! - `rendered_line_end_source_bytes.len() == rendered_lines.len()`. +//! - `rendered_line_end_source_totals.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 @@ -51,6 +52,7 @@ use std::time::Instant; use crate::table_detect::is_table_delimiter_line; use crate::table_detect::is_table_header_line; use crate::table_detect::parse_table_segments; +use std::collections::HashMap; use super::StreamState; @@ -77,6 +79,8 @@ struct StreamCore { rendered_lines: Vec>, /// Per-rendered-line source byte offsets (raw source coordinates). rendered_line_end_source_bytes: Vec, + /// Per-rendered-line total count of lines sharing that source byte offset. + rendered_line_end_source_totals: Vec, /// Lines enqueued into the commit-animation queue. enqueued_stable_len: usize, /// Lines actually emitted to scrollback. @@ -109,6 +113,7 @@ impl StreamCore { raw_source: String::new(), rendered_lines: Vec::new(), rendered_line_end_source_bytes: Vec::new(), + rendered_line_end_source_totals: Vec::new(), enqueued_stable_len: 0, emitted_stable_len: 0, cwd: cwd.to_path_buf(), @@ -227,6 +232,7 @@ impl StreamCore { self.raw_source.clear(); self.rendered_lines.clear(); self.rendered_line_end_source_bytes.clear(); + self.rendered_line_end_source_totals.clear(); self.enqueued_stable_len = 0; self.emitted_stable_len = 0; self.emitted_stable_source_bytes = 0; @@ -245,10 +251,23 @@ impl StreamCore { ); self.rendered_lines = rendered.lines; self.rendered_line_end_source_bytes = rendered.line_end_source_bytes; + let mut source_totals: HashMap = HashMap::new(); + for source_end in &self.rendered_line_end_source_bytes { + *source_totals.entry(*source_end).or_insert(0) += 1; + } + self.rendered_line_end_source_totals = self + .rendered_line_end_source_bytes + .iter() + .map(|source_end| source_totals.get(source_end).copied().unwrap_or(1)) + .collect(); debug_assert_eq!( self.rendered_line_end_source_bytes.len(), self.rendered_lines.len() ); + debug_assert_eq!( + self.rendered_line_end_source_totals.len(), + self.rendered_lines.len() + ); } /// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any @@ -383,16 +402,19 @@ impl StreamCore { } 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] { + for (offset, line_end_source) in self.rendered_line_end_source_bytes[emitted_start..end] + .iter() + .enumerate() + { + let line_index = emitted_start + offset; 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); + .rendered_line_end_source_totals + .get(line_index) + .copied() + .unwrap_or(1); } else if *line_end_source == self.emitted_stable_source_bytes { self.emitted_stable_lines_at_source_byte += 1; } @@ -447,11 +469,10 @@ impl StreamCore { .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); + .rendered_line_end_source_totals + .get(emitted_len - 1) + .copied() + .unwrap_or(1); self.emitted_stable_source_bytes = boundary; self.emitted_stable_lines_at_source_byte = emitted_at_boundary; @@ -1203,6 +1224,39 @@ mod tests { ); } + #[test] + fn remap_emitted_len_preserves_source_boundary_tie_breaks() { + let cwd = test_cwd(); + let mut core = StreamCore::new(Some(80), &cwd); + core.raw_source = "line\nline\nline\n".to_string(); + core.rendered_lines = vec![ + Line::from("row-0"), + Line::from("row-1"), + Line::from("row-2"), + Line::from("row-3"), + Line::from("row-4"), + ]; + core.rendered_line_end_source_bytes = vec![5, 5, 9, 14, 14]; + core.rendered_line_end_source_totals = vec![2, 2, 1, 2, 2]; + core.emitted_stable_len = 3; + core.emitted_stable_source_bytes = 5; + core.emitted_stable_lines_at_source_byte_total = 2; + + core.emitted_stable_lines_at_source_byte = 1; + assert_eq!( + core.remap_emitted_len_from_source_boundary(), + 1, + "expected one emitted line at source boundary when one was previously emitted", + ); + + core.emitted_stable_lines_at_source_byte = 2; + assert_eq!( + core.remap_emitted_len_from_source_boundary(), + 2, + "expected all lines at source boundary when boundary was fully emitted", + ); + } + #[tokio::test] async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { let mut ctrl = stream_controller(/*width*/ None);