From 341efe5d606b9d78d04ee33091570deeca5f67f8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 22 Feb 2026 14:58:22 -0300 Subject: [PATCH] fix(tui): address stream interrupt and markdown edge cases Improve resize reflow scheduling so due reflows request an immediate frame instead of waiting for unrelated redraws, and clear queued stream output on `Op::Interrupt` so stale lines stop rendering immediately. Harden markdown/table parsing by treating tab indentation as 4-column whitespace in `strip_line_indent` and ignoring escaped-only pipes in `parse_table_segments` unless explicit outer pipes are present. --- codex-rs/tui/src/app.rs | 30 +++++++++++++++----- codex-rs/tui/src/chatwidget.rs | 8 ++++++ codex-rs/tui/src/markdown.rs | 35 +++++++++++++++--------- codex-rs/tui/src/streaming/controller.rs | 16 +++++++++-- codex-rs/tui/src/table_detect.rs | 14 ++++++---- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2b75c86bd4..1080163f2d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3323,16 +3323,29 @@ impl App { } } - fn schedule_resize_reflow(&mut self) { - self.resize_reflow_pending_until = Some(Instant::now() + RESIZE_REFLOW_DEBOUNCE); + fn schedule_resize_reflow(&mut self) -> bool { + let now = Instant::now(); + let due_now = self + .resize_reflow_pending_until + .is_some_and(|deadline| now >= deadline); + self.resize_reflow_pending_until = Some(now + RESIZE_REFLOW_DEBOUNCE); + due_now } /// After stream consolidation, schedule a follow-up reflow if one ran mid-stream. fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) { if self.reflow_ran_during_stream { - self.schedule_resize_reflow(); - tui.frame_requester() - .schedule_frame_in(RESIZE_REFLOW_DEBOUNCE); + if self.schedule_resize_reflow() { + tui.frame_requester().schedule_frame(); + } else { + tui.frame_requester() + .schedule_frame_in(RESIZE_REFLOW_DEBOUNCE); + } + } else if self + .resize_reflow_pending_until + .is_some_and(|deadline| Instant::now() >= deadline) + { + tui.frame_requester().schedule_frame(); } self.reflow_ran_during_stream = false; } @@ -3347,8 +3360,11 @@ impl App { let width_changed = previous_width.is_some_and(|width| width != size.width); if width_changed { self.chat_widget.on_terminal_resize(size.width); - self.schedule_resize_reflow(); - frame_requester.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE); + if self.schedule_resize_reflow() { + frame_requester.schedule_frame(); + } else { + frame_requester.schedule_frame_in(RESIZE_REFLOW_DEBOUNCE); + } } else if previous_width.is_none() { self.chat_widget.on_terminal_resize(size.width); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bc48e542fd..107aaf5f86 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10599,6 +10599,14 @@ impl ChatWidget { && self.agent_turn_running { self.interrupt_requested_for_turn = true; + if let Some(controller) = self.stream_controller.as_mut() { + controller.clear_queue(); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.clear_queue(); + } + self.clear_active_stream_tail(); + self.request_redraw(); } if op.is_review() && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index d437885da9..a528845fd5 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -101,15 +101,25 @@ fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> { // (which makes it an indented code line per CommonMark). fn strip_line_indent(line: &str) -> Option<&str> { let without_newline = line.strip_suffix('\n').unwrap_or(line); - let leading_ws = without_newline - .as_bytes() - .iter() - .take_while(|byte| **byte == b' ') - .count(); - if leading_ws > 3 { - return None; + let mut byte_idx = 0usize; + let mut column = 0usize; + for b in without_newline.as_bytes() { + match b { + b' ' => { + byte_idx += 1; + column += 1; + } + b'\t' => { + byte_idx += 1; + column += 4; + } + _ => break, + } + if column >= 4 { + return None; + } } - Some(&without_newline[leading_ws..]) + Some(&without_newline[byte_idx..]) } // Parse an opening fence line, returning the fence metadata and whether @@ -223,11 +233,10 @@ fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> { } ActiveFence::MarkdownCandidate(mut data) => { if is_close_fence(line, data.fence) { - if markdown_fence_contains_table(&content_from_ranges( - markdown_source, - &data.content_ranges, - ), data.fence.is_blockquoted) - { + if markdown_fence_contains_table( + &content_from_ranges(markdown_source, &data.content_ranges), + data.fence.is_blockquoted, + ) { for range in data.content_ranges { push_source_range(range); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 21372ff439..d49e80fe12 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -326,9 +326,9 @@ impl StreamCore { let holdback_state = self.holdback_scanner.state(); let tail_budget = match holdback_state { TableHoldbackState::Confirmed { table_start: start } - | TableHoldbackState::PendingHeader { header_start: start } => { - self.tail_budget_from_source_start(start) - } + | TableHoldbackState::PendingHeader { + header_start: start, + } => self.tail_budget_from_source_start(start), TableHoldbackState::None => 0, }; tracing::trace!( @@ -468,6 +468,11 @@ impl StreamController { self.core.has_tail() } + pub(crate) fn clear_queue(&mut self) { + self.core.state.clear_queue(); + self.core.enqueued_stable_len = self.core.emitted_stable_len; + } + pub(crate) fn set_width(&mut self, width: Option) { self.core.set_width(width); } @@ -553,6 +558,11 @@ impl PlanStreamController { self.core.oldest_queued_age(now) } + pub(crate) fn clear_queue(&mut self) { + self.core.state.clear_queue(); + self.core.enqueued_stable_len = self.core.emitted_stable_len; + } + pub(crate) fn set_width(&mut self, width: Option) { self.core.set_width(width); } diff --git a/codex-rs/tui/src/table_detect.rs b/codex-rs/tui/src/table_detect.rs index 861e44467b..cf031c777e 100644 --- a/codex-rs/tui/src/table_detect.rs +++ b/codex-rs/tui/src/table_detect.rs @@ -28,21 +28,23 @@ /// Split a pipe-delimited line into trimmed segments. /// -/// Returns `None` if the line is empty or has no `|` marker. +/// Returns `None` if the line is empty or has no unescaped separator marker. /// Leading/trailing pipes are stripped before splitting. pub(crate) fn parse_table_segments(line: &str) -> Option> { let trimmed = line.trim(); - if trimmed.is_empty() || !trimmed.contains('|') { + if trimmed.is_empty() { return None; } + let has_outer_pipe = trimmed.starts_with('|') || trimmed.ends_with('|'); let content = trimmed.strip_prefix('|').unwrap_or(trimmed); let content = content.strip_suffix('|').unwrap_or(content); + let raw_segments = split_unescaped_pipe(content); + if !has_outer_pipe && raw_segments.len() <= 1 { + return None; + } - let segments: Vec<&str> = split_unescaped_pipe(content) - .into_iter() - .map(str::trim) - .collect(); + let segments: Vec<&str> = raw_segments.into_iter().map(str::trim).collect(); (!segments.is_empty()).then_some(segments) }