diff --git a/README.md b/README.md index 3a7d536..af90ee0 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode) - **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`. - **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`. -- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`. +- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:w`, `:session save`, `:theme`. +- **Quick Exit**: Press `Ctrl+C` twice in Normal mode to quit quickly (first press still cancels active generations). - **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings. - **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log. diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index ed519a2..baa9c96 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -66,6 +66,7 @@ const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200); const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); const RESIZE_STEP: f32 = 0.05; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; +const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SlashOutcome { @@ -251,18 +252,19 @@ pub struct ChatApp { last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection resize_snap_index: usize, // Cycles through 25/50/75 snaps last_snap_direction: Option, - file_tree: FileTreeState, // Workspace file tree state - file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback - file_panel_collapsed: bool, // Whether the file panel is collapsed - file_panel_width: u16, // Cached file panel width - saved_sessions: Vec, // Cached list of saved sessions - selected_session_index: usize, // Index of selected session in browser - help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) - theme: Theme, // Current theme + last_ctrl_c: Option, // Track timing for double Ctrl+C quit + file_tree: FileTreeState, // Workspace file tree state + file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback + file_panel_collapsed: bool, // Whether the file panel is collapsed + file_panel_width: u16, // Cached file panel width + saved_sessions: Vec, // Cached list of saved sessions + selected_session_index: usize, // Index of selected session in browser + help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) + theme: Theme, // Current theme available_themes: Vec, // Cached list of theme names - selected_theme_index: usize, // Index of selected theme in browser + selected_theme_index: usize, // Index of selected theme in browser pending_consent: Option, // Pending consent request - system_status: String, // System/status messages (tool execution, status, etc) + system_status: String, // System/status messages (tool execution, status, etc) toasts: ToastManager, /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, @@ -488,6 +490,7 @@ impl ChatApp { last_resize_tap: None, resize_snap_index: 0, last_snap_direction: None, + last_ctrl_c: None, file_tree, file_icons, file_panel_collapsed: true, @@ -3992,6 +3995,12 @@ impl ChatApp { pub async fn handle_event(&mut self, event: Event) -> Result { use crossterm::event::{KeyCode, KeyModifiers}; + if let Some(last) = self.last_ctrl_c + && last.elapsed() > DOUBLE_CTRL_C_WINDOW + { + self.last_ctrl_c = None; + } + match event { Event::Tick => { self.poll_repo_search(); @@ -4012,6 +4021,15 @@ impl ChatApp { // Ignore paste events in other modes } Event::Key(key) => { + let is_ctrl_c = matches!( + (key.code, key.modifiers), + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) + ); + + if !is_ctrl_c { + self.last_ctrl_c = None; + } + // Handle consent dialog first (highest priority) if let Some(consent_state) = &self.pending_consent { match key.code { @@ -4259,16 +4277,30 @@ impl ChatApp { self.handle_workspace_resize(PaneDirection::Down); return Ok(AppState::Running); } - (KeyCode::Char('q'), KeyModifiers::NONE) => { - return Ok(AppState::Quit); - } (KeyCode::Char('c'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { if self.cancel_active_generation()? { + self.last_ctrl_c = None; return Ok(AppState::Running); } - return Ok(AppState::Quit); + + let now = Instant::now(); + if let Some(last) = self.last_ctrl_c + && now.duration_since(last) <= DOUBLE_CTRL_C_WINDOW + { + self.status = "Exiting…".to_string(); + self.set_system_status(String::new()); + self.last_ctrl_c = None; + return Ok(AppState::Quit); + } + + self.last_ctrl_c = Some(now); + self.status = "Press Ctrl+C again to quit".to_string(); + self.set_system_status( + "Press Ctrl+C again to quit OWLEN".to_string(), + ); + return Ok(AppState::Running); } (KeyCode::Char('j'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 0e93177..c6c619d 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -3171,8 +3171,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { .fg(theme.user_message_role), )]), Line::from(" :h, :help → show this help"), - Line::from(" :q, :quit → quit application"), - Line::from(" :reload → reload configuration and themes"), + Line::from(" :quit → quit application"), + Line::from(" Ctrl+C twice → quit application"), + Line::from(" :reload → reload configuration and themes"), Line::from(" :layout save/load → persist or restore pane layout"), Line::from(""), Line::from(vec![Span::styled( @@ -3200,8 +3201,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { .add_modifier(Modifier::BOLD) .fg(theme.user_message_role), )]), - Line::from(" :save [name] → save current session (with optional name)"), - Line::from(" :w [name] → alias for :save"), + Line::from(" :session save [name] → save current session (optional name)"), Line::from(" :load, :o → browse and load saved sessions"), Line::from(" :sessions, :ls → browse saved sessions"), Line::from(""), @@ -3223,6 +3223,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { )]), Line::from(" :open → open file in code side panel"), Line::from(" :close → close the code side panel"), + Line::from(" :w[!] [path] → write active file (optionally to path)"), + Line::from(" :q[!] → close active file (append ! to discard)"), + Line::from(" :wq[!] [path] → save then close active file"), // New mode and tool commands added in phases 0‑5 Line::from(" :code → switch to code mode (CLI: owlen --code)"), Line::from(" :mode → change current mode explicitly"), @@ -3244,8 +3247,8 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { .add_modifier(Modifier::BOLD) .fg(theme.user_message_role), )]), - Line::from(" :save → save with auto-generated name"), - Line::from(" :save my-session → save with custom name"), + Line::from(" :session save → save with auto-generated name"), + Line::from(" :session save my-session → save with custom name"), Line::from(" • AI generates description automatically (configurable)"), Line::from(" • Sessions stored in platform-specific directories"), Line::from(""), @@ -3386,7 +3389,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { ), Span::raw(":Jump "), Span::styled( - "Esc/q", + "Esc", Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), @@ -3416,7 +3419,7 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(""), Line::from("No saved sessions found."), Line::from(""), - Line::from("Save your current session with :save [name]"), + Line::from("Save your current session with :session save [name]"), Line::from(""), Line::from("Press Esc to close."), ]; @@ -3650,7 +3653,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { let footer = Paragraph::new(vec![ Line::from(""), - Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc/q: Cancel"), + Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc: Cancel"), ]) .alignment(Alignment::Center) .style(Style::default().fg(theme.placeholder).bg(theme.background));