feat(tui): add double‑Ctrl+C quick‑exit and update command help texts

- Introduce “Ctrl+C twice” shortcut for quitting the application and display corresponding help line.
- Rename and clarify session‑related commands (`:session save`) and add short aliases (`:w[!]`, `:q[!]`, `:wq[!]`) with updated help entries.
- Adjust quit help text to remove `:q, :quit` redundancy and replace with the new quick‑exit hint.
- Update UI key hint to show only “Esc” for cancel actions.
- Implement double‑Ctrl+C detection in `ChatApp` using `DOUBLE_CTRL_C_WINDOW`, track `last_ctrl_c`, reset on other keys, and show status messages prompting the second press.
- Minor wording tweaks in help dialogs and README to reflect the new command syntax and quick‑exit behavior.
This commit is contained in:
2025-10-13 19:51:00 +02:00
parent c81d0f1593
commit 58dd6f3efa
3 changed files with 60 additions and 24 deletions

View File

@@ -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`. - **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`. - **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. - **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. - **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.

View File

@@ -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_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450);
const RESIZE_STEP: f32 = 0.05; const RESIZE_STEP: f32 = 0.05;
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashOutcome { enum SlashOutcome {
@@ -251,18 +252,19 @@ pub struct ChatApp {
last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection
resize_snap_index: usize, // Cycles through 25/50/75 snaps resize_snap_index: usize, // Cycles through 25/50/75 snaps
last_snap_direction: Option<PaneDirection>, last_snap_direction: Option<PaneDirection>,
file_tree: FileTreeState, // Workspace file tree state last_ctrl_c: Option<Instant>, // Track timing for double Ctrl+C quit
file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback file_tree: FileTreeState, // Workspace file tree state
file_panel_collapsed: bool, // Whether the file panel is collapsed file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback
file_panel_width: u16, // Cached file panel width file_panel_collapsed: bool, // Whether the file panel is collapsed
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions file_panel_width: u16, // Cached file panel width
selected_session_index: usize, // Index of selected session in browser saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1)) selected_session_index: usize, // Index of selected session in browser
theme: Theme, // Current theme help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1))
theme: Theme, // Current theme
available_themes: Vec<String>, // Cached list of theme names available_themes: Vec<String>, // 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<ConsentDialogState>, // Pending consent request pending_consent: Option<ConsentDialogState>, // 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, toasts: ToastManager,
/// Simple execution budget: maximum number of tool calls allowed per session. /// Simple execution budget: maximum number of tool calls allowed per session.
_execution_budget: usize, _execution_budget: usize,
@@ -488,6 +490,7 @@ impl ChatApp {
last_resize_tap: None, last_resize_tap: None,
resize_snap_index: 0, resize_snap_index: 0,
last_snap_direction: None, last_snap_direction: None,
last_ctrl_c: None,
file_tree, file_tree,
file_icons, file_icons,
file_panel_collapsed: true, file_panel_collapsed: true,
@@ -3992,6 +3995,12 @@ impl ChatApp {
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> { pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
use crossterm::event::{KeyCode, KeyModifiers}; 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 { match event {
Event::Tick => { Event::Tick => {
self.poll_repo_search(); self.poll_repo_search();
@@ -4012,6 +4021,15 @@ impl ChatApp {
// Ignore paste events in other modes // Ignore paste events in other modes
} }
Event::Key(key) => { 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) // Handle consent dialog first (highest priority)
if let Some(consent_state) = &self.pending_consent { if let Some(consent_state) = &self.pending_consent {
match key.code { match key.code {
@@ -4259,16 +4277,30 @@ impl ChatApp {
self.handle_workspace_resize(PaneDirection::Down); self.handle_workspace_resize(PaneDirection::Down);
return Ok(AppState::Running); return Ok(AppState::Running);
} }
(KeyCode::Char('q'), KeyModifiers::NONE) => {
return Ok(AppState::Quit);
}
(KeyCode::Char('c'), modifiers) (KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) => if modifiers.contains(KeyModifiers::CONTROL) =>
{ {
if self.cancel_active_generation()? { if self.cancel_active_generation()? {
self.last_ctrl_c = None;
return Ok(AppState::Running); 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) (KeyCode::Char('j'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) => if modifiers.contains(KeyModifiers::CONTROL) =>

View File

@@ -3171,8 +3171,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]),
Line::from(" :h, :help → show this help"), Line::from(" :h, :help → show this help"),
Line::from(" :q, :quit → quit application"), Line::from(" :quit → quit application"),
Line::from(" :reload → reload configuration and themes"), 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(" :layout save/load → persist or restore pane layout"),
Line::from(""), Line::from(""),
Line::from(vec![Span::styled( Line::from(vec![Span::styled(
@@ -3200,8 +3201,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]),
Line::from(" :save [name] → save current session (with optional name)"), Line::from(" :session save [name] → save current session (optional name)"),
Line::from(" :w [name] → alias for :save"),
Line::from(" :load, :o → browse and load saved sessions"), Line::from(" :load, :o → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"), Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""), Line::from(""),
@@ -3223,6 +3223,9 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
)]), )]),
Line::from(" :open <path> → open file in code side panel"), Line::from(" :open <path> → open file in code side panel"),
Line::from(" :close → close the 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 05 // New mode and tool commands added in phases 05
Line::from(" :code → switch to code mode (CLI: owlen --code)"), Line::from(" :code → switch to code mode (CLI: owlen --code)"),
Line::from(" :mode <chat|code> → change current mode explicitly"), Line::from(" :mode <chat|code> → change current mode explicitly"),
@@ -3244,8 +3247,8 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
.fg(theme.user_message_role), .fg(theme.user_message_role),
)]), )]),
Line::from(" :save → save with auto-generated name"), Line::from(" :session save → save with auto-generated name"),
Line::from(" :save my-session → save with custom name"), Line::from(" :session save my-session → save with custom name"),
Line::from(" • AI generates description automatically (configurable)"), Line::from(" • AI generates description automatically (configurable)"),
Line::from(" • Sessions stored in platform-specific directories"), Line::from(" • Sessions stored in platform-specific directories"),
Line::from(""), Line::from(""),
@@ -3386,7 +3389,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
), ),
Span::raw(":Jump "), Span::raw(":Jump "),
Span::styled( Span::styled(
"Esc/q", "Esc",
Style::default() Style::default()
.fg(theme.focused_panel_border) .fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -3416,7 +3419,7 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(""), Line::from(""),
Line::from("No saved sessions found."), Line::from("No saved sessions found."),
Line::from(""), 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(""),
Line::from("Press Esc to close."), Line::from("Press Esc to close."),
]; ];
@@ -3650,7 +3653,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let footer = Paragraph::new(vec![ let footer = Paragraph::new(vec![
Line::from(""), 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) .alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background)); .style(Style::default().fg(theme.placeholder).bg(theme.background));