From bab956c501b46313c2bad023583f137dd17830f5 Mon Sep 17 00:00:00 2001 From: Aris Setiawan Date: Sun, 29 Mar 2026 12:29:20 +0700 Subject: [PATCH 1/3] fix(tui): remove Esc key exit functionality and update input hints - Removed the Esc key functionality that allowed users to exit the application. - Updated the input hints to streamline user instructions by removing the Esc exit option. --- crates/cli/src/tui/app.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 660404a..abd247c 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1814,7 +1814,7 @@ pub fn run_blocking( )) } else if g.input_buffer.is_empty() { Line::from(Span::styled( - "Enter send · Tab agent · Ctrl+V image · /image · Ctrl+P palette · Esc exit · Ctrl+L clear", + "Enter send · Tab agent · Ctrl+V image · /image · Ctrl+P palette · Ctrl+L clear", Style::default().fg(theme::MUTED), )) } else { @@ -3388,11 +3388,6 @@ pub fn run_blocking( } match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - g.should_exit = true; - let _ = cmd_tx.send(TuiCmd::Exit); - break; - } (KeyCode::Char('c'), KeyModifiers::CONTROL) => { let _ = cmd_tx.send(TuiCmd::CancelTurn); } From 7ecfccf76f127093653f42cda0981d8ec8c62b9b Mon Sep 17 00:00:00 2001 From: Aris Setiawan Date: Sun, 29 Mar 2026 12:32:48 +0700 Subject: [PATCH 2/3] fix(tui): update input hints and add Ctrl+Q exit functionality - Revised input hints to include the new Ctrl+Q exit command for improved user guidance. - Implemented Ctrl+Q key binding to allow users to exit the application, enhancing usability. --- crates/cli/src/tui/app.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index abd247c..40bb3b6 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1814,7 +1814,7 @@ pub fn run_blocking( )) } else if g.input_buffer.is_empty() { Line::from(Span::styled( - "Enter send · Tab agent · Ctrl+V image · /image · Ctrl+P palette · Ctrl+L clear", + "Enter send · Tab agent · Ctrl+V image · /image · Ctrl+P palette · Ctrl+Q exit · Ctrl+L clear", Style::default().fg(theme::MUTED), )) } else { @@ -3388,6 +3388,11 @@ pub fn run_blocking( } match (key.code, key.modifiers) { + (KeyCode::Char('q'), KeyModifiers::CONTROL) => { + g.should_exit = true; + let _ = cmd_tx.send(TuiCmd::Exit); + break; + } (KeyCode::Char('c'), KeyModifiers::CONTROL) => { let _ = cmd_tx.send(TuiCmd::CancelTurn); } From 97d0e59eb852f2a464d6afaf320175d18f0785ae Mon Sep 17 00:00:00 2001 From: Aris Setiawan Date: Sun, 29 Mar 2026 12:56:51 +0700 Subject: [PATCH 3/3] feat(tui): implement cancel functionality with Esc key and update busy state handling - Added cancel functionality to allow users to cancel ongoing operations using the Esc key when in specific busy states (Thinking, Streaming, ToolRunning). - Updated the TuiSessionState to handle the transition to Idle state when a run is cancelled. - Enhanced the run_blocking function to display a cancel hint in the UI, improving user experience during long-running tasks. --- crates/cli/src/repl.rs | 2 + crates/cli/src/runner.rs | 5 ++ crates/cli/src/tui/app.rs | 96 ++++++++++++++++++++++++++++++++++--- crates/cli/src/tui/state.rs | 6 ++- crates/core/src/agent.rs | 28 +++++++---- 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index b342888..2a1b402 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -1646,6 +1646,7 @@ impl Repl { let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let st = tui_state.clone(); let banner = self.run_mode; + let cancel_flag = self.runtime.cancel_handle(); let ui = tokio::task::spawn_blocking(move || { run_blocking( st, @@ -1653,6 +1654,7 @@ impl Repl { Some(answer_for_tui), Some(approval_for_tui), banner, + Some(cancel_flag), ) }); diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs index ca750c5..f5dc8ba 100644 --- a/crates/cli/src/runner.rs +++ b/crates/cli/src/runner.rs @@ -9,6 +9,7 @@ use nca_runtime::ipc::IpcHandle; use nca_runtime::supervisor::{Supervisor, SupervisorConfig, SupervisorHandle}; use std::path::Path; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio::sync::mpsc; /// Resolve a pending `ask_question` without going through `SessionRuntime` (e.g. TUI side task @@ -163,6 +164,10 @@ impl SessionRuntime { self.supervisor.request_cancel(); } + pub fn cancel_handle(&self) -> Arc { + self.supervisor.cancel_handle() + } + pub fn event_tx(&self) -> Option> { self.supervisor.event_tx() } diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 40bb3b6..422ad55 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -24,7 +24,7 @@ use crossterm::{ }, }; use nca_common::config::ProviderKind; -use nca_common::event::QuestionSelection; +use nca_common::event::{BusyState, QuestionSelection}; use nca_core::approval::suggest_allow_pattern; use nca_core::skills::{SkillCatalog, SkillSource}; use ratatui::{ @@ -753,6 +753,13 @@ fn toolbar_permission_is_bypass(mode: &str) -> bool { mode.contains("BypassPermissions") } +fn escape_cancels_active_turn(state: &TuiSessionState) -> bool { + matches!( + state.current_busy_state, + BusyState::Thinking | BusyState::Streaming | BusyState::ToolRunning + ) +} + fn rect_contains(r: Rect, col: u16, row: u16) -> bool { col >= r.x && col < r.x.saturating_add(r.width) @@ -1371,6 +1378,7 @@ pub fn run_blocking( question_answer_tx: Option>, approval_answer_tx: Option>, show_run_banner: bool, + cancel_flag: Option>, ) -> anyhow::Result<()> { let mut terminal = setup_terminal()?; @@ -1641,6 +1649,28 @@ pub fn run_blocking( Style::default().fg(theme::MUTED), ); + let cancel_hint_text = " Esc cancel "; + let cancel_hint = escape_cancels_active_turn(&g).then(|| { + Span::styled( + cancel_hint_text, + Style::default() + .fg(Color::Black) + .bg(theme::WARN) + .add_modifier(Modifier::BOLD), + ) + }); + let status_rect = if cancel_hint.is_some() && st_r.width > cancel_hint_text.len() as u16 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(cancel_hint_text.len() as u16), + ]) + .split(st_r)[0] + } else { + st_r + }; + // Compute the character-cell x-offset before any borrow of `g` escapes into `status_spans`. let branch_char_offset = 4 + g.model.len() + 4 + g.agent_profile.len() + 4; let branch_text = if g.current_branch.is_empty() { @@ -1653,12 +1683,12 @@ pub fn run_blocking( .add_modifier(Modifier::UNDERLINED); // Store the branch chip bounds for click hit-testing. - if st_r.width > branch_char_offset as u16 && !branch_text.is_empty() { + if status_rect.width > branch_char_offset as u16 && !branch_text.is_empty() { let chip_len = branch_text.len() as u16; g.branch_chip_bounds = Some(Rect::new( - st_r.x + branch_char_offset as u16, - st_r.y, - chip_len.min(st_r.width - branch_char_offset as u16), + status_rect.x + branch_char_offset as u16, + status_rect.y, + chip_len.min(status_rect.width - branch_char_offset as u16), 1, )); } else { @@ -1708,7 +1738,21 @@ pub fn run_blocking( status_spans.push(time_span); let status = Line::from(status_spans); let bar = Paragraph::new(status).style(Style::default().bg(theme::SURFACE)); - frame.render_widget(bar, st_r); + frame.render_widget(bar, status_rect); + if let Some(cancel_hint) = cancel_hint { + let hint_width = cancel_hint_text.len() as u16; + if st_r.width > hint_width { + let hint_rect = Rect::new( + st_r.x + st_r.width.saturating_sub(hint_width), + st_r.y, + hint_width, + 1, + ); + let hint_bar = Paragraph::new(Line::from(cancel_hint)) + .style(Style::default().bg(theme::SURFACE)); + frame.render_widget(hint_bar, hint_rect); + } + } if let Some(sr) = slash_opt { if slash_panel_visible(&g.input_buffer) && !slash_filtered.is_empty() { @@ -3388,12 +3432,23 @@ pub fn run_blocking( } match (key.code, key.modifiers) { + (KeyCode::Esc, _) if escape_cancels_active_turn(&g) => { + if let Some(ref flag) = cancel_flag { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + } + g.blocks + .push(DisplayBlock::System("Cancelling current run...".into())); + let _ = cmd_tx.send(TuiCmd::CancelTurn); + } (KeyCode::Char('q'), KeyModifiers::CONTROL) => { g.should_exit = true; let _ = cmd_tx.send(TuiCmd::Exit); break; } (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + if let Some(ref flag) = cancel_flag { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + } let _ = cmd_tx.send(TuiCmd::CancelTurn); } (KeyCode::Char('l'), KeyModifiers::CONTROL) => { @@ -3777,8 +3832,11 @@ mod approval_parse_tests { use super::{ TuiCmd, apply_selected_at_completion, branch_picker_enter_command, completed_at_mention_range_before_cursor, composer_line, delete_completed_at_mention, - filtered_branch_indices, parse_approval_verdict, + escape_cancels_active_turn, filtered_branch_indices, parse_approval_verdict, }; + use crate::tui::state::TuiSessionState; + use nca_common::event::BusyState; + use std::path::PathBuf; #[test] fn parses_yes_with_punctuation_and_synonyms() { @@ -3889,4 +3947,28 @@ mod approval_parse_tests { assert_eq!(mention_span.style.bg, Some(super::theme::MENTION_BG)); } + + #[test] + fn escape_only_cancels_active_turn_states() { + let mut state = TuiSessionState::new( + "session".into(), + "model".into(), + "@build".into(), + "AcceptEdits".into(), + PathBuf::from("."), + ); + assert!(!escape_cancels_active_turn(&state)); + + state.set_busy_state(BusyState::Thinking); + assert!(escape_cancels_active_turn(&state)); + + state.set_busy_state(BusyState::Streaming); + assert!(escape_cancels_active_turn(&state)); + + state.set_busy_state(BusyState::ToolRunning); + assert!(escape_cancels_active_turn(&state)); + + state.set_busy_state(BusyState::ApprovalPending); + assert!(!escape_cancels_active_turn(&state)); + } } diff --git a/crates/cli/src/tui/state.rs b/crates/cli/src/tui/state.rs index 542a85b..7b1a992 100644 --- a/crates/cli/src/tui/state.rs +++ b/crates/cli/src/tui/state.rs @@ -632,7 +632,11 @@ impl TuiSessionState { } AgentEvent::Error { message } => { self.blocks.push(DisplayBlock::ErrorLine(message.clone())); - self.set_busy_state(BusyState::Error); + if message.to_ascii_lowercase().contains("run cancelled") { + self.set_busy_state(BusyState::Idle); + } else { + self.set_busy_state(BusyState::Error); + } } AgentEvent::Checkpoint { .. } => {} AgentEvent::ChildSessionSpawned { diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index 85f5904..ac71e9c 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use crate::approval::{ApprovalPolicy, ApprovalVerdict}; use crate::cost::CostTracker; @@ -160,14 +161,25 @@ impl AgentLoop { let mut tool_calls: Vec = Vec::new(); let mut got_usage = false; - while let Some(chunk) = stream.recv().await { - if self.is_cancelled() { - self.emit(AgentEvent::Error { - message: "Run cancelled while streaming model output".into(), - }) - .await; - return Err(ProviderError::Other("run cancelled".into())); - } + let mut cancel_poll = tokio::time::interval(Duration::from_millis(25)); + cancel_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + let chunk = tokio::select! { + _ = cancel_poll.tick() => { + if self.is_cancelled() { + self.emit(AgentEvent::Error { + message: "Run cancelled while streaming model output".into(), + }) + .await; + return Err(ProviderError::Other("run cancelled".into())); + } + continue; + } + chunk = stream.recv() => chunk, + }; + let Some(chunk) = chunk else { + break; + }; match chunk { StreamChunk::TextDelta(delta) => { if assistant_text.is_empty() {