notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

keybindings.rs (8722B)


      1 use crate::config::AiMode;
      2 use egui::Key;
      3 
      4 /// Keybinding actions that can be triggered globally
      5 #[derive(Debug, Clone, PartialEq)]
      6 pub enum KeyAction {
      7     /// Accept/Allow a pending permission request
      8     AcceptPermission,
      9     /// Deny a pending permission request
     10     DenyPermission,
     11     /// Tentatively accept, waiting for message (Shift+1)
     12     TentativeAccept,
     13     /// Tentatively deny, waiting for message (Shift+2)
     14     TentativeDeny,
     15     /// Allow always — add to session allowlist and accept (3)
     16     AllowAlways,
     17     /// Tentatively allow always, waiting for message (Shift+3)
     18     TentativeAllowAlways,
     19     /// Cancel tentative state (Escape when tentative)
     20     CancelTentative,
     21     /// Switch to agent by number (0-indexed)
     22     SwitchToAgent(usize),
     23     /// Cycle to next agent
     24     NextAgent,
     25     /// Cycle to previous agent
     26     PreviousAgent,
     27     /// Spawn a new agent (Ctrl+T)
     28     NewAgent,
     29     /// Interrupt/stop the current AI operation
     30     Interrupt,
     31     /// Toggle between scene view and classic view
     32     ToggleView,
     33     /// Cycle permission mode: Default → Plan → AcceptEdits (Ctrl+M)
     34     CyclePermissionMode,
     35     /// Delete the active session
     36     DeleteActiveSession,
     37     /// Navigate to next item in focus queue (Ctrl+N)
     38     FocusQueueNext,
     39     /// Navigate to previous item in focus queue (Ctrl+P)
     40     FocusQueuePrev,
     41     /// Toggle Done status for current focus queue item (Ctrl+D)
     42     FocusQueueToggleDone,
     43     /// Toggle auto-steal focus mode (Ctrl+\)
     44     ToggleAutoSteal,
     45     /// Open external editor for composing input (Ctrl+G)
     46     OpenExternalEditor,
     47     /// Clone the active agent with the same working directory (Ctrl+Shift+T)
     48     CloneAgent,
     49 }
     50 
     51 /// Check for keybinding actions.
     52 /// Most keybindings use Ctrl modifier to avoid conflicts with text input.
     53 /// Exception: 1/2 for permission responses work without Ctrl but only when no text input has focus.
     54 /// In Chat mode, agentic-specific keybindings (scene view, plan mode, focus queue) are disabled.
     55 pub fn check_keybindings(
     56     ctx: &egui::Context,
     57     has_pending_permission: bool,
     58     has_pending_question: bool,
     59     in_tentative_state: bool,
     60     ai_mode: AiMode,
     61 ) -> Option<KeyAction> {
     62     let is_agentic = ai_mode == AiMode::Agentic;
     63 
     64     // Escape in tentative state cancels the tentative mode (agentic only)
     65     if is_agentic
     66         && in_tentative_state
     67         && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape))
     68     {
     69         return Some(KeyAction::CancelTentative);
     70     }
     71 
     72     // Escape otherwise works to interrupt AI (even when text input has focus)
     73     if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
     74         return Some(KeyAction::Interrupt);
     75     }
     76 
     77     let ctrl = egui::Modifiers::CTRL;
     78     let ctrl_shift = egui::Modifiers::CTRL | egui::Modifiers::SHIFT;
     79 
     80     // Ctrl+Tab / Ctrl+Shift+Tab for cycling through agents/chats
     81     // Works even with text input focus since Ctrl modifier makes it unambiguous
     82     // IMPORTANT: Check Ctrl+Shift+Tab first because consume_key uses matches_logically
     83     // which ignores extra Shift, so Ctrl+Tab would consume Ctrl+Shift+Tab otherwise
     84     if let Some(action) = ctx.input_mut(|i| {
     85         if i.consume_key(ctrl_shift, Key::Tab) {
     86             Some(KeyAction::PreviousAgent)
     87         } else if i.consume_key(ctrl, Key::Tab) {
     88             Some(KeyAction::NextAgent)
     89         } else {
     90             None
     91         }
     92     }) {
     93         return Some(action);
     94     }
     95 
     96     // Focus queue navigation - agentic only
     97     if is_agentic {
     98         // Ctrl+N for higher priority (toward NeedsInput)
     99         if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::N)) {
    100             return Some(KeyAction::FocusQueueNext);
    101         }
    102 
    103         // Ctrl+P for lower priority (toward Done)
    104         if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::P)) {
    105             return Some(KeyAction::FocusQueuePrev);
    106         }
    107     }
    108 
    109     // Ctrl+Shift+T to clone the active agent (check before Ctrl+T) - agentic only
    110     if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl_shift) && i.key_pressed(Key::T)) {
    111         return Some(KeyAction::CloneAgent);
    112     }
    113 
    114     // Ctrl+T to spawn a new agent/chat
    115     if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::T)) {
    116         return Some(KeyAction::NewAgent);
    117     }
    118 
    119     // Ctrl+L to toggle between scene view and list view - agentic only
    120     if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::L)) {
    121         return Some(KeyAction::ToggleView);
    122     }
    123 
    124     // Ctrl+G to open external editor for composing input
    125     if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::G)) {
    126         return Some(KeyAction::OpenExternalEditor);
    127     }
    128 
    129     // Ctrl+M to cycle permission mode - agentic only
    130     if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::M)) {
    131         return Some(KeyAction::CyclePermissionMode);
    132     }
    133 
    134     // Ctrl+D to toggle Done status for current focus queue item - agentic only
    135     if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::D)) {
    136         return Some(KeyAction::FocusQueueToggleDone);
    137     }
    138 
    139     // Ctrl+\ to toggle auto-steal focus mode (Ctrl+Space conflicts with macOS input source switching) - agentic only
    140     if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::Backslash))
    141     {
    142         return Some(KeyAction::ToggleAutoSteal);
    143     }
    144 
    145     // Delete key to delete active session (only when no text input has focus)
    146     if !ctx.wants_keyboard_input() && ctx.input(|i| i.key_pressed(Key::Delete)) {
    147         return Some(KeyAction::DeleteActiveSession);
    148     }
    149 
    150     // Ctrl+1-9 for switching agents/chats (works even with text input focus)
    151     // Check this BEFORE permission bindings so Ctrl+number always switches agents
    152     if let Some(action) = ctx.input(|i| {
    153         if !i.modifiers.matches_exact(ctrl) {
    154             return None;
    155         }
    156 
    157         for (idx, key) in [
    158             Key::Num1,
    159             Key::Num2,
    160             Key::Num3,
    161             Key::Num4,
    162             Key::Num5,
    163             Key::Num6,
    164             Key::Num7,
    165             Key::Num8,
    166             Key::Num9,
    167         ]
    168         .iter()
    169         .enumerate()
    170         {
    171             if i.key_pressed(*key) {
    172                 return Some(KeyAction::SwitchToAgent(idx));
    173             }
    174         }
    175 
    176         None
    177     }) {
    178         return Some(action);
    179     }
    180 
    181     // Permission keybindings - agentic only
    182     // When there's a pending permission (but NOT an AskUserQuestion):
    183     // - 1 = accept, 2 = deny (no modifiers)
    184     // - Shift+1 = tentative accept, Shift+2 = tentative deny (for adding message)
    185     // This is checked AFTER Ctrl+number so Ctrl bindings take precedence
    186     // IMPORTANT: Only handle these when no text input has focus, to avoid
    187     // capturing keypresses when user is typing a message in tentative state
    188     // AskUserQuestion uses number keys for option selection, so we skip these bindings
    189     if is_agentic && has_pending_permission && !has_pending_question && !ctx.wants_keyboard_input()
    190     {
    191         // Shift+1 = tentative accept, Shift+2 = tentative deny
    192         // Note: egui may report shifted keys as their symbol (e.g., Shift+1 as Exclamationmark)
    193         // We check for both the symbol key and Shift+Num key to handle different behaviors
    194         if let Some(action) = ctx.input_mut(|i| {
    195             // Shift+1: check for '!' (Exclamationmark) which egui reports on some systems
    196             if i.key_pressed(Key::Exclamationmark) {
    197                 return Some(KeyAction::TentativeAccept);
    198             }
    199             // Shift+2: check with shift modifier (egui may report Num2 with shift held)
    200             if i.modifiers.shift && i.key_pressed(Key::Num2) {
    201                 return Some(KeyAction::TentativeDeny);
    202             }
    203             // Shift+3: tentative allow always
    204             if i.modifiers.shift && i.key_pressed(Key::Num3) {
    205                 return Some(KeyAction::TentativeAllowAlways);
    206             }
    207             None
    208         }) {
    209             return Some(action);
    210         }
    211 
    212         // Bare keypresses (no modifiers) for immediate accept/deny/always
    213         if let Some(action) = ctx.input(|i| {
    214             if !i.modifiers.any() {
    215                 if i.key_pressed(Key::Num1) {
    216                     return Some(KeyAction::AcceptPermission);
    217                 } else if i.key_pressed(Key::Num2) {
    218                     return Some(KeyAction::DenyPermission);
    219                 } else if i.key_pressed(Key::Num3) {
    220                     return Some(KeyAction::AllowAlways);
    221                 }
    222             }
    223             None
    224         }) {
    225             return Some(action);
    226         }
    227     }
    228 
    229     None
    230 }