fix(tui/approvals): immediate UI cancel on deny; FIFO approval queue

- Add AppEvent::CancelRunningTask; handle in App loop to call ChatWidget cancel
- Approval modal: send CancelRunningTask on Abort before forwarding ExecApproval
- ChatWidget: expose cancel_running_task_from_approval() to trigger UI cleanup
- Approval modal: process queued approvals FIFO (VecDeque) to preserve order

Closes #49
This commit is contained in:
James Peter
2025-09-08 09:34:04 +10:00
parent 70b3b76774
commit 0930b6b086
5 changed files with 30 additions and 4 deletions
+5
View File
@@ -633,6 +633,11 @@ impl App<'_> {
self.input_running.store(false, Ordering::Release);
break 'main;
}
AppEvent::CancelRunningTask => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.cancel_running_task_from_approval();
}
}
// fallthrough handled by break
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
+6
View File
@@ -140,5 +140,11 @@ pub(crate) enum AppEvent {
/// Register an image placeholder inserted by the composer with its backing path
/// so ChatWidget can resolve it to a LocalImage on submit.
RegisterPastedImage { placeholder: String, path: PathBuf },
/// Immediately cancel any running task in the ChatWidget. This is used by
/// the approval modal to reflect a user's Abort decision instantly in the UI
/// (clear spinner/status, finalize running exec/tool cells) while the core
/// continues its own abort/cleanup in parallel.
CancelRunningTask,
}
@@ -10,11 +10,12 @@ use crate::user_approval_widget::UserApprovalWidget;
use super::BottomPane;
use super::BottomPaneView;
use super::CancellationEvent;
use std::collections::VecDeque;
/// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView<'a> {
current: UserApprovalWidget<'a>,
queue: Vec<ApprovalRequest>,
queue: VecDeque<ApprovalRequest>,
app_event_tx: AppEventSender,
}
@@ -22,19 +23,19 @@ impl ApprovalModalView<'_> {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
Self {
current: UserApprovalWidget::new(request, app_event_tx.clone()),
queue: Vec::new(),
queue: VecDeque::new(),
app_event_tx,
}
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
self.queue.push_back(req);
}
/// Advance to next request if the current one is finished.
fn maybe_advance(&mut self) {
if self.current.is_complete() {
if let Some(req) = self.queue.pop() {
if let Some(req) = self.queue.pop_front() {
self.current = UserApprovalWidget::new(req, self.app_event_tx.clone());
}
}
+7
View File
@@ -3660,6 +3660,13 @@ impl ChatWidget<'_> {
}
}
/// Cancel the current running task from a non-keyboard context (e.g. approval modal).
/// This bypasses modal key handling and invokes the same immediate UI cleanup path
/// as pressing Ctrl-C/Esc while a task is running.
pub(crate) fn cancel_running_task_from_approval(&mut self) {
self.interrupt_running_task();
}
pub(crate) fn insert_history_lines(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
let kind = self.stream_state.current_kind.unwrap_or(StreamKind::Answer);
self.insert_history_lines_with_kind(kind, None, lines);
+7
View File
@@ -298,6 +298,13 @@ impl UserApprovalWidget<'_> {
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistory(lines));
// If the user aborted an exec approval, immediately cancel any running task
// so the UI reflects their intent (clear spinner/status) without waiting
// for backend cleanup. Core still receives the Abort below.
if matches!((&self.approval_request, decision), (ApprovalRequest::Exec { .. }, ReviewDecision::Abort)) {
self.app_event_tx.send(AppEvent::CancelRunningTask);
}
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
id: id.clone(),