diff --git a/Cargo.lock b/Cargo.lock index 52a67d8..f48ef5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,7 @@ dependencies = [ "futures", "ignore", "image", + "libc", "notify", "notify-rust", "pretty_assertions", diff --git a/Cargo.toml b/Cargo.toml index bcbfd2f..4c88c59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ dirs = "6.0.0" fastrand = "2.4.1" futures = "0.3.31" ignore = "0.4.25" +libc = "0.2" notify = "8.2.0" notify-rust = "4.17.0" pulldown-cmark = "0.13.3" diff --git a/src/app/connect/mod.rs b/src/app/connect/mod.rs index e05c77e..dcd9f62 100644 --- a/src/app/connect/mod.rs +++ b/src/app/connect/mod.rs @@ -175,6 +175,7 @@ pub fn create_app(cli: &Cli) -> App { tool_call_index: HashMap::new(), todos: Vec::::new(), focus: FocusManager::default(), + keymap: super::keymap::ResolvedKeymap::defaults(), available_commands: Vec::new(), plugins: PluginsState::default(), available_agents: Vec::new(), diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index a7a508d..9d055ae 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -17,9 +17,11 @@ use super::{ PendingCommandAck, SurfaceMode, SystemSeverity, TerminalSizeChange, TextBlock, }; use crate::agent::model; -use crate::app::keys::reclaim_input_from_inline_prompt_if_needed; +#[cfg(all(test, target_os = "macos"))] +use crate::app::keys::CMD_MOD; #[cfg(test)] -use crate::app::keys::{CMD_MOD, WORD_NAV_MOD}; +use crate::app::keys::WORD_NAV_MOD; +use crate::app::keys::{KeyOutcome, RuntimeCommand, reclaim_input_from_inline_prompt_if_needed}; use crate::app::todos::apply_plan_todos; #[cfg(test)] use crossterm::event::KeyEvent; @@ -27,36 +29,63 @@ use crossterm::event::{Event, KeyEventKind}; pub use client::handle_client_event; -pub fn handle_terminal_event(app: &mut App, event: Event) { +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TerminalEventOutcome { + changed: bool, + runtime_command: Option, +} + +impl TerminalEventOutcome { + pub(crate) fn runtime_command(self) -> Option { + self.runtime_command + } + + fn ignored() -> Self { + Self { changed: false, runtime_command: None } + } + + fn handled(changed: bool) -> Self { + Self { changed, runtime_command: None } + } + + fn from_key_outcome(outcome: KeyOutcome) -> Self { + Self { changed: outcome.changed(), runtime_command: outcome.runtime_command() } + } +} + +pub fn handle_terminal_event(app: &mut App, event: Event) -> TerminalEventOutcome { if matches!(app.terminal_lifecycle, super::TerminalLifecycleState::ReleasedToChild(_)) && !matches!(&event, Event::Resize(_, _)) { - return; + return TerminalEventOutcome::ignored(); } - let changed = match event { + let outcome = match event { Event::Key(key) if should_dispatch_key_event(key) => dispatch_key_by_view(app, key), Event::Mouse(mouse) => { dispatch_mouse_by_view(app, mouse); - true + TerminalEventOutcome::handled(true) } - Event::Paste(text) => dispatch_paste_by_view(app, &text), + Event::Paste(text) => TerminalEventOutcome::handled(dispatch_paste_by_view(app, &text)), Event::FocusGained => { app.notifications.on_focus_gained(); app.sync_git_context(); - true + TerminalEventOutcome::handled(true) } Event::FocusLost => { app.notifications.on_focus_lost(); - true + TerminalEventOutcome::handled(true) + } + Event::Resize(width, height) => { + TerminalEventOutcome::handled(handle_resize(app, width, height)) } - Event::Resize(width, height) => handle_resize(app, width, height), // Non-press key events (Release, Repeat) -- ignored. - Event::Key(_) => false, + Event::Key(_) => TerminalEventOutcome::ignored(), }; - if changed { + if outcome.changed { app.request_active_surface_repaint(); } + outcome } fn should_dispatch_key_event(key: crossterm::event::KeyEvent) -> bool { @@ -142,23 +171,23 @@ fn log_resize_classification(app: &App, size_change: TerminalSizeChange, action: ); } -fn dispatch_key_by_view(app: &mut App, key: crossterm::event::KeyEvent) -> bool { +fn dispatch_key_by_view(app: &mut App, key: crossterm::event::KeyEvent) -> TerminalEventOutcome { match app.surface_mode { SurfaceMode::Chat => { app.active_paste_session = None; - super::keys::dispatch_key_by_focus(app, key) + TerminalEventOutcome::from_key_outcome(super::keys::dispatch_key_by_focus(app, key)) } SurfaceMode::Fullscreen(FullscreenView::Config) => { super::config::handle_key(app, key); - true + TerminalEventOutcome::handled(true) } SurfaceMode::Fullscreen(FullscreenView::Trusted) => { super::trust::handle_key(app, key); - true + TerminalEventOutcome::handled(true) } SurfaceMode::Fullscreen(FullscreenView::SessionPicker) => { super::session_picker::handle_key(app, key); - true + TerminalEventOutcome::handled(true) } } } @@ -501,6 +530,10 @@ mod tests { use crate::agent::events::ClientEvent; use crate::agent::events::ServiceStatusSeverity; use crate::agent::events::TerminalProcess; + use crate::app::keymap::{ + KeyAction, KeyBinding, KeyBindingSource, KeyContext, KeySpec, ResolvedKeymap, + TerminalAction, + }; use crate::app::slash::{SlashCandidate, SlashContext, SlashState}; use crate::app::{ BlockCache, CancelOrigin, ChatRebuildKind, ComposerRenderState, FocusOwner, FocusTarget, @@ -3831,14 +3864,19 @@ mod tests { } #[test] - fn ctrl_z_and_y_undo_and_redo_textarea_history() { + fn configured_undo_and_redo_restore_textarea_history() { let mut app = make_test_app(); app.input.set_text("hello world"); handle_normal_key(&mut app, KeyEvent::new(KeyCode::Backspace, WORD_NAV_MOD)); assert_eq!(app.input.text(), "hello "); + #[cfg(target_os = "macos")] handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('z'), CMD_MOD)); + #[cfg(target_os = "windows")] + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)); + #[cfg(all(unix, not(target_os = "macos")))] + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('_'), KeyModifiers::CONTROL)); assert_eq!(app.input.text(), "hello world"); #[cfg(target_os = "macos")] @@ -3847,10 +3885,26 @@ mod tests { KeyEvent::new(KeyCode::Char('z'), CMD_MOD | KeyModifiers::SHIFT), ); #[cfg(not(target_os = "macos"))] - handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('y'), CMD_MOD)); + handle_normal_key( + &mut app, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL | KeyModifiers::SHIFT), + ); assert_eq!(app.input.text(), "hello "); } + #[test] + fn ctrl_y_yanks_the_last_killed_text() { + let mut app = make_test_app(); + app.input.set_text("hello world"); + app.input.move_home(); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), ""); + + handle_normal_key(&mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(app.input.text(), "hello world"); + } + #[test] fn ctrl_left_right_move_by_word() { let mut app = make_test_app(); @@ -4450,7 +4504,7 @@ mod tests { } #[test] - fn permission_ctrl_y_resolves_pending_permission() { + fn permission_ctrl_y_does_not_resolve_pending_permission() { let mut app = make_test_app(); let mut response_rx = attach_pending_permission( &mut app, @@ -4475,16 +4529,15 @@ mod tests { Event::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)), ); - let resp = response_rx.try_recv().expect("ctrl+y should resolve pending permission"); - let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(selected.option_id.clone(), "allow"); - assert!(app.pending_interaction_ids.is_empty()); + assert!(matches!( + response_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] - fn permission_ctrl_a_resolves_pending_permission() { + fn permission_ctrl_a_does_not_resolve_pending_permission() { let mut app = make_test_app(); let mut response_rx = attach_pending_permission( &mut app, @@ -4514,16 +4567,15 @@ mod tests { Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)), ); - let resp = response_rx.try_recv().expect("ctrl+a should resolve pending permission"); - let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(selected.option_id.clone(), "allow-always"); - assert!(app.pending_interaction_ids.is_empty()); + assert!(matches!( + response_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] - fn permission_ctrl_n_works_even_when_mention_focus_owns_navigation() { + fn permission_ctrl_n_does_not_bypass_mention_focus() { let mut app = make_test_app(); let mut response_rx = attach_pending_permission( &mut app, @@ -4563,16 +4615,15 @@ mod tests { Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)), ); - let resp = response_rx.try_recv().expect("ctrl+n should resolve pending permission"); - let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(selected.option_id.clone(), "deny"); - assert!(app.pending_interaction_ids.is_empty()); + assert!(matches!( + response_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] - fn plan_approval_raw_ctrl_y_resolves_without_editing_input() { + fn plan_approval_raw_ctrl_y_does_not_resolve_permission() { let mut app = make_test_app(); app.input.set_text("seed"); let mut response_rx = attach_pending_permission( @@ -4598,13 +4649,12 @@ mod tests { Event::Key(KeyEvent::new(KeyCode::Char('\u{19}'), KeyModifiers::NONE)), ); - let resp = response_rx.try_recv().expect("raw ctrl+y should resolve plan approval"); - let model::RequestPermissionOutcome::Selected(selected) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(selected.option_id.clone(), "plan-approve"); + assert!(matches!( + response_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); assert_eq!(app.input.text(), "seed"); - assert!(app.pending_interaction_ids.is_empty()); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -4691,6 +4741,36 @@ mod tests { assert!(app.should_quit); } + #[test] + fn ctrl_c_clears_local_draft_before_quitting() { + let mut app = make_test_app(); + app.input.set_text("draft"); + app.pending_submit = Some(app.input.snapshot()); + app.pending_paste_text = "queued paste".to_owned(); + app.pending_images.push(crate::app::clipboard_image::ImageAttachment { + data: "image-data".to_owned(), + mime_type: "image/png".to_owned(), + }); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(!app.should_quit); + assert!(app.input.is_empty()); + assert!(app.pending_submit.is_none()); + assert!(app.pending_paste_text.is_empty()); + assert!(app.pending_images.is_empty()); + + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + + assert!(app.should_quit); + } + #[test] fn ctrl_q_quits() { let mut app = make_test_app(); @@ -4703,6 +4783,26 @@ mod tests { assert!(app.should_quit); } + #[test] + fn terminal_event_outcome_carries_runtime_command() { + let mut app = make_test_app(); + app.keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::Global, + KeySpec::char('s', KeyModifiers::CONTROL), + KeyAction::Terminal(TerminalAction::Suspend), + KeyBindingSource::Config, + )]) + .expect("custom test keymap should validate"); + + let outcome = handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)), + ); + + assert!(outcome.changed); + assert_eq!(outcome.runtime_command, Some(crate::app::keys::RuntimeCommand::SuspendProcess)); + } + #[test] fn connecting_state_ctrl_q_quits() { let mut app = make_test_app(); diff --git a/src/app/inline_interactions.rs b/src/app/inline_interactions.rs index 75d1f65..7988bf8 100644 --- a/src/app/inline_interactions.rs +++ b/src/app/inline_interactions.rs @@ -1,9 +1,7 @@ // Copyright 2025 Simon Peter Rothgang // SPDX-License-Identifier: Apache-2.0 -use super::{ - App, FocusTarget, InvalidationLevel, MessageBlock, ToolCallInfo, permissions, questions, -}; +use super::{App, FocusTarget, InvalidationLevel, MessageBlock, ToolCallInfo}; use crossterm::event::{KeyCode, KeyEvent}; pub(super) fn focused_interaction_id(app: &App) -> Option<&str> { @@ -222,23 +220,3 @@ pub(super) fn handle_interaction_focus_cycle( set_interaction_focused(app, 0, true); Some(true) } - -pub(super) fn handle_inline_interaction_key(app: &mut App, key: KeyEvent) -> bool { - normalize_pending_interaction_queue(app); - let interaction_has_focus = focused_interaction_is_active(app); - let has_question = questions::has_focused_question(app); - let plan_approval = permissions::focused_permission_is_plan_approval(app); - - if let Some(consumed) = handle_interaction_focus_cycle( - app, - key, - interaction_has_focus, - has_question || plan_approval, - ) { - return consumed; - } - if has_question { - return questions::handle_question_key(app, key, interaction_has_focus).unwrap_or(false); - } - permissions::handle_permission_key(app, key, interaction_has_focus) -} diff --git a/src/app/input.rs b/src/app/input.rs index eae159f..d06da22 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -306,6 +306,30 @@ impl InputState { changed } + pub fn textarea_delete_line_before(&mut self) -> bool { + let changed = self.editor.delete_line_by_head(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_delete_line_after(&mut self) -> bool { + let changed = self.editor.delete_line_by_end(); + if changed { + self.bump_content_version(); + } + changed + } + + pub fn textarea_yank(&mut self) -> bool { + let changed = self.editor.paste(); + if changed { + self.bump_content_version(); + } + changed + } + pub fn insert_newline(&mut self) { let _ = self.textarea_insert_newline(); } diff --git a/src/app/keymap/mod.rs b/src/app/keymap/mod.rs new file mode 100644 index 0000000..8acea9a --- /dev/null +++ b/src/app/keymap/mod.rs @@ -0,0 +1,1707 @@ +// Copyright 2025 Simon Peter Rothgang +// SPDX-License-Identifier: Apache-2.0 + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fmt; +use std::str::FromStr; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct KeySpec { + code: KeyCodeSpec, + modifiers: KeyModifiers, +} + +impl KeySpec { + pub fn new(code: KeyCodeSpec, modifiers: KeyModifiers) -> Self { + Self { code: code.normalized_for_modifiers(modifiers), modifiers } + } + + pub fn char(ch: char, modifiers: KeyModifiers) -> Self { + Self::new(KeyCodeSpec::Char(ch), modifiers) + } + + pub fn from_event(key: KeyEvent) -> Option { + let mut modifiers = key.modifiers; + let code = match key.code { + KeyCode::Backspace => KeyCodeSpec::Backspace, + KeyCode::Enter => KeyCodeSpec::Enter, + KeyCode::Left => KeyCodeSpec::Left, + KeyCode::Right => KeyCodeSpec::Right, + KeyCode::Up => KeyCodeSpec::Up, + KeyCode::Down => KeyCodeSpec::Down, + KeyCode::Home => KeyCodeSpec::Home, + KeyCode::End => KeyCodeSpec::End, + KeyCode::PageUp => KeyCodeSpec::PageUp, + KeyCode::PageDown => KeyCodeSpec::PageDown, + KeyCode::Tab => KeyCodeSpec::Tab, + KeyCode::BackTab => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCodeSpec::Tab + } + KeyCode::Delete => KeyCodeSpec::Delete, + KeyCode::Insert => KeyCodeSpec::Insert, + KeyCode::F(index) => KeyCodeSpec::F(index), + KeyCode::Char(ch) => normalized_char_code(ch, &mut modifiers), + KeyCode::Esc => KeyCodeSpec::Esc, + KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => return None, + }; + Some(Self::new(code, modifiers)) + } + + pub fn code(&self) -> KeyCodeSpec { + self.code + } + + pub fn modifiers(&self) -> KeyModifiers { + self.modifiers + } + + pub fn matches_event(&self, key: KeyEvent) -> bool { + Self::from_event(key).is_some_and(|candidate| candidate == *self) + } +} + +impl fmt::Display for KeySpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut parts = Vec::new(); + if self.modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_owned()); + } + if self.modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_owned()); + } + if self.modifiers.contains(KeyModifiers::SUPER) { + parts.push("cmd".to_owned()); + } + if self.modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_owned()); + } + parts.push(self.code.to_string()); + formatter.write_str(&parts.join("-")) + } +} + +impl FromStr for KeySpec { + type Err = ParseKeySpecError; + + fn from_str(input: &str) -> Result { + let normalized = input.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(ParseKeySpecError::Empty); + } + + let mut modifiers = KeyModifiers::NONE; + let tokens: Vec<&str> = normalized.split('-').filter(|token| !token.is_empty()).collect(); + if tokens.is_empty() { + return Err(ParseKeySpecError::Empty); + } + + let mut key_start = None; + for (index, token) in tokens.iter().enumerate() { + if let Some(modifier) = parse_modifier(token) { + if modifiers.contains(modifier) { + return Err(ParseKeySpecError::DuplicateModifier((*token).to_owned())); + } + modifiers.insert(modifier); + } else { + key_start = Some(index); + break; + } + } + + let Some(key_start) = key_start else { + return Err(ParseKeySpecError::MissingKey); + }; + let key_name = tokens[key_start..].join("-"); + let code = parse_key_code(&key_name) + .ok_or_else(|| ParseKeySpecError::UnsupportedKey(key_name.clone()))?; + Ok(Self::new(code, modifiers)) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeyCodeSpec { + Char(char), + Enter, + Esc, + Backspace, + Delete, + Insert, + Tab, + Left, + Right, + Up, + Down, + Home, + End, + PageUp, + PageDown, + F(u8), +} + +impl KeyCodeSpec { + fn normalized_for_modifiers(self, modifiers: KeyModifiers) -> Self { + match self { + Self::Char(ch) if should_canonicalize_char(ch, modifiers) => { + Self::Char(ch.to_ascii_lowercase()) + } + _ => self, + } + } +} + +impl fmt::Display for KeyCodeSpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Char(' ') => formatter.write_str("space"), + Self::Char(ch) if ch.is_ascii_control() => { + write!(formatter, "u+{:04x}", u32::from(*ch)) + } + Self::Char(ch) => write!(formatter, "{ch}"), + Self::Enter => formatter.write_str("enter"), + Self::Esc => formatter.write_str("esc"), + Self::Backspace => formatter.write_str("backspace"), + Self::Delete => formatter.write_str("delete"), + Self::Insert => formatter.write_str("insert"), + Self::Tab => formatter.write_str("tab"), + Self::Left => formatter.write_str("left"), + Self::Right => formatter.write_str("right"), + Self::Up => formatter.write_str("up"), + Self::Down => formatter.write_str("down"), + Self::Home => formatter.write_str("home"), + Self::End => formatter.write_str("end"), + Self::PageUp => formatter.write_str("page-up"), + Self::PageDown => formatter.write_str("page-down"), + Self::F(index) => write!(formatter, "f{index}"), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ParseKeySpecError { + Empty, + MissingKey, + DuplicateModifier(String), + UnsupportedKey(String), +} + +impl fmt::Display for ParseKeySpecError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("key spec is empty"), + Self::MissingKey => formatter.write_str("key spec is missing a key"), + Self::DuplicateModifier(modifier) => { + write!(formatter, "key spec repeats modifier '{modifier}'") + } + Self::UnsupportedKey(key) => write!(formatter, "unsupported key '{key}'"), + } + } +} + +impl Error for ParseKeySpecError {} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeyContext { + Global, + ChatBlocked, + ChatInput, + AutocompleteMention, + AutocompleteSlash, + AutocompleteSubagent, + InlinePermission, + InlineQuestion, +} + +impl KeyContext { + pub fn as_str(self) -> &'static str { + match self { + Self::Global => "global", + Self::ChatBlocked => "chat_blocked", + Self::ChatInput => "chat_input", + Self::AutocompleteMention => "autocomplete_mention", + Self::AutocompleteSlash => "autocomplete_slash", + Self::AutocompleteSubagent => "autocomplete_subagent", + Self::InlinePermission => "inline_permission", + Self::InlineQuestion => "inline_question", + } + } + + pub fn resolution_chain(self) -> &'static [KeyContext] { + match self { + Self::Global => &[Self::Global], + Self::ChatBlocked => &[Self::ChatBlocked, Self::Global], + Self::ChatInput => &[Self::ChatInput, Self::Global], + Self::AutocompleteMention => &[Self::AutocompleteMention, Self::Global], + Self::AutocompleteSlash => &[Self::AutocompleteSlash, Self::Global], + Self::AutocompleteSubagent => &[Self::AutocompleteSubagent, Self::Global], + Self::InlinePermission => &[Self::InlinePermission, Self::Global], + Self::InlineQuestion => &[Self::InlineQuestion, Self::Global], + } + } +} + +impl fmt::Display for KeyContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeyAction { + App(AppAction), + Input(InputAction), + Autocomplete(AutocompleteAction), + Interaction(InteractionAction), + Terminal(TerminalAction), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum AppAction { + Quit, + ClearInputOrQuit, + Redraw, + CancelTurn, + SubmitInput, + FocusPromptOrAcceptSuggestion, + CycleMode, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum InputAction { + MoveCharLeft, + MoveCharRight, + MoveWordLeft, + MoveWordRight, + MoveLineStart, + MoveLineEnd, + MoveUp, + MoveDown, + DeleteCharBefore, + DeleteCharAfter, + DeleteWordBefore, + DeleteWordAfter, + KillLineStart, + KillLineEnd, + Yank, + Undo, + Redo, + InsertNewline, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum AutocompleteAction { + MovePrevious, + MoveNext, + Confirm, + Cancel, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum InteractionAction { + MovePrevious, + MoveNext, + MoveStart, + MoveEnd, + Confirm, + Cancel, + FocusNext, + ToggleSelection, + ToggleNotes, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TerminalAction { + Suspend, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KeyActionDescriptor { + pub action: KeyAction, + pub id: &'static str, + pub label: &'static str, + pub description: &'static str, + pub default_contexts: &'static [KeyContext], +} + +impl KeyAction { + pub fn id(self) -> &'static str { + self.descriptor().id + } + + pub fn label(self) -> &'static str { + self.descriptor().label + } + + pub fn description(self) -> &'static str { + self.descriptor().description + } + + pub fn descriptor(self) -> &'static KeyActionDescriptor { + action_descriptor(self).unwrap_or_else(|| { + unreachable!("key action {self:?} is missing from the action catalog") + }) + } + + pub fn from_id(id: &str) -> Option { + action_catalog() + .iter() + .find(|descriptor| descriptor.id == id) + .map(|descriptor| descriptor.action) + } +} + +pub fn action_descriptor(action: KeyAction) -> Option<&'static KeyActionDescriptor> { + action_catalog().iter().find(|descriptor| descriptor.action == action) +} + +pub fn action_catalog() -> &'static [KeyActionDescriptor] { + ACTION_CATALOG +} + +const ACTION_CATALOG: &[KeyActionDescriptor] = &[ + KeyActionDescriptor { + action: KeyAction::App(AppAction::Quit), + id: "app.quit", + label: "Quit", + description: "Quit the application.", + default_contexts: &[KeyContext::Global, KeyContext::ChatBlocked], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::ClearInputOrQuit), + id: "app.clear_input_or_quit", + label: "Clear draft / quit", + description: "Clear local input state, or quit when input is already empty.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::Redraw), + id: "app.redraw", + label: "Redraw screen", + description: "Request a visible chat redraw.", + default_contexts: &[KeyContext::Global], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::CancelTurn), + id: "app.cancel_turn", + label: "Cancel turn", + description: "Cancel the active turn from chat input.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::SubmitInput), + id: "app.submit_input", + label: "Send message", + description: "Submit the current chat input.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion), + id: "app.focus_prompt_or_accept_suggestion", + label: "Focus prompt / accept suggestion", + description: "Focus a pending prompt, or accept the current prompt suggestion.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::App(AppAction::CycleMode), + id: "app.cycle_mode", + label: "Cycle mode", + description: "Cycle to the next available model mode.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveCharLeft), + id: "input.move_char_left", + label: "Move left", + description: "Move the input cursor one character left.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveCharRight), + id: "input.move_char_right", + label: "Move right", + description: "Move the input cursor one character right.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveWordLeft), + id: "input.move_word_left", + label: "Move word left", + description: "Move the input cursor one word left.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveWordRight), + id: "input.move_word_right", + label: "Move word right", + description: "Move the input cursor one word right.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveLineStart), + id: "input.move_line_start", + label: "Move line start", + description: "Move the input cursor to the start of the line.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveLineEnd), + id: "input.move_line_end", + label: "Move line end", + description: "Move the input cursor to the end of the line.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveUp), + id: "input.move_up", + label: "Move up", + description: "Move the input cursor up, or browse chat history.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::MoveDown), + id: "input.move_down", + label: "Move down", + description: "Move the input cursor down, or browse chat history.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::DeleteCharBefore), + id: "input.delete_char_before", + label: "Delete before cursor", + description: "Delete the character before the input cursor.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::DeleteCharAfter), + id: "input.delete_char_after", + label: "Delete after cursor", + description: "Delete the character after the input cursor.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::DeleteWordBefore), + id: "input.delete_word_before", + label: "Delete word before cursor", + description: "Delete the word before the input cursor.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::DeleteWordAfter), + id: "input.delete_word_after", + label: "Delete word after cursor", + description: "Delete the word after the input cursor.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::KillLineStart), + id: "input.kill_line_start", + label: "Kill line start", + description: "Delete input text from the cursor to the start of the line.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::KillLineEnd), + id: "input.kill_line_end", + label: "Kill line end", + description: "Delete input text from the cursor to the end of the line.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::Yank), + id: "input.yank", + label: "Yank", + description: "Paste the most recently killed input text.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::Undo), + id: "input.undo", + label: "Undo", + description: "Undo the previous input edit.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::Redo), + id: "input.redo", + label: "Redo", + description: "Redo the previously undone input edit.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Input(InputAction::InsertNewline), + id: "input.insert_newline", + label: "Insert newline", + description: "Insert a newline into the current input draft.", + default_contexts: &[KeyContext::ChatInput], + }, + KeyActionDescriptor { + action: KeyAction::Autocomplete(AutocompleteAction::MovePrevious), + id: "autocomplete.move_previous", + label: "Previous suggestion", + description: "Move to the previous autocomplete suggestion.", + default_contexts: &[ + KeyContext::AutocompleteMention, + KeyContext::AutocompleteSlash, + KeyContext::AutocompleteSubagent, + ], + }, + KeyActionDescriptor { + action: KeyAction::Autocomplete(AutocompleteAction::MoveNext), + id: "autocomplete.move_next", + label: "Next suggestion", + description: "Move to the next autocomplete suggestion.", + default_contexts: &[ + KeyContext::AutocompleteMention, + KeyContext::AutocompleteSlash, + KeyContext::AutocompleteSubagent, + ], + }, + KeyActionDescriptor { + action: KeyAction::Autocomplete(AutocompleteAction::Confirm), + id: "autocomplete.confirm", + label: "Confirm suggestion", + description: "Confirm the selected autocomplete suggestion.", + default_contexts: &[ + KeyContext::AutocompleteMention, + KeyContext::AutocompleteSlash, + KeyContext::AutocompleteSubagent, + ], + }, + KeyActionDescriptor { + action: KeyAction::Autocomplete(AutocompleteAction::Cancel), + id: "autocomplete.cancel", + label: "Cancel autocomplete", + description: "Close the active autocomplete menu.", + default_contexts: &[ + KeyContext::AutocompleteMention, + KeyContext::AutocompleteSlash, + KeyContext::AutocompleteSubagent, + ], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::MovePrevious), + id: "interaction.move_previous", + label: "Previous option", + description: "Move to the previous inline prompt option.", + default_contexts: &[KeyContext::InlinePermission, KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::MoveNext), + id: "interaction.move_next", + label: "Next option", + description: "Move to the next inline prompt option.", + default_contexts: &[KeyContext::InlinePermission, KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::MoveStart), + id: "interaction.move_start", + label: "First option", + description: "Move to the first inline prompt option.", + default_contexts: &[KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::MoveEnd), + id: "interaction.move_end", + label: "Last option", + description: "Move to the last inline prompt option.", + default_contexts: &[KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::Confirm), + id: "interaction.confirm", + label: "Confirm option", + description: "Confirm the selected inline prompt option.", + default_contexts: &[KeyContext::InlinePermission, KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::Cancel), + id: "interaction.cancel", + label: "Cancel prompt", + description: "Cancel or reject the active inline prompt.", + default_contexts: &[KeyContext::InlinePermission, KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::FocusNext), + id: "interaction.focus_next", + label: "Return to draft / next prompt", + description: "Return focus to the draft, or move to the next inline permission prompt.", + default_contexts: &[KeyContext::InlinePermission], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::ToggleSelection), + id: "interaction.toggle_selection", + label: "Toggle selection", + description: "Toggle the selected inline question option.", + default_contexts: &[KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Interaction(InteractionAction::ToggleNotes), + id: "interaction.toggle_notes", + label: "Toggle notes", + description: "Toggle notes editing for the active inline question.", + default_contexts: &[KeyContext::InlineQuestion], + }, + KeyActionDescriptor { + action: KeyAction::Terminal(TerminalAction::Suspend), + id: "terminal.suspend", + label: "Suspend process", + description: "Suspend the TUI process after restoring terminal state.", + default_contexts: &[KeyContext::Global], + }, +]; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeyBindingSource { + Default, + Config, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeyBinding { + pub context: KeyContext, + pub spec: KeySpec, + pub action: KeyAction, + pub source: KeyBindingSource, +} + +impl KeyBinding { + pub fn new( + context: KeyContext, + spec: KeySpec, + action: KeyAction, + source: KeyBindingSource, + ) -> Self { + Self { context, spec, action, source } + } + + fn default(context: KeyContext, spec: KeySpec, action: KeyAction) -> Self { + Self::new(context, spec, action, KeyBindingSource::Default) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResolvedKeyAction { + pub action: KeyAction, + pub requested_context: KeyContext, + pub matched_context: KeyContext, + pub source: KeyBindingSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedHelpBinding { + pub spec: KeySpec, + pub action: KeyAction, + pub requested_context: KeyContext, + pub matched_context: KeyContext, + pub source: KeyBindingSource, +} + +impl ResolvedHelpBinding { + pub fn descriptor(&self) -> &'static KeyActionDescriptor { + self.action.descriptor() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum KeymapBuildError { + DuplicateBinding { + context: KeyContext, + spec: KeySpec, + existing_action: KeyAction, + duplicate_action: KeyAction, + }, + UncataloguedAction { + action: KeyAction, + }, + ShadowedGlobalBinding { + context: KeyContext, + spec: KeySpec, + context_action: KeyAction, + global_action: KeyAction, + }, + ProtectedGlobalActionConflict { + context: KeyContext, + spec: KeySpec, + action: KeyAction, + }, + PlatformInvalidBinding { + context: KeyContext, + spec: KeySpec, + reason: &'static str, + }, + UnsupportedDefaultBinding { + context: KeyContext, + spec: KeySpec, + action: KeyAction, + reason: &'static str, + }, +} + +impl fmt::Display for KeymapBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateBinding { context, spec, existing_action, duplicate_action } => write!( + formatter, + "duplicate key binding for {spec} in {context}: {existing_action:?} and {duplicate_action:?}" + ), + Self::UncataloguedAction { action } => { + write!(formatter, "key binding references uncatalogued action {action:?}") + } + Self::ShadowedGlobalBinding { context, spec, context_action, global_action } => write!( + formatter, + "key binding for {spec} in {context} shadows global binding: {context_action:?} and {global_action:?}" + ), + Self::ProtectedGlobalActionConflict { context, spec, action } => write!( + formatter, + "key binding for {spec} in {context} conflicts with protected global action {}", + action.id() + ), + Self::PlatformInvalidBinding { context, spec, reason } => { + write!( + formatter, + "key binding for {spec} in {context} is invalid on this platform: {reason}" + ) + } + Self::UnsupportedDefaultBinding { context, spec, action, reason } => write!( + formatter, + "default key binding for {spec} in {context} uses unsupported action {}: {reason}", + action.id() + ), + } + } +} + +impl Error for KeymapBuildError {} + +#[derive(Clone, Debug)] +pub struct ResolvedKeymap { + bindings: Vec, + actions: HashMap, +} + +impl ResolvedKeymap { + pub fn from_bindings( + bindings: impl IntoIterator, + ) -> Result { + let mut ordered_bindings = Vec::new(); + let mut actions: HashMap = HashMap::new(); + for binding in bindings { + if action_descriptor(binding.action).is_none() { + return Err(KeymapBuildError::UncataloguedAction { action: binding.action }); + } + if let Some(reason) = platform_invalid_binding_reason(&binding) { + return Err(KeymapBuildError::PlatformInvalidBinding { + context: binding.context, + spec: binding.spec, + reason, + }); + } + let lookup = KeyBindingLookup { context: binding.context, spec: binding.spec.clone() }; + if let Some(existing_binding) = actions.get(&lookup).copied() { + return Err(KeymapBuildError::DuplicateBinding { + context: binding.context, + spec: binding.spec, + existing_action: existing_binding.action, + duplicate_action: binding.action, + }); + } + actions + .insert(lookup, ResolvedBinding { action: binding.action, source: binding.source }); + ordered_bindings.push(binding); + } + validate_resolution_conflicts(&actions)?; + Ok(Self { bindings: ordered_bindings, actions }) + } + + pub fn defaults() -> Self { + match Self::validate_defaults() { + Ok(keymap) => keymap, + Err(error) => unreachable!("default keymap should validate: {error}"), + } + } + + pub fn validate_defaults() -> Result { + Self::from_bindings(default_bindings()) + } + + pub fn action_for(&self, context: KeyContext, spec: &KeySpec) -> Option { + let lookup = KeyBindingLookup { context, spec: spec.clone() }; + self.actions.get(&lookup).map(|binding| binding.action) + } + + pub fn action_for_event(&self, context: KeyContext, key: KeyEvent) -> Option { + let spec = KeySpec::from_event(key)?; + self.action_for(context, &spec) + } + + pub fn resolve(&self, context: KeyContext, spec: &KeySpec) -> Option { + for matched_context in context.resolution_chain() { + let lookup = KeyBindingLookup { context: *matched_context, spec: spec.clone() }; + if let Some(binding) = self.actions.get(&lookup).copied() { + return Some(ResolvedKeyAction { + action: binding.action, + requested_context: context, + matched_context: *matched_context, + source: binding.source, + }); + } + } + None + } + + pub fn resolve_event(&self, context: KeyContext, key: KeyEvent) -> Option { + let spec = KeySpec::from_event(key)?; + self.resolve(context, &spec) + } + + pub fn bindings(&self) -> &[KeyBinding] { + &self.bindings + } + + pub fn help_bindings_for_context(&self, context: KeyContext) -> Vec { + let mut seen = HashSet::new(); + self.bindings + .iter() + .filter_map(|binding| { + let resolved = self.resolve(context, &binding.spec)?; + if resolved.action != binding.action || resolved.matched_context != binding.context + { + return None; + } + let key = (binding.spec.clone(), binding.action, binding.context); + if !seen.insert(key) { + return None; + } + Some(ResolvedHelpBinding { + spec: binding.spec.clone(), + action: binding.action, + requested_context: context, + matched_context: binding.context, + source: binding.source, + }) + }) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ResolvedBinding { + action: KeyAction, + source: KeyBindingSource, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct KeyBindingLookup { + context: KeyContext, + spec: KeySpec, +} + +fn validate_resolution_conflicts( + actions: &HashMap, +) -> Result<(), KeymapBuildError> { + for (lookup, binding) in actions { + if lookup.context == KeyContext::Global + || !lookup.context.resolution_chain().contains(&KeyContext::Global) + { + continue; + } + + let global_lookup = + KeyBindingLookup { context: KeyContext::Global, spec: lookup.spec.clone() }; + let Some(global_binding) = actions.get(&global_lookup).copied() else { + continue; + }; + + if is_protected_global_action(global_binding.action) { + return Err(KeymapBuildError::ProtectedGlobalActionConflict { + context: lookup.context, + spec: lookup.spec.clone(), + action: global_binding.action, + }); + } + + return Err(KeymapBuildError::ShadowedGlobalBinding { + context: lookup.context, + spec: lookup.spec.clone(), + context_action: binding.action, + global_action: global_binding.action, + }); + } + + Ok(()) +} + +fn is_protected_global_action(action: KeyAction) -> bool { + matches!( + action, + KeyAction::App(AppAction::Quit | AppAction::Redraw) + | KeyAction::Terminal(TerminalAction::Suspend) + ) +} + +fn platform_invalid_binding_reason(binding: &KeyBinding) -> Option<&'static str> { + if binding.spec.modifiers().contains(KeyModifiers::SUPER) && !cfg!(target_os = "macos") { + return Some("cmd/super bindings are only supported on macOS"); + } + if cfg!(target_os = "windows") + && binding.action == KeyAction::Terminal(TerminalAction::Suspend) + && is_ctrl_z(&binding.spec) + { + return Some("ctrl-z suspend is not supported on Windows"); + } + None +} + +fn is_ctrl_z(spec: &KeySpec) -> bool { + spec.code() == KeyCodeSpec::Char('z') && spec.modifiers() == KeyModifiers::CONTROL +} + +pub fn default_bindings() -> Vec { + let mut bindings = Vec::new(); + bindings.extend(global_default_bindings()); + bindings.extend(chat_blocked_default_bindings()); + bindings.extend(chat_control_default_bindings()); + bindings.extend(chat_navigation_default_bindings()); + bindings.extend(chat_readline_default_bindings()); + bindings.extend(chat_history_default_bindings()); + bindings.extend(autocomplete_default_bindings()); + bindings.extend(interaction_default_bindings()); + bindings +} + +fn global_default_bindings() -> Vec { + let bindings = vec![ + KeyBinding::default( + KeyContext::Global, + KeySpec::char('q', KeyModifiers::CONTROL), + KeyAction::App(AppAction::Quit), + ), + KeyBinding::default( + KeyContext::Global, + KeySpec::char('l', KeyModifiers::CONTROL), + KeyAction::App(AppAction::Redraw), + ), + ]; + #[cfg(unix)] + { + let mut unix_bindings = bindings; + unix_bindings.push(KeyBinding::default( + KeyContext::Global, + KeySpec::char('z', KeyModifiers::CONTROL), + KeyAction::Terminal(TerminalAction::Suspend), + )); + unix_bindings + } + #[cfg(not(unix))] + { + bindings + } +} + +fn chat_blocked_default_bindings() -> [KeyBinding; 1] { + [KeyBinding::default( + KeyContext::ChatBlocked, + KeySpec::char('c', KeyModifiers::CONTROL), + KeyAction::App(AppAction::Quit), + )] +} + +fn chat_control_default_bindings() -> [KeyBinding; 7] { + [ + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::char('c', KeyModifiers::CONTROL), + KeyAction::App(AppAction::ClearInputOrQuit), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Esc, KeyModifiers::NONE), + KeyAction::App(AppAction::CancelTurn), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Enter, KeyModifiers::NONE), + KeyAction::App(AppAction::SubmitInput), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Enter, KeyModifiers::SHIFT), + KeyAction::Input(InputAction::InsertNewline), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Enter, KeyModifiers::CONTROL), + KeyAction::Input(InputAction::InsertNewline), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Tab, KeyModifiers::NONE), + KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion), + ), + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Tab, KeyModifiers::SHIFT), + KeyAction::App(AppAction::CycleMode), + ), + ] +} + +fn chat_navigation_default_bindings() -> [KeyBinding; 16] { + [ + chat_key(KeyCodeSpec::Left, KeyModifiers::NONE, InputAction::MoveCharLeft), + chat_key(KeyCodeSpec::Right, KeyModifiers::NONE, InputAction::MoveCharRight), + chat_key(KeyCodeSpec::Up, KeyModifiers::NONE, InputAction::MoveUp), + chat_key(KeyCodeSpec::Down, KeyModifiers::NONE, InputAction::MoveDown), + chat_key(KeyCodeSpec::Home, KeyModifiers::NONE, InputAction::MoveLineStart), + chat_key(KeyCodeSpec::End, KeyModifiers::NONE, InputAction::MoveLineEnd), + chat_key(KeyCodeSpec::Backspace, KeyModifiers::NONE, InputAction::DeleteCharBefore), + chat_key(KeyCodeSpec::Delete, KeyModifiers::NONE, InputAction::DeleteCharAfter), + chat_key(KeyCodeSpec::Left, KeyModifiers::CONTROL, InputAction::MoveWordLeft), + chat_key(KeyCodeSpec::Right, KeyModifiers::CONTROL, InputAction::MoveWordRight), + chat_key(KeyCodeSpec::Left, KeyModifiers::ALT, InputAction::MoveWordLeft), + chat_key(KeyCodeSpec::Right, KeyModifiers::ALT, InputAction::MoveWordRight), + chat_key(KeyCodeSpec::Backspace, KeyModifiers::CONTROL, InputAction::DeleteWordBefore), + chat_key(KeyCodeSpec::Delete, KeyModifiers::CONTROL, InputAction::DeleteWordAfter), + chat_key(KeyCodeSpec::Backspace, KeyModifiers::ALT, InputAction::DeleteWordBefore), + chat_key(KeyCodeSpec::Delete, KeyModifiers::ALT, InputAction::DeleteWordAfter), + ] +} + +fn chat_readline_default_bindings() -> [KeyBinding; 13] { + [ + chat_input('a', KeyModifiers::CONTROL, InputAction::MoveLineStart), + chat_input('e', KeyModifiers::CONTROL, InputAction::MoveLineEnd), + chat_input('b', KeyModifiers::CONTROL, InputAction::MoveCharLeft), + chat_input('f', KeyModifiers::CONTROL, InputAction::MoveCharRight), + chat_input('d', KeyModifiers::CONTROL, InputAction::DeleteCharAfter), + chat_input('k', KeyModifiers::CONTROL, InputAction::KillLineEnd), + chat_input('u', KeyModifiers::CONTROL, InputAction::KillLineStart), + chat_input('y', KeyModifiers::CONTROL, InputAction::Yank), + chat_input('b', KeyModifiers::ALT, InputAction::MoveWordLeft), + chat_input('f', KeyModifiers::ALT, InputAction::MoveWordRight), + chat_input('d', KeyModifiers::ALT, InputAction::DeleteWordAfter), + chat_input('w', KeyModifiers::CONTROL, InputAction::DeleteWordBefore), + chat_input('h', KeyModifiers::CONTROL, InputAction::DeleteCharBefore), + ] +} + +fn chat_history_default_bindings() -> Vec { + let mut bindings = Vec::new(); + + #[cfg(target_os = "macos")] + { + bindings.push(chat_input('z', KeyModifiers::SUPER, InputAction::Undo)); + bindings.push(chat_input( + 'z', + KeyModifiers::SUPER | KeyModifiers::SHIFT, + InputAction::Redo, + )); + bindings.push(chat_input('y', KeyModifiers::SUPER, InputAction::Redo)); + } + + #[cfg(target_os = "windows")] + { + bindings.push(chat_input('z', KeyModifiers::CONTROL, InputAction::Undo)); + bindings.push(chat_input( + 'z', + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + InputAction::Redo, + )); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + bindings.push(chat_input('_', KeyModifiers::CONTROL, InputAction::Undo)); + bindings.push(chat_input('/', KeyModifiers::CONTROL, InputAction::Undo)); + bindings.push(chat_input( + 'z', + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + InputAction::Redo, + )); + } + + bindings +} + +fn autocomplete_default_bindings() -> Vec { + let mut bindings = Vec::new(); + for context in [ + KeyContext::AutocompleteMention, + KeyContext::AutocompleteSlash, + KeyContext::AutocompleteSubagent, + ] { + bindings.extend([ + autocomplete(context, KeyCodeSpec::Up, AutocompleteAction::MovePrevious), + autocomplete(context, KeyCodeSpec::Down, AutocompleteAction::MoveNext), + autocomplete(context, KeyCodeSpec::Enter, AutocompleteAction::Confirm), + autocomplete(context, KeyCodeSpec::Tab, AutocompleteAction::Confirm), + autocomplete(context, KeyCodeSpec::Esc, AutocompleteAction::Cancel), + ]); + } + bindings +} + +fn interaction_default_bindings() -> Vec { + let mut bindings = Vec::new(); + bindings.extend([ + interaction( + KeyContext::InlinePermission, + KeyCodeSpec::Left, + InteractionAction::MovePrevious, + ), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Up, InteractionAction::MovePrevious), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Right, InteractionAction::MoveNext), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Down, InteractionAction::MoveNext), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Enter, InteractionAction::Confirm), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Esc, InteractionAction::Cancel), + interaction(KeyContext::InlinePermission, KeyCodeSpec::Tab, InteractionAction::FocusNext), + ]); + bindings.extend([ + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Left, InteractionAction::MovePrevious), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Up, InteractionAction::MovePrevious), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Right, InteractionAction::MoveNext), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Down, InteractionAction::MoveNext), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Home, InteractionAction::MoveStart), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::End, InteractionAction::MoveEnd), + interaction( + KeyContext::InlineQuestion, + KeyCodeSpec::Char(' '), + InteractionAction::ToggleSelection, + ), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Enter, InteractionAction::Confirm), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Esc, InteractionAction::Cancel), + interaction(KeyContext::InlineQuestion, KeyCodeSpec::Tab, InteractionAction::ToggleNotes), + interaction_with_modifiers( + KeyContext::InlineQuestion, + KeyCodeSpec::Tab, + KeyModifiers::SHIFT, + InteractionAction::ToggleNotes, + ), + ]); + bindings +} + +fn chat_input(ch: char, modifiers: KeyModifiers, action: InputAction) -> KeyBinding { + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::char(ch, modifiers), + KeyAction::Input(action), + ) +} + +fn chat_key(code: KeyCodeSpec, modifiers: KeyModifiers, action: InputAction) -> KeyBinding { + KeyBinding::default( + KeyContext::ChatInput, + KeySpec::new(code, modifiers), + KeyAction::Input(action), + ) +} + +fn autocomplete(context: KeyContext, code: KeyCodeSpec, action: AutocompleteAction) -> KeyBinding { + KeyBinding::default( + context, + KeySpec::new(code, KeyModifiers::NONE), + KeyAction::Autocomplete(action), + ) +} + +fn interaction(context: KeyContext, code: KeyCodeSpec, action: InteractionAction) -> KeyBinding { + interaction_with_modifiers(context, code, KeyModifiers::NONE, action) +} + +fn interaction_with_modifiers( + context: KeyContext, + code: KeyCodeSpec, + modifiers: KeyModifiers, + action: InteractionAction, +) -> KeyBinding { + KeyBinding::default(context, KeySpec::new(code, modifiers), KeyAction::Interaction(action)) +} + +fn normalized_char_code(ch: char, modifiers: &mut KeyModifiers) -> KeyCodeSpec { + if let Some(alpha) = control_char_to_alpha(ch) + && !modifiers.contains(KeyModifiers::ALT) + { + modifiers.insert(KeyModifiers::CONTROL); + return KeyCodeSpec::Char(alpha); + } + KeyCodeSpec::Char(ch) +} + +fn control_char_to_alpha(ch: char) -> Option { + let value = u32::from(ch); + if (1..=26).contains(&value) { char::from_u32(value + u32::from(b'a') - 1) } else { None } +} + +fn should_canonicalize_char(ch: char, modifiers: KeyModifiers) -> bool { + ch.is_ascii_alphabetic() + && modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT | KeyModifiers::SUPER, + ) +} + +fn parse_modifier(token: &str) -> Option { + match token { + "ctrl" | "control" => Some(KeyModifiers::CONTROL), + "alt" | "option" => Some(KeyModifiers::ALT), + "shift" => Some(KeyModifiers::SHIFT), + "cmd" | "command" | "super" => Some(KeyModifiers::SUPER), + _ => None, + } +} + +fn parse_key_code(key_name: &str) -> Option { + match key_name { + "enter" | "return" => Some(KeyCodeSpec::Enter), + "esc" | "escape" => Some(KeyCodeSpec::Esc), + "backspace" | "bs" => Some(KeyCodeSpec::Backspace), + "delete" | "del" => Some(KeyCodeSpec::Delete), + "insert" | "ins" => Some(KeyCodeSpec::Insert), + "tab" => Some(KeyCodeSpec::Tab), + "left" => Some(KeyCodeSpec::Left), + "right" => Some(KeyCodeSpec::Right), + "up" => Some(KeyCodeSpec::Up), + "down" => Some(KeyCodeSpec::Down), + "home" => Some(KeyCodeSpec::Home), + "end" => Some(KeyCodeSpec::End), + "pageup" | "page-up" => Some(KeyCodeSpec::PageUp), + "pagedown" | "page-down" => Some(KeyCodeSpec::PageDown), + "space" => Some(KeyCodeSpec::Char(' ')), + _ => parse_function_key(key_name).or_else(|| parse_single_char_key(key_name)), + } +} + +fn parse_function_key(key_name: &str) -> Option { + let digits = key_name.strip_prefix('f')?; + let index = digits.parse::().ok()?; + (index > 0).then_some(KeyCodeSpec::F(index)) +} + +fn parse_single_char_key(key_name: &str) -> Option { + let mut chars = key_name.chars(); + let ch = chars.next()?; + chars.next().is_none().then_some(KeyCodeSpec::Char(ch)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn key_spec_from_event_accepts_standard_ctrl_v_encoding() { + let key = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + assert_eq!(KeySpec::from_event(key), Some("ctrl-v".parse().expect("parse key"))); + } + + #[test] + fn key_spec_from_event_accepts_raw_control_character_encoding() { + let key = KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE); + + assert_eq!(KeySpec::from_event(key), Some("ctrl-v".parse().expect("parse key"))); + } + + #[test] + fn key_spec_from_event_rejects_raw_control_character_with_alt_as_plain_ctrl() { + let key = KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::ALT); + + assert_ne!(KeySpec::from_event(key), Some("ctrl-v".parse().expect("parse key"))); + } + + #[test] + fn key_spec_matching_uses_exact_modifiers() { + let spec: KeySpec = "ctrl-v".parse().expect("parse key"); + let ctrl_alt_v = + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL | KeyModifiers::ALT); + + assert!(!spec.matches_event(ctrl_alt_v)); + } + + #[test] + fn key_spec_parse_and_display_are_canonical() { + for (raw, canonical) in [ + ("ctrl-a", "ctrl-a"), + ("alt-b", "alt-b"), + ("shift-enter", "shift-enter"), + ("cmd-z", "cmd-z"), + ("esc", "esc"), + ("backspace", "backspace"), + ("delete", "delete"), + ("ctrl-left", "ctrl-left"), + ("option-f", "alt-f"), + ("command-shift-z", "cmd-shift-z"), + ] { + let spec: KeySpec = raw.parse().expect("parse key"); + + assert_eq!(spec.to_string(), canonical); + } + } + + #[test] + fn resolved_keymap_resolves_binding_in_the_right_context() { + let keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::ChatInput, + "ctrl-a".parse().expect("parse key"), + KeyAction::Input(InputAction::MoveLineStart), + KeyBindingSource::Default, + )]) + .expect("build keymap"); + + assert_eq!( + keymap.action_for(KeyContext::ChatInput, &"ctrl-a".parse().expect("parse key")), + Some(KeyAction::Input(InputAction::MoveLineStart)) + ); + } + + #[test] + fn resolved_keymap_resolves_global_fallback_with_metadata() { + let keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::Global, + "ctrl-q".parse().expect("parse key"), + KeyAction::App(AppAction::Quit), + KeyBindingSource::Default, + )]) + .expect("build keymap"); + + assert_eq!( + keymap.resolve(KeyContext::ChatInput, &"ctrl-q".parse().expect("parse key")), + Some(ResolvedKeyAction { + action: KeyAction::App(AppAction::Quit), + requested_context: KeyContext::ChatInput, + matched_context: KeyContext::Global, + source: KeyBindingSource::Default, + }) + ); + } + + #[test] + fn resolved_keymap_help_bindings_follow_resolution_chain() { + let keymap = ResolvedKeymap::from_bindings([ + KeyBinding::new( + KeyContext::Global, + "ctrl-q".parse().expect("parse key"), + KeyAction::App(AppAction::Quit), + KeyBindingSource::Default, + ), + KeyBinding::new( + KeyContext::ChatInput, + "ctrl-x".parse().expect("parse key"), + KeyAction::App(AppAction::SubmitInput), + KeyBindingSource::Default, + ), + ]) + .expect("build keymap"); + + let bindings = keymap.help_bindings_for_context(KeyContext::ChatInput); + + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].spec, "ctrl-q".parse().expect("parse key")); + assert_eq!(bindings[0].matched_context, KeyContext::Global); + assert_eq!(bindings[0].descriptor().label, "Quit"); + assert_eq!(bindings[1].spec, "ctrl-x".parse().expect("parse key")); + assert_eq!(bindings[1].matched_context, KeyContext::ChatInput); + assert_eq!(bindings[1].descriptor().label, "Send message"); + } + + #[test] + fn resolved_keymap_does_not_resolve_binding_outside_resolution_chain() { + let keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::ChatInput, + "ctrl-a".parse().expect("parse key"), + KeyAction::Input(InputAction::MoveLineStart), + KeyBindingSource::Default, + )]) + .expect("build keymap"); + + assert_eq!( + keymap.resolve(KeyContext::InlinePermission, &"ctrl-a".parse().expect("parse key")), + None + ); + } + + #[test] + fn resolved_keymap_rejects_duplicate_binding_in_same_context() { + let result = ResolvedKeymap::from_bindings([ + KeyBinding::new( + KeyContext::ChatInput, + "ctrl-a".parse().expect("parse key"), + KeyAction::Input(InputAction::MoveLineStart), + KeyBindingSource::Default, + ), + KeyBinding::new( + KeyContext::ChatInput, + "ctrl-a".parse().expect("parse key"), + KeyAction::App(AppAction::Redraw), + KeyBindingSource::Default, + ), + ]); + + assert!(matches!(result, Err(KeymapBuildError::DuplicateBinding { .. }))); + } + + #[test] + fn resolved_keymap_rejects_shadowed_global_binding() { + let result = ResolvedKeymap::from_bindings([ + KeyBinding::new( + KeyContext::Global, + "ctrl-g".parse().expect("parse key"), + KeyAction::App(AppAction::CancelTurn), + KeyBindingSource::Config, + ), + KeyBinding::new( + KeyContext::ChatInput, + "ctrl-g".parse().expect("parse key"), + KeyAction::Input(InputAction::MoveLineStart), + KeyBindingSource::Config, + ), + ]); + + assert!(matches!(result, Err(KeymapBuildError::ShadowedGlobalBinding { .. }))); + } + + #[test] + fn resolved_keymap_rejects_protected_global_action_conflict() { + let result = ResolvedKeymap::from_bindings([ + KeyBinding::new( + KeyContext::Global, + "ctrl-q".parse().expect("parse key"), + KeyAction::App(AppAction::Quit), + KeyBindingSource::Default, + ), + KeyBinding::new( + KeyContext::ChatInput, + "ctrl-q".parse().expect("parse key"), + KeyAction::Input(InputAction::MoveLineStart), + KeyBindingSource::Config, + ), + ]); + + assert!(matches!(result, Err(KeymapBuildError::ProtectedGlobalActionConflict { .. }))); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn resolved_keymap_rejects_cmd_bindings_off_macos() { + let result = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::ChatInput, + "cmd-z".parse().expect("parse key"), + KeyAction::Input(InputAction::Undo), + KeyBindingSource::Config, + )]); + + assert!(matches!(result, Err(KeymapBuildError::PlatformInvalidBinding { .. }))); + } + + #[cfg(target_os = "windows")] + #[test] + fn resolved_keymap_rejects_ctrl_z_suspend_on_windows() { + let result = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::Global, + "ctrl-z".parse().expect("parse key"), + KeyAction::Terminal(TerminalAction::Suspend), + KeyBindingSource::Config, + )]); + + assert!(matches!(result, Err(KeymapBuildError::PlatformInvalidBinding { .. }))); + } + + #[test] + fn action_catalog_has_unique_stable_ids() { + let mut ids = HashSet::new(); + + for descriptor in action_catalog() { + assert!(!descriptor.id.is_empty(), "{descriptor:?}"); + assert!(!descriptor.label.is_empty(), "{descriptor:?}"); + assert!(!descriptor.description.is_empty(), "{descriptor:?}"); + assert!(!descriptor.default_contexts.is_empty(), "{descriptor:?}"); + assert!(ids.insert(descriptor.id), "duplicate action id {}", descriptor.id); + assert_eq!(KeyAction::from_id(descriptor.id), Some(descriptor.action)); + assert_eq!(descriptor.action.id(), descriptor.id); + assert_eq!(descriptor.action.label(), descriptor.label); + assert_eq!(descriptor.action.description(), descriptor.description); + } + + assert_eq!(KeyAction::from_id("input.missing"), None); + } + + #[test] + fn default_bindings_reference_catalogued_actions() { + for binding in default_bindings() { + let descriptor = action_descriptor(binding.action) + .unwrap_or_else(|| panic!("missing descriptor for {:?}", binding.action)); + + assert_eq!(descriptor.action, binding.action); + } + } + + #[test] + fn default_keymap_contains_chat_input_readline_bindings() { + let keymap = ResolvedKeymap::defaults(); + + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), + ), + Some(KeyAction::Input(InputAction::Yank)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), + ), + Some(KeyAction::Input(InputAction::MoveLineStart)) + ); + } + + #[test] + fn default_keymap_does_not_bind_permission_ctrl_shortcuts() { + let keymap = ResolvedKeymap::defaults(); + + for key in [ + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + ] { + assert_eq!(keymap.action_for_event(KeyContext::InlinePermission, key), None); + } + } + + #[test] + fn default_keymap_resolves_enter_by_context() { + let keymap = ResolvedKeymap::defaults(); + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + + assert_eq!( + keymap.action_for_event(KeyContext::ChatInput, enter), + Some(KeyAction::App(AppAction::SubmitInput)) + ); + assert_eq!( + keymap.action_for_event(KeyContext::InlinePermission, enter), + Some(KeyAction::Interaction(InteractionAction::Confirm)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::InlineQuestion, + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + ), + Some(KeyAction::Interaction(InteractionAction::ToggleSelection)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::InlineQuestion, + KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ), + Some(KeyAction::Interaction(InteractionAction::MoveStart)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::InlineQuestion, + KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), + ), + Some(KeyAction::Interaction(InteractionAction::ToggleNotes)) + ); + } + + #[test] + fn default_keymap_uses_platform_history_bindings() { + let keymap = ResolvedKeymap::defaults(); + + #[cfg(target_os = "macos")] + { + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SUPER), + ), + Some(KeyAction::Input(InputAction::Undo)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SUPER | KeyModifiers::SHIFT), + ), + Some(KeyAction::Input(InputAction::Redo)) + ); + } + + #[cfg(target_os = "windows")] + { + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ), + Some(KeyAction::Input(InputAction::Undo)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL | KeyModifiers::SHIFT,), + ), + Some(KeyAction::Input(InputAction::Redo)) + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('_'), KeyModifiers::CONTROL), + ), + Some(KeyAction::Input(InputAction::Undo)) + ); + assert_eq!( + keymap.action_for_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL), + ), + Some(KeyAction::Input(InputAction::Undo)) + ); + } + + #[cfg(unix)] + { + assert_eq!( + keymap + .resolve_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ) + .map(|resolved| resolved.action), + Some(KeyAction::Terminal(TerminalAction::Suspend)) + ); + } + + #[cfg(target_os = "windows")] + { + assert_ne!( + keymap + .resolve_event( + KeyContext::ChatInput, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ) + .map(|resolved| resolved.action), + Some(KeyAction::Terminal(TerminalAction::Suspend)) + ); + } + } + + #[test] + fn default_keymap_bindings_are_conflict_free() { + ResolvedKeymap::validate_defaults().expect("default keymap should be conflict-free"); + } +} diff --git a/src/app/keys.rs b/src/app/keys.rs index 379db55..388c253 100644 --- a/src/app/keys.rs +++ b/src/app/keys.rs @@ -6,29 +6,66 @@ use super::{App, AppStatus, CancelOrigin, FocusOwner, InvalidationLevel, ModeInf #[cfg(not(test))] use crate::app::SystemSeverity; use crate::app::inline_interactions::{ - clear_inline_interaction_focus, focus_next_inline_interaction, handle_inline_interaction_key, + clear_inline_interaction_focus, focus_next_inline_interaction, + normalize_pending_interaction_queue, +}; +use crate::app::keymap::{ + AppAction, AutocompleteAction, InputAction, InteractionAction, KeyAction, KeyContext, + TerminalAction, }; use crate::app::state::AutocompleteKind; -use crate::app::{mention, questions, slash, subagent}; +use crate::app::{mention, permissions, questions, slash, subagent}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::rc::Rc; use std::time::Instant; -#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RuntimeCommand { + SuspendProcess, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyOutcome { + Ignored, + Handled(bool), + Runtime(RuntimeCommand), +} + +impl KeyOutcome { + pub(crate) fn changed(self) -> bool { + match self { + Self::Ignored => false, + Self::Handled(changed) => changed, + Self::Runtime(_) => true, + } + } + + fn handled(self) -> bool { + !matches!(self, Self::Ignored) + } + + pub(crate) fn runtime_command(self) -> Option { + match self { + Self::Runtime(command) => Some(command), + Self::Ignored | Self::Handled(_) => None, + } + } +} + +impl From for KeyOutcome { + fn from(changed: bool) -> Self { + Self::Handled(changed) + } +} + +#[cfg(all(test, target_os = "macos"))] pub(crate) const CMD_MOD: KeyModifiers = KeyModifiers::SUPER; -#[cfg(not(target_os = "macos"))] -pub(crate) const CMD_MOD: KeyModifiers = KeyModifiers::CONTROL; -#[cfg(target_os = "macos")] +#[cfg(all(test, target_os = "macos"))] pub(crate) const WORD_NAV_MOD: KeyModifiers = KeyModifiers::ALT; -#[cfg(not(target_os = "macos"))] +#[cfg(all(test, not(target_os = "macos")))] pub(crate) const WORD_NAV_MOD: KeyModifiers = KeyModifiers::CONTROL; -#[cfg(target_os = "macos")] -pub(crate) const WORD_NAV_MOD_EXCLUDED: KeyModifiers = KeyModifiers::empty(); -#[cfg(not(target_os = "macos"))] -pub(crate) const WORD_NAV_MOD_EXCLUDED: KeyModifiers = KeyModifiers::ALT; - fn is_ctrl_shortcut(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) } @@ -51,81 +88,69 @@ pub(super) fn is_ctrl_char_shortcut(key: KeyEvent, expected: char) -> bool { } } -fn is_permission_ctrl_shortcut(key: KeyEvent) -> bool { - is_ctrl_char_shortcut(key, 'y') - || is_ctrl_char_shortcut(key, 'a') - || is_ctrl_char_shortcut(key, 'n') -} - -fn handle_always_allowed_shortcuts(app: &mut App, key: KeyEvent) -> bool { - if is_ctrl_char_shortcut(key, 'q') { - app.should_quit = true; - return true; - } - if is_ctrl_char_shortcut(key, 'c') { - app.should_quit = true; - return true; - } - false -} - -pub(super) fn dispatch_key_by_focus(app: &mut App, key: KeyEvent) -> bool { - if handle_always_allowed_shortcuts(app, key) { - return true; - } - +pub(super) fn dispatch_key_by_focus(app: &mut App, key: KeyEvent) -> KeyOutcome { if matches!(app.status, AppStatus::Connecting | AppStatus::CommandPending | AppStatus::Error) || app.is_compacting { - return handle_blocked_input_shortcuts(app, key); - } - - if handle_global_shortcuts(app, key) { - return true; + return handle_keymap_context(app, KeyContext::ChatBlocked, key); } match app.focus_owner() { FocusOwner::Mention => handle_autocomplete_key(app, key), FocusOwner::Permission => { + normalize_pending_interaction_queue(app); + if app.focus_owner() != FocusOwner::Permission { + return handle_normal_key(app, key); + } if should_reclaim_input_focus_before_inline_interaction(app, key) { reclaim_input_from_inline_prompt_if_needed(app); handle_normal_key(app, key) - } else if handle_inline_interaction_key(app, key) { - true } else { - handle_normal_key(app, key) + let context = active_inline_interaction_context(app); + if context == KeyContext::InlineQuestion + && let Some(outcome) = questions::handle_question_note_key(app, key) + { + return outcome; + } + let outcome = handle_keymap_context(app, context, key); + if outcome.handled() { outcome } else { handle_normal_key(app, key) } } } FocusOwner::Input => handle_normal_key(app, key), } } -/// During blocked-input states (Connecting, `CommandPending`, Error), keep input disabled and only allow -/// navigation/help shortcuts. -fn handle_blocked_input_shortcuts(app: &mut App, key: KeyEvent) -> bool { - if is_ctrl_char_shortcut(key, 'l') { - app.request_chat_visible_rebuild(); - return true; - } - false +fn first_handled(primary: KeyOutcome, fallback: impl FnOnce() -> KeyOutcome) -> KeyOutcome { + if primary.handled() { primary } else { fallback() } +} + +fn printable_outcome(changed: bool) -> KeyOutcome { + if changed { KeyOutcome::Handled(true) } else { KeyOutcome::Ignored } } -/// Handle shortcuts that should work regardless of current focus owner. -fn handle_global_shortcuts(app: &mut App, key: KeyEvent) -> bool { - // Permission quick shortcuts are global when permissions are pending. - if !app.pending_interaction_ids.is_empty() && is_permission_ctrl_shortcut(key) { - return handle_inline_interaction_key(app, key); +fn active_inline_interaction_context(app: &App) -> KeyContext { + if questions::has_focused_question(app) { + KeyContext::InlineQuestion + } else { + KeyContext::InlinePermission } +} - match (key.code, key.modifiers) { - (KeyCode::Char('l'), m) if m == KeyModifiers::CONTROL => { - app.request_chat_visible_rebuild(); - true - } - _ => false, +fn handle_keymap_context(app: &mut App, context: KeyContext, key: KeyEvent) -> KeyOutcome { + match resolve_key_action_for_context(app, context, key) { + Some(action) => execute_key_action(app, action, key), + None => KeyOutcome::Ignored, } } +fn resolve_key_action_for_context( + app: &App, + context: KeyContext, + key: KeyEvent, +) -> Option { + app.keymap.resolve_event(context, key).map(|resolved| resolved.action) +} + #[inline] pub(super) fn is_printable_text_modifiers(modifiers: KeyModifiers) -> bool { let ctrl_alt = @@ -133,31 +158,48 @@ pub(super) fn is_printable_text_modifiers(modifiers: KeyModifiers) -> bool { !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) || ctrl_alt } -pub(super) fn handle_normal_key(app: &mut App, key: KeyEvent) -> bool { +pub(super) fn handle_normal_key(app: &mut App, key: KeyEvent) -> KeyOutcome { let input_version_before = app.input.version; if should_ignore_key_during_paste(app, key) { - return false; + return KeyOutcome::Ignored; } - let changed = handle_normal_key_actions(app, key); + let outcome = handle_chat_input_key(app, key); - if app.input.version != input_version_before && should_sync_autocomplete_after_key(app, key) { + if app.input.version != input_version_before { mention::sync_with_cursor(app); slash::sync_with_cursor(app); subagent::sync_with_cursor(app); } - changed + outcome } fn should_ignore_key_during_paste(app: &mut App, key: KeyEvent) -> bool { + if paste_suppression_bypass_action(app, key).is_some() { + return false; + } if app.pending_submit.is_some() && is_editing_like_key(key) { app.pending_submit = None; } !app.pending_paste_text.is_empty() && is_editing_like_key(key) } +fn paste_suppression_bypass_action(app: &App, key: KeyEvent) -> Option { + resolve_key_action_for_context(app, KeyContext::ChatInput, key).filter(|action| { + matches!( + action, + KeyAction::App( + AppAction::ClearInputOrQuit + | AppAction::Quit + | AppAction::Redraw + | AppAction::CancelTurn + ) + ) + }) +} + fn is_editing_like_key(key: KeyEvent) -> bool { matches!( key.code, @@ -174,41 +216,235 @@ fn should_reclaim_input_focus_before_inline_interaction(app: &App, key: KeyEvent } } -fn handle_normal_key_actions(app: &mut App, key: KeyEvent) -> bool { - if handle_turn_control_key(app, key) { - return true; +fn handle_chat_input_key(app: &mut App, key: KeyEvent) -> KeyOutcome { + if handle_clipboard_paste_key(app, key) { + return KeyOutcome::Handled(true); } - if handle_submit_key(app, key) { - return true; + if let Some(action) = resolve_key_action_for_context(app, KeyContext::ChatInput, key) { + return first_handled(execute_key_action(app, action, key), || { + printable_outcome(handle_printable_key(app, key)) + }); } - if handle_history_key(app, key) { - return true; + printable_outcome(handle_printable_key(app, key)) +} + +fn execute_key_action(app: &mut App, action: KeyAction, key: KeyEvent) -> KeyOutcome { + match action { + KeyAction::App(action) => execute_app_action(app, action), + KeyAction::Input(action) => execute_input_action(app, action), + KeyAction::Autocomplete(action) => execute_autocomplete_action(app, action).into(), + KeyAction::Interaction(action) => execute_interaction_action(app, action, key), + KeyAction::Terminal(action) => execute_terminal_action(action), } - if handle_navigation_key(app, key) { +} + +fn execute_app_action(app: &mut App, action: AppAction) -> KeyOutcome { + match action { + AppAction::Quit => { + app.should_quit = true; + KeyOutcome::Handled(true) + } + AppAction::ClearInputOrQuit => clear_input_or_quit(app).into(), + AppAction::Redraw => { + app.request_chat_visible_rebuild(); + KeyOutcome::Handled(true) + } + AppAction::CancelTurn => handle_turn_control(app).into(), + AppAction::SubmitInput => handle_submit(app).into(), + AppAction::FocusPromptOrAcceptSuggestion => { + (handle_focus_toggle(app) || handle_prompt_suggestion(app)).into() + } + AppAction::CycleMode => handle_mode_cycle(app).into(), + } +} + +fn clear_input_or_quit(app: &mut App) -> bool { + let has_local_input = !app.input.is_empty() + || !app.pending_images.is_empty() + || !app.pending_paste_text.is_empty() + || app.pending_submit.is_some(); + if !has_local_input { + app.should_quit = true; return true; } - if handle_focus_toggle_key(app, key) { + + app.input.clear(); + app.pending_images.clear(); + app.pending_paste_text.clear(); + app.pending_paste_session = None; + app.active_paste_session = None; + app.pending_submit = None; + app.request_chat_repaint(); + true +} + +fn execute_input_action(app: &mut App, action: InputAction) -> KeyOutcome { + reclaim_input_from_inline_prompt_if_needed(app); + let handled = match action { + InputAction::MoveCharLeft => app.input.textarea_move_left(), + InputAction::MoveCharRight => app.input.textarea_move_right(), + InputAction::MoveWordLeft => app.input.textarea_move_word_left(), + InputAction::MoveWordRight => app.input.textarea_move_word_right(), + InputAction::MoveLineStart => app.input.textarea_move_home(), + InputAction::MoveLineEnd => app.input.textarea_move_end(), + InputAction::MoveUp => { + let _ = try_move_input_cursor_up(app); + true + } + InputAction::MoveDown => { + let _ = try_move_input_cursor_down(app); + true + } + InputAction::DeleteCharBefore => delete_input_char_before(app), + InputAction::DeleteCharAfter => delete_input_char_after(app), + InputAction::DeleteWordBefore => delete_input_word_before(app), + InputAction::DeleteWordAfter => delete_input_word_after(app), + InputAction::KillLineStart => app.input.textarea_delete_line_before(), + InputAction::KillLineEnd => app.input.textarea_delete_line_after(), + InputAction::Yank => app.input.textarea_yank(), + InputAction::Undo => { + let _ = app.input.textarea_undo(); + true + } + InputAction::Redo => { + let _ = app.input.textarea_redo(); + true + } + InputAction::InsertNewline => insert_explicit_newline(app), + }; + handled.into() +} + +fn delete_input_char_before(app: &mut App) -> bool { + if try_delete_image_badge(app, "before") { return true; } - if handle_prompt_suggestion_key(app, key) { + app.input.textarea_delete_char_before() +} + +fn delete_input_char_after(app: &mut App) -> bool { + if try_delete_image_badge(app, "after") { return true; } - if handle_mode_cycle_key(app, key) { + app.input.textarea_delete_char_after() +} + +fn delete_input_word_before(app: &mut App) -> bool { + if try_delete_image_badge(app, "before") { return true; } - if handle_clipboard_paste_key(app, key) { + app.input.textarea_delete_word_before() +} + +fn delete_input_word_after(app: &mut App) -> bool { + if try_delete_image_badge(app, "after") { return true; } - if handle_editing_key(app, key) { + app.input.textarea_delete_word_after() +} + +fn insert_explicit_newline(app: &mut App) -> bool { + if app.paste_burst.on_enter(Instant::now()) { + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "enter_routed_to_paste_buffer", + message = "enter was routed through the paste buffer", + outcome = "success", + ); return true; } - handle_printable_key(app, key) + app.pending_submit = None; + tracing::debug!( + target: crate::logging::targets::APP_INPUT, + event_name = "explicit_newline_inserted", + message = "explicit newline inserted instead of submit", + outcome = "success", + ); + app.input.textarea_insert_newline() } -fn handle_turn_control_key(app: &mut App, key: KeyEvent) -> bool { - if !matches!(key.code, KeyCode::Esc) { - return false; +fn execute_autocomplete_action(app: &mut App, action: AutocompleteAction) -> bool { + match app.active_autocomplete_kind() { + Some(AutocompleteKind::Mention) => execute_mention_action(app, action), + Some(AutocompleteKind::Slash) => execute_slash_action(app, action), + Some(AutocompleteKind::Subagent) => execute_subagent_action(app, action), + None => false, + } +} + +fn execute_mention_action(app: &mut App, action: AutocompleteAction) -> bool { + match action { + AutocompleteAction::MovePrevious => mention::move_up(app), + AutocompleteAction::MoveNext => mention::move_down(app), + AutocompleteAction::Confirm => mention::confirm_selection(app), + AutocompleteAction::Cancel => mention::deactivate(app), + } + true +} + +fn execute_slash_action(app: &mut App, action: AutocompleteAction) -> bool { + match action { + AutocompleteAction::MovePrevious => { + if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { + slash::move_up(app); + } + } + AutocompleteAction::MoveNext => { + if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { + slash::move_down(app); + } + } + AutocompleteAction::Confirm => { + if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { + slash::confirm_selection(app); + } + } + AutocompleteAction::Cancel => slash::deactivate(app), + } + true +} + +fn execute_subagent_action(app: &mut App, action: AutocompleteAction) -> bool { + match action { + AutocompleteAction::MovePrevious => { + if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { + subagent::move_up(app); + } + } + AutocompleteAction::MoveNext => { + if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { + subagent::move_down(app); + } + } + AutocompleteAction::Confirm => { + if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { + subagent::confirm_selection(app); + } + } + AutocompleteAction::Cancel => subagent::deactivate(app), } + true +} + +fn execute_interaction_action( + app: &mut App, + action: InteractionAction, + key: KeyEvent, +) -> KeyOutcome { + if questions::has_focused_question(app) { + questions::execute_question_action(app, action, key) + } else { + permissions::execute_permission_action(app, action, key) + } +} + +fn execute_terminal_action(action: TerminalAction) -> KeyOutcome { + match action { + TerminalAction::Suspend => KeyOutcome::Runtime(RuntimeCommand::SuspendProcess), + } +} + +fn handle_turn_control(app: &mut App) -> bool { app.pending_submit = None; // Clear any pending image attachments on Escape. if !app.pending_images.is_empty() { @@ -229,11 +465,7 @@ fn handle_turn_control_key(app: &mut App, key: KeyEvent) -> bool { true } -fn handle_submit_key(app: &mut App, key: KeyEvent) -> bool { - if !matches!(key.code, KeyCode::Enter) { - return false; - } - +fn handle_submit(app: &mut App) -> bool { let now = Instant::now(); // During an active burst or the post-burst suppression window, Enter @@ -248,117 +480,36 @@ fn handle_submit_key(app: &mut App, key: KeyEvent) -> bool { return true; } - if !key.modifiers.contains(KeyModifiers::SHIFT) - && !key.modifiers.contains(KeyModifiers::CONTROL) - { - app.pending_submit = Some(app.input.snapshot()); - tracing::debug!( - target: crate::logging::targets::APP_INPUT, - event_name = "deferred_submit_armed", - message = "deferred submit snapshot armed", - outcome = "start", - ); - return false; - } - app.pending_submit = None; + app.pending_submit = Some(app.input.snapshot()); tracing::debug!( target: crate::logging::targets::APP_INPUT, - event_name = "explicit_newline_inserted", - message = "explicit newline inserted instead of submit", - outcome = "success", + event_name = "deferred_submit_armed", + message = "deferred submit snapshot armed", + outcome = "start", ); - app.input.textarea_insert_newline() -} - -fn handle_history_key(app: &mut App, key: KeyEvent) -> bool { - if is_undo_shortcut(key.code, key.modifiers) { - app.input.textarea_undo(); - return true; - } - if is_redo_shortcut(key.code, key.modifiers) { - app.input.textarea_redo(); - return true; - } false } -fn is_undo_shortcut(code: KeyCode, modifiers: KeyModifiers) -> bool { - matches!(code, KeyCode::Char('z')) && modifiers == CMD_MOD -} - -#[cfg(target_os = "macos")] -fn is_redo_shortcut(code: KeyCode, modifiers: KeyModifiers) -> bool { - let command_shift_z = matches!(code, KeyCode::Char('z' | 'Z')) - && modifiers.contains(CMD_MOD) - && modifiers.contains(KeyModifiers::SHIFT) - && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); - let command_upper_z = matches!(code, KeyCode::Char('Z')) && modifiers == CMD_MOD; - let command_y = matches!(code, KeyCode::Char('y')) && modifiers == CMD_MOD; - command_shift_z || command_upper_z || command_y -} - -#[cfg(not(target_os = "macos"))] -fn is_redo_shortcut(code: KeyCode, modifiers: KeyModifiers) -> bool { - matches!(code, KeyCode::Char('y')) && modifiers == CMD_MOD -} - -fn handle_navigation_key(app: &mut App, key: KeyEvent) -> bool { - match (key.code, key.modifiers) { - (KeyCode::Left, m) if m.contains(WORD_NAV_MOD) && !m.intersects(WORD_NAV_MOD_EXCLUDED) => { - app.input.textarea_move_word_left() - } - (KeyCode::Right, m) if m.contains(WORD_NAV_MOD) && !m.intersects(WORD_NAV_MOD_EXCLUDED) => { - app.input.textarea_move_word_right() - } - (KeyCode::Left, _) => app.input.textarea_move_left(), - (KeyCode::Right, _) => app.input.textarea_move_right(), - (KeyCode::Up, _) => { - let _ = try_move_input_cursor_up(app); - true - } - (KeyCode::Down, _) => { - let _ = try_move_input_cursor_down(app); - true - } - (KeyCode::Home, _) => app.input.textarea_move_home(), - (KeyCode::End, _) => app.input.textarea_move_end(), - _ => false, - } -} - -fn handle_focus_toggle_key(app: &mut App, key: KeyEvent) -> bool { - match (key.code, key.modifiers) { - (KeyCode::Tab, m) - if !m.contains(KeyModifiers::SHIFT) - && !m.contains(KeyModifiers::CONTROL) - && !m.contains(KeyModifiers::ALT) => - { - if app.pending_interaction_ids.is_empty() { - false - } else { - match app.focus_owner() { - FocusOwner::Permission => { - clear_inline_interaction_focus(app); - true - } - FocusOwner::Input => { - focus_next_inline_interaction(app); - true - } - FocusOwner::Mention => false, - } +fn handle_focus_toggle(app: &mut App) -> bool { + if app.pending_interaction_ids.is_empty() { + false + } else { + match app.focus_owner() { + FocusOwner::Permission => { + clear_inline_interaction_focus(app); + true } + FocusOwner::Input => { + focus_next_inline_interaction(app); + true + } + FocusOwner::Mention => false, } - _ => false, } } -fn handle_prompt_suggestion_key(app: &mut App, key: KeyEvent) -> bool { - if !matches!(key.code, KeyCode::Tab) - || !key.modifiers.is_empty() - || app.focus_owner() != FocusOwner::Input - || !app.input.is_empty() - { +fn handle_prompt_suggestion(app: &mut App) -> bool { + if app.focus_owner() != FocusOwner::Input || !app.input.is_empty() { return false; } @@ -372,10 +523,7 @@ fn handle_prompt_suggestion_key(app: &mut App, key: KeyEvent) -> bool { true } -fn handle_mode_cycle_key(app: &mut App, key: KeyEvent) -> bool { - if !matches!(key.code, KeyCode::BackTab) { - return false; - } +fn handle_mode_cycle(app: &mut App) -> bool { let Some(ref mode) = app.mode else { return true; }; @@ -493,44 +641,6 @@ pub(super) fn reclaim_input_from_inline_prompt_if_needed(app: &mut App) { } } -fn handle_editing_key(app: &mut App, key: KeyEvent) -> bool { - match (key.code, key.modifiers) { - (KeyCode::Backspace, m) - if m.contains(WORD_NAV_MOD) && !m.intersects(WORD_NAV_MOD_EXCLUDED) => - { - reclaim_input_from_inline_prompt_if_needed(app); - if try_delete_image_badge(app, "before") { - return true; - } - app.input.textarea_delete_word_before() - } - (KeyCode::Delete, m) - if m.contains(WORD_NAV_MOD) && !m.intersects(WORD_NAV_MOD_EXCLUDED) => - { - reclaim_input_from_inline_prompt_if_needed(app); - if try_delete_image_badge(app, "after") { - return true; - } - app.input.textarea_delete_word_after() - } - (KeyCode::Backspace, _) => { - reclaim_input_from_inline_prompt_if_needed(app); - if try_delete_image_badge(app, "before") { - return true; - } - app.input.textarea_delete_char_before() - } - (KeyCode::Delete, _) => { - reclaim_input_from_inline_prompt_if_needed(app); - if try_delete_image_badge(app, "after") { - return true; - } - app.input.textarea_delete_char_after() - } - _ => false, - } -} - /// If the cursor is inside or adjacent to an `[Image #N]` badge, delete the /// entire badge, remove the associated image from `pending_images`, and /// renumber remaining badges. Returns `true` if a badge was deleted. @@ -606,64 +716,51 @@ fn try_move_input_cursor_down(app: &mut App) -> bool { (app.input.cursor_row(), app.input.cursor_col()) != before } -fn should_sync_autocomplete_after_key(_app: &App, key: KeyEvent) -> bool { - match (key.code, key.modifiers) { - ( - KeyCode::Up - | KeyCode::Down - | KeyCode::Left - | KeyCode::Right - | KeyCode::Home - | KeyCode::End - | KeyCode::Backspace - | KeyCode::Delete - | KeyCode::Enter, - _, - ) => true, - (code, modifiers) - if is_undo_shortcut(code, modifiers) || is_redo_shortcut(code, modifiers) => - { - true - } - (KeyCode::Char(_), m) if is_printable_text_modifiers(m) => true, - _ => false, - } +/// Handle keystrokes while mention/slash autocomplete dropdown is active. +pub(super) fn handle_autocomplete_key(app: &mut App, key: KeyEvent) -> KeyOutcome { + let context = match app.active_autocomplete_kind() { + Some(AutocompleteKind::Mention) => KeyContext::AutocompleteMention, + Some(AutocompleteKind::Slash) => KeyContext::AutocompleteSlash, + Some(AutocompleteKind::Subagent) => KeyContext::AutocompleteSubagent, + None => return handle_normal_key(app, key), + }; + first_handled(handle_keymap_context(app, context, key), || { + handle_autocomplete_fallback_key(app, key) + }) } -/// Handle keystrokes while mention/slash autocomplete dropdown is active. -pub(super) fn handle_autocomplete_key(app: &mut App, key: KeyEvent) -> bool { +fn handle_autocomplete_fallback_key(app: &mut App, key: KeyEvent) -> KeyOutcome { match app.active_autocomplete_kind() { - Some(AutocompleteKind::Mention) => return handle_mention_key(app, key), - Some(AutocompleteKind::Slash) => return handle_slash_key(app, key), - Some(AutocompleteKind::Subagent) => return handle_subagent_key(app, key), - None => {} + Some(AutocompleteKind::Mention) => handle_mention_key(app, key), + Some(AutocompleteKind::Slash) => handle_slash_key(app, key), + Some(AutocompleteKind::Subagent) => handle_subagent_key(app, key), + None => handle_normal_key(app, key), } - dispatch_key_by_focus(app, key) } /// Handle keystrokes while the `@` mention autocomplete dropdown is active. -pub(super) fn handle_mention_key(app: &mut App, key: KeyEvent) -> bool { +pub(super) fn handle_mention_key(app: &mut App, key: KeyEvent) -> KeyOutcome { match (key.code, key.modifiers) { (KeyCode::Up, _) => { mention::move_up(app); - true + KeyOutcome::Handled(true) } (KeyCode::Down, _) => { mention::move_down(app); - true + KeyOutcome::Handled(true) } (KeyCode::Enter | KeyCode::Tab, _) => { mention::confirm_selection(app); - true + KeyOutcome::Handled(true) } (KeyCode::Esc, _) => { mention::deactivate(app); - true + KeyOutcome::Handled(true) } (KeyCode::Backspace, _) => { let changed = app.input.textarea_delete_char_before(); mention::update_query(app); - changed + changed.into() } (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { let changed = app.input.textarea_insert_char(c); @@ -672,7 +769,7 @@ pub(super) fn handle_mention_key(app: &mut App, key: KeyEvent) -> bool { } else { mention::update_query(app); } - changed + changed.into() } // Any other key: deactivate mention and forward to normal handling _ => { @@ -683,39 +780,39 @@ pub(super) fn handle_mention_key(app: &mut App, key: KeyEvent) -> bool { } /// Handle keystrokes while slash autocomplete dropdown is active. -fn handle_slash_key(app: &mut App, key: KeyEvent) -> bool { +fn handle_slash_key(app: &mut App, key: KeyEvent) -> KeyOutcome { match (key.code, key.modifiers) { (KeyCode::Up, _) => { if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { slash::move_up(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Down, _) => { if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { slash::move_down(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Enter | KeyCode::Tab, _) => { if app.slash.as_ref().is_some_and(|slash| !slash.candidates.is_empty()) { slash::confirm_selection(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Esc, _) => { slash::deactivate(app); - true + KeyOutcome::Handled(true) } (KeyCode::Backspace, _) => { let changed = app.input.textarea_delete_char_before(); slash::update_query(app); - changed + changed.into() } (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { let changed = app.input.textarea_insert_char(c); slash::update_query(app); - changed + changed.into() } _ => { slash::deactivate(app); @@ -725,39 +822,39 @@ fn handle_slash_key(app: &mut App, key: KeyEvent) -> bool { } /// Handle keystrokes while `&` subagent autocomplete dropdown is active. -fn handle_subagent_key(app: &mut App, key: KeyEvent) -> bool { +fn handle_subagent_key(app: &mut App, key: KeyEvent) -> KeyOutcome { match (key.code, key.modifiers) { (KeyCode::Up, _) => { if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { subagent::move_up(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Down, _) => { if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { subagent::move_down(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Enter | KeyCode::Tab, _) => { if app.subagent.as_ref().is_some_and(|subagent| !subagent.query.is_empty()) { subagent::confirm_selection(app); } - true + KeyOutcome::Handled(true) } (KeyCode::Esc, _) => { subagent::deactivate(app); - true + KeyOutcome::Handled(true) } (KeyCode::Backspace, _) => { let changed = app.input.textarea_delete_char_before(); subagent::update_query(app); - changed + changed.into() } (KeyCode::Char(c), m) if is_printable_text_modifiers(m) => { let changed = app.input.textarea_insert_char(c); subagent::update_query(app); - changed + changed.into() } _ => { subagent::deactivate(app); @@ -770,6 +867,7 @@ fn handle_subagent_key(app: &mut App, key: KeyEvent) -> bool { mod tests { use super::*; use crate::app::FocusTarget; + use crate::app::keymap::{KeyBinding, KeyBindingSource, KeyCodeSpec, KeySpec, ResolvedKeymap}; use crossterm::event::{KeyCode, KeyModifiers}; use std::time::{Duration, Instant}; @@ -803,6 +901,65 @@ mod tests { assert!(blocked); } + #[test] + fn queued_paste_allows_app_control_shortcuts() { + let mut app = App::test_default(); + app.pending_paste_text = "clipboard".to_owned(); + + let blocked = should_ignore_key_during_paste( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(!blocked); + } + + #[test] + fn terminal_suspend_action_returns_runtime_command() { + let mut app = App::test_default(); + + let outcome = execute_key_action( + &mut app, + KeyAction::Terminal(TerminalAction::Suspend), + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ); + + assert_eq!(outcome, KeyOutcome::Runtime(RuntimeCommand::SuspendProcess)); + } + + #[test] + fn input_action_returns_handled_outcome() { + let mut app = App::test_default(); + app.input.set_text("ab"); + let _ = app.input.set_cursor(0, 2); + + let outcome = execute_key_action( + &mut app, + KeyAction::Input(InputAction::MoveCharLeft), + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + + assert_eq!(outcome, KeyOutcome::Handled(true)); + assert_eq!(app.input.cursor_col(), 1); + } + + #[test] + fn ignored_key_action_allows_printable_chat_fallback() { + let mut app = App::test_default(); + app.keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::ChatInput, + KeySpec::new(KeyCodeSpec::Char('x'), KeyModifiers::NONE), + KeyAction::Interaction(InteractionAction::MoveNext), + KeyBindingSource::Config, + )]) + .expect("custom test keymap should validate"); + + let outcome = + handle_chat_input_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(outcome, KeyOutcome::Handled(true)); + assert_eq!(app.input.text(), "x"); + } + #[test] fn autocomplete_focus_routes_keys_to_active_slash_state() { let mut app = App::test_default(); @@ -830,7 +987,7 @@ mod tests { let handled = handle_autocomplete_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert!(handled); + assert!(handled.changed()); let slash = app.slash.as_ref().expect("slash autocomplete should stay active"); assert_eq!(slash.dialog.selected, 1); } @@ -846,7 +1003,7 @@ mod tests { let handled = handle_autocomplete_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(handled); + assert!(handled.changed()); assert_eq!(app.input.text(), "/1m-context "); assert!(app.slash.is_some()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1ce4d04..1ae8c29 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ mod git_context; mod inline_interactions; pub(crate) mod input; mod input_submit; +pub mod keymap; mod keys; mod lifecycle; pub(crate) mod mention; @@ -57,16 +58,16 @@ pub use lifecycle::{ pub use service_status_check::start_service_status_check; pub(crate) use state::MarkdownRenderKey; pub use state::{ - App, AppStatus, BlockCache, CacheMetrics, CancelOrigin, ChatMessage, ChatMessageId, - ChatRenderState, ChatRenderTraceState, ComposerRenderState, ExtraUsage, HistoryOutputId, - ImageAttachmentBlock, IncrementalMarkdown, InlinePermission, InlineQuestion, InvalidationLevel, - LayoutInvalidation, LiveRegionRenderState, LoginHint, McpState, MessageBlock, MessageBlockId, - MessageRole, MessageUsage, ModeInfo, ModeState, NoticeBlock, NoticeDedupKey, NoticeStage, - PasteSessionState, PendingCommandAck, RateLimitIncidentKey, RecentSessionInfo, SelectionPoint, - SessionPickerState, SessionUsageState, SystemSeverity, TerminalSize, TerminalSizeChange, - TerminalSnapshotMode, TextBlock, TextBlockSpacing, TodoItem, TodoStatus, ToolCallInfo, - ToolCallScope, TurnNoticeLocation, TurnNoticeRef, UpdateNoticeState, UsageSnapshot, - UsageSourceKind, UsageSourceMode, UsageState, UsageWindow, WelcomeBlock, + App, AppStatus, AutocompleteKind, BlockCache, CacheMetrics, CancelOrigin, ChatMessage, + ChatMessageId, ChatRenderState, ChatRenderTraceState, ComposerRenderState, ExtraUsage, + HistoryOutputId, ImageAttachmentBlock, IncrementalMarkdown, InlinePermission, InlineQuestion, + InvalidationLevel, LayoutInvalidation, LiveRegionRenderState, LoginHint, McpState, + MessageBlock, MessageBlockId, MessageRole, MessageUsage, ModeInfo, ModeState, NoticeBlock, + NoticeDedupKey, NoticeStage, PasteSessionState, PendingCommandAck, RateLimitIncidentKey, + RecentSessionInfo, SelectionPoint, SessionPickerState, SessionUsageState, SystemSeverity, + TerminalSize, TerminalSizeChange, TerminalSnapshotMode, TextBlock, TextBlockSpacing, TodoItem, + TodoStatus, ToolCallInfo, ToolCallScope, TurnNoticeLocation, TurnNoticeRef, UpdateNoticeState, + UsageSnapshot, UsageSourceKind, UsageSourceMode, UsageState, UsageWindow, WelcomeBlock, hash_text_block_content, hash_welcome_block_content, is_execute_tool_name, }; pub use trust::TrustSelection; @@ -75,6 +76,7 @@ pub use view::{FullscreenView, SurfaceMode}; use crate::agent::events::ClientEvent; use crate::agent::model; +use anyhow::Context as _; use crossterm::event::EventStream; use futures::{FutureExt as _, StreamExt}; use std::time::{Duration, Instant}; @@ -115,7 +117,8 @@ async fn run_tui_loop( let time_to_next = tick_duration.saturating_sub(last_render.elapsed()); tokio::select! { Some(Ok(event)) = events.next() => { - events::handle_terminal_event(app, event); + let outcome = events::handle_terminal_event(app, event); + handle_runtime_command(app, terminal_runtime, outcome.runtime_command())?; } Some(event) = app.event_rx.recv() => { handle_runtime_client_event(app, event, &mut service_status_check_started); @@ -139,7 +142,8 @@ async fn run_tui_loop( loop { // Try terminal events first (keeps typing responsive) if let Some(Some(Ok(event))) = events.next().now_or_never() { - events::handle_terminal_event(app, event); + let outcome = events::handle_terminal_event(app, event); + handle_runtime_command(app, terminal_runtime, outcome.runtime_command())?; continue; } // Then client events @@ -239,6 +243,61 @@ async fn run_tui_loop( Ok(()) } +fn handle_runtime_command( + app: &mut App, + terminal_runtime: &mut terminal_runtime::TerminalRuntime, + command: Option, +) -> anyhow::Result<()> { + match command { + Some(keys::RuntimeCommand::SuspendProcess) => suspend_tui_process(app, terminal_runtime), + None => Ok(()), + } +} + +fn suspend_tui_process( + app: &mut App, + terminal_runtime: &mut terminal_runtime::TerminalRuntime, +) -> anyhow::Result<()> { + tab_title::restore_tab_title(&app.cwd); + terminal_runtime.restore(app); + + #[cfg(unix)] + let suspend_result = suspend_current_process(); + #[cfg(not(unix))] + let suspend_result = { + suspend_current_process(); + Ok(()) + }; + let resumed_runtime = terminal_runtime::TerminalRuntime::bootstrap(app) + .context("failed to restore terminal after process resume")?; + *terminal_runtime = resumed_runtime; + tab_title::update_tab_title(&app.status, app.spinner_frame, &app.cwd); + app.request_active_surface_repaint(); + + suspend_result +} + +#[cfg(unix)] +fn suspend_current_process() -> anyhow::Result<()> { + // SAFETY: `raise` targets the current process with a constant signal and does + // not dereference pointers or rely on external memory validity. + let result = unsafe { libc::raise(libc::SIGTSTP) }; + (result == 0) + .then_some(()) + .ok_or_else(std::io::Error::last_os_error) + .context("failed to suspend TUI process") +} + +#[cfg(not(unix))] +fn suspend_current_process() { + tracing::warn!( + target: crate::logging::targets::APP_LIFECYCLE, + event_name = "runtime_suspend_ignored", + message = "process suspend is not supported on this platform", + outcome = "ignored", + ); +} + fn handle_runtime_client_event( app: &mut App, event: ClientEvent, diff --git a/src/app/permissions.rs b/src/app/permissions.rs index 97bbae7..2e178f7 100644 --- a/src/app/permissions.rs +++ b/src/app/permissions.rs @@ -2,13 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 use super::inline_interactions::{ - focus_next_inline_interaction, focused_interaction, focused_interaction_dirty_idx, - get_focused_interaction_tc, invalidate_if_changed, pop_next_valid_interaction_id, + clear_inline_interaction_focus, focus_next_inline_interaction, focused_interaction, + focused_interaction_dirty_idx, focused_interaction_is_active, get_focused_interaction_tc, + handle_interaction_focus_cycle, invalidate_if_changed, normalize_pending_interaction_queue, + pop_next_valid_interaction_id, }; -use super::keys::is_ctrl_char_shortcut; use super::{App, InvalidationLevel, MessageBlock}; use crate::agent::model; use crate::agent::model::PermissionOptionKind; +use crate::app::keymap::InteractionAction; +use crate::app::keys::KeyOutcome; use crossterm::event::{KeyCode, KeyEvent}; fn focused_permission(app: &App) -> Option<&crate::app::InlinePermission> { @@ -51,26 +54,6 @@ fn option_tokens(option: &model::PermissionOption) -> (bool, bool, bool, bool) { (allow_like, reject_like, persistent_like, session_like) } -fn option_is_allow_once_fallback(option: &model::PermissionOption) -> bool { - let (allow_like, reject_like, persistent_like, session_like) = option_tokens(option); - allow_like && !reject_like && !persistent_like && !session_like -} - -fn option_is_allow_always_fallback(option: &model::PermissionOption) -> bool { - let (allow_like, reject_like, persistent_like, _) = option_tokens(option); - allow_like && !reject_like && persistent_like -} - -fn option_is_allow_non_once_fallback(option: &model::PermissionOption) -> bool { - let (allow_like, reject_like, persistent_like, session_like) = option_tokens(option); - allow_like && !reject_like && (persistent_like || session_like) -} - -fn option_is_reject_once_fallback(option: &model::PermissionOption) -> bool { - let (allow_like, reject_like, persistent_like, _) = option_tokens(option); - reject_like && !allow_like && !persistent_like -} - fn option_is_reject_fallback(option: &model::PermissionOption) -> bool { let (allow_like, reject_like, _, _) = option_tokens(option); reject_like && !allow_like @@ -84,6 +67,75 @@ pub(super) fn focused_permission_is_plan_approval(app: &App) -> bool { }) } +fn focused_permission_option_count(app: &App) -> usize { + focused_permission(app).map_or(0, |permission| permission.options.len()) +} + +pub(super) fn execute_permission_action( + app: &mut App, + action: InteractionAction, + key: KeyEvent, +) -> KeyOutcome { + normalize_pending_interaction_queue(app); + if !focused_interaction_is_active(app) || focused_permission(app).is_none() { + return KeyOutcome::Ignored; + } + + let option_count = focused_permission_option_count(app); + match action { + InteractionAction::MovePrevious => { + if let Some(outcome) = handle_permission_vertical_focus_cycle(app, key) { + return outcome; + } + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_permission_option_left(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveNext => { + if let Some(outcome) = handle_permission_vertical_focus_cycle(app, key) { + return outcome; + } + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_permission_option_right(app, option_count); + KeyOutcome::Handled(true) + } + InteractionAction::Confirm => { + if option_count == 0 { + return KeyOutcome::Ignored; + } + respond_permission(app, None); + KeyOutcome::Handled(true) + } + InteractionAction::Cancel => { + if !respond_permission_reject_or_cancel(app, option_count) { + return KeyOutcome::Ignored; + } + KeyOutcome::Handled(true) + } + InteractionAction::FocusNext => { + clear_inline_interaction_focus(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveStart + | InteractionAction::MoveEnd + | InteractionAction::ToggleSelection + | InteractionAction::ToggleNotes => KeyOutcome::Ignored, + } +} + +fn handle_permission_vertical_focus_cycle(app: &mut App, key: KeyEvent) -> Option { + if !matches!(key.code, KeyCode::Up | KeyCode::Down) || focused_permission_is_plan_approval(app) + { + return None; + } + + handle_interaction_focus_cycle(app, key, true, false).map(|_consumed| KeyOutcome::Handled(true)) +} + fn move_permission_option_left(app: &mut App) { let dirty_idx = focused_interaction_dirty_idx(app); let mut changed = false; @@ -114,132 +166,20 @@ fn move_permission_option_right(app: &mut App, option_count: usize) { invalidate_if_changed(app, dirty_idx, changed); } -fn handle_permission_option_keys( - app: &mut App, - key: KeyEvent, - interaction_has_focus: bool, - option_count: usize, - plan_approval: bool, -) -> Option { - if !interaction_has_focus { - return None; - } - match key.code { - KeyCode::Left if option_count > 0 => { - move_permission_option_left(app); - Some(true) - } - KeyCode::Right if option_count > 0 => { - move_permission_option_right(app, option_count); - Some(true) - } - KeyCode::Up if plan_approval && option_count > 0 => { - move_permission_option_left(app); - Some(true) - } - KeyCode::Down if plan_approval && option_count > 0 => { - move_permission_option_right(app, option_count); - Some(true) - } - KeyCode::Enter if option_count > 0 => { - respond_permission(app, None); - Some(true) - } - KeyCode::Esc => { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::RejectOnce) - .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::RejectAlways)) - .or_else(|| focused_option_index_where(app, option_is_reject_fallback)) - { - respond_permission(app, Some(idx)); - Some(true) - } else if option_count > 0 { - respond_permission(app, Some(option_count - 1)); - Some(true) - } else { - Some(false) - } - } - _ => None, - } -} - -fn handle_permission_quick_shortcuts(app: &mut App, key: KeyEvent) -> Option { - if !matches!(key.code, KeyCode::Char(_)) { - return None; - } - if focused_permission_is_plan_approval(app) { - if is_ctrl_char_shortcut(key, 'y') { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::PlanApprove) - { - respond_permission(app, Some(idx)); - return Some(true); - } - return Some(false); - } - if is_ctrl_char_shortcut(key, 'n') { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::PlanReject) { - respond_permission(app, Some(idx)); - return Some(true); - } - return Some(false); - } - if is_ctrl_char_shortcut(key, 'a') { - return Some(false); - } - return None; - } - if is_ctrl_char_shortcut(key, 'y') { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::AllowOnce) - .or_else(|| focused_option_index_where(app, option_is_allow_once_fallback)) - .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowSession)) - .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowAlways)) - .or_else(|| focused_option_index_where(app, option_is_allow_always_fallback)) - .or_else(|| focused_option_index_where(app, option_is_allow_non_once_fallback)) - { - respond_permission(app, Some(idx)); - return Some(true); - } - return Some(false); - } - if is_ctrl_char_shortcut(key, 'a') { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::AllowSession) - .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::AllowAlways)) - .or_else(|| focused_option_index_where(app, option_is_allow_non_once_fallback)) - { - respond_permission(app, Some(idx)); - return Some(true); - } - return Some(false); - } - if is_ctrl_char_shortcut(key, 'n') { - if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::RejectOnce) - .or_else(|| focused_option_index_where(app, option_is_reject_once_fallback)) - { - respond_permission(app, Some(idx)); - return Some(true); - } - return Some(false); - } - None -} - -pub(super) fn handle_permission_key( - app: &mut App, - key: KeyEvent, - interaction_has_focus: bool, -) -> bool { - let option_count = focused_permission(app).map_or(0, |permission| permission.options.len()); - let plan_approval = focused_permission_is_plan_approval(app); - - if let Some(consumed) = - handle_permission_option_keys(app, key, interaction_has_focus, option_count, plan_approval) +fn respond_permission_reject_or_cancel(app: &mut App, option_count: usize) -> bool { + if let Some(idx) = focused_option_index_by_kind(app, PermissionOptionKind::RejectOnce) + .or_else(|| focused_option_index_by_kind(app, PermissionOptionKind::RejectAlways)) + .or_else(|| focused_option_index_where(app, option_is_reject_fallback)) { - return consumed; - } - if let Some(consumed) = handle_permission_quick_shortcuts(app, key) { - return consumed; + respond_permission(app, Some(idx)); + true + } else if option_count > 0 { + respond_permission(app, Some(option_count - 1)); + true + } else { + respond_permission_cancel(app); + true } - false } fn respond_permission(app: &mut App, override_index: Option) { @@ -300,7 +240,6 @@ fn respond_permission(app: &mut App, override_index: Option) { focus_next_inline_interaction(app); } -#[cfg(test)] fn respond_permission_cancel(app: &mut App) { let Some(tool_id) = pop_next_valid_interaction_id(app) else { return; @@ -332,6 +271,7 @@ fn respond_permission_cancel(app: &mut App) { #[cfg(test)] mod tests { use super::*; + use crate::app::keymap::KeyContext; use crate::app::{ App, AppStatus, BlockCache, ChatMessage, IncrementalMarkdown, InlinePermission, MessageBlock, MessageRole, ToolCallInfo, @@ -432,23 +372,22 @@ mod tests { assert!(permission_focused(&app, "perm-1")); assert!(!permission_focused(&app, "perm-2")); - let consumed = crate::app::inline_interactions::handle_interaction_focus_cycle( + let consumed = execute_permission_action( &mut app, + InteractionAction::MoveNext, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), - true, - false, ); - assert_eq!(consumed, Some(true)); + assert_eq!(consumed, KeyOutcome::Handled(true)); assert_eq!(app.pending_interaction_ids, vec!["perm-2", "perm-1"]); assert!(permission_focused(&app, "perm-2")); assert!(!permission_focused(&app, "perm-1")); - let consumed = handle_permission_key( + let consumed = execute_permission_action( &mut app, + InteractionAction::Confirm, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - true, ); - assert!(consumed); + assert_eq!(consumed, KeyOutcome::Handled(true)); let resp2 = rx2.try_recv().expect("focused permission should receive response"); let model::RequestPermissionOutcome::Selected(sel2) = resp2.outcome else { @@ -460,372 +399,44 @@ mod tests { } #[test] - fn step3_lowercase_a_is_not_consumed_by_permission_shortcuts() { - let mut app = App::test_default(); - let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); + fn default_permission_keymap_has_no_letter_shortcuts() { + let app = App::test_default(); - let consumed = handle_permission_key( - &mut app, + for key in [ KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), - true, - ); - - assert!(!consumed, "lowercase 'a' should flow to normal typing"); - assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); - assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn step4_ctrl_y_maps_to_allow_once_kind_and_only_resolves_one_permission() { - let mut app = App::test_default(); - let mut rx1 = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "allow-always", - "Allow always", - PermissionOptionKind::AllowAlways, - ), - model::PermissionOption::new( - "allow-once", - "Allow once", - PermissionOptionKind::AllowOnce, - ), - model::PermissionOption::new( - "reject-once", - "Reject", - PermissionOptionKind::RejectOnce, - ), - ], - true, - ); - let mut rx2 = add_permission(&mut app, "perm-2", allow_options(), false); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), - true, - ); - assert!(consumed); - - let resp1 = rx1.try_recv().expect("first permission should be answered"); - let model::RequestPermissionOutcome::Selected(sel1) = resp1.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel1.option_id.clone(), "allow-once"); - assert_eq!(app.pending_interaction_ids, vec!["perm-2"]); - assert!(matches!(rx2.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn plain_y_and_n_are_not_consumed() { - let mut app = App::test_default(); - let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); - - let consumed_y = handle_permission_key( - &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), - true, - ); - let consumed_n = handle_permission_key( - &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - true, - ); - - assert!(!consumed_y); - assert!(!consumed_n); - assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); - assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn ctrl_y_approves_plan_permission() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "plan-approve", - "Approve", - PermissionOptionKind::PlanApprove, - ), - model::PermissionOption::new( - "plan-reject", - "Reject", - PermissionOptionKind::PlanReject, - ), - ], - true, - ); - - let consumed = handle_permission_key( - &mut app, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), - true, - ); - assert!(consumed); - - let resp = rx.try_recv().expect("plan permission should be answered by ctrl+y"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "plan-approve"); - } - - #[test] - fn raw_ctrl_y_approves_plan_permission() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "plan-approve", - "Approve", - PermissionOptionKind::PlanApprove, - ), - model::PermissionOption::new( - "plan-reject", - "Reject", - PermissionOptionKind::PlanReject, - ), - ], - true, - ); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('\u{19}'), KeyModifiers::NONE), - true, - ); - assert!(consumed); - - let resp = rx.try_recv().expect("plan permission should be answered by raw ctrl+y"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "plan-approve"); - } - - #[test] - fn ctrl_n_rejects_plan_permission() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "plan-approve", - "Approve", - PermissionOptionKind::PlanApprove, - ), - model::PermissionOption::new( - "plan-reject", - "Reject", - PermissionOptionKind::PlanReject, - ), - ], - true, - ); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), - true, - ); - assert!(consumed); - - let resp = rx.try_recv().expect("plan permission should be answered by ctrl+n"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "plan-reject"); - } - - #[test] - fn plain_y_and_n_are_not_consumed_for_plan_approval() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "plan-approve", - "Approve", - PermissionOptionKind::PlanApprove, - ), - model::PermissionOption::new( - "plan-reject", - "Reject", - PermissionOptionKind::PlanReject, - ), - ], - true, - ); - - let consumed_y = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), - true, - ); - let consumed_n = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - true, - ); - - assert!(!consumed_y); - assert!(!consumed_n); - assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); - assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn ctrl_n_rejects_focused_permission() { - let mut app = App::test_default(); - let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); - - let consumed = handle_permission_key( - &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), - true, - ); - assert!(consumed); - assert!(app.pending_interaction_ids.is_empty()); - - let resp = rx.try_recv().expect("permission should be answered by ctrl+n"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "reject-once"); - } - - #[test] - fn ctrl_n_does_not_trigger_reject_always() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "allow-once", - "Allow once", - PermissionOptionKind::AllowOnce, - ), - model::PermissionOption::new( - "reject-always", - "Reject always", - PermissionOptionKind::RejectAlways, - ), - ], - true, - ); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), - true, - ); - assert!(!consumed); - assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); - assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn ctrl_a_matches_allow_always_by_label_when_kind_is_missing() { - let mut app = App::test_default(); - let mut rx = add_permission( - &mut app, - "perm-1", - vec![ - model::PermissionOption::new( - "allow-once", - "Allow once", - PermissionOptionKind::AllowOnce, - ), - model::PermissionOption::new( - "allow-always", - "Allow always", - PermissionOptionKind::AllowOnce, - ), - model::PermissionOption::new( - "reject-once", - "Reject", - PermissionOptionKind::RejectOnce, - ), - ], - true, - ); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), - true, - ); - assert!(consumed); - - let resp = rx.try_recv().expect("permission should be answered by ctrl+a fallback"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "allow-always"); - } - - #[test] - fn ctrl_a_accepts_uppercase_with_shift_modifier() { - let mut app = App::test_default(); - let mut rx = add_permission(&mut app, "perm-1", allow_options(), true); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Char('A'), KeyModifiers::CONTROL | KeyModifiers::SHIFT), - true, - ); - assert!(consumed); - - let resp = rx.try_recv().expect("permission should be answered by uppercase ctrl+a"); - let model::RequestPermissionOutcome::Selected(sel) = resp.outcome else { - panic!("expected selected permission response"); - }; - assert_eq!(sel.option_id.clone(), "allow-always"); + KeyEvent::new(KeyCode::Char('\u{19}'), KeyModifiers::NONE), + ] { + assert_eq!(app.keymap.action_for_event(KeyContext::InlinePermission, key), None); + } } #[test] - fn left_right_not_consumed_when_permission_not_focused() { + fn permission_actions_are_ignored_when_prompt_is_not_focused() { let mut app = App::test_default(); let mut rx = add_permission(&mut app, "perm-1", allow_options(), false); - let consumed_left = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), - false, - ); - let consumed_right = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), - false, + assert_eq!( + execute_permission_action( + &mut app, + InteractionAction::MoveNext, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ), + KeyOutcome::Ignored ); - - assert!(!consumed_left); - assert!(!consumed_right); - assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - } - - #[test] - fn enter_not_consumed_when_permission_not_focused() { - let mut app = App::test_default(); - let mut rx = add_permission(&mut app, "perm-1", allow_options(), false); - - let consumed = handle_permission_key( - &mut app, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - false, + assert_eq!( + execute_permission_action( + &mut app, + InteractionAction::Confirm, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + KeyOutcome::Ignored ); - assert!(!consumed); assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); } diff --git a/src/app/questions.rs b/src/app/questions.rs index 032b758..e239cf2 100644 --- a/src/app/questions.rs +++ b/src/app/questions.rs @@ -3,10 +3,13 @@ use super::inline_interactions::{ focus_next_inline_interaction, focused_interaction, focused_interaction_dirty_idx, - get_focused_interaction_tc, invalidate_if_changed, pop_next_valid_interaction_id, + focused_interaction_is_active, get_focused_interaction_tc, invalidate_if_changed, + normalize_pending_interaction_queue, pop_next_valid_interaction_id, }; use super::{App, InvalidationLevel, MessageBlock}; use crate::agent::model; +use crate::app::keymap::InteractionAction; +use crate::app::keys::KeyOutcome; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; fn focused_question(app: &App) -> Option<&crate::app::InlineQuestion> { @@ -25,6 +28,144 @@ fn focused_question_option_count(app: &App) -> usize { focused_question(app).map_or(0, |question| question.prompt.options.len()) } +pub(super) fn execute_question_action( + app: &mut App, + action: InteractionAction, + key: KeyEvent, +) -> KeyOutcome { + normalize_pending_interaction_queue(app); + if !has_focused_question(app) || !focused_interaction_is_active(app) { + return KeyOutcome::Ignored; + } + + if focused_question_is_editing_notes(app) { + return execute_question_note_action(app, action, key); + } + + let option_count = focused_question_option_count(app); + match action { + InteractionAction::MovePrevious => { + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_question_option_left(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveNext => { + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_question_option_right(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveStart => { + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_question_option_to_start(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveEnd => { + if option_count == 0 { + return KeyOutcome::Handled(false); + } + move_question_option_to_end(app); + KeyOutcome::Handled(true) + } + InteractionAction::ToggleSelection => { + if option_count == 0 { + return KeyOutcome::Handled(false); + } + toggle_question_selection(app); + KeyOutcome::Handled(true) + } + InteractionAction::ToggleNotes => { + set_question_notes_editing(app, true); + KeyOutcome::Handled(true) + } + InteractionAction::Confirm => { + if option_count == 0 { + return KeyOutcome::Ignored; + } + respond_question(app); + KeyOutcome::Handled(true) + } + InteractionAction::Cancel => { + respond_question_cancel(app); + KeyOutcome::Handled(true) + } + InteractionAction::FocusNext => KeyOutcome::Ignored, + } +} + +fn execute_question_note_action( + app: &mut App, + action: InteractionAction, + key: KeyEvent, +) -> KeyOutcome { + match action { + InteractionAction::MovePrevious => { + if !matches!(key.code, KeyCode::Up) { + move_question_notes_cursor(app, -1); + } + KeyOutcome::Handled(true) + } + InteractionAction::MoveNext => { + if !matches!(key.code, KeyCode::Down) { + move_question_notes_cursor(app, 1); + } + KeyOutcome::Handled(true) + } + InteractionAction::MoveStart => { + move_question_notes_cursor_to_start(app); + KeyOutcome::Handled(true) + } + InteractionAction::MoveEnd => { + move_question_notes_cursor_to_end(app); + KeyOutcome::Handled(true) + } + InteractionAction::ToggleNotes => { + set_question_notes_editing(app, false); + KeyOutcome::Handled(true) + } + InteractionAction::Confirm => { + respond_question(app); + KeyOutcome::Handled(true) + } + InteractionAction::Cancel => { + respond_question_cancel(app); + KeyOutcome::Handled(true) + } + InteractionAction::FocusNext | InteractionAction::ToggleSelection => KeyOutcome::Ignored, + } +} + +pub(super) fn handle_question_note_key(app: &mut App, key: KeyEvent) -> Option { + normalize_pending_interaction_queue(app); + if !has_focused_question(app) + || !focused_interaction_is_active(app) + || !focused_question_is_editing_notes(app) + { + return None; + } + + match key.code { + KeyCode::Backspace => { + delete_question_note_char_before(app); + Some(KeyOutcome::Handled(true)) + } + KeyCode::Delete => { + delete_question_note_char_after(app); + Some(KeyOutcome::Handled(true)) + } + KeyCode::Char(ch) if is_printable_question_note_modifiers(key.modifiers) => { + insert_question_note_char(app, ch); + Some(KeyOutcome::Handled(true)) + } + _ => None, + } +} + fn is_printable_question_note_modifiers(modifiers: KeyModifiers) -> bool { let ctrl_alt = modifiers.contains(KeyModifiers::CONTROL) && modifiers.contains(KeyModifiers::ALT); @@ -355,102 +496,6 @@ fn respond_question_cancel(app: &mut App) { focus_next_inline_interaction(app); } -pub(super) fn handle_question_key( - app: &mut App, - key: KeyEvent, - interaction_has_focus: bool, -) -> Option { - if !has_focused_question(app) || !interaction_has_focus { - return None; - } - let option_count = focused_question_option_count(app); - - if focused_question_is_editing_notes(app) { - return match key.code { - KeyCode::Left => { - move_question_notes_cursor(app, -1); - Some(true) - } - KeyCode::Right => { - move_question_notes_cursor(app, 1); - Some(true) - } - KeyCode::Home => { - move_question_notes_cursor_to_start(app); - Some(true) - } - KeyCode::End => { - move_question_notes_cursor_to_end(app); - Some(true) - } - KeyCode::Backspace => { - delete_question_note_char_before(app); - Some(true) - } - KeyCode::Delete => { - delete_question_note_char_after(app); - Some(true) - } - KeyCode::Tab | KeyCode::BackTab => { - set_question_notes_editing(app, false); - Some(true) - } - KeyCode::Enter => { - respond_question(app); - Some(true) - } - KeyCode::Esc => { - respond_question_cancel(app); - Some(true) - } - KeyCode::Up | KeyCode::Down => Some(true), - KeyCode::Char(ch) if is_printable_question_note_modifiers(key.modifiers) => { - insert_question_note_char(app, ch); - Some(true) - } - _ => None, - }; - } - - match key.code { - KeyCode::Left | KeyCode::Up if option_count > 0 => { - move_question_option_left(app); - Some(true) - } - KeyCode::Right | KeyCode::Down if option_count > 0 => { - move_question_option_right(app); - Some(true) - } - KeyCode::Home if option_count > 0 => { - move_question_option_to_start(app); - Some(true) - } - KeyCode::End if option_count > 0 => { - move_question_option_to_end(app); - Some(true) - } - KeyCode::Char(' ') if option_count > 0 => { - toggle_question_selection(app); - Some(true) - } - KeyCode::Tab | KeyCode::BackTab => { - set_question_notes_editing(app, true); - Some(true) - } - KeyCode::Enter if option_count > 0 => { - respond_question(app); - Some(true) - } - KeyCode::Esc => { - respond_question_cancel(app); - Some(true) - } - KeyCode::Backspace | KeyCode::Delete => Some(true), - KeyCode::Char(_) if is_printable_question_note_modifiers(key.modifiers) => Some(true), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*; @@ -540,13 +585,19 @@ mod tests { true, ); - let consumed_right = - handle_question_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), true); - let consumed_enter = - handle_question_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), true); + let consumed_right = execute_question_action( + &mut app, + InteractionAction::MoveNext, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + let consumed_enter = execute_question_action( + &mut app, + InteractionAction::Confirm, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); - assert_eq!(consumed_right, Some(true)); - assert_eq!(consumed_enter, Some(true)); + assert_eq!(consumed_right, KeyOutcome::Handled(true)); + assert_eq!(consumed_enter, KeyOutcome::Handled(true)); assert!(app.pending_interaction_ids.is_empty()); let resp = rx.try_recv().expect("question should be answered"); @@ -581,42 +632,53 @@ mod tests { ); assert_eq!( - handle_question_key( + execute_question_action( &mut app, + InteractionAction::ToggleSelection, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), - true ), - Some(true) + KeyOutcome::Handled(true) ); assert_eq!( - handle_question_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), true), - Some(true) + execute_question_action( + &mut app, + InteractionAction::MoveNext, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ), + KeyOutcome::Handled(true) ); assert_eq!( - handle_question_key( + execute_question_action( &mut app, + InteractionAction::ToggleSelection, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), - true ), - Some(true) + KeyOutcome::Handled(true) ); assert_eq!( - handle_question_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), true), - Some(true) + execute_question_action( + &mut app, + InteractionAction::ToggleNotes, + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + ), + KeyOutcome::Handled(true) ); for ch in ['n', 'o', 't', 'e'] { assert_eq!( - handle_question_key( + handle_question_note_key( &mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), - true ), - Some(true) + Some(KeyOutcome::Handled(true)) ); } assert_eq!( - handle_question_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), true), - Some(true) + execute_question_action( + &mut app, + InteractionAction::Confirm, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + KeyOutcome::Handled(true) ); let resp = rx.try_recv().expect("question should be answered"); diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index 491d7e7..546ca90 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -49,6 +49,7 @@ use super::focus::{FocusContext, FocusManager, FocusOwner, FocusTarget}; use super::git_context::GitContextState; use super::inline_interactions::{clear_inline_interaction_focus, focus_next_inline_interaction}; use super::input::{InputSnapshot, InputState, parse_paste_placeholder_before_cursor}; +use super::keymap::ResolvedKeymap; use super::mention; use super::plugins::PluginsState; use super::slash; @@ -205,6 +206,8 @@ pub struct App { pub todos: Vec, /// Focus manager for directional/navigation key ownership. pub focus: FocusManager, + /// Resolved keyboard bindings used by chat-surface dispatch. + pub keymap: ResolvedKeymap, /// Commands advertised by the agent via `AvailableCommandsUpdate`. pub available_commands: Vec, /// Plugin inventory and UI state for the Config > Plugins view. @@ -851,6 +854,7 @@ impl App { tool_call_index: HashMap::default(), todos: Vec::new(), focus: FocusManager::default(), + keymap: ResolvedKeymap::defaults(), available_commands: Vec::new(), plugins: PluginsState::default(), available_agents: Vec::new(), diff --git a/src/ui/help.rs b/src/ui/help.rs index d46739e..4bd1f66 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -1,7 +1,11 @@ // Copyright 2025 Simon Peter Rothgang // SPDX-License-Identifier: Apache-2.0 -use crate::app::{App, AppStatus, ConfigHelpSection, FocusOwner}; +use crate::app::keymap::{ + AppAction, KeyAction, KeyCodeSpec, KeyContext, KeySpec, ResolvedHelpBinding, +}; +use crate::app::{App, AppStatus, AutocompleteKind, ConfigHelpSection, FocusOwner}; +use crossterm::event::KeyModifiers; pub(crate) fn help_items(app: &App, section: ConfigHelpSection) -> Vec<(String, String)> { match section { @@ -29,81 +33,98 @@ pub(crate) fn subagent_help_items(app: &App) -> Vec<(String, String)> { fn build_key_help_items(app: &App) -> Vec<(String, String)> { if app.status == AppStatus::Connecting { - return blocked_input_help_items("Unavailable while connecting"); + return blocked_input_help_items(app, "Unavailable while connecting"); } if app.status == AppStatus::CommandPending { - return blocked_input_help_items(&format!( - "Unavailable while command runs ({})", - pending_command_help_label(app) - )); + return blocked_input_help_items( + app, + &format!("Unavailable while command runs ({})", pending_command_help_label(app)), + ); } if app.status == AppStatus::Error { - return blocked_input_help_items("Unavailable after error"); + return blocked_input_help_items(app, "Unavailable after error"); } - let mut items: Vec<(String, String)> = vec![ - ("Ctrl+c".to_owned(), "Quit".to_owned()), - ("Ctrl+q".to_owned(), "Quit".to_owned()), - ("Ctrl+l".to_owned(), "Redraw screen".to_owned()), - ("Shift+Tab".to_owned(), "Cycle mode".to_owned()), - ("Ctrl+Up/Down".to_owned(), "Scroll chat".to_owned()), - ("Mouse wheel".to_owned(), "Scroll chat".to_owned()), - ]; + let context = active_key_help_context(app); + let mut items = keymap_help_rows(app, context); if app.is_compacting { items.push(("Status".to_owned(), "Compacting context".to_owned())); } - let focus_owner = app.focus_owner(); + items.push(("Mouse wheel".to_owned(), "Scroll chat".to_owned())); + if context == KeyContext::ChatInput { + items.push(("Paste".to_owned(), "Insert text".to_owned())); + } - if !app.pending_interaction_ids.is_empty() { - match focus_owner { - FocusOwner::Input => { - items.push(("Tab".to_owned(), "Focus pending prompt".to_owned())); - } - FocusOwner::Permission => { - items.push(("Tab".to_owned(), "Return to draft".to_owned())); + items +} + +#[derive(Clone, Debug)] +struct HelpActionGroup { + action: KeyAction, + keys: Vec, +} + +fn keymap_help_rows(app: &App, context: KeyContext) -> Vec<(String, String)> { + let mut groups: Vec = Vec::new(); + for binding in app.keymap.help_bindings_for_context(context) { + if !should_show_help_binding(app, context, &binding) { + continue; + } + let key = format_help_key_spec(&binding.spec); + if let Some(group) = groups.iter_mut().find(|group| group.action == binding.action) { + if !group.keys.contains(&key) { + group.keys.push(key); } - FocusOwner::Mention => {} + } else { + groups.push(HelpActionGroup { action: binding.action, keys: vec![key] }); } } - if focus_owner != FocusOwner::Mention && focus_owner != FocusOwner::Permission { - items.push(("Enter".to_owned(), "Send message".to_owned())); - items.push(("Shift+Enter".to_owned(), "Insert newline".to_owned())); - items.push(("Up/Down".to_owned(), "Move cursor / scroll chat".to_owned())); - items.push(("Left/Right".to_owned(), "Move cursor".to_owned())); - items.push(("Ctrl+Left/Right".to_owned(), "Word left/right".to_owned())); - items.push(("Home/End".to_owned(), "Line start/end".to_owned())); - items.push(("Backspace".to_owned(), "Delete before".to_owned())); - items.push(("Delete".to_owned(), "Delete after".to_owned())); - items.push(("Ctrl+Backspace/Delete".to_owned(), "Delete word".to_owned())); - items.push(("Ctrl+z/y".to_owned(), "Undo/redo".to_owned())); - items.push(("Paste".to_owned(), "Insert text".to_owned())); - } + groups + .into_iter() + .map(|group| (group.keys.join(", "), help_action_label(app, group.action).to_owned())) + .collect() +} - if matches!(app.status, AppStatus::Thinking | AppStatus::Running) { - items.push(("Esc".to_owned(), "Cancel current turn".to_owned())); - } else { - items.push(("Esc".to_owned(), "No-op (idle)".to_owned())); +fn should_show_help_binding(app: &App, context: KeyContext, binding: &ResolvedHelpBinding) -> bool { + if context == KeyContext::ChatInput + && matches!(binding.action, KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion)) + && app.pending_interaction_ids.is_empty() + { + return false; } + true +} - if !app.pending_interaction_ids.is_empty() && focus_owner == FocusOwner::Permission { - if app.pending_interaction_ids.len() > 1 { - items.push(("Up/Down".to_owned(), "Switch prompt focus".to_owned())); +fn help_action_label(app: &App, action: KeyAction) -> &'static str { + match action { + KeyAction::App(AppAction::CancelTurn) + if matches!(app.status, AppStatus::Thinking | AppStatus::Running) => + { + "Cancel current turn" } - if focused_question_prompt(app) { - items.push(("Left/Right".to_owned(), "Move selection".to_owned())); - items.push(("Tab".to_owned(), "Toggle notes editor".to_owned())); - items.push(("Enter".to_owned(), "Confirm answer".to_owned())); - items.push(("Esc".to_owned(), "Cancel prompt".to_owned())); - } else { - items.push(("Left/Right".to_owned(), "Select option".to_owned())); - items.push(("Enter".to_owned(), "Confirm option".to_owned())); - items.push(("Ctrl+y/a/n".to_owned(), "Quick select".to_owned())); - items.push(("Esc".to_owned(), "Reject".to_owned())); + KeyAction::App(AppAction::CancelTurn) => "Clear pending input state", + KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion) + if !app.pending_interaction_ids.is_empty() => + { + "Focus pending prompt" } + _ => action.label(), } +} - items +fn active_key_help_context(app: &App) -> KeyContext { + match app.focus_owner() { + FocusOwner::Mention => match app.active_autocomplete_kind() { + Some(AutocompleteKind::Mention) => KeyContext::AutocompleteMention, + Some(AutocompleteKind::Slash) => KeyContext::AutocompleteSlash, + Some(AutocompleteKind::Subagent) => KeyContext::AutocompleteSubagent, + None => KeyContext::ChatInput, + }, + FocusOwner::Permission if focused_question_prompt(app) => KeyContext::InlineQuestion, + FocusOwner::Permission => KeyContext::InlinePermission, + FocusOwner::Input => KeyContext::ChatInput, + } } fn focused_question_prompt(app: &App) -> bool { @@ -121,16 +142,53 @@ fn focused_question_prompt(app: &App) -> bool { tc.pending_question.is_some() } -fn blocked_input_help_items(input_line: &str) -> Vec<(String, String)> { - vec![ - ("Ctrl+c".to_owned(), "Quit".to_owned()), - ("Ctrl+q".to_owned(), "Quit".to_owned()), - ("Up/Down".to_owned(), "Scroll chat".to_owned()), - ("Ctrl+Up/Down".to_owned(), "Scroll chat".to_owned()), - ("Mouse wheel".to_owned(), "Scroll chat".to_owned()), - ("Ctrl+l".to_owned(), "Redraw screen".to_owned()), - ("Input keys".to_owned(), input_line.to_owned()), - ] +fn blocked_input_help_items(app: &App, input_line: &str) -> Vec<(String, String)> { + let mut rows = keymap_help_rows(app, KeyContext::ChatBlocked); + rows.push(("Mouse wheel".to_owned(), "Scroll chat".to_owned())); + rows.push(("Input keys".to_owned(), input_line.to_owned())); + rows +} + +fn format_help_key_spec(spec: &KeySpec) -> String { + let mut parts = Vec::new(); + let modifiers = spec.modifiers(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("Ctrl".to_owned()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("Alt".to_owned()); + } + if modifiers.contains(KeyModifiers::SUPER) { + parts.push("Cmd".to_owned()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("Shift".to_owned()); + } + parts.push(format_help_key_code(spec.code())); + parts.join("+") +} + +fn format_help_key_code(code: KeyCodeSpec) -> String { + match code { + KeyCodeSpec::Char(' ') => "Space".to_owned(), + KeyCodeSpec::Char(ch) if ch.is_ascii_alphabetic() => ch.to_ascii_uppercase().to_string(), + KeyCodeSpec::Char(ch) => ch.to_string(), + KeyCodeSpec::Enter => "Enter".to_owned(), + KeyCodeSpec::Esc => "Esc".to_owned(), + KeyCodeSpec::Backspace => "Backspace".to_owned(), + KeyCodeSpec::Delete => "Delete".to_owned(), + KeyCodeSpec::Insert => "Insert".to_owned(), + KeyCodeSpec::Tab => "Tab".to_owned(), + KeyCodeSpec::Left => "Left".to_owned(), + KeyCodeSpec::Right => "Right".to_owned(), + KeyCodeSpec::Up => "Up".to_owned(), + KeyCodeSpec::Down => "Down".to_owned(), + KeyCodeSpec::Home => "Home".to_owned(), + KeyCodeSpec::End => "End".to_owned(), + KeyCodeSpec::PageUp => "PageUp".to_owned(), + KeyCodeSpec::PageDown => "PageDown".to_owned(), + KeyCodeSpec::F(index) => format!("F{index}"), + } } fn pending_command_help_label(app: &App) -> String { @@ -226,3 +284,74 @@ fn build_subagent_help_items(app: &App) -> Vec<(String, String)> { rows.extend(agents); rows } + +#[cfg(test)] +mod tests { + use super::{key_help_items, keymap_help_rows}; + use crate::app::App; + use crate::app::keymap::{ + AppAction, KeyAction, KeyBinding, KeyBindingSource, KeyContext, ResolvedKeymap, + }; + + fn has_row(rows: &[(String, String)], key: &str, action: &str) -> bool { + rows.iter().any(|(left, right)| left == key && right == action) + } + + #[test] + fn chat_input_shortcuts_are_generated_from_keymap() { + let app = App::test_default(); + + let rows = key_help_items(&app); + + assert!(has_row(&rows, "Enter", "Send message")); + assert!(has_row(&rows, "Shift+Enter, Ctrl+Enter", "Insert newline")); + assert!(has_row(&rows, "Home, Ctrl+A", "Move line start")); + assert!(has_row(&rows, "End, Ctrl+E", "Move line end")); + assert!(has_row(&rows, "Ctrl+Y", "Yank")); + assert!(!rows.iter().any(|(left, right)| left == "Ctrl+z/y" || right == "Undo/redo")); + } + + #[test] + fn chat_input_shortcuts_reflect_resolved_keymap_changes() { + let mut app = App::test_default(); + app.keymap = ResolvedKeymap::from_bindings([KeyBinding::new( + KeyContext::ChatInput, + "ctrl-x".parse().expect("parse key"), + KeyAction::App(AppAction::SubmitInput), + KeyBindingSource::Config, + )]) + .expect("build keymap"); + + let rows = key_help_items(&app); + + assert!(has_row(&rows, "Ctrl+X", "Send message")); + assert!(!rows.iter().any(|(left, _)| left == "Enter")); + } + + #[test] + fn permission_shortcuts_are_generated_without_removed_ctrl_approvals() { + let app = App::test_default(); + + let rows = keymap_help_rows(&app, KeyContext::InlinePermission); + + assert!(has_row(&rows, "Left, Up", "Previous option")); + assert!(has_row(&rows, "Right, Down", "Next option")); + assert!(has_row(&rows, "Enter", "Confirm option")); + assert!(has_row(&rows, "Esc", "Cancel prompt")); + assert!(!rows.iter().any(|(left, _)| { + left.contains("Ctrl+A") || left.contains("Ctrl+Y") || left.contains("Ctrl+N") + })); + } + + #[test] + fn question_shortcuts_include_selection_and_notes_bindings() { + let app = App::test_default(); + + let rows = keymap_help_rows(&app, KeyContext::InlineQuestion); + + assert!(has_row(&rows, "Space", "Toggle selection")); + assert!(has_row(&rows, "Tab, Shift+Tab", "Toggle notes")); + assert!(has_row(&rows, "Enter", "Confirm option")); + assert!(has_row(&rows, "Esc", "Cancel prompt")); + } +} diff --git a/src/ui/tool_call/interactions.rs b/src/ui/tool_call/interactions.rs index 0e9e492..ce9b14e 100644 --- a/src/ui/tool_call/interactions.rs +++ b/src/ui/tool_call/interactions.rs @@ -85,17 +85,6 @@ pub(super) fn render_permission_lines( } spans.extend(name_spans); } - - let shortcut = match opt.kind { - PermissionOptionKind::AllowOnce => " (Ctrl+y)", - PermissionOptionKind::AllowSession | PermissionOptionKind::AllowAlways => " (Ctrl+a)", - PermissionOptionKind::RejectOnce => " (Ctrl+n)", - PermissionOptionKind::RejectAlways - | PermissionOptionKind::QuestionChoice - | PermissionOptionKind::PlanApprove - | PermissionOptionKind::PlanReject => "", - }; - spans.push(Span::styled(shortcut, Style::default().fg(theme::DIM))); } lines.push(Line::from(spans)); @@ -210,10 +199,10 @@ fn render_plan_approval_lines(tc: &ToolCallInfo, perm: &InlinePermission) -> Vec // Stacked approve / reject options. for (i, opt) in perm.options.iter().enumerate() { let is_selected = i == perm.selected_index; - let (icon, icon_color, shortcut) = match opt.kind { - PermissionOptionKind::PlanApprove => ("\u{2713}", Color::Green, " [Ctrl+y]"), - PermissionOptionKind::PlanReject => ("\u{2717}", Color::Red, " [Ctrl+n]"), - _ => ("\u{00b7}", Color::Gray, ""), + let (icon, icon_color) = match opt.kind { + PermissionOptionKind::PlanApprove => ("\u{2713}", Color::Green), + PermissionOptionKind::PlanReject => ("\u{2717}", Color::Red), + _ => ("\u{00b7}", Color::Gray), }; let name_style = if is_selected { @@ -233,12 +222,11 @@ fn render_plan_approval_lines(tc: &ToolCallInfo, perm: &InlinePermission) -> Vec } line_spans.push(Span::styled(format!("{icon} "), Style::default().fg(icon_color))); line_spans.push(Span::styled(opt.name.clone(), name_style)); - line_spans.push(Span::styled(shortcut, Style::default().fg(theme::DIM))); lines.push(Line::from(line_spans)); } lines.push(Line::from(Span::styled( - " \u{2191}\u{2193} select enter confirm Ctrl+y approve Ctrl+n/esc reject", + " \u{2191}\u{2193} select enter confirm esc reject", Style::default().fg(theme::DIM), ))); @@ -584,7 +572,7 @@ mod tests { } #[test] - fn plan_approval_hints_use_ctrl_shortcuts_only() { + fn plan_approval_hints_use_selection_and_enter() { let tc = test_tool_call("ExitPlanMode"); let perm = test_permission(PermissionOptionKind::PlanApprove); @@ -596,11 +584,10 @@ mod tests { .collect::>() .join("\n"); - assert!(rendered.contains("[Ctrl+y]")); - assert!(rendered.contains("[Ctrl+n]")); - assert!(rendered.contains("Ctrl+y approve")); - assert!(rendered.contains("Ctrl+n/esc reject")); - assert!(!rendered.contains(" [y]")); - assert!(!rendered.contains(" [n]")); + assert!(rendered.contains("select")); + assert!(rendered.contains("enter confirm")); + assert!(rendered.contains("esc reject")); + assert!(!rendered.contains("Ctrl+y")); + assert!(!rendered.contains("Ctrl+n")); } } diff --git a/src/ui/tool_call/mod.rs b/src/ui/tool_call/mod.rs index 3f14b61..8f51999 100644 --- a/src/ui/tool_call/mod.rs +++ b/src/ui/tool_call/mod.rs @@ -937,6 +937,43 @@ mod tests { assert!(!rendered.iter().any(|line| line.starts_with("+ "))); } + #[test] + fn plan_file_markdown_body_is_not_capped_by_tool_height() { + let mut tc = test_tool_call( + "Write .claude/plans/long.md", + "Write", + model::ToolCallStatus::Completed, + ); + let plan_text = (0..24).map(|idx| format!("- Step {idx}")).collect::>().join("\n"); + tc.content = vec![model::ToolCallContent::Diff(model::Diff::new( + ".claude/plans/long.md", + plan_text, + ))]; + + let body = standard::render_tool_call_body(&tc, 80); + let rendered = rendered_line_texts_trimmed(&body); + + assert!(body.len() > TOOL_BODY_MAX_LINES); + assert!(rendered.iter().any(|line| line.contains("Step 23"))); + assert!(!rendered.iter().any(|line| line.contains("hidden"))); + assert!(!rendered.iter().any(|line| line.contains("omitted"))); + } + + #[test] + fn non_plan_write_diff_body_stays_capped_by_tool_height() { + let mut tc = + test_tool_call("Write notes/long.md", "Write", model::ToolCallStatus::Completed); + let new_text = (0..80).map(|idx| format!("line {idx}")).collect::>().join("\n"); + tc.content = + vec![model::ToolCallContent::Diff(model::Diff::new("notes/long.md", new_text))]; + + let body = standard::render_tool_call_body(&tc, 80); + let rendered = rendered_line_texts_trimmed(&body); + + assert_eq!(body.len(), TOOL_BODY_MAX_LINES); + assert!(rendered.iter().any(|line| line.contains("diff lines omitted"))); + } + #[test] fn internal_error_detection_accepts_xml_payload() { let payload = diff --git a/src/ui/tool_call/standard.rs b/src/ui/tool_call/standard.rs index 4ce02a5..1990a3a 100644 --- a/src/ui/tool_call/standard.rs +++ b/src/ui/tool_call/standard.rs @@ -111,13 +111,23 @@ fn render_standard_body(tc: &ToolCallInfo, width: u16, lines: &mut Vec { + let protected_source_lines = protected_content_source_lines(tc, &content_lines); + cap_tool_content_lines( + content_lines, + content_width, + "source lines hidden", + protected_source_lines, + ) + } + ToolContentHeightPolicy::Unbounded => { + wrap_content_lines_with_source(content_lines, content_width) + .into_iter() + .map(|wrapped| wrapped.row) + .collect() + } + }; if let Some(ref perm) = tc.pending_permission { content_lines.extend(render_permission_lines(tc, perm)); @@ -400,6 +410,38 @@ fn tool_body_uses_summary_only(tc: &ToolCallInfo) -> bool { || matches!(tc.sdk_tool_name.as_str(), "Agent" | "Task" | "WebSearch" | "WebFetch") } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ToolContentHeightPolicy { + Bounded, + Unbounded, +} + +fn tool_content_height_policy(tc: &ToolCallInfo) -> ToolContentHeightPolicy { + if renders_only_plan_file_content(tc) { + ToolContentHeightPolicy::Unbounded + } else { + ToolContentHeightPolicy::Bounded + } +} + +fn renders_only_plan_file_content(tc: &ToolCallInfo) -> bool { + let mut saw_plan_file = false; + + for content in &tc.content { + match content { + model::ToolCallContent::Diff(diff) if is_plan_file_path(&diff.path) => { + saw_plan_file = true; + } + model::ToolCallContent::Terminal(_) => {} + model::ToolCallContent::Diff(_) + | model::ToolCallContent::McpResource(_) + | model::ToolCallContent::Content(_) => return false, + } + } + + saw_plan_file +} + fn is_read_tool(tc: &ToolCallInfo) -> bool { tc.sdk_tool_name.eq_ignore_ascii_case("read") } diff --git a/src/ui/welcome.rs b/src/ui/welcome.rs index c88e829..a85482d 100644 --- a/src/ui/welcome.rs +++ b/src/ui/welcome.rs @@ -93,7 +93,7 @@ fn welcome_value_missing(value: &str) -> bool { pub(crate) fn selected_tip(block: &WelcomeBlock) -> &'static str { let Some(first_tip) = WELCOME_TIPS.first().copied() else { - return "Enter sends, Shift+Enter inserts a newline, and Ctrl+C quits"; + return "Enter sends, Shift+Enter inserts a newline, and Ctrl+C clears or quits"; }; let len_u64 = u64::try_from(WELCOME_TIPS.len()).unwrap_or(1); let idx_u64 = block.tip_seed % len_u64;