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