notedeck

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

commit 07ef281f75a0a5dad30a091f310b602b2d92e9a9
parent b2ceec2d49bb2c1330ef0c8336682b762d3be30d
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 26 Jan 2026 17:38:09 -0800

dave: add keyboard shortcuts for permission requests and agent navigation

Keybindings (active when not typing):
- Y/A: Accept permission request
- D: Deny permission request
- 1-9: Switch to agent by position
- Tab/Shift+Tab: Cycle through agents
- N: New agent (scene view)

Includes visual hints on buttons and session list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/notedeck_dave/src/ui/dave.rs | 12+++++++-----
Acrates/notedeck_dave/src/ui/keybindings.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Mcrates/notedeck_dave/src/ui/session_list.rs | 45+++++++++++++++++++++++++++++++++++++++------
5 files changed, 257 insertions(+), 48 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -33,8 +33,8 @@ pub use tools::{ ToolResponses, }; pub use ui::{ - AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SceneAction, SceneResponse, - SessionListAction, SessionListUi, SettingsPanelAction, + check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, KeyAction, + SceneAction, SceneResponse, SessionListAction, SessionListUi, SettingsPanelAction, }; pub use vec3::Vec3; @@ -326,11 +326,19 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr strip.cell(|ui| { // Scene toolbar at top ui.horizontal(|ui| { - if ui.button("+ New Agent").clicked() { + if ui + .button("+ New Agent [N]") + .on_hover_text("Press N to spawn new agent") + .clicked() + { dave_response = DaveResponse::new(DaveAction::NewChat); } ui.separator(); - if ui.button("Classic View").clicked() { + if ui + .button("Classic View") + .on_hover_text("Tab/Shift+Tab to cycle agents") + .clicked() + { self.show_scene = false; } }); @@ -535,6 +543,100 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Get the first pending permission request ID for the active session + fn first_pending_permission(&self) -> Option<uuid::Uuid> { + self.session_manager + .get_active() + .and_then(|session| session.pending_permissions.keys().next().copied()) + } + + /// Handle a permission response (from UI button or keybinding) + fn handle_permission_response(&mut self, request_id: uuid::Uuid, response: PermissionResponse) { + if let Some(session) = self.session_manager.get_active_mut() { + // Record the response type in the message for UI display + let response_type = match &response { + PermissionResponse::Allow => messages::PermissionResponseType::Allowed, + PermissionResponse::Deny { .. } => messages::PermissionResponseType::Denied, + }; + + for msg in &mut session.chat { + if let Message::PermissionRequest(req) = msg { + if req.id == request_id { + req.response = Some(response_type); + break; + } + } + } + + if let Some(sender) = session.pending_permissions.remove(&request_id) { + if sender.send(response).is_err() { + tracing::error!( + "Failed to send permission response for request {}", + request_id + ); + } + } else { + tracing::warn!("No pending permission found for request {}", request_id); + } + } + } + + /// Switch to agent by index in the ordered list (0-indexed) + fn switch_to_agent_by_index(&mut self, index: usize) { + let ids = self.session_manager.session_ids(); + if let Some(&id) = ids.get(index) { + self.session_manager.switch_to(id); + // Also update scene selection if in scene view + if self.show_scene { + self.scene.select(id); + } + } + } + + /// Cycle to the next agent + fn cycle_next_agent(&mut self) { + let ids = self.session_manager.session_ids(); + if ids.is_empty() { + return; + } + let current_idx = self + .session_manager + .active_id() + .and_then(|active| ids.iter().position(|&id| id == active)) + .unwrap_or(0); + let next_idx = (current_idx + 1) % ids.len(); + if let Some(&id) = ids.get(next_idx) { + self.session_manager.switch_to(id); + if self.show_scene { + self.scene.select(id); + } + } + } + + /// Cycle to the previous agent + fn cycle_prev_agent(&mut self) { + let ids = self.session_manager.session_ids(); + if ids.is_empty() { + return; + } + let current_idx = self + .session_manager + .active_id() + .and_then(|active| ids.iter().position(|&id| id == active)) + .unwrap_or(0); + let prev_idx = if current_idx == 0 { + ids.len() - 1 + } else { + current_idx - 1 + }; + if let Some(&id) = ids.get(prev_idx) { + self.session_manager.switch_to(id); + if self.show_scene { + self.scene.select(id); + } + } + } + /// Handle a user send action triggered by the ui fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) { if let Some(session) = self.session_manager.get_active_mut() { @@ -591,6 +693,39 @@ impl notedeck::App for Dave { } } + // Handle global keybindings (when no text input has focus) + if let Some(key_action) = check_keybindings(ui.ctx()) { + match key_action { + KeyAction::AcceptPermission => { + if let Some(request_id) = self.first_pending_permission() { + self.handle_permission_response(request_id, PermissionResponse::Allow); + } + } + KeyAction::DenyPermission => { + if let Some(request_id) = self.first_pending_permission() { + self.handle_permission_response( + request_id, + PermissionResponse::Deny { + reason: "User denied via keyboard".into(), + }, + ); + } + } + KeyAction::SwitchToAgent(index) => { + self.switch_to_agent_by_index(index); + } + KeyAction::NextAgent => { + self.cycle_next_agent(); + } + KeyAction::PreviousAgent => { + self.cycle_prev_agent(); + } + KeyAction::NewAgent => { + self.handle_new_chat(); + } + } + } + //update_dave(self, ctx, ui.ctx()); let should_send = self.process_events(ctx); if let Some(action) = self.ui(ctx, ui).action { @@ -620,39 +755,7 @@ impl notedeck::App for Dave { request_id, response, } => { - // Send the permission response back to the callback - if let Some(session) = self.session_manager.get_active_mut() { - // Record the response type in the message for UI display - let response_type = match &response { - PermissionResponse::Allow => messages::PermissionResponseType::Allowed, - PermissionResponse::Deny { .. } => { - messages::PermissionResponseType::Denied - } - }; - - for msg in &mut session.chat { - if let Message::PermissionRequest(req) = msg { - if req.id == request_id { - req.response = Some(response_type); - break; - } - } - } - - if let Some(sender) = session.pending_permissions.remove(&request_id) { - if sender.send(response).is_err() { - tracing::error!( - "Failed to send permission response for request {}", - request_id - ); - } - } else { - tracing::warn!( - "No pending permission found for request {}", - request_id - ); - } - } + self.handle_permission_response(request_id, response); } DaveAction::Interrupt => { self.handle_interrupt(ui); diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -368,22 +368,23 @@ impl<'a> DaveUi<'a> { action } - /// Render Allow/Deny buttons aligned to the right + /// Render Allow/Deny buttons aligned to the right with keybinding hints fn permission_buttons( request: &PermissionRequest, ui: &mut egui::Ui, action: &mut Option<DaveAction>, ) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Deny button (red) + // Deny button (red) with [D] hint if ui .add( egui::Button::new( - egui::RichText::new("Deny") + egui::RichText::new("Deny [D]") .color(ui.visuals().widgets.active.fg_stroke.color), ) .fill(egui::Color32::from_rgb(178, 34, 34)), ) + .on_hover_text("Press D to deny") .clicked() { *action = Some(DaveAction::PermissionResponse { @@ -394,15 +395,16 @@ impl<'a> DaveUi<'a> { }); } - // Allow button (green) + // Allow button (green) with [Y] hint if ui .add( egui::Button::new( - egui::RichText::new("Allow") + egui::RichText::new("Allow [Y]") .color(ui.visuals().widgets.active.fg_stroke.color), ) .fill(egui::Color32::from_rgb(34, 139, 34)), ) + .on_hover_text("Press Y or A to allow") .clicked() { *action = Some(DaveAction::PermissionResponse { diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -0,0 +1,69 @@ +use egui::Key; + +/// Keybinding actions that can be triggered globally +#[derive(Debug, Clone, PartialEq)] +pub enum KeyAction { + /// Accept/Allow a pending permission request + AcceptPermission, + /// Deny a pending permission request + DenyPermission, + /// Switch to agent by number (0-indexed) + SwitchToAgent(usize), + /// Cycle to next agent + NextAgent, + /// Cycle to previous agent + PreviousAgent, + /// Spawn a new agent + NewAgent, +} + +/// Check for keybinding actions when no text input has focus +pub fn check_keybindings(ctx: &egui::Context) -> Option<KeyAction> { + // Only process when no text input has focus + if ctx.wants_keyboard_input() { + return None; + } + + ctx.input(|i| { + // Permission response keys: Y/A for accept, N/D for deny + if i.key_pressed(Key::Y) || i.key_pressed(Key::A) { + return Some(KeyAction::AcceptPermission); + } + // Note: N is already used for new agent in scene view, so we use D for deny + // or N only when there's a pending permission + if i.key_pressed(Key::D) { + return Some(KeyAction::DenyPermission); + } + + // Number keys 1-9 for switching agents + for (idx, key) in [ + Key::Num1, + Key::Num2, + Key::Num3, + Key::Num4, + Key::Num5, + Key::Num6, + Key::Num7, + Key::Num8, + Key::Num9, + ] + .iter() + .enumerate() + { + if i.key_pressed(*key) { + return Some(KeyAction::SwitchToAgent(idx)); + } + } + + // Tab / Shift+Tab for cycling through agents + if i.key_pressed(Key::Tab) { + if i.modifiers.shift { + return Some(KeyAction::PreviousAgent); + } else { + return Some(KeyAction::NextAgent); + } + } + + None + }) +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -1,10 +1,12 @@ mod dave; pub mod diff; +pub mod keybindings; pub mod scene; pub mod session_list; mod settings; pub use dave::{DaveAction, DaveResponse, DaveUi}; +pub use keybindings::{check_keybindings, KeyAction}; pub use scene::{AgentScene, SceneAction, SceneResponse}; pub use session_list::{SessionListAction, SessionListUi}; pub use settings::{DaveSettingsPanel, SettingsPanelAction}; diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -73,10 +73,16 @@ impl<'a> SessionListUi<'a> { let mut action = None; let active_id = self.session_manager.active_id(); - for session in self.session_manager.sessions_ordered() { + for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() { let is_active = Some(session.id) == active_id; + // Show keyboard shortcut hint for first 9 sessions (1-9 keys) + let shortcut_hint = if index < 9 { + Some(index + 1) + } else { + None + }; - let response = self.session_item_ui(ui, &session.title, is_active); + let response = self.session_item_ui(ui, &session.title, is_active, shortcut_hint); if response.clicked() { action = Some(SessionListAction::SwitchTo(session.id)); @@ -94,10 +100,21 @@ impl<'a> SessionListUi<'a> { action } - fn session_item_ui(&self, ui: &mut egui::Ui, title: &str, is_active: bool) -> egui::Response { + fn session_item_ui( + &self, + ui: &mut egui::Ui, + title: &str, + is_active: bool, + shortcut_hint: Option<usize>, + ) -> egui::Response { let desired_size = egui::vec2(ui.available_width(), 36.0); let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); - let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); + let hover_text = shortcut_hint + .map(|n| format!("Press {} to switch", n)) + .unwrap_or_default(); + let response = response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text_at_pointer(hover_text); // Paint background: active > hovered > transparent let fill = if is_active { @@ -111,8 +128,24 @@ impl<'a> SessionListUi<'a> { let corner_radius = 8.0; ui.painter().rect_filled(rect, corner_radius, fill); - // Draw title text (left-aligned, vertically centered) - let text_pos = rect.left_center() + egui::vec2(8.0, 0.0); + // Draw shortcut hint on the left if available + let text_start_x = if let Some(num) = shortcut_hint { + let hint_text = format!("{}", num); + let hint_pos = rect.left_center() + egui::vec2(12.0, 0.0); + ui.painter().text( + hint_pos, + egui::Align2::LEFT_CENTER, + &hint_text, + egui::FontId::monospace(12.0), + ui.visuals().text_color().gamma_multiply(0.5), + ); + 32.0 + } else { + 8.0 + }; + + // Draw title text + let text_pos = rect.left_center() + egui::vec2(text_start_x, 0.0); ui.painter().text( text_pos, egui::Align2::LEFT_CENTER,