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.
This commit is contained in:
Felipe Coury
2026-04-06 13:42:16 -03:00
committed by Felipe Coury
parent 36761c5863
commit 341efe5d60
5 changed files with 74 additions and 29 deletions
+23 -7
View File
@@ -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);
}
+8
View File
@@ -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);
+22 -13
View File
@@ -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);
}
+13 -3
View File
@@ -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<usize>) {
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<usize>) {
self.core.set_width(width);
}
+8 -6
View File
@@ -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<Vec<&str>> {
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)
}