fix(tui): drop interrupted stream tails

This commit is contained in:
Felipe Coury
2026-06-05 20:40:09 -03:00
parent cf8935be51
commit 032c5dfe54
3 changed files with 112 additions and 0 deletions

View File

@@ -1830,6 +1830,8 @@ impl ChatWidget {
if let Some(controller) = self.plan_stream_controller.as_mut() {
controller.clear_queue();
}
self.stream_controller = None;
self.plan_stream_controller = None;
self.clear_active_stream_tail();
self.request_redraw();
}

View File

@@ -1519,6 +1519,48 @@ async fn finalize_turn_persists_structural_plan_tail_as_history_cell() {
assert!(chat.plan_stream_controller.is_none());
}
#[tokio::test]
async fn interrupted_stream_tail_is_not_persisted_on_finalize_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_message_delta("prefix already emitted\n".to_string());
chat.on_agent_message_delta("preview tail should be dropped\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
let active_before = chat
.transcript
.active_cell
.as_ref()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
.unwrap_or_default();
assert!(
active_before.contains("preview tail should be dropped"),
"expected preview tail before interrupt, got {active_before:?}",
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt { .. }) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
chat.finalize_turn();
let inserted = drain_insert_history(&mut rx);
let rendered = inserted
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
!rendered.contains("preview tail should be dropped"),
"interrupted preview tail should not be persisted, got {rendered:?}",
);
assert!(chat.stream_controller.is_none());
}
#[tokio::test]
async fn raw_output_toggle_refreshes_active_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -792,6 +792,29 @@ fn visible_text_source_boundary(source: &str, visible_prefix: &str) -> Option<us
continue;
}
if ch == '&'
&& let Some((entity_char, entity_end)) = markdown_entity_at(source, idx)
{
match visible_chars.peek().copied() {
Some(expected) if expected == entity_char => {
visible_chars.next();
if visible_chars.peek().is_none() {
return Some(entity_end);
}
while let Some((next_idx, _)) = iter.peek().copied() {
if next_idx < entity_end {
iter.next();
} else {
break;
}
}
at_line_start = false;
continue;
}
_ => return None,
}
}
if ch == '[' {
let mut label_end = None;
let mut label_chars = Vec::new();
@@ -904,6 +927,21 @@ fn skip_line_marker(
false
}
fn markdown_entity_at(source: &str, start: usize) -> Option<(char, usize)> {
for (entity, ch) in [
("&amp;", '&'),
("&lt;", '<'),
("&gt;", '>'),
("&quot;", '"'),
("&#39;", '\''),
] {
if source[start..].starts_with(entity) {
return Some((ch, start + entity.len()));
}
}
None
}
fn skip_link_destination(source: &str, label_end: usize) -> Option<usize> {
let after_label = source.get(label_end..)?;
if !after_label.starts_with('(') {
@@ -3039,6 +3077,36 @@ mod tests {
);
}
#[test]
fn controller_set_render_mode_raw_suffix_skips_markdown_entity() {
let mut ctrl = stream_controller(Some(/*width*/ 9));
ctrl.push("alpha &amp; 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 entity 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("amp;") && !joined.contains("alpha &amp;"),
"raw suffix must not resume inside an already-visible markdown entity; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}",
);
assert!(
joined.contains("beta") && joined.contains("tail line"),
"raw suffix should preserve text after the markdown entity; 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));