mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/agents): hook Esc to cancel active agent runs
This commit is contained in:
@@ -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 }] };
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user