diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 586ade6b7a..0cb32b7d54 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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(); } 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 aa925e9a43..6c84bff814 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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::>() + .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; diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 8b1f7d53bc..9d91b69115 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -792,6 +792,29 @@ fn visible_text_source_boundary(source: &str, visible_prefix: &str) -> Option { + 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 [ + ("&", '&'), + ("<", '<'), + (">", '>'), + (""", '"'), + ("'", '\''), + ] { + if source[start..].starts_with(entity) { + return Some((ch, start + entity.len())); + } + } + None +} + fn skip_link_destination(source: &str, label_end: usize) -> Option { 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 & 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 &"), + "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));