From 440ee345530857ebf872ae91d9a0fc73bd3ccf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Thu, 29 Jan 2026 20:53:12 +0800 Subject: [PATCH 1/2] feat: add Force Quit command to TUI Add a Force Quit command to the TUI command palette that allows users to exit immediately without the double Ctrl+C confirmation. Changes: - Add ForceQuit variant to CommandPaletteAction enum - Add Force Quit command to command palette with Ctrl+Q shortcut - Handle ForceQuit action in app.rs to set should_quit flag - Add ForceQuit pattern match in chat.rs component The command appears in the command palette as 'Force Quit' with the shortcut . --- crates/coco-tui/src/actions.rs | 1 + crates/coco-tui/src/app.rs | 3 +++ crates/coco-tui/src/components/chat.rs | 3 +++ crates/coco-tui/src/components/command_palette.rs | 7 +++++++ 4 files changed, 14 insertions(+) diff --git a/crates/coco-tui/src/actions.rs b/crates/coco-tui/src/actions.rs index ab391e4..d977259 100644 --- a/crates/coco-tui/src/actions.rs +++ b/crates/coco-tui/src/actions.rs @@ -93,6 +93,7 @@ pub enum CommandPaletteAction { SwitchTheme(String), SwitchModel(Option), Shell, + ForceQuit, } impl From for Action { diff --git a/crates/coco-tui/src/app.rs b/crates/coco-tui/src/app.rs index 68a8a55..e90ba2b 100644 --- a/crates/coco-tui/src/app.rs +++ b/crates/coco-tui/src/app.rs @@ -259,6 +259,9 @@ impl App { } match action { Action::Quit => self.should_quit = true, + Action::CommandPalette(CommandPaletteAction::ForceQuit) => { + self.should_quit = true; + } Action::Render => self.render()?, Action::CommandPalette(CommandPaletteAction::Shell) => { self.root.handle_action(&action); diff --git a/crates/coco-tui/src/components/chat.rs b/crates/coco-tui/src/components/chat.rs index 9aa7d26..2f3230b 100644 --- a/crates/coco-tui/src/components/chat.rs +++ b/crates/coco-tui/src/components/chat.rs @@ -2711,6 +2711,9 @@ impl Component for Chat<'static> { CommandPaletteAction::Shell => { self.update_focus(Focus::InputBlur); } + CommandPaletteAction::ForceQuit => { + // Force quit is handled at app level + } }, Action::SubmitPrompt(prompt) => { if self.state.state == ChatState::Ready { diff --git a/crates/coco-tui/src/components/command_palette.rs b/crates/coco-tui/src/components/command_palette.rs index 86cc979..36f705d 100644 --- a/crates/coco-tui/src/components/command_palette.rs +++ b/crates/coco-tui/src/components/command_palette.rs @@ -31,6 +31,7 @@ const COMMAND_REGENERATE_SUMMARY: &str = "Regenerate Session Summary"; const COMMAND_SWITCH_THEME: &str = "Switch Theme"; const COMMAND_SWITCH_MODEL: &str = "Switch Model"; const COMMAND_SHELL: &str = "Shell"; +const COMMAND_FORCE_QUIT: &str = "Force Quit"; const BREADCRUMB_ROOT: &str = "Command Palette"; const BREADCRUMB_SESSIONS: &str = "Sessions"; @@ -322,6 +323,10 @@ impl CommandPalette { name: COMMAND_SHELL.to_string(), shortcut: Some("".to_string()), }, + Command { + name: COMMAND_FORCE_QUIT.to_string(), + shortcut: Some("".to_string()), + }, ] } @@ -583,6 +588,7 @@ impl CommandPalette { Some(COMMAND_NEW_SESSION) => Some(CommandPaletteAction::NewSession), Some(COMMAND_TRANSCRIPT) => Some(CommandPaletteAction::Transcript), Some(COMMAND_SHELL) => Some(CommandPaletteAction::Shell), + Some(COMMAND_FORCE_QUIT) => Some(CommandPaletteAction::ForceQuit), Some(unknown) => { warn!(?unknown, "unknown command"); None @@ -681,6 +687,7 @@ impl Component for CommandPalette { None } (Main, KM::CONTROL, Char('x' | 'X')) => Some(CommandPaletteAction::Shell), + (Main, KM::CONTROL, Char('q' | 'Q')) => Some(CommandPaletteAction::ForceQuit), (_, KM::NONE, Char('k')) => { self.command_list.select_prev(); None From a5cfed382cc552e795977093b748eb727b83b06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Thu, 29 Jan 2026 21:12:13 +0800 Subject: [PATCH 2/2] feat: support Ctrl+Q as exit shortcut for Force Quit command --- crates/coco-tui/src/components/chat.rs | 49 ++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/coco-tui/src/components/chat.rs b/crates/coco-tui/src/components/chat.rs index 2f3230b..b735e04 100644 --- a/crates/coco-tui/src/components/chat.rs +++ b/crates/coco-tui/src/components/chat.rs @@ -217,14 +217,22 @@ enum TranscriptScope { const CTRL_C_WINDOW: Duration = Duration::from_secs(2); const SESSION_SUMMARY_MAX_LEN: usize = 80; const NOTIFY_TITLE: &str = "coco"; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ExitShortcut { + CtrlC, + CtrlQ, +} + #[derive(Debug, Default)] struct CancellationGuard { last_hit: State>, + last_shortcut: State>, cancel_token: Option, } impl CancellationGuard { - pub fn try_fire(&mut self) -> bool { + pub fn try_fire(&mut self, shortcut: ExitShortcut) -> bool { let now = Instant::now(); if let Some(last) = self.last_hit.get() && now.duration_since(last) <= CTRL_C_WINDOW @@ -236,12 +244,14 @@ impl CancellationGuard { } *self.last_hit.write() = Some(now); + *self.last_shortcut.write() = Some(shortcut); false } pub fn reset(&mut self) { *self.last_hit.write() = None; + *self.last_shortcut.write() = None; } pub fn on_trick(&mut self) { @@ -257,6 +267,10 @@ impl CancellationGuard { self.last_hit.is_some() } + pub fn last_shortcut(&self) -> Option { + self.last_shortcut.get() + } + pub fn token(&mut self) -> CancellationToken { if let Some(token) = &self.cancel_token { return token.clone(); @@ -1194,16 +1208,20 @@ impl Chat<'static> { self.state.state == ChatState::Ready } - fn handle_ctrl_c(&mut self) { - if !self.cancellation_guard.try_fire() { + fn handle_exit_shortcut(&mut self, shortcut: ExitShortcut, action: Action) { + if !self.cancellation_guard.try_fire(shortcut) { return; } if self.is_ready_for_exit() { - global::action_tx().send(Action::Quit).unwrap(); + global::action_tx().send(action).unwrap(); } } + fn handle_ctrl_c(&mut self) { + self.handle_exit_shortcut(ExitShortcut::CtrlC, Action::Quit); + } + fn submit_value(&mut self, value: String) { if value.is_empty() { debug!("submitting with empty value, skipping"); @@ -1411,10 +1429,15 @@ impl Chat<'static> { if !self.cancellation_guard.is_armed() { return None; } + let shortcut = self.cancellation_guard.last_shortcut()?; + let shortcut_name = match shortcut { + ExitShortcut::CtrlC => "Ctrl+C", + ExitShortcut::CtrlQ => "Ctrl+Q", + }; let message = if self.is_ready_for_exit() { - "Press Ctrl+C again to exit" + format!("Press {shortcut_name} again to exit") } else { - "Press Ctrl+C again to cancel" + format!("Press {shortcut_name} again to cancel") }; let theme = global::theme(); Some(Line::from(Span::styled( @@ -2388,6 +2411,20 @@ impl Component for Chat<'static> { self.handle_ctrl_c(); return; } + if matches!( + key, + KeyEvent { + code: Char('q') | Char('Q'), + modifiers: KM::CONTROL, + .. + } + ) { + self.handle_exit_shortcut( + ExitShortcut::CtrlQ, + Action::CommandPalette(CommandPaletteAction::ForceQuit), + ); + return; + } if self.view == ViewMode::Chat && matches!( key,