notedeck

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

commit ed4c3747c17d3e0a80ac9829b3ca92b624eca0d5
parent 98b21aa3d484e28216ece8e04747ab934ca1d001
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 27 Jan 2026 13:12:44 -0800

dave: implement focus queue system

Replace auto-focus-stealing with a priority queue. When agents need
attention (NeedsInput, Error, Done), they're added to a queue instead
of immediately stealing focus. Users navigate with keybindings.

New keybindings:
- Ctrl+N: next queue item
- Ctrl+P: previous queue item
- Ctrl+D: dismiss current queue item
- Ctrl+T: new agent (moved from Ctrl+N)
- Ctrl+M: toggle plan mode (moved from Ctrl+P)

Queue prioritizes: NeedsInput (high) > Error (medium) > Done (low)

UI shows queue position/total badge with priority-colored variant.
Keybind hints shown when Ctrl is held.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 13+++++++++----
Acrates/notedeck_dave/src/focus_queue.rs | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_dave/src/ui/badge.rs | 23++++++++---------------
Mcrates/notedeck_dave/src/ui/dave.rs | 49+++++++++++++++++++++++++++++++++++++++++++------
Mcrates/notedeck_dave/src/ui/keybindings.rs | 31++++++++++++++++++++++++++-----
Mcrates/notedeck_dave/src/ui/scene.rs | 35-----------------------------------
Mtodos.txt | 28++++++++++++++++++++++++----
8 files changed, 538 insertions(+), 84 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -6,8 +6,7 @@ use crate::tools::Tool; use crate::Message; use claude_agent_sdk_rs::{ ClaudeAgentOptions, ClaudeClient, ContentBlock, Message as ClaudeMessage, PermissionMode, - PermissionResult, PermissionResultAllow, PermissionResultDeny, ToolUseBlock, - UserContentBlock, + PermissionResult, PermissionResultAllow, PermissionResultDeny, ToolUseBlock, UserContentBlock, }; use dashmap::DashMap; use futures::future::BoxFuture; @@ -396,7 +395,9 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< } } } - _ => {} + other => { + tracing::debug!("Received unhandled message type: {:?}", other); + } } } Some(Err(err)) => { @@ -424,7 +425,11 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< ctx.request_repaint(); } SessionCommand::SetPermissionMode { mode, ctx } => { - tracing::debug!("Session {} setting permission mode to {:?}", session_id, mode); + tracing::debug!( + "Session {} setting permission mode to {:?}", + session_id, + mode + ); if let Err(err) = client.set_permission_mode(mode).await { tracing::error!("Failed to set permission mode: {}", err); } diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs @@ -0,0 +1,333 @@ +use crate::agent_status::AgentStatus; +use crate::session::SessionId; +use std::collections::HashMap; + +/// Priority levels for the focus queue (higher = more urgent) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FocusPriority { + /// Low priority - agent completed work (may need follow-up) + Done = 0, + /// Medium priority - agent encountered an error + Error = 1, + /// High priority - agent needs user input (permission request) + NeedsInput = 2, +} + +impl FocusPriority { + /// Convert from AgentStatus, returns None for non-queue-worthy statuses + pub fn from_status(status: AgentStatus) -> Option<Self> { + match status { + AgentStatus::NeedsInput => Some(FocusPriority::NeedsInput), + AgentStatus::Error => Some(FocusPriority::Error), + AgentStatus::Done => Some(FocusPriority::Done), + AgentStatus::Idle | AgentStatus::Working => None, + } + } + + /// Get the color associated with this priority + pub fn color(&self) -> egui::Color32 { + match self { + FocusPriority::NeedsInput => egui::Color32::from_rgb(255, 200, 0), // Yellow/amber + FocusPriority::Error => egui::Color32::from_rgb(220, 60, 60), // Red + FocusPriority::Done => egui::Color32::from_rgb(70, 130, 220), // Blue + } + } +} + +/// An entry in the focus queue +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QueueEntry { + pub session_id: SessionId, + pub priority: FocusPriority, +} + +/// Priority queue for agents needing attention. +/// Uses separate vectors for each priority level for simpler management. +/// Navigation goes through high priority first, then medium, then low. +pub struct FocusQueue { + /// High priority - needs input (permission requests) + needs_input: Vec<SessionId>, + /// Medium priority - errors + errors: Vec<SessionId>, + /// Low priority - done + done: Vec<SessionId>, + /// Current navigation position as (priority_level, index within that level) + /// priority_level: 0 = needs_input, 1 = errors, 2 = done + cursor: Option<(usize, usize)>, + /// Track previous status to detect transitions + previous_statuses: HashMap<SessionId, AgentStatus>, +} + +impl Default for FocusQueue { + fn default() -> Self { + Self::new() + } +} + +impl FocusQueue { + pub fn new() -> Self { + Self { + needs_input: Vec::new(), + errors: Vec::new(), + done: Vec::new(), + cursor: None, + previous_statuses: HashMap::new(), + } + } + + fn get_vec(&self, priority: FocusPriority) -> &Vec<SessionId> { + match priority { + FocusPriority::NeedsInput => &self.needs_input, + FocusPriority::Error => &self.errors, + FocusPriority::Done => &self.done, + } + } + + fn get_vec_mut(&mut self, priority: FocusPriority) -> &mut Vec<SessionId> { + match priority { + FocusPriority::NeedsInput => &mut self.needs_input, + FocusPriority::Error => &mut self.errors, + FocusPriority::Done => &mut self.done, + } + } + + fn get_vec_by_level(&self, level: usize) -> &Vec<SessionId> { + match level { + 0 => &self.needs_input, + 1 => &self.errors, + _ => &self.done, + } + } + + /// Find which vec contains a session and its index + fn find_session(&self, session_id: SessionId) -> Option<(FocusPriority, usize)> { + if let Some(idx) = self.needs_input.iter().position(|&id| id == session_id) { + return Some((FocusPriority::NeedsInput, idx)); + } + if let Some(idx) = self.errors.iter().position(|&id| id == session_id) { + return Some((FocusPriority::Error, idx)); + } + if let Some(idx) = self.done.iter().position(|&id| id == session_id) { + return Some((FocusPriority::Done, idx)); + } + None + } + + /// Enqueue a session with the given priority. + /// If already in queue at different priority, moves it. + pub fn enqueue(&mut self, session_id: SessionId, priority: FocusPriority) { + // Remove from any existing position first + if let Some((old_priority, _)) = self.find_session(session_id) { + if old_priority == priority { + return; // Already in correct queue + } + self.get_vec_mut(old_priority) + .retain(|&id| id != session_id); + } + + // Add to appropriate queue + self.get_vec_mut(priority).push(session_id); + + // Initialize cursor if this is the first item + if self.cursor.is_none() && self.len() == 1 { + let level = match priority { + FocusPriority::NeedsInput => 0, + FocusPriority::Error => 1, + FocusPriority::Done => 2, + }; + self.cursor = Some((level, 0)); + } + } + + /// Remove a session from the queue + pub fn dequeue(&mut self, session_id: SessionId) { + if let Some((priority, idx)) = self.find_session(session_id) { + let vec = self.get_vec_mut(priority); + vec.remove(idx); + + // Adjust cursor if needed + if let Some((level, cursor_idx)) = self.cursor { + let priority_level = match priority { + FocusPriority::NeedsInput => 0, + FocusPriority::Error => 1, + FocusPriority::Done => 2, + }; + + if self.is_empty() { + self.cursor = None; + } else if level == priority_level { + // Removed from current level + let vec_len = self.get_vec(priority).len(); + if vec_len == 0 { + // Level is now empty, move to next non-empty level + self.cursor = self.find_next_valid_cursor(level, 0); + } else if idx <= cursor_idx { + // Adjust index within level + let new_idx = if idx < cursor_idx { + cursor_idx - 1 + } else { + cursor_idx.min(vec_len - 1) + }; + self.cursor = Some((level, new_idx)); + } + } + } + } + } + + /// Find the next valid cursor position starting from given level + fn find_next_valid_cursor( + &self, + start_level: usize, + _start_idx: usize, + ) -> Option<(usize, usize)> { + // Try levels in order: needs_input (0), errors (1), done (2) + for level in 0..3 { + let check_level = (start_level + level) % 3; + let vec = self.get_vec_by_level(check_level); + if !vec.is_empty() { + return Some((check_level, 0)); + } + } + None + } + + /// Total number of items across all queues + pub fn len(&self) -> usize { + self.needs_input.len() + self.errors.len() + self.done.len() + } + + /// Check if all queues are empty + pub fn is_empty(&self) -> bool { + self.needs_input.is_empty() && self.errors.is_empty() && self.done.is_empty() + } + + /// Navigate to next item in queue (wraps around) + /// Order: all needs_input -> all errors -> all done -> wrap to needs_input + pub fn next(&mut self) -> Option<SessionId> { + if self.is_empty() { + return None; + } + + let (level, idx) = self.cursor.unwrap_or((0, 0)); + + // Try next in current level + let vec_len = self.get_vec_by_level(level).len(); + if idx + 1 < vec_len { + let session_id = self.get_vec_by_level(level)[idx + 1]; + self.cursor = Some((level, idx + 1)); + return Some(session_id); + } + + // Move to next non-empty level + for offset in 1..=3 { + let next_level = (level + offset) % 3; + let next_vec = self.get_vec_by_level(next_level); + if !next_vec.is_empty() { + let session_id = next_vec[0]; + self.cursor = Some((next_level, 0)); + return Some(session_id); + } + } + + // Shouldn't reach here if not empty + None + } + + /// Navigate to previous item in queue (wraps around) + pub fn prev(&mut self) -> Option<SessionId> { + if self.is_empty() { + return None; + } + + let (level, idx) = self.cursor.unwrap_or((0, 0)); + + // Try previous in current level + if idx > 0 { + let session_id = self.get_vec_by_level(level)[idx - 1]; + self.cursor = Some((level, idx - 1)); + return Some(session_id); + } + + // Move to previous non-empty level (going backwards) + for offset in 1..=3 { + let prev_level = (level + 3 - offset) % 3; + let prev_vec = self.get_vec_by_level(prev_level); + if !prev_vec.is_empty() { + let last_idx = prev_vec.len() - 1; + let session_id = prev_vec[last_idx]; + self.cursor = Some((prev_level, last_idx)); + return Some(session_id); + } + } + + None + } + + /// Get the current queue entry without changing position + pub fn current(&self) -> Option<QueueEntry> { + let (level, idx) = self.cursor?; + let vec = self.get_vec_by_level(level); + let session_id = *vec.get(idx)?; + let priority = match level { + 0 => FocusPriority::NeedsInput, + 1 => FocusPriority::Error, + _ => FocusPriority::Done, + }; + Some(QueueEntry { + session_id, + priority, + }) + } + + /// Get current position (1-indexed for display) across all queues + pub fn current_position(&self) -> Option<usize> { + let (level, idx) = self.cursor?; + let mut pos = idx + 1; // 1-indexed + + // Add counts from higher priority levels + for l in 0..level { + pos += self.get_vec_by_level(l).len(); + } + + Some(pos) + } + + /// Get queue info for UI display: (position, total, priority) + /// Returns None if queue is empty + pub fn ui_info(&self) -> Option<(usize, usize, FocusPriority)> { + let entry = self.current()?; + let position = self.current_position()?; + Some((position, self.len(), entry.priority)) + } + + /// Update queue based on status changes. + /// Call this after updating all session statuses. + pub fn update_from_statuses( + &mut self, + sessions: impl Iterator<Item = (SessionId, AgentStatus)>, + ) { + for (session_id, status) in sessions { + let prev_status = self.previous_statuses.get(&session_id).copied(); + + // Detect transition to a queue-worthy state + if prev_status != Some(status) { + if let Some(priority) = FocusPriority::from_status(status) { + // State transitioned to NeedsInput, Error, or Done + self.enqueue(session_id, priority); + } else { + // State transitioned away from queue-worthy (e.g., back to Working) + self.dequeue(session_id); + } + } + + self.previous_statuses.insert(session_id, status); + } + } + + /// Remove tracking for a deleted session + pub fn remove_session(&mut self, session_id: SessionId) { + self.dequeue(session_id); + self.previous_statuses.remove(&session_id); + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -3,6 +3,7 @@ mod avatar; mod backend; mod config; pub mod file_update; +mod focus_queue; pub(crate) mod mesh; mod messages; mod quaternion; @@ -12,10 +13,11 @@ mod ui; mod vec3; use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend}; -use claude_agent_sdk_rs::PermissionMode; use chrono::{Duration, Local}; +use claude_agent_sdk_rs::PermissionMode; use egui_wgpu::RenderState; use enostr::KeypairUnowned; +use focus_queue::FocusQueue; use nostrdb::Transaction; use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse}; use std::collections::HashMap; @@ -63,6 +65,8 @@ pub struct Dave { show_scene: bool, /// Tracks when first Escape was pressed for interrupt confirmation interrupt_pending_since: Option<Instant>, + /// Focus queue for agents needing attention + focus_queue: FocusQueue, } /// Calculate an anonymous user_id from a keypair @@ -147,6 +151,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr scene: AgentScene::new(), show_scene: false, // Default to list view interrupt_pending_since: None, + focus_queue: FocusQueue::new(), } } @@ -363,6 +368,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .show(ui, |ui| { if let Some(selected_id) = self.scene.primary_selection() { let interrupt_pending = self.is_interrupt_pending(); + let queue_info = self.focus_queue.ui_info(); if let Some(session) = self.session_manager.get_mut(selected_id) { // Show title ui.heading(&session.title); @@ -376,7 +382,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr !session.pending_permissions.is_empty(); let plan_mode_active = session.permission_mode == PermissionMode::Plan; - let response = DaveUi::new( + let mut dave_ui = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -387,8 +393,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .ui(app_ctx, ui); + .permission_message_state(session.permission_message_state); + + if let Some((pos, total, priority)) = queue_info { + dave_ui = dave_ui.focus_queue_info(pos, total, priority); + } + + let response = dave_ui.ui(app_ctx, ui); if response.action.is_some() { dave_response = response; @@ -482,13 +493,14 @@ 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 queue_info = self.focus_queue.ui_info(); 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() { let is_working = session.status() == crate::agent_status::AgentStatus::Working; let has_pending_permission = !session.pending_permissions.is_empty(); let plan_mode_active = session.permission_mode == PermissionMode::Plan; - DaveUi::new( + let mut dave_ui = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -498,8 +510,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .ui(app_ctx, ui) + .permission_message_state(session.permission_message_state); + + if let Some((pos, total, priority)) = queue_info { + dave_ui = dave_ui.focus_queue_info(pos, total, priority); + } + + dave_ui.ui(app_ctx, ui) } else { DaveResponse::default() } @@ -553,11 +570,12 @@ 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 queue_info = self.focus_queue.ui_info(); 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(); let plan_mode_active = session.permission_mode == PermissionMode::Plan; - DaveUi::new( + let mut dave_ui = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -567,8 +585,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .ui(app_ctx, ui) + .permission_message_state(session.permission_message_state); + + if let Some((pos, total, priority)) = queue_info { + dave_ui = dave_ui.focus_queue_info(pos, total, priority); + } + + dave_ui.ui(app_ctx, ui) } else { DaveResponse::default() } @@ -590,6 +613,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { + // Remove from focus queue first + self.focus_queue.remove_session(id); if self.session_manager.delete_session(id) { // Clean up backend resources (e.g., close persistent connections) let session_id = format!("dave-session-{}", id); @@ -811,6 +836,51 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Navigate to the next item in the focus queue + fn focus_queue_next(&mut self) { + if let Some(session_id) = self.focus_queue.next() { + self.session_manager.switch_to(session_id); + if self.show_scene { + self.scene.select(session_id); + if let Some(session) = self.session_manager.get(session_id) { + self.scene.focus_on(session.scene_position); + } + } + // Focus input if no permission request is pending + if let Some(session) = self.session_manager.get_mut(session_id) { + if session.pending_permissions.is_empty() { + session.focus_requested = true; + } + } + } + } + + /// Navigate to the previous item in the focus queue + fn focus_queue_prev(&mut self) { + if let Some(session_id) = self.focus_queue.prev() { + self.session_manager.switch_to(session_id); + if self.show_scene { + self.scene.select(session_id); + if let Some(session) = self.session_manager.get(session_id) { + self.scene.focus_on(session.scene_position); + } + } + // Focus input if no permission request is pending + if let Some(session) = self.session_manager.get_mut(session_id) { + if session.pending_permissions.is_empty() { + session.focus_requested = true; + } + } + } + } + + /// Dismiss the current item from the focus queue + fn focus_queue_dismiss(&mut self) { + if let Some(entry) = self.focus_queue.current() { + self.focus_queue.dequeue(entry.session_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() { @@ -874,7 +944,9 @@ impl notedeck::App for Dave { .get_active() .map(|s| s.permission_message_state != crate::session::PermissionMessageState::None) .unwrap_or(false); - if let Some(key_action) = check_keybindings(ui.ctx(), has_pending_permission, in_tentative_state) { + if let Some(key_action) = + check_keybindings(ui.ctx(), has_pending_permission, in_tentative_state) + { match key_action { KeyAction::AcceptPermission => { if let Some(request_id) = self.first_pending_permission() { @@ -955,6 +1027,15 @@ impl notedeck::App for Dave { self.delete_session(id); } } + KeyAction::FocusQueueNext => { + self.focus_queue_next(); + } + KeyAction::FocusQueuePrev => { + self.focus_queue_prev(); + } + KeyAction::FocusQueueDismiss => { + self.focus_queue_dismiss(); + } } } @@ -967,10 +1048,9 @@ impl notedeck::App for Dave { // Update all session statuses after processing events self.session_manager.update_all_statuses(); - // Check for agents needing attention and auto-switch to them - if let Some(attention_id) = self.scene.check_attention(&self.session_manager) { - self.session_manager.switch_to(attention_id); - } + // Update focus queue based on status changes (replaces auto-focus-stealing) + let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); + self.focus_queue.update_from_statuses(status_iter); if let Some(action) = self.ui(ctx, ui).action { match action { diff --git a/crates/notedeck_dave/src/ui/badge.rs b/crates/notedeck_dave/src/ui/badge.rs @@ -127,11 +127,9 @@ impl<'a> StatusBadge<'a> { // Calculate text size for proper allocation let font_id = egui::FontId::proportional(11.0); - let galley = ui.painter().layout_no_wrap( - self.text.to_string(), - font_id.clone(), - text_color, - ); + let galley = + ui.painter() + .layout_no_wrap(self.text.to_string(), font_id.clone(), text_color); // Calculate keybind box size if present let keybind_box_size = 14.0; @@ -164,8 +162,7 @@ impl<'a> StatusBadge<'a> { } else { 0.0 }; - let text_pos = - rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; + let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; painter.galley(text_pos, galley, text_color); // Draw keybind box if present @@ -234,11 +231,9 @@ impl<'a> ActionButton<'a> { pub fn show(self, ui: &mut Ui) -> Response { // Calculate text size for proper allocation let font_id = egui::FontId::proportional(13.0); - let galley = ui.painter().layout_no_wrap( - self.text.to_string(), - font_id.clone(), - self.text_color, - ); + let galley = + ui.painter() + .layout_no_wrap(self.text.to_string(), font_id.clone(), self.text_color); // Calculate keybind box size if present let keybind_box_size = 16.0; @@ -280,8 +275,7 @@ impl<'a> ActionButton<'a> { } else { 0.0 }; - let text_pos = - rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; + let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; painter.galley(text_pos, galley, self.text_color); // Draw keybind hint on left side (white border, no fill) @@ -315,4 +309,3 @@ impl<'a> ActionButton<'a> { response } } - diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -2,6 +2,7 @@ use super::diff; use crate::{ config::DaveSettings, file_update::FileUpdate, + focus_queue::FocusPriority, messages::{ Message, PermissionRequest, PermissionResponse, PermissionResponseType, ToolResult, }, @@ -29,6 +30,8 @@ pub struct DaveUi<'a> { plan_mode_active: bool, /// State for tentative permission response (waiting for message) permission_message_state: PermissionMessageState, + /// Focus queue info: (current_position, total, priority) + focus_queue_info: Option<(usize, usize, FocusPriority)>, } /// The response the app generates. The response contains an optional @@ -112,6 +115,7 @@ impl<'a> DaveUi<'a> { focus_requested, plan_mode_active: false, permission_message_state: PermissionMessageState::None, + focus_queue_info: None, } } @@ -145,6 +149,16 @@ impl<'a> DaveUi<'a> { self } + pub fn focus_queue_info( + mut self, + position: usize, + total: usize, + priority: FocusPriority, + ) -> Self { + self.focus_queue_info = Some((position, total, priority)); + self + } + fn chat_margin(&self, ctx: &egui::Context) -> i8 { if self.compact || notedeck::ui::is_narrow(ctx) { 20 @@ -502,7 +516,11 @@ impl<'a> DaveUi<'a> { } else { ui.visuals().weak_text_color() }; - ui.label(egui::RichText::new("(⇧ for message)").color(hint_color).small()); + ui.label( + egui::RichText::new("(⇧ for message)") + .color(hint_color) + .small(), + ); } } }); @@ -682,16 +700,34 @@ 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 = super::badge::StatusBadge::new("PLAN") - .variant(if self.plan_mode_active { + let mut 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("P"); + badge = badge.keybind("M"); + } + badge.show(ui).on_hover_text("Ctrl+M to toggle plan mode"); + + // Show focus queue indicator if there are items in the queue + if let Some((position, total, priority)) = self.focus_queue_info { + let variant = match priority { + FocusPriority::NeedsInput => super::badge::BadgeVariant::Warning, + FocusPriority::Error => super::badge::BadgeVariant::Destructive, + FocusPriority::Done => super::badge::BadgeVariant::Info, + }; + let queue_text = format!("{}/{}", position, total); + let mut queue_badge = + super::badge::StatusBadge::new(&queue_text).variant(variant); + if ctrl_held { + queue_badge = queue_badge.keybind("N/P/D"); + } + queue_badge + .show(ui) + .on_hover_text("Focus Queue: Ctrl+N next, Ctrl+P prev, Ctrl+D dismiss"); } - badge.show(ui).on_hover_text("Ctrl+P to toggle plan mode"); let r = ui.add( egui::TextEdit::multiline(self.input) @@ -723,7 +759,8 @@ impl<'a> DaveUi<'a> { // Unfocus text input when there's a pending permission request // UNLESS we're in tentative state (user needs to type message) - let in_tentative_state = self.permission_message_state != PermissionMessageState::None; + let in_tentative_state = + self.permission_message_state != PermissionMessageState::None; if self.has_pending_permission && !in_tentative_state { r.surrender_focus(); } diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -19,16 +19,22 @@ pub enum KeyAction { NextAgent, /// Cycle to previous agent PreviousAgent, - /// Spawn a new agent + /// Spawn a new agent (Ctrl+T) NewAgent, /// Interrupt/stop the current AI operation Interrupt, /// Toggle between scene view and classic view ToggleView, - /// Toggle plan mode for the active session + /// Toggle plan mode for the active session (Ctrl+M) TogglePlanMode, /// Delete the active session DeleteActiveSession, + /// Navigate to next item in focus queue (Ctrl+N) + FocusQueueNext, + /// Navigate to previous item in focus queue (Ctrl+P) + FocusQueuePrev, + /// Dismiss current item from focus queue (Ctrl+D) + FocusQueueDismiss, } /// Check for keybinding actions. @@ -66,8 +72,18 @@ pub fn check_keybindings( return Some(action); } - // Ctrl+N to spawn a new agent (works even with text input focus) + // Ctrl+N for focus queue next if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::N)) { + return Some(KeyAction::FocusQueueNext); + } + + // Ctrl+P for focus queue previous + if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::P)) { + return Some(KeyAction::FocusQueuePrev); + } + + // Ctrl+T to spawn a new agent + if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::T)) { return Some(KeyAction::NewAgent); } @@ -76,11 +92,16 @@ pub fn check_keybindings( return Some(KeyAction::ToggleView); } - // Ctrl+P to toggle plan mode - if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::P)) { + // Ctrl+M to toggle plan mode + if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::M)) { return Some(KeyAction::TogglePlanMode); } + // Ctrl+D to dismiss current item from focus queue + if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::D)) { + return Some(KeyAction::FocusQueueDismiss); + } + // 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/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -15,8 +15,6 @@ pub struct AgentScene { camera_target: Option<Vec2>, /// Animation progress (0.0 to 1.0) animation_progress: f32, - /// Sessions that have already been alerted (to avoid re-jumping) - alerted_sessions: Vec<SessionId>, } /// State for box/marquee selection @@ -66,7 +64,6 @@ impl AgentScene { drag_select: None, camera_target: None, animation_progress: 1.0, - alerted_sessions: Vec::new(), } } @@ -104,38 +101,6 @@ impl AgentScene { self.animation_progress = 0.0; } - /// Check for agents needing attention and jump to them if not already alerted. - /// Returns the ID of the agent that was jumped to, if any. - pub fn check_attention(&mut self, session_manager: &SessionManager) -> Option<SessionId> { - // Clean up alerted list - remove sessions that no longer need input - self.alerted_sessions.retain(|id| { - session_manager - .get(*id) - .map(|s| s.status() == AgentStatus::NeedsInput) - .unwrap_or(false) - }); - - // Find first session needing attention that we haven't alerted yet - for session in session_manager.iter() { - if session.status() == AgentStatus::NeedsInput - && !self.alerted_sessions.contains(&session.id) - { - // Mark as alerted - self.alerted_sessions.push(session.id); - - // Focus camera on this agent - self.focus_on(session.scene_position); - - // Select this agent - self.select(session.id); - - return Some(session.id); - } - } - - None - } - /// Render the scene pub fn ui( &mut self, diff --git a/todos.txt b/todos.txt @@ -1,11 +1,31 @@ - [ ] in crates/notedeck_dave, when an agent steals focus from another agent to ask a question, i want it to focus back to where it was -- [ ] 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 +- [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 -- [ ] plan: need a way to respond to a approve/deny in crates/notedeck_dave but giving a response as well +- [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 +- [ ] plan: plan a way to run claude commands such as /compact, with the ability to specify arguments as well (/compact <input>) etc -- [ ] have the default view be classic instead of scene view. also don't call it classic, call it list +- [x] have the default view be classic instead of scene view. also don't call it classic, call it list + +- [ ] make crates/notedeck_dave/src/lib.rs smaller + +- [x] update [1] Yes and [2] No options to Allow/Deny, use key indicator widget instead of [1] text + +- [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 + +- [ ] chat sidebar text should show the user's or AI's last message, not our last message + +- [ ] add a modeswitch (ctrl-something) for "auto-steal focus". when enabled, AI requests instantly steal focus. when disabled, requests are queued instead + +- [ ] add keybinding to open external editor (like vim) for composing input + +- [ ] persist conversation across app restarts + +- [ ] handle ExitPlanMode which simply exits plan mode. claude-code sends this when its done its planning phase + +- [ ] handle claude-code questions/answers (AskUserQuestion tool - could be a list of questions)