fix(tui/core): make cancel + exit immediate during streaming\n\n- TUI: on interrupt, finalize active stream and drop further deltas so output stops instantly.\n- TUI: second Ctrl+C sends ExitRequest immediately (don’t wait for ShutdownComplete).\n- Core: abort running agent on Op::Shutdown to stop model streaming promptly.\n\nThis fixes cases where Ctrl+C/Esc stopped the spinner but the model kept streaming until completion.

This commit is contained in:
James Peter
2025-09-01 23:10:52 +10:00
parent 3437f28f50
commit 74bfed68be
3 changed files with 74 additions and 21 deletions
+5
View File
@@ -1777,6 +1777,11 @@ async fn submission_loop(
Op::Shutdown => {
info!("Shutting down Codex instance");
// Ensure any running agent is aborted so streaming stops promptly.
if let Some(sess_arc) = sess.as_ref() {
sess_arc.abort();
}
// Gracefully flush and shutdown rollout recorder on session end so tests
// that inspect the rollout file do not race with the background writer.
if let Some(sess_arc) = sess {
+7 -1
View File
@@ -482,7 +482,13 @@ impl App<'_> {
kind: KeyEventKind::Press,
..
} => match &mut self.app_state {
AppState::Chat { widget } => { widget.on_ctrl_c(); }
AppState::Chat { widget } => {
// Exit immediately on the second Ctrl+C instead of
// waiting for the backend ShutdownComplete (which
// can be delayed behind streaming events).
let handled = matches!(widget.on_ctrl_c(), crate::bottom_pane::CancellationEvent::Handled);
if handled { self.app_event_tx.send(AppEvent::ExitRequest); }
}
AppState::Onboarding { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); }
},
KeyEvent {
+62 -20
View File
@@ -259,6 +259,12 @@ pub(crate) struct ChatWidget<'a> {
next_seq: u64,
// Sequence marker for the final assistant message in the current turn
seq_answer_final: Option<u64>,
// When true, drop any further streaming deltas for the current turn.
// Set as soon as the user interrupts (Esc/Ctrl+C) to ensure UI stops
// showing additional output immediately, even if the backend takes a
// moment to propagate cancellation.
drop_streaming: bool,
}
struct PendingJumpBack {
@@ -860,6 +866,12 @@ impl ChatWidget<'_> {
/// Handle streaming delta for both answer and reasoning
fn handle_streaming_delta(&mut self, kind: StreamKind, id: String, delta: String) {
if self.drop_streaming {
// Ignore any post‑cancel deltas. The stream will be finalized or
// cleared once the backend surfaces the interrupt/complete event.
tracing::debug!("dropping streaming delta after cancel (kind={:?}, id={})", kind, id);
return;
}
tracing::debug!("handle_streaming_delta kind={:?}, delta={:?}", kind, delta);
// Remember which stream is currently active so we can group inserts
self.current_stream_kind = Some(kind);
@@ -1018,23 +1030,47 @@ impl ChatWidget<'_> {
for cell in &self.history_cells {
cell.trigger_fade();
}
// Create a new exec cell for the command and insert directly into history
// so its position remains stable. This avoids showing a completed
// command above a still-visible running overlay.
// Determine the high-level action (read/search/list/run) and avoid
// inserting running history cells for informational actions. This
// eliminates the brief flash of multiple "Read" sections while
// consecutive reads are executing; they will now appear only once
// completed and merge immediately with prior Read output.
let parsed_command = ev.parsed_cmd.clone();
let action = history_cell::action_from_parsed(&parsed_command);
// Still track run lifecycle for layout/metrics
self.height_manager.borrow_mut().record_event(HeightEvent::RunBegin);
// For read/search/list actions: don't insert a running cell in history.
// Track the command so End can append the finalized result (and merge).
if matches!(action, "read" | "search" | "list") {
self.running_commands.insert(
ev.call_id.clone(),
RunningCommand { command: ev.command.clone(), parsed: parsed_command, history_index: None },
);
// Update concise status text without creating a history section
let status = match action {
"read" => "reading files…",
"search" => "searching…",
"list" => "listing files…",
_ => "working…",
};
self.bottom_pane.update_status_text(status.to_string());
return;
}
// For generic run/test/lint/etc: insert a running exec cell so users see
// the command context immediately; these are not merged during running.
let cell = history_cell::new_active_exec_command(ev.command.clone(), parsed_command.clone());
// Push to history and remember the index
let before_len = self.history_cells.len();
self.add_to_history(cell);
let idx = if self.history_cells.len() > 0 { self.history_cells.len() - 1 } else { before_len };
// Still track run lifecycle for layout/metrics
self.height_manager.borrow_mut().record_event(HeightEvent::RunBegin);
// Store in running commands with history index
self.running_commands.insert(
ev.call_id.clone(),
RunningCommand { command: ev.command, parsed: parsed_command, history_index: Some(idx) },
RunningCommand { command: ev.command.clone(), parsed: parsed_command, history_index: Some(idx) },
);
// Update status: show that a command is running
@@ -1212,6 +1248,13 @@ impl ChatWidget<'_> {
};
let new_kind = to_kind(new_exec);
// Do NOT merge generic "run" execs. Merging "Ran" blocks across steps can
// pull later command output above intervening content and confuse chronology.
// We still allow merging of Read/Search/List summaries where grouping is helpful.
if matches!(new_kind, history_cell::ExecKind::Run) {
return;
}
// Case 1: previous is also a completed ExecCell with same header -> merge into MergedExecCell preserving styling
if let Some(prev_exec) = self.history_cells[idx - 1]
.as_any()
@@ -1556,6 +1599,7 @@ impl ChatWidget<'_> {
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.stream.clear_all();
self.drop_streaming = false;
self.agents_ready_to_start = false;
self.active_task_ids.clear();
self.maybe_hide_spinner();
@@ -1568,6 +1612,9 @@ impl ChatWidget<'_> {
// Finalize any visible running indicators as interrupted (Exec/Web/Custom)
self.finalize_all_running_as_interrupted();
self.bottom_pane.clear_ctrl_c_quit_hint();
// Stop any active UI streams immediately so output ceases at once.
self.finalize_active_stream();
self.drop_streaming = true;
self.submit_op(Op::Interrupt);
// If nothing else is active, drop spinner; otherwise status already updated.
if self.active_task_ids.is_empty() {
@@ -1791,6 +1838,7 @@ impl ChatWidget<'_> {
browser_is_external: false,
next_seq: 1,
seq_answer_final: None,
drop_streaming: false,
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
@@ -1911,6 +1959,7 @@ impl ChatWidget<'_> {
browser_is_external: false,
next_seq: 1,
seq_answer_final: None,
drop_streaming: false,
}
}
@@ -3064,6 +3113,8 @@ impl ChatWidget<'_> {
self.closed_reasoning_ids.clear();
self.ended_call_ids.clear();
self.bottom_pane.clear_ctrl_c_quit_hint();
// Accept streaming again for this turn
self.drop_streaming = false;
// Mark this task id as active and ensure the status stays visible
self.active_task_ids.insert(id.clone());
self.bottom_pane.set_task_running(true);
@@ -4583,9 +4634,8 @@ impl ChatWidget<'_> {
// If that impl is removed, downcast_ref will fail and we won't find the streaming cell,
// causing the final to append a new Assistant cell (duplicate).
let streaming_idx = if let Some(ref want) = id {
// Prefer an exact id match
let by_id = self
.history_cells
// Only replace a streaming cell if its id matches this final.
self.history_cells
.iter()
.rposition(|c| {
if let Some(sc) = c.as_any().downcast_ref::<history_cell::StreamingContentCell>() {
@@ -4593,17 +4643,9 @@ impl ChatWidget<'_> {
} else {
false
}
});
// Fallback: use the latest streaming cell regardless of id if no exact match
by_id.or_else(|| {
self.history_cells
.iter()
.rposition(|c| c.as_any().downcast_ref::<history_cell::StreamingContentCell>().is_some())
})
})
} else {
self.history_cells
.iter()
.rposition(|c| c.as_any().downcast_ref::<history_cell::StreamingContentCell>().is_some())
None
};
if let Some(idx) = streaming_idx {
tracing::debug!("final-answer: replacing StreamingContentCell at idx={} by id match", idx);