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:
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,