Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/coco-tui/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub enum CommandPaletteAction {
SwitchTheme(String),
SwitchModel(Option<String>),
Shell,
ForceQuit,
}

impl From<CommandPaletteAction> for Action {
Expand Down
3 changes: 3 additions & 0 deletions crates/coco-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 46 additions & 6 deletions crates/coco-tui/src/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Instant>>,
last_shortcut: State<Option<ExitShortcut>>,
cancel_token: Option<CancellationToken>,
}

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
Expand All @@ -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) {
Expand All @@ -257,6 +267,10 @@ impl CancellationGuard {
self.last_hit.is_some()
}

pub fn last_shortcut(&self) -> Option<ExitShortcut> {
self.last_shortcut.get()
}

pub fn token(&mut self) -> CancellationToken {
if let Some(token) = &self.cancel_token {
return token.clone();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2711,6 +2748,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 {
Expand Down
7 changes: 7 additions & 0 deletions crates/coco-tui/src/components/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -322,6 +323,10 @@ impl CommandPalette {
name: COMMAND_SHELL.to_string(),
shortcut: Some("<C-x>".to_string()),
},
Command {
name: COMMAND_FORCE_QUIT.to_string(),
shortcut: Some("<C-q>".to_string()),
},
]
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down