fix(tui/agents): hook Esc to cancel active agent runs

This commit is contained in:
James Peter
2025-10-22 13:46:39 +10:00
parent 57ae2a876a
commit 6cbd68203c
4 changed files with 175 additions and 1 deletions
+55
View File
@@ -2898,6 +2898,61 @@ async fn submission_loop(
};
tokio::spawn(async move { sess.abort() });
}
Op::CancelAgents { batch_ids, agent_ids } => {
let sess_arc = match sess.as_ref() {
Some(sess) => Arc::clone(sess),
None => {
send_no_session_event(sub.id).await;
continue;
}
};
let mut manager = AGENT_MANAGER.write().await;
let mut seen_batches: HashSet<String> = HashSet::new();
let mut seen_agents: HashSet<String> = HashSet::new();
let mut cancelled = 0usize;
for batch in batch_ids {
let trimmed = batch.trim();
if trimmed.is_empty() {
continue;
}
if !seen_batches.insert(trimmed.to_string()) {
continue;
}
cancelled += manager.cancel_batch(trimmed).await;
}
for agent_id in agent_ids {
let trimmed = agent_id.trim();
if trimmed.is_empty() {
continue;
}
if !seen_agents.insert(trimmed.to_string()) {
continue;
}
if manager.cancel_agent(trimmed).await {
cancelled += 1;
}
}
drop(manager);
send_agent_status_update(&sess_arc).await;
let message = if cancelled == 0 {
"No running agents to cancel.".to_string()
} else {
let suffix = if cancelled == 1 { "" } else { "s" };
format!("Cancelled {cancelled} running agent{suffix}.")
};
let event = sess_arc.make_event(
&sub.id,
EventMsg::AgentMessage(AgentMessageEvent { message }),
);
sess_arc.send_event(event).await;
}
Op::AddPendingInputDeveloper { text } => {
let sess = match sess.as_ref() { Some(s) => s.clone(), None => { send_no_session_event(sub.id).await; continue; } };
let dev_msg = ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }] };
+10
View File
@@ -114,6 +114,16 @@ pub enum Op {
/// This server sends no corresponding Event
Interrupt,
/// Cancel running agents immediately without waiting for the model to issue a tool call.
CancelAgents {
/// Agent batch identifiers to cancel.
#[serde(default)]
batch_ids: Vec<String>,
/// Specific agent identifiers to cancel when no batch is available.
#[serde(default)]
agent_ids: Vec<String>,
},
/// Input from the user
UserInput {
/// User input items, see `InputItem`
+100
View File
@@ -1,4 +1,5 @@
use once_cell::sync::Lazy;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::collections::HashSet;
@@ -180,6 +181,7 @@ pub(crate) enum EscIntent {
AutoDismissSummary,
DiffConfirm,
AgentsTerminal,
CancelAgents,
CancelTask,
ClearComposer,
ShowUndoHint,
@@ -2588,6 +2590,78 @@ impl ChatWidget<'_> {
.any(|a| matches!(a.status, AgentStatus::Pending | AgentStatus::Running))
}
fn has_cancelable_agents(&self) -> bool {
self
.active_agents
.iter()
.any(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running))
}
fn collect_cancelable_agents(&self) -> (Vec<String>, Vec<String>) {
let mut batch_ids: BTreeSet<String> = BTreeSet::new();
let mut agent_ids: BTreeSet<String> = BTreeSet::new();
for agent in &self.active_agents {
if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) {
continue;
}
if let Some(batch) = agent.batch_id.as_ref() {
let trimmed = batch.trim();
if !trimmed.is_empty() {
batch_ids.insert(trimmed.to_string());
continue;
}
}
let trimmed_id = agent.id.trim();
if !trimmed_id.is_empty() {
agent_ids.insert(trimmed_id.to_string());
}
}
(
batch_ids.into_iter().collect(),
agent_ids.into_iter().collect(),
)
}
fn cancel_active_agents(&mut self) -> bool {
let (batch_ids, agent_ids) = self.collect_cancelable_agents();
if batch_ids.is_empty() && agent_ids.is_empty() {
return false;
}
let mut status_parts = Vec::new();
if !batch_ids.is_empty() {
let count = batch_ids.len();
status_parts.push(if count == 1 {
"1 batch".to_string()
} else {
format!("{count} batches")
});
}
if !agent_ids.is_empty() {
let count = agent_ids.len();
status_parts.push(if count == 1 {
"1 agent".to_string()
} else {
format!("{count} agents")
});
}
let descriptor = if status_parts.is_empty() {
"agents".to_string()
} else {
status_parts.join(", ")
};
self.push_background_tail(format!("Cancelling {descriptor}…"));
self.bottom_pane.update_status_text("cancelling agents".to_string());
self.bottom_pane.set_task_running(true);
self.submit_op(Op::CancelAgents { batch_ids, agent_ids });
true
}
/// Hide the bottom spinner/status if the UI is idle (no streams, tools, agents, or tasks).
fn maybe_hide_spinner(&mut self) {
let any_tools_running = !self.exec.running_commands.is_empty()
@@ -17932,6 +18006,10 @@ fi\n\
return EscRoute::new(EscIntent::AutoDismissSummary, true, false);
}
if self.has_cancelable_agents() {
return EscRoute::new(EscIntent::CancelAgents, true, false);
}
if self.auto_manual_entry_active() && !self.composer_is_empty() {
return EscRoute::new(EscIntent::ClearComposer, true, false);
}
@@ -17988,6 +18066,7 @@ fi\n\
self.handle_key_event(key_event);
true
}
EscIntent::CancelAgents => self.cancel_active_agents(),
EscIntent::CancelTask => {
let _ = self.on_ctrl_c();
true
@@ -22101,6 +22180,27 @@ mod tests {
assert!(!route.allows_double_esc);
}
#[test]
fn esc_router_prioritizes_agent_cancel_before_cli_interrupt() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
chat.active_agents.push(AgentInfo {
id: "agent-1".to_string(),
name: "Agent 1".to_string(),
status: AgentStatus::Running,
batch_id: Some("batch-1".to_string()),
model: None,
result: None,
error: None,
last_progress: None,
});
chat.bottom_pane.set_task_running(true);
let route = chat.describe_esc_context();
assert_eq!(route.intent, EscIntent::CancelAgents);
}
#[test]
fn esc_router_cancels_running_task() {
let mut harness = ChatWidgetHarness::new();
+10 -1
View File
@@ -486,7 +486,16 @@ impl AgentRunCell {
}
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text_value = format!(" [Ctrl+A] Expand · [Esc] Stop");
let has_running_agents = self
.agents
.iter()
.any(|agent| matches!(agent.status_kind, AgentStatusKind::Running | AgentStatusKind::Pending));
let text_value = if has_running_agents {
" [Ctrl+A] Expand · [Esc] Stop".to_string()
} else {
" [Ctrl+A] Expand".to_string()
};
let text = truncate_with_ellipsis(text_value.as_str(), body_width);
let segment = CardSegment::new(text, secondary_text_style(style));
CardRow::new(BORDER_BOTTOM.to_string(), Self::accent_style(style), vec![segment], None)