notedeck

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

commit f3f9cefb00ed60259e36162069262ca470a97e26
parent 707ec6e5736c404dd6cf5d03339caff077b909c5
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 28 Jan 2026 21:05:25 -0800

dave: add auto-steal focus mode with cursor position restore

Add Ctrl+Space toggle for auto-steal focus mode that automatically
switches to sessions needing input. When focus is stolen, the previous
cursor position is saved and restored after all NeedsInput items are
processed, allowing users to return to where they were working.

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

Diffstat:
Mcrates/notedeck_dave/src/focus_queue.rs | 28++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 30+++++++++++++++++++++++++++---
Mcrates/notedeck_dave/src/ui/keybindings.rs | 7+++++++
Mtodos.txt | 12+++++++++---
5 files changed, 181 insertions(+), 6 deletions(-)

diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs @@ -168,6 +168,34 @@ impl FocusQueue { Some(self.cursor? + 1) // 1-indexed } + /// Get the raw cursor index (0-indexed) + pub fn cursor_index(&self) -> Option<usize> { + self.cursor + } + + /// Set the cursor to a specific index, clamping to valid range + pub fn set_cursor(&mut self, index: usize) { + if self.entries.is_empty() { + self.cursor = None; + } else { + self.cursor = Some(index.min(self.entries.len() - 1)); + } + } + + /// Find the first entry with NeedsInput priority and return its index + pub fn first_needs_input_index(&self) -> Option<usize> { + self.entries + .iter() + .position(|e| e.priority == FocusPriority::NeedsInput) + } + + /// Check if there are any NeedsInput items in the queue + pub fn has_needs_input(&self) -> bool { + self.entries + .iter() + .any(|e| e.priority == FocusPriority::NeedsInput) + } + pub fn ui_info(&self) -> Option<(usize, usize, FocusPriority)> { let entry = self.current()?; Some((self.current_position()?, self.len(), entry.priority)) diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -69,6 +69,10 @@ pub struct Dave { interrupt_pending_since: Option<Instant>, /// Focus queue for agents needing attention focus_queue: FocusQueue, + /// Auto-steal focus mode: automatically cycle through focus queue items + auto_steal_focus: bool, + /// The cursor index to return to after processing all NeedsInput items + home_cursor: Option<usize>, } /// Calculate an anonymous user_id from a keypair @@ -154,6 +158,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr show_scene: false, // Default to list view interrupt_pending_since: None, focus_queue: FocusQueue::new(), + auto_steal_focus: false, + home_cursor: None, } } @@ -371,6 +377,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Check if Ctrl is held for showing keybinding hints let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let auto_steal_focus = self.auto_steal_focus; StripBuilder::new(ui) .size(Size::relative(0.25)) // Scene area: 25% @@ -451,6 +458,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .question_answers(&mut session.question_answers) .question_index(&mut session.question_index) .is_compacting(session.is_compacting) + .auto_steal_focus(auto_steal_focus) .ui(app_ctx, ui); if response.action.is_some() { @@ -546,6 +554,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Now we can mutably borrow for chat let interrupt_pending = self.is_interrupt_pending(); + let auto_steal_focus = self.auto_steal_focus; let chat_response = ui .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { if let Some(session) = self.session_manager.get_active_mut() { @@ -566,6 +575,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .question_answers(&mut session.question_answers) .question_index(&mut session.question_index) .is_compacting(session.is_compacting) + .auto_steal_focus(auto_steal_focus) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -620,6 +630,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } else { // Show chat let interrupt_pending = self.is_interrupt_pending(); + let auto_steal_focus = self.auto_steal_focus; if let Some(session) = self.session_manager.get_active_mut() { let is_working = session.status() == crate::agent_status::AgentStatus::Working; let has_pending_permission = !session.pending_permissions.is_empty(); @@ -638,6 +649,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .question_answers(&mut session.question_answers) .question_index(&mut session.question_index) .is_compacting(session.is_compacting) + .auto_steal_focus(auto_steal_focus) .ui(app_ctx, ui) } else { DaveResponse::default() @@ -1071,6 +1083,98 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Toggle auto-steal focus mode + fn toggle_auto_steal(&mut self) { + self.auto_steal_focus = !self.auto_steal_focus; + + if self.auto_steal_focus { + // Enabling: record current cursor position as home + self.home_cursor = self.focus_queue.cursor_index(); + tracing::debug!( + "Auto-steal focus enabled, home cursor: {:?}", + self.home_cursor + ); + } else { + // Disabling: switch back to home cursor position if set + if let Some(home_idx) = self.home_cursor.take() { + self.focus_queue.set_cursor(home_idx); + // Switch to the session at that cursor position + if let Some(entry) = self.focus_queue.current() { + self.session_manager.switch_to(entry.session_id); + if self.show_scene { + self.scene.select(entry.session_id); + if let Some(session) = self.session_manager.get(entry.session_id) { + self.scene.focus_on(session.scene_position); + } + } + } + tracing::debug!("Auto-steal focus disabled, returned to home cursor"); + } + } + + // Request focus on input after toggle + if let Some(session) = self.session_manager.get_active_mut() { + session.focus_requested = true; + } + } + + /// Process auto-steal focus logic: switch to focus queue items as needed + fn process_auto_steal_focus(&mut self) { + if !self.auto_steal_focus { + return; + } + + let has_needs_input = self.focus_queue.has_needs_input(); + + if has_needs_input { + // There are NeedsInput items - check if we need to steal focus + let current_entry = self.focus_queue.current(); + let already_on_needs_input = current_entry + .map(|e| e.priority == focus_queue::FocusPriority::NeedsInput) + .unwrap_or(false); + + if !already_on_needs_input { + // Save current position before stealing (only if we haven't saved yet) + if self.home_cursor.is_none() { + self.home_cursor = self.focus_queue.cursor_index(); + tracing::debug!("Auto-steal: saved home cursor {:?}", self.home_cursor); + } + + // Jump to first NeedsInput item + if let Some(idx) = self.focus_queue.first_needs_input_index() { + self.focus_queue.set_cursor(idx); + if let Some(entry) = self.focus_queue.current() { + self.session_manager.switch_to(entry.session_id); + if self.show_scene { + self.scene.select(entry.session_id); + if let Some(session) = self.session_manager.get(entry.session_id) { + self.scene.focus_on(session.scene_position); + } + } + tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id); + } + } + } + } else if let Some(home_idx) = self.home_cursor.take() { + // No more NeedsInput items - return to saved cursor position + self.focus_queue.set_cursor(home_idx); + if let Some(entry) = self.focus_queue.current() { + self.session_manager.switch_to(entry.session_id); + if self.show_scene { + self.scene.select(entry.session_id); + if let Some(session) = self.session_manager.get(entry.session_id) { + self.scene.focus_on(session.scene_position); + } + } + tracing::debug!( + "Auto-steal: returned to home cursor, session {:?}", + entry.session_id + ); + } + } + // If no NeedsInput and no home_cursor saved, do nothing - allow free navigation + } + /// 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() { @@ -1252,6 +1356,9 @@ impl notedeck::App for Dave { KeyAction::FocusQueueDismiss => { self.focus_queue_dismiss(); } + KeyAction::ToggleAutoSteal => { + self.toggle_auto_steal(); + } } } @@ -1268,6 +1375,9 @@ impl notedeck::App for Dave { let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); self.focus_queue.update_from_statuses(status_iter); + // Process auto-steal focus mode + self.process_auto_steal_focus(); + if let Some(action) = self.ui(ctx, ui).action { match action { DaveAction::ToggleChrome => { diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -38,6 +38,8 @@ pub struct DaveUi<'a> { question_index: Option<&'a mut HashMap<Uuid, usize>>, /// Whether conversation compaction is in progress is_compacting: bool, + /// Whether auto-steal focus mode is active + auto_steal_focus: bool, } /// The response the app generates. The response contains an optional @@ -129,6 +131,7 @@ impl<'a> DaveUi<'a> { question_answers: None, question_index: None, is_compacting: false, + auto_steal_focus: false, } } @@ -177,6 +180,11 @@ impl<'a> DaveUi<'a> { self } + pub fn auto_steal_focus(mut self, auto_steal_focus: bool) -> Self { + self.auto_steal_focus = auto_steal_focus; + self + } + fn chat_margin(&self, ctx: &egui::Context) -> i8 { if self.compact || notedeck::ui::is_narrow(ctx) { 20 @@ -818,16 +826,32 @@ impl<'a> DaveUi<'a> { // Show plan mode indicator with optional keybind hint when Ctrl is held let ctrl_held = ui.input(|i| i.modifiers.ctrl); - let mut badge = + let mut plan_badge = super::badge::StatusBadge::new("PLAN").variant(if self.plan_mode_active { super::badge::BadgeVariant::Info } else { super::badge::BadgeVariant::Default }); if ctrl_held { - badge = badge.keybind("M"); + plan_badge = plan_badge.keybind("M"); + } + plan_badge + .show(ui) + .on_hover_text("Ctrl+M to toggle plan mode"); + + // Show auto-steal focus indicator + let mut auto_badge = + super::badge::StatusBadge::new("AUTO").variant(if self.auto_steal_focus { + super::badge::BadgeVariant::Info + } else { + super::badge::BadgeVariant::Default + }); + if ctrl_held { + auto_badge = auto_badge.keybind("⎵"); } - badge.show(ui).on_hover_text("Ctrl+M to toggle plan mode"); + auto_badge + .show(ui) + .on_hover_text("Ctrl+Space to toggle auto-focus mode"); let r = ui.add( egui::TextEdit::multiline(self.input) diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -35,6 +35,8 @@ pub enum KeyAction { FocusQueuePrev, /// Dismiss current item from focus queue (Ctrl+D) FocusQueueDismiss, + /// Toggle auto-steal focus mode (Ctrl+Space) + ToggleAutoSteal, } /// Check for keybinding actions. @@ -105,6 +107,11 @@ pub fn check_keybindings( return Some(KeyAction::FocusQueueDismiss); } + // Ctrl+Space to toggle auto-steal focus mode + if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::Space)) { + return Some(KeyAction::ToggleAutoSteal); + } + // Delete key to delete active session (only when no text input has focus) if !ctx.wants_keyboard_input() && ctx.input(|i| i.key_pressed(Key::Delete)) { return Some(KeyAction::DeleteActiveSession); diff --git a/todos.txt b/todos.txt @@ -2,11 +2,11 @@ - [x] in crates/notedeck_dave, i want to be able to switch to plan mode like you can in the claude-code cli. I believe there -- [ ] plan: plan a feature for creating a queue of tool requests/questions +- [x] plan: plan a feature for creating a queue of tool requests/questions - [x] plan: need a way to respond to a approve/deny in crates/notedeck_dave but giving a response as well -- [ ] plan: plan a way to run claude commands such as /compact, with the ability to specify arguments as well (/compact <input>) etc +- [x] plan: plan a way to run claude commands such as /compact, with the ability to specify arguments as well (/compact <input>) etc - [x] have the default view be classic instead of scene view. also don't call it classic, call it list @@ -16,7 +16,7 @@ - [x] have the approve/deny message appear near the bottom right of the diff instead of top right -- [ ] ctrl-shift-tab should go in reverse node selection order +- [x] ctrl-shift-tab should go in reverse node selection order - [ ] chat sidebar text should show the user's or AI's last message, not our last message @@ -24,6 +24,8 @@ - [ ] add keybinding to open external editor (like vim) for composing input +- [ ] auto-accept mode: add a toggle that automatically approves agent tool calls without requiring manual confirmation. useful for trusted tasks or batch mode. could be global or per-agent + - [ ] persist conversation across app restarts - [ ] handle ExitPlanMode which simply exits plan mode. claude-code sends this when its done its planning phase @@ -31,3 +33,7 @@ - [x] handle claude-code questions/answers (AskUserQuestion tool - could be a list of questions) - [x] AskUserQuestion: show a small summary view of the question and selected option(s) after answering + +- [ ] auto-accept single line changes: automatically approve Edit tool calls that only modify a single line, reducing confirmation friction for trivial edits + +- [ ] preserve edit view after approval/denial: when approving or denying an edit, keep the diff visible instead of making it disappear. allows reviewing what was changed even after responding