notedeck

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

commit d747fccd100af21e227d03bb4b7f228f368a907e
parent c16e6cc51fa32b866bd747aba65a67883242b914
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  9 Feb 2026 12:17:59 -0800

dave: extract standalone UI functions into ui module

Move UI rendering and action handling from lib.rs methods into
standalone functions in ui/mod.rs with explicit inputs. This reduces
lib.rs from ~1463 to ~922 lines and improves testability.

New standalone functions:
- Overlay rendering: settings_overlay_ui, directory_picker_overlay_ui,
  session_picker_overlay_ui
- View rendering: scene_ui, desktop_ui, narrow_ui
- Action handling: handle_key_action, handle_send_action, handle_ui_action

New return types: OverlayResult, SceneViewAction, KeyActionResult,
SendActionResult, UiActionResult

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

Diffstat:
Mcrates/notedeck_dave/src/backend/openai.rs | 9++++-----
Mcrates/notedeck_dave/src/file_update.rs | 5++++-
Mcrates/notedeck_dave/src/lib.rs | 886++++++++++++++++---------------------------------------------------------------
Mcrates/notedeck_dave/src/ui/directory_picker.rs | 5+++--
Mcrates/notedeck_dave/src/ui/mod.rs | 699+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/scene.rs | 111++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/notedeck_dave/src/ui/session_picker.rs | 4+++-
Mcrates/notedeck_dave/src/update.rs | 6+++---
8 files changed, 945 insertions(+), 780 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs @@ -154,13 +154,12 @@ impl AiBackend for OpenAiBackend { }; } - if !parsed_tool_calls.is_empty() { - if tx + if !parsed_tool_calls.is_empty() + && tx .send(DaveApiResponse::ToolCalls(parsed_tool_calls)) .is_ok() - { - ctx.request_repaint(); - } + { + ctx.request_repaint(); } tracing::debug!("stream closed"); diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs @@ -83,7 +83,10 @@ impl FileUpdate { let file_path = obj.get("file_path")?.as_str()?.to_string(); let content = obj.get("content")?.as_str()?.to_string(); - Some(FileUpdate::new(file_path, FileUpdateType::Write { content })) + Some(FileUpdate::new( + file_path, + FileUpdateType::Write { content }, + )) } _ => None, } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -44,8 +44,9 @@ pub use tools::{ }; pub use ui::{ check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, - DirectoryPicker, DirectoryPickerAction, KeyAction, SceneAction, SceneResponse, - SessionListAction, SessionListUi, SessionPicker, SessionPickerAction, SettingsPanelAction, + DirectoryPicker, DirectoryPickerAction, KeyAction, KeyActionResult, OverlayResult, SceneAction, + SceneResponse, SceneViewAction, SendActionResult, SessionListAction, SessionListUi, + SessionPicker, SessionPickerAction, SettingsPanelAction, UiActionResult, }; pub use vec3::Vec3; @@ -421,260 +422,109 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { // Check overlays first - they take over the entire UI match self.active_overlay { - DaveOverlay::Settings => return self.settings_overlay_ui(app_ctx, ui), - DaveOverlay::DirectoryPicker => return self.directory_picker_overlay_ui(app_ctx, ui), - DaveOverlay::SessionPicker => return self.session_picker_overlay_ui(app_ctx, ui), - DaveOverlay::None => {} - } - - // Normal routing - if is_narrow(ui.ctx()) { - self.narrow_ui(app_ctx, ui) - } else if self.show_scene { - self.scene_ui(app_ctx, ui) - } else { - self.desktop_ui(app_ctx, ui) - } - } - - /// Full-screen settings overlay - fn settings_overlay_ui( - &mut self, - _app_ctx: &mut AppContext, - ui: &mut egui::Ui, - ) -> DaveResponse { - if let Some(action) = self.settings_panel.overlay_ui(ui, &self.settings) { - match action { - SettingsPanelAction::Save(new_settings) => { - self.apply_settings(new_settings.clone()); - self.active_overlay = DaveOverlay::None; - return DaveResponse::new(DaveAction::UpdateSettings(new_settings)); - } - SettingsPanelAction::Cancel => { - self.active_overlay = DaveOverlay::None; + DaveOverlay::Settings => { + match ui::settings_overlay_ui(&mut self.settings_panel, &self.settings, ui) { + OverlayResult::ApplySettings(new_settings) => { + self.apply_settings(new_settings.clone()); + self.active_overlay = DaveOverlay::None; + return DaveResponse::new(DaveAction::UpdateSettings(new_settings)); + } + OverlayResult::Close => { + self.active_overlay = DaveOverlay::None; + } + _ => {} } + return DaveResponse::default(); } - } - DaveResponse::default() - } - - /// Full-screen directory picker overlay - fn directory_picker_overlay_ui( - &mut self, - _app_ctx: &mut AppContext, - ui: &mut egui::Ui, - ) -> DaveResponse { - let has_sessions = !self.session_manager.is_empty(); - if let Some(action) = self.directory_picker.overlay_ui(ui, has_sessions) { - match action { - DirectoryPickerAction::DirectorySelected(path) => { - // Check if there are resumable sessions for this directory - let resumable_sessions = discover_sessions(&path); - if resumable_sessions.is_empty() { - // No previous sessions, create new directly + DaveOverlay::DirectoryPicker => { + let has_sessions = !self.session_manager.is_empty(); + match ui::directory_picker_overlay_ui(&mut self.directory_picker, has_sessions, ui) + { + OverlayResult::DirectorySelected(path) => { self.create_session_with_cwd(path); self.active_overlay = DaveOverlay::None; - } else { - // Show session picker to let user choose + } + OverlayResult::ShowSessionPicker(path) => { self.session_picker.open(path); self.active_overlay = DaveOverlay::SessionPicker; } - } - DirectoryPickerAction::Cancelled => { - // Only close if there are existing sessions to fall back to - if has_sessions { + OverlayResult::Close => { self.active_overlay = DaveOverlay::None; } + _ => {} } - DirectoryPickerAction::BrowseRequested => { - // Handled internally by the picker + return DaveResponse::default(); + } + DaveOverlay::SessionPicker => { + match ui::session_picker_overlay_ui(&mut self.session_picker, ui) { + OverlayResult::ResumeSession { + cwd, + session_id, + title, + } => { + self.create_resumed_session_with_cwd(cwd, session_id, title); + self.session_picker.close(); + self.active_overlay = DaveOverlay::None; + } + OverlayResult::NewSession { cwd } => { + self.create_session_with_cwd(cwd); + self.session_picker.close(); + self.active_overlay = DaveOverlay::None; + } + OverlayResult::BackToDirectoryPicker => { + self.session_picker.close(); + self.active_overlay = DaveOverlay::DirectoryPicker; + } + _ => {} } + return DaveResponse::default(); } + DaveOverlay::None => {} } - DaveResponse::default() - } - /// Full-screen session picker overlay (for resuming Claude sessions) - fn session_picker_overlay_ui( - &mut self, - _app_ctx: &mut AppContext, - ui: &mut egui::Ui, - ) -> DaveResponse { - if let Some(action) = self.session_picker.overlay_ui(ui) { - match action { - SessionPickerAction::ResumeSession { - cwd, - session_id, - title, - } => { - // Create a session that resumes the existing Claude conversation - self.create_resumed_session_with_cwd(cwd, session_id, title); - self.session_picker.close(); - self.active_overlay = DaveOverlay::None; - } - SessionPickerAction::NewSession { cwd } => { - // User chose to start fresh - self.create_session_with_cwd(cwd); - self.session_picker.close(); - self.active_overlay = DaveOverlay::None; - } - SessionPickerAction::BackToDirectoryPicker => { - // Go back to directory picker - self.session_picker.close(); - self.active_overlay = DaveOverlay::DirectoryPicker; - } - } + // Normal routing + if is_narrow(ui.ctx()) { + self.narrow_ui(app_ctx, ui) + } else if self.show_scene { + self.scene_ui(app_ctx, ui) + } else { + self.desktop_ui(app_ctx, ui) } - DaveResponse::default() } /// Scene view with RTS-style agent visualization and chat side panel fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { - use egui_extras::{Size, StripBuilder}; - - let mut dave_response = DaveResponse::default(); - let mut scene_response: Option<SceneResponse> = None; - - // 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% - .size(Size::remainder()) // Chat panel: 75% - .clip(true) // Clip content to cell bounds - .horizontal(|mut strip| { - // Scene area (main) - strip.cell(|ui| { - // Scene toolbar at top - ui.horizontal(|ui| { - if ui - .button("+ New Agent") - .on_hover_text("Hold Ctrl to see keybindings") - .clicked() - { - dave_response = DaveResponse::new(DaveAction::NewChat); - } - // Show keybinding hint only when Ctrl is held - if ctrl_held { - ui::keybind_hint(ui, "N"); - } - ui.separator(); - if ui - .button("List View") - .on_hover_text("Ctrl+L to toggle views") - .clicked() - { - self.show_scene = false; - } - if ctrl_held { - ui::keybind_hint(ui, "L"); - } - }); - ui.separator(); - - // Render the scene, passing ctrl_held for keybinding hints - scene_response = Some(self.scene.ui( - &self.session_manager, - &self.focus_queue, - ui, - ctrl_held, - )); - }); - - // Chat side panel - strip.cell(|ui| { - egui::Frame::new() - .fill(ui.visuals().faint_bg_color) - .inner_margin(egui::Margin::symmetric(8, 12)) - .show(ui, |ui| { - if let Some(selected_id) = self.scene.primary_selection() { - let interrupt_pending = self.is_interrupt_pending(); - if let Some(session) = self.session_manager.get_mut(selected_id) { - // Show title - ui.heading(&session.title); - ui.separator(); - - let is_working = session.status() - == crate::agent_status::AgentStatus::Working; - - // Render chat UI for selected session - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let mut ui_builder = DaveUi::new( - self.model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, - ) - .compact(true) - .is_working(is_working) - .interrupt_pending(interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - // Add agentic-specific UI state if available - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state( - agentic.permission_message_state, - ) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); - } - - let response = ui_builder.ui(app_ctx, ui); + let is_interrupt_pending = self.is_interrupt_pending(); + let (dave_response, view_action) = ui::scene_ui( + &mut self.session_manager, + &mut self.scene, + &self.focus_queue, + &self.model_config, + is_interrupt_pending, + self.auto_steal_focus, + app_ctx, + ui, + ); - if response.action.is_some() { - dave_response = response; - } - } - } else { - // No selection - ui.centered_and_justified(|ui| { - ui.label("Select an agent to view chat"); - }); - } - }); - }); - }); - - // Handle scene actions after strip rendering - if let Some(response) = scene_response { - if let Some(action) = response.action { - match action { - SceneAction::SelectionChanged(ids) => { - // Selection updated, sync with session manager's active - if let Some(id) = ids.first() { - self.session_manager.switch_to(*id); - } - } - SceneAction::SpawnAgent => { - dave_response = DaveResponse::new(DaveAction::NewChat); - } - SceneAction::DeleteSelected => { - for id in self.scene.selected.clone() { - self.delete_session(id); - } - // Focus another node after deletion - if let Some(session) = self.session_manager.sessions_ordered().first() { - self.scene.select(session.id); - } else { - self.scene.clear_selection(); - } - } - SceneAction::AgentMoved { id, position } => { - if let Some(session) = self.session_manager.get_mut(id) { - if let Some(agentic) = &mut session.agentic { - agentic.scene_position = position; - } - } - } + // Handle view actions + match view_action { + SceneViewAction::ToggleToListView => { + self.show_scene = false; + } + SceneViewAction::SpawnAgent => { + return DaveResponse::new(DaveAction::NewChat); + } + SceneViewAction::DeleteSelected(ids) => { + for id in ids { + self.delete_session(id); + } + if let Some(session) = self.session_manager.sessions_ordered().first() { + self.scene.select(session.id); + } else { + self.scene.clear_selection(); } } + SceneViewAction::None => {} } dave_response @@ -682,90 +532,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Desktop layout with sidebar for session list fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { - let available = ui.available_rect_before_wrap(); - let sidebar_width = 280.0; - let ctrl_held = ui.input(|i| i.modifiers.ctrl); - - let sidebar_rect = - egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height())); - let chat_rect = egui::Rect::from_min_size( - egui::pos2(available.min.x + sidebar_width, available.min.y), - egui::vec2(available.width() - sidebar_width, available.height()), + let is_interrupt_pending = self.is_interrupt_pending(); + let (chat_response, session_action, toggle_scene) = ui::desktop_ui( + &mut self.session_manager, + &self.focus_queue, + &self.model_config, + is_interrupt_pending, + self.auto_steal_focus, + self.ai_mode, + app_ctx, + ui, ); - // Render sidebar first - borrow released after this - let session_action = ui - .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| { - egui::Frame::new() - .fill(ui.visuals().faint_bg_color) - .inner_margin(egui::Margin::symmetric(8, 12)) - .show(ui, |ui| { - // Add scene view toggle button - only in Agentic mode - if self.ai_mode == AiMode::Agentic { - ui.horizontal(|ui| { - if ui - .button("Scene View") - .on_hover_text("Ctrl+L to toggle views") - .clicked() - { - self.show_scene = true; - } - if ctrl_held { - ui::keybind_hint(ui, "L"); - } - }); - ui.separator(); - } - SessionListUi::new( - &self.session_manager, - &self.focus_queue, - ctrl_held, - self.ai_mode, - ) - .ui(ui) - }) - .inner - }) - .inner; - - // 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() { - let is_working = session.status() == crate::agent_status::AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let mut ui_builder = DaveUi::new( - self.model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, - ) - .is_working(is_working) - .interrupt_pending(interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); - } - - ui_builder.ui(app_ctx, ui) - } else { - DaveResponse::default() - } - }) - .inner; + if toggle_scene { + self.show_scene = true; + } - // Handle actions after rendering if let Some(action) = session_action { match action { SessionListAction::NewSession => return DaveResponse::new(DaveAction::NewChat), @@ -783,72 +565,36 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Narrow/mobile layout - shows either session list or chat fn narrow_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { - if self.show_session_list { - // Show session list - let ctrl_held = ui.input(|i| i.modifiers.ctrl); - let session_action = egui::Frame::new() - .fill(ui.visuals().faint_bg_color) - .inner_margin(egui::Margin::symmetric(8, 12)) - .show(ui, |ui| { - SessionListUi::new( - &self.session_manager, - &self.focus_queue, - ctrl_held, - self.ai_mode, - ) - .ui(ui) - }) - .inner; - if let Some(action) = session_action { - match action { - SessionListAction::NewSession => { - self.handle_new_chat(); - self.show_session_list = false; - } - SessionListAction::SwitchTo(id) => { - self.session_manager.switch_to(id); - self.show_session_list = false; - } - SessionListAction::Delete(id) => { - self.delete_session(id); - } + let is_interrupt_pending = self.is_interrupt_pending(); + let (dave_response, session_action) = ui::narrow_ui( + &mut self.session_manager, + &self.focus_queue, + &self.model_config, + is_interrupt_pending, + self.auto_steal_focus, + self.ai_mode, + self.show_session_list, + app_ctx, + ui, + ); + + if let Some(action) = session_action { + match action { + SessionListAction::NewSession => { + self.handle_new_chat(); + self.show_session_list = false; } - } - DaveResponse::default() - } 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.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let mut ui_builder = DaveUi::new( - self.model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, - ) - .is_working(is_working) - .interrupt_pending(interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); + SessionListAction::SwitchTo(id) => { + self.session_manager.switch_to(id); + self.show_session_list = false; + } + SessionListAction::Delete(id) => { + self.delete_session(id); } - - ui_builder.ui(app_ctx, ui) - } else { - DaveResponse::default() } } + + dave_response } fn handle_new_chat(&mut self) { @@ -962,7 +708,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Check if interrupt confirmation has timed out and clear it fn check_interrupt_timeout(&mut self) { - self.interrupt_pending_since = update::check_interrupt_timeout(self.interrupt_pending_since); + self.interrupt_pending_since = + update::check_interrupt_timeout(self.interrupt_pending_since); } /// Returns true if an interrupt is pending confirmation @@ -970,21 +717,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.interrupt_pending_since.is_some() } - /// Handle an interrupt action - stop the current AI operation - fn handle_interrupt(&mut self, ctx: &egui::Context) { - update::execute_interrupt(&mut self.session_manager, self.backend.as_ref(), ctx); - } - - /// Toggle plan mode for the active session - fn toggle_plan_mode(&mut self, ctx: &egui::Context) { - update::toggle_plan_mode(&mut self.session_manager, self.backend.as_ref(), ctx); - } - - /// Exit plan mode for the active session (switch to Default mode) - fn exit_plan_mode(&mut self, ctx: &egui::Context) { - update::exit_plan_mode(&mut self.session_manager, self.backend.as_ref(), ctx); - } - /// Get the first pending permission request ID for the active session fn first_pending_permission(&self) -> Option<uuid::Uuid> { update::first_pending_permission(&self.session_manager) @@ -995,350 +727,71 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr update::has_pending_question(&self.session_manager) } - /// Check if the first pending permission is an ExitPlanMode tool call - fn has_pending_exit_plan_mode(&self) -> bool { - update::has_pending_exit_plan_mode(&self.session_manager) - } - - /// Handle a permission response (from UI button or keybinding) - fn handle_permission_response(&mut self, request_id: uuid::Uuid, response: PermissionResponse) { - update::handle_permission_response(&mut self.session_manager, request_id, response); - } - - /// Handle a user's response to an AskUserQuestion tool call - fn handle_question_response(&mut self, request_id: uuid::Uuid, answers: Vec<QuestionAnswer>) { - update::handle_question_response(&mut self.session_manager, request_id, answers); - } - - /// Switch to agent by index in the ordered list (0-indexed) - fn switch_to_agent_by_index(&mut self, index: usize) { - update::switch_to_agent_by_index( - &mut self.session_manager, - &mut self.scene, - self.show_scene, - index, - ); - } - - /// Cycle to the next agent - fn cycle_next_agent(&mut self) { - update::cycle_next_agent(&mut self.session_manager, &mut self.scene, self.show_scene); - } - - /// Cycle to the previous agent - fn cycle_prev_agent(&mut self) { - update::cycle_prev_agent(&mut self.session_manager, &mut self.scene, self.show_scene); - } - - /// Navigate to the next item in the focus queue - fn focus_queue_next(&mut self) { - update::focus_queue_next( - &mut self.session_manager, - &mut self.focus_queue, - &mut self.scene, - self.show_scene, - ); - } - - /// Navigate to the previous item in the focus queue - fn focus_queue_prev(&mut self) { - update::focus_queue_prev( - &mut self.session_manager, - &mut self.focus_queue, - &mut self.scene, - self.show_scene, - ); - } - - /// Toggle Done status for the current focus queue item. - fn focus_queue_toggle_done(&mut self) { - update::focus_queue_toggle_done(&mut self.focus_queue); - } - - /// Toggle auto-steal focus mode - fn toggle_auto_steal(&mut self) { - self.auto_steal_focus = update::toggle_auto_steal( + /// Handle a keybinding action + fn handle_key_action(&mut self, key_action: KeyAction, ui: &egui::Ui) { + match ui::handle_key_action( + key_action, &mut self.session_manager, &mut self.scene, - self.show_scene, - self.auto_steal_focus, - &mut self.home_session, - ); - } - - /// Open an external editor for composing the input text (non-blocking) - fn open_external_editor(&mut self) { - update::open_external_editor(&mut self.session_manager); - } - - /// Poll for external editor completion (called each frame) - fn poll_editor_job(&mut self) { - update::poll_editor_job(&mut self.session_manager); - } - - /// Process auto-steal focus logic: switch to focus queue items as needed - fn process_auto_steal_focus(&mut self) { - update::process_auto_steal_focus( - &mut self.session_manager, &mut self.focus_queue, - &mut self.scene, + self.backend.as_ref(), self.show_scene, self.auto_steal_focus, &mut self.home_session, - ); - } - - /// Handle a keybinding action - fn handle_key_action(&mut self, key_action: KeyAction, ui: &egui::Ui) { - match key_action { - KeyAction::AcceptPermission => { - if let Some(request_id) = self.first_pending_permission() { - self.handle_permission_response( - request_id, - PermissionResponse::Allow { message: None }, - ); - // Restore input focus after permission response - if let Some(session) = self.session_manager.get_active_mut() { - session.focus_requested = true; - } - } - } - KeyAction::DenyPermission => { - if let Some(request_id) = self.first_pending_permission() { - self.handle_permission_response( - request_id, - PermissionResponse::Deny { - reason: "User denied".into(), - }, - ); - // Restore input focus after permission response - if let Some(session) = self.session_manager.get_active_mut() { - session.focus_requested = true; - } - } - } - KeyAction::TentativeAccept => { - // Enter tentative accept mode - user will type message, then Enter to send - if let Some(session) = self.session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = - crate::session::PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } - } - KeyAction::TentativeDeny => { - // Enter tentative deny mode - user will type message, then Enter to send - if let Some(session) = self.session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = - crate::session::PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } - } - KeyAction::CancelTentative => { - // Cancel tentative mode - if let Some(session) = self.session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = - crate::session::PermissionMessageState::None; - } - } - } - 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(); - } - KeyAction::CloneAgent => { - self.clone_active_agent(); - } - KeyAction::Interrupt => { - self.handle_interrupt_request(ui.ctx()); - } - KeyAction::ToggleView => { + &mut self.active_overlay, + ui.ctx(), + ) { + KeyActionResult::ToggleView => { self.show_scene = !self.show_scene; } - KeyAction::TogglePlanMode => { - self.toggle_plan_mode(ui.ctx()); - // Restore input focus after toggling plan mode - if let Some(session) = self.session_manager.get_active_mut() { - session.focus_requested = true; - } - } - KeyAction::DeleteActiveSession => { - if let Some(id) = self.session_manager.active_id() { - self.delete_session(id); - } - } - KeyAction::FocusQueueNext => { - self.focus_queue_next(); - } - KeyAction::FocusQueuePrev => { - self.focus_queue_prev(); + KeyActionResult::HandleInterrupt => { + self.handle_interrupt_request(ui.ctx()); } - KeyAction::FocusQueueToggleDone => { - self.focus_queue_toggle_done(); + KeyActionResult::CloneAgent => { + self.clone_active_agent(); } - KeyAction::ToggleAutoSteal => { - self.toggle_auto_steal(); + KeyActionResult::DeleteSession(id) => { + self.delete_session(id); } - KeyAction::OpenExternalEditor => { - self.open_external_editor(); + KeyActionResult::SetAutoSteal(new_state) => { + self.auto_steal_focus = new_state; } + KeyActionResult::None => {} } } /// Handle the Send action, including tentative permission states fn handle_send_action(&mut self, ctx: &AppContext, ui: &egui::Ui) { - // Check if we're in tentative state - if so, send permission response with message - let tentative_state = self - .session_manager - .get_active() - .and_then(|s| s.agentic.as_ref()) - .map(|a| a.permission_message_state) - .unwrap_or(crate::session::PermissionMessageState::None); - - match tentative_state { - crate::session::PermissionMessageState::TentativeAccept => { - // Send permission Allow with the message from input - // If this is ExitPlanMode, also exit plan mode - let is_exit_plan_mode = self.has_pending_exit_plan_mode(); - if let Some(request_id) = self.first_pending_permission() { - let message = self - .session_manager - .get_active() - .map(|s| s.input.clone()) - .filter(|m| !m.is_empty()); - // Clear input - if let Some(session) = self.session_manager.get_active_mut() { - session.input.clear(); - } - if is_exit_plan_mode { - self.exit_plan_mode(ui.ctx()); - } - self.handle_permission_response( - request_id, - PermissionResponse::Allow { message }, - ); - } - } - crate::session::PermissionMessageState::TentativeDeny => { - // Send permission Deny with the message from input - if let Some(request_id) = self.first_pending_permission() { - let reason = self - .session_manager - .get_active() - .map(|s| s.input.clone()) - .filter(|m| !m.is_empty()) - .unwrap_or_else(|| "User denied".into()); - // Clear input - if let Some(session) = self.session_manager.get_active_mut() { - session.input.clear(); - } - self.handle_permission_response( - request_id, - PermissionResponse::Deny { reason }, - ); - } - } - crate::session::PermissionMessageState::None => { - // Normal send behavior + match ui::handle_send_action(&mut self.session_manager, self.backend.as_ref(), ui.ctx()) { + SendActionResult::SendMessage => { self.handle_user_send(ctx, ui); } + SendActionResult::Handled => {} } } /// Handle a UI action from DaveUi - fn handle_ui_action(&mut self, action: DaveAction, ctx: &AppContext, ui: &egui::Ui) -> Option<AppAction> { - match action { - DaveAction::ToggleChrome => { - return Some(AppAction::ToggleChrome); - } - DaveAction::Note(n) => { - return Some(AppAction::Note(n)); - } - DaveAction::NewChat => { - self.handle_new_chat(); - } - DaveAction::Send => { + fn handle_ui_action( + &mut self, + action: DaveAction, + ctx: &AppContext, + ui: &egui::Ui, + ) -> Option<AppAction> { + match ui::handle_ui_action( + action, + &mut self.session_manager, + self.backend.as_ref(), + &mut self.active_overlay, + &mut self.show_session_list, + ui.ctx(), + ) { + UiActionResult::AppAction(app_action) => Some(app_action), + UiActionResult::SendAction => { self.handle_send_action(ctx, ui); + None } - DaveAction::ShowSessionList => { - self.show_session_list = !self.show_session_list; - } - DaveAction::OpenSettings => { - self.active_overlay = DaveOverlay::Settings; - } - DaveAction::UpdateSettings(_settings) => { - // Parent app can poll settings() after update - } - DaveAction::PermissionResponse { - request_id, - response, - } => { - self.handle_permission_response(request_id, response); - } - DaveAction::Interrupt => { - self.handle_interrupt(ui.ctx()); - } - DaveAction::TentativeAccept => { - // Enter tentative accept mode (from Shift+click) - if let Some(session) = self.session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = - crate::session::PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } - } - DaveAction::TentativeDeny => { - // Enter tentative deny mode (from Shift+click) - if let Some(session) = self.session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = - crate::session::PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } - } - DaveAction::QuestionResponse { - request_id, - answers, - } => { - self.handle_question_response(request_id, answers); - } - DaveAction::ExitPlanMode { - request_id, - approved, - } => { - if approved { - // Exit plan mode and allow the tool call - self.exit_plan_mode(ui.ctx()); - self.handle_permission_response( - request_id, - PermissionResponse::Allow { message: None }, - ); - } else { - // Deny the tool call - self.handle_permission_response( - request_id, - PermissionResponse::Deny { - reason: "User rejected plan".into(), - }, - ); - } - } + UiActionResult::Handled => None, } - None } /// Handle a user send action triggered by the ui @@ -1408,7 +861,7 @@ impl notedeck::App for Dave { self.poll_ipc_commands(); // Poll for external editor completion - self.poll_editor_job(); + update::poll_editor_job(&mut self.session_manager); // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); @@ -1443,7 +896,14 @@ impl notedeck::App for Dave { self.focus_queue.update_from_statuses(status_iter); // Process auto-steal focus mode - self.process_auto_steal_focus(); + update::process_auto_steal_focus( + &mut self.session_manager, + &mut self.focus_queue, + &mut self.scene, + self.show_scene, + self.auto_steal_focus, + &mut self.home_session, + ); // Render UI and handle actions if let Some(action) = self.ui(ctx, ui).action { diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs @@ -129,8 +129,9 @@ impl DirectoryPicker { // Handle Ctrl+B key for browse (track whether we need to trigger it) // Only trigger when Ctrl is held to avoid intercepting TextEdit input - let trigger_browse = - ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B)) && self.pending_folder_pick.is_none(); + let trigger_browse = ctrl_held + && ui.input(|i| i.key_pressed(egui::Key::B)) + && self.pending_folder_pick.is_none(); // Full panel frame egui::Frame::new() diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -22,3 +22,702 @@ pub use scene::{AgentScene, SceneAction, SceneResponse}; pub use session_list::{SessionListAction, SessionListUi}; pub use session_picker::{SessionPicker, SessionPickerAction}; pub use settings::{DaveSettingsPanel, SettingsPanelAction}; + +// ============================================================================= +// Standalone UI Functions +// ============================================================================= + +use crate::agent_status::AgentStatus; +use crate::config::{AiMode, DaveSettings, ModelConfig}; +use crate::focus_queue::FocusQueue; +use crate::messages::PermissionResponse; +use crate::session::{PermissionMessageState, SessionId, SessionManager}; +use crate::session_discovery::discover_sessions; +use crate::update; +use crate::DaveOverlay; + +/// UI result from overlay rendering +pub enum OverlayResult { + /// No action taken + None, + /// Close the overlay + Close, + /// Directory was selected (no resumable sessions) + DirectorySelected(std::path::PathBuf), + /// Show session picker for the given directory + ShowSessionPicker(std::path::PathBuf), + /// Resume a session + ResumeSession { + cwd: std::path::PathBuf, + session_id: String, + title: String, + }, + /// Create a new session in the given directory + NewSession { cwd: std::path::PathBuf }, + /// Go back to directory picker + BackToDirectoryPicker, + /// Apply new settings + ApplySettings(DaveSettings), +} + +/// Render the settings overlay UI. +pub fn settings_overlay_ui( + settings_panel: &mut DaveSettingsPanel, + settings: &DaveSettings, + ui: &mut egui::Ui, +) -> OverlayResult { + if let Some(action) = settings_panel.overlay_ui(ui, settings) { + match action { + SettingsPanelAction::Save(new_settings) => { + return OverlayResult::ApplySettings(new_settings); + } + SettingsPanelAction::Cancel => { + return OverlayResult::Close; + } + } + } + OverlayResult::None +} + +/// Render the directory picker overlay UI. +pub fn directory_picker_overlay_ui( + directory_picker: &mut DirectoryPicker, + has_sessions: bool, + ui: &mut egui::Ui, +) -> OverlayResult { + if let Some(action) = directory_picker.overlay_ui(ui, has_sessions) { + match action { + DirectoryPickerAction::DirectorySelected(path) => { + let resumable_sessions = discover_sessions(&path); + if resumable_sessions.is_empty() { + return OverlayResult::DirectorySelected(path); + } else { + return OverlayResult::ShowSessionPicker(path); + } + } + DirectoryPickerAction::Cancelled => { + if has_sessions { + return OverlayResult::Close; + } + } + DirectoryPickerAction::BrowseRequested => {} + } + } + OverlayResult::None +} + +/// Render the session picker overlay UI. +pub fn session_picker_overlay_ui( + session_picker: &mut SessionPicker, + ui: &mut egui::Ui, +) -> OverlayResult { + if let Some(action) = session_picker.overlay_ui(ui) { + match action { + SessionPickerAction::ResumeSession { + cwd, + session_id, + title, + } => { + return OverlayResult::ResumeSession { + cwd, + session_id, + title, + }; + } + SessionPickerAction::NewSession { cwd } => { + return OverlayResult::NewSession { cwd }; + } + SessionPickerAction::BackToDirectoryPicker => { + return OverlayResult::BackToDirectoryPicker; + } + } + } + OverlayResult::None +} + +/// Scene view action returned after rendering +pub enum SceneViewAction { + None, + ToggleToListView, + SpawnAgent, + DeleteSelected(Vec<SessionId>), +} + +/// Render the scene view with RTS-style agent visualization and chat side panel. +#[allow(clippy::too_many_arguments)] +pub fn scene_ui( + session_manager: &mut SessionManager, + scene: &mut AgentScene, + focus_queue: &FocusQueue, + model_config: &ModelConfig, + is_interrupt_pending: bool, + auto_steal_focus: bool, + app_ctx: &mut notedeck::AppContext, + ui: &mut egui::Ui, +) -> (DaveResponse, SceneViewAction) { + use egui_extras::{Size, StripBuilder}; + + let mut dave_response = DaveResponse::default(); + let mut scene_response_opt: Option<SceneResponse> = None; + let mut view_action = SceneViewAction::None; + + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + + StripBuilder::new(ui) + .size(Size::relative(0.25)) + .size(Size::remainder()) + .clip(true) + .horizontal(|mut strip| { + strip.cell(|ui| { + ui.horizontal(|ui| { + if ui + .button("+ New Agent") + .on_hover_text("Hold Ctrl to see keybindings") + .clicked() + { + view_action = SceneViewAction::SpawnAgent; + } + if ctrl_held { + keybind_hint(ui, "N"); + } + ui.separator(); + if ui + .button("List View") + .on_hover_text("Ctrl+L to toggle views") + .clicked() + { + view_action = SceneViewAction::ToggleToListView; + } + if ctrl_held { + keybind_hint(ui, "L"); + } + }); + ui.separator(); + scene_response_opt = Some(scene.ui(session_manager, focus_queue, ui, ctrl_held)); + }); + + strip.cell(|ui| { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| { + if let Some(selected_id) = scene.primary_selection() { + if let Some(session) = session_manager.get_mut(selected_id) { + ui.heading(&session.title); + ui.separator(); + + let is_working = session.status() == AgentStatus::Working; + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + + let mut ui_builder = DaveUi::new( + model_config.trial, + &session.chat, + &mut session.input, + &mut session.focus_requested, + session.ai_mode, + ) + .compact(true) + .is_working(is_working) + .interrupt_pending(is_interrupt_pending) + .has_pending_permission(has_pending_permission) + .plan_mode_active(plan_mode_active) + .auto_steal_focus(auto_steal_focus); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + let response = ui_builder.ui(app_ctx, ui); + if response.action.is_some() { + dave_response = response; + } + } + } else { + ui.centered_and_justified(|ui| { + ui.label("Select an agent to view chat"); + }); + } + }); + }); + }); + + // Handle scene actions + if let Some(response) = scene_response_opt { + if let Some(action) = response.action { + match action { + SceneAction::SelectionChanged(ids) => { + if let Some(id) = ids.first() { + session_manager.switch_to(*id); + } + } + SceneAction::SpawnAgent => { + view_action = SceneViewAction::SpawnAgent; + } + SceneAction::DeleteSelected => { + view_action = SceneViewAction::DeleteSelected(scene.selected.clone()); + } + SceneAction::AgentMoved { id, position } => { + if let Some(session) = session_manager.get_mut(id) { + if let Some(agentic) = &mut session.agentic { + agentic.scene_position = position; + } + } + } + } + } + } + + (dave_response, view_action) +} + +/// Desktop layout with sidebar for session list. +#[allow(clippy::too_many_arguments)] +pub fn desktop_ui( + session_manager: &mut SessionManager, + focus_queue: &FocusQueue, + model_config: &ModelConfig, + is_interrupt_pending: bool, + auto_steal_focus: bool, + ai_mode: AiMode, + app_ctx: &mut notedeck::AppContext, + ui: &mut egui::Ui, +) -> (DaveResponse, Option<SessionListAction>, bool) { + let available = ui.available_rect_before_wrap(); + let sidebar_width = 280.0; + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let mut toggle_scene = false; + + let sidebar_rect = + egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height())); + let chat_rect = egui::Rect::from_min_size( + egui::pos2(available.min.x + sidebar_width, available.min.y), + egui::vec2(available.width() - sidebar_width, available.height()), + ); + + let session_action = ui + .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| { + if ai_mode == AiMode::Agentic { + ui.horizontal(|ui| { + if ui + .button("Scene View") + .on_hover_text("Ctrl+L to toggle views") + .clicked() + { + toggle_scene = true; + } + if ctrl_held { + keybind_hint(ui, "L"); + } + }); + ui.separator(); + } + SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui) + }) + .inner + }) + .inner; + + let chat_response = ui + .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { + if let Some(session) = session_manager.get_active_mut() { + let is_working = session.status() == AgentStatus::Working; + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + + let mut ui_builder = DaveUi::new( + model_config.trial, + &session.chat, + &mut session.input, + &mut session.focus_requested, + session.ai_mode, + ) + .is_working(is_working) + .interrupt_pending(is_interrupt_pending) + .has_pending_permission(has_pending_permission) + .plan_mode_active(plan_mode_active) + .auto_steal_focus(auto_steal_focus); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + ui_builder.ui(app_ctx, ui) + } else { + DaveResponse::default() + } + }) + .inner; + + (chat_response, session_action, toggle_scene) +} + +/// Narrow/mobile layout - shows either session list or chat. +#[allow(clippy::too_many_arguments)] +pub fn narrow_ui( + session_manager: &mut SessionManager, + focus_queue: &FocusQueue, + model_config: &ModelConfig, + is_interrupt_pending: bool, + auto_steal_focus: bool, + ai_mode: AiMode, + show_session_list: bool, + app_ctx: &mut notedeck::AppContext, + ui: &mut egui::Ui, +) -> (DaveResponse, Option<SessionListAction>) { + if show_session_list { + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let session_action = egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| { + SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui) + }) + .inner; + (DaveResponse::default(), session_action) + } else if let Some(session) = session_manager.get_active_mut() { + let is_working = session.status() == AgentStatus::Working; + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + + let mut ui_builder = DaveUi::new( + model_config.trial, + &session.chat, + &mut session.input, + &mut session.focus_requested, + session.ai_mode, + ) + .is_working(is_working) + .interrupt_pending(is_interrupt_pending) + .has_pending_permission(has_pending_permission) + .plan_mode_active(plan_mode_active) + .auto_steal_focus(auto_steal_focus); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + (ui_builder.ui(app_ctx, ui), None) + } else { + (DaveResponse::default(), None) + } +} + +/// Result from handling a key action +pub enum KeyActionResult { + None, + ToggleView, + HandleInterrupt, + CloneAgent, + DeleteSession(SessionId), + SetAutoSteal(bool), +} + +/// Handle a keybinding action. +#[allow(clippy::too_many_arguments)] +pub fn handle_key_action( + key_action: KeyAction, + session_manager: &mut SessionManager, + scene: &mut AgentScene, + focus_queue: &mut FocusQueue, + backend: &dyn crate::backend::AiBackend, + show_scene: bool, + auto_steal_focus: bool, + home_session: &mut Option<SessionId>, + active_overlay: &mut DaveOverlay, + ctx: &egui::Context, +) -> KeyActionResult { + match key_action { + KeyAction::AcceptPermission => { + if let Some(request_id) = update::first_pending_permission(session_manager) { + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { message: None }, + ); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } + } + KeyActionResult::None + } + KeyAction::DenyPermission => { + if let Some(request_id) = update::first_pending_permission(session_manager) { + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Deny { + reason: "User denied".into(), + }, + ); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } + } + KeyActionResult::None + } + KeyAction::TentativeAccept => { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = PermissionMessageState::TentativeAccept; + } + session.focus_requested = true; + } + KeyActionResult::None + } + KeyAction::TentativeDeny => { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = PermissionMessageState::TentativeDeny; + } + session.focus_requested = true; + } + KeyActionResult::None + } + KeyAction::CancelTentative => { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = PermissionMessageState::None; + } + } + KeyActionResult::None + } + KeyAction::SwitchToAgent(index) => { + update::switch_to_agent_by_index(session_manager, scene, show_scene, index); + KeyActionResult::None + } + KeyAction::NextAgent => { + update::cycle_next_agent(session_manager, scene, show_scene); + KeyActionResult::None + } + KeyAction::PreviousAgent => { + update::cycle_prev_agent(session_manager, scene, show_scene); + KeyActionResult::None + } + KeyAction::NewAgent => { + *active_overlay = DaveOverlay::DirectoryPicker; + KeyActionResult::None + } + KeyAction::CloneAgent => KeyActionResult::CloneAgent, + KeyAction::Interrupt => KeyActionResult::HandleInterrupt, + KeyAction::ToggleView => KeyActionResult::ToggleView, + KeyAction::TogglePlanMode => { + update::toggle_plan_mode(session_manager, backend, ctx); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } + KeyActionResult::None + } + KeyAction::DeleteActiveSession => { + if let Some(id) = session_manager.active_id() { + KeyActionResult::DeleteSession(id) + } else { + KeyActionResult::None + } + } + KeyAction::FocusQueueNext => { + update::focus_queue_next(session_manager, focus_queue, scene, show_scene); + KeyActionResult::None + } + KeyAction::FocusQueuePrev => { + update::focus_queue_prev(session_manager, focus_queue, scene, show_scene); + KeyActionResult::None + } + KeyAction::FocusQueueToggleDone => { + update::focus_queue_toggle_done(focus_queue); + KeyActionResult::None + } + KeyAction::ToggleAutoSteal => { + let new_state = update::toggle_auto_steal( + session_manager, + scene, + show_scene, + auto_steal_focus, + home_session, + ); + KeyActionResult::SetAutoSteal(new_state) + } + KeyAction::OpenExternalEditor => { + update::open_external_editor(session_manager); + KeyActionResult::None + } + } +} + +/// Result from handling a send action +pub enum SendActionResult { + /// Permission response was sent, no further action needed + Handled, + /// Normal send - caller should send the user message + SendMessage, +} + +/// Handle the Send action, including tentative permission states. +pub fn handle_send_action( + session_manager: &mut SessionManager, + backend: &dyn crate::backend::AiBackend, + ctx: &egui::Context, +) -> SendActionResult { + let tentative_state = session_manager + .get_active() + .and_then(|s| s.agentic.as_ref()) + .map(|a| a.permission_message_state) + .unwrap_or(PermissionMessageState::None); + + match tentative_state { + PermissionMessageState::TentativeAccept => { + let is_exit_plan_mode = update::has_pending_exit_plan_mode(session_manager); + if let Some(request_id) = update::first_pending_permission(session_manager) { + let message = session_manager + .get_active() + .map(|s| s.input.clone()) + .filter(|m| !m.is_empty()); + if let Some(session) = session_manager.get_active_mut() { + session.input.clear(); + } + if is_exit_plan_mode { + update::exit_plan_mode(session_manager, backend, ctx); + } + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { message }, + ); + } + SendActionResult::Handled + } + PermissionMessageState::TentativeDeny => { + if let Some(request_id) = update::first_pending_permission(session_manager) { + let reason = session_manager + .get_active() + .map(|s| s.input.clone()) + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| "User denied".into()); + if let Some(session) = session_manager.get_active_mut() { + session.input.clear(); + } + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Deny { reason }, + ); + } + SendActionResult::Handled + } + PermissionMessageState::None => SendActionResult::SendMessage, + } +} + +/// Result from handling a UI action +pub enum UiActionResult { + /// Action was fully handled + Handled, + /// Send action - caller should handle send + SendAction, + /// Return an AppAction + AppAction(notedeck::AppAction), +} + +/// Handle a UI action from DaveUi. +#[allow(clippy::too_many_arguments)] +pub fn handle_ui_action( + action: DaveAction, + session_manager: &mut SessionManager, + backend: &dyn crate::backend::AiBackend, + active_overlay: &mut DaveOverlay, + show_session_list: &mut bool, + ctx: &egui::Context, +) -> UiActionResult { + match action { + DaveAction::ToggleChrome => UiActionResult::AppAction(notedeck::AppAction::ToggleChrome), + DaveAction::Note(n) => UiActionResult::AppAction(notedeck::AppAction::Note(n)), + DaveAction::NewChat => { + *active_overlay = DaveOverlay::DirectoryPicker; + UiActionResult::Handled + } + DaveAction::Send => UiActionResult::SendAction, + DaveAction::ShowSessionList => { + *show_session_list = !*show_session_list; + UiActionResult::Handled + } + DaveAction::OpenSettings => { + *active_overlay = DaveOverlay::Settings; + UiActionResult::Handled + } + DaveAction::UpdateSettings(_settings) => UiActionResult::Handled, + DaveAction::PermissionResponse { + request_id, + response, + } => { + update::handle_permission_response(session_manager, request_id, response); + UiActionResult::Handled + } + DaveAction::Interrupt => { + update::execute_interrupt(session_manager, backend, ctx); + UiActionResult::Handled + } + DaveAction::TentativeAccept => { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = PermissionMessageState::TentativeAccept; + } + session.focus_requested = true; + } + UiActionResult::Handled + } + DaveAction::TentativeDeny => { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = PermissionMessageState::TentativeDeny; + } + session.focus_requested = true; + } + UiActionResult::Handled + } + DaveAction::QuestionResponse { + request_id, + answers, + } => { + update::handle_question_response(session_manager, request_id, answers); + UiActionResult::Handled + } + DaveAction::ExitPlanMode { + request_id, + approved, + } => { + if approved { + update::exit_plan_mode(session_manager, backend, ctx); + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { message: None }, + ); + } else { + update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Deny { + reason: "User rejected plan".into(), + }, + ); + } + UiActionResult::Handled + } + } +} diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -147,66 +147,67 @@ impl AgentScene { let mut scene_rect = self.scene_rect; let selected_ids = &self.selected; - let scene_response = egui::Scene::new() - .zoom_range(0.1..=1.0) - .show(ui, &mut scene_rect, |ui| { - // Draw agents and collect interaction responses - // Use sessions_ordered() to match keybinding order (Ctrl+1 = first in order, etc.) - for (keybind_idx, session) in - session_manager.sessions_ordered().into_iter().enumerate() - { - // Scene view only makes sense for agentic sessions - let Some(agentic) = &session.agentic else { - continue; - }; - - let id = session.id; - let keybind_number = keybind_idx + 1; // 1-indexed for display - let position = agentic.scene_position; - let status = session.status(); - let title = &session.title; - let is_selected = selected_ids.contains(&id); - let queue_priority = focus_queue.get_session_priority(id); - - let agent_response = Self::draw_agent( - ui, - id, - keybind_number, - position, - status, - title, - &agentic.cwd, - is_selected, - ctrl_held, - queue_priority, - ); - - if agent_response.clicked() { - let shift = ui.input(|i| i.modifiers.shift); - clicked_agent = Some((id, shift, position)); - } + let scene_response = + egui::Scene::new() + .zoom_range(0.1..=1.0) + .show(ui, &mut scene_rect, |ui| { + // Draw agents and collect interaction responses + // Use sessions_ordered() to match keybinding order (Ctrl+1 = first in order, etc.) + for (keybind_idx, session) in + session_manager.sessions_ordered().into_iter().enumerate() + { + // Scene view only makes sense for agentic sessions + let Some(agentic) = &session.agentic else { + continue; + }; + + let id = session.id; + let keybind_number = keybind_idx + 1; // 1-indexed for display + let position = agentic.scene_position; + let status = session.status(); + let title = &session.title; + let is_selected = selected_ids.contains(&id); + let queue_priority = focus_queue.get_session_priority(id); + + let agent_response = Self::draw_agent( + ui, + id, + keybind_number, + position, + status, + title, + &agentic.cwd, + is_selected, + ctrl_held, + queue_priority, + ); + + if agent_response.clicked() { + let shift = ui.input(|i| i.modifiers.shift); + clicked_agent = Some((id, shift, position)); + } - if agent_response.dragged() && is_selected { - let delta = agent_response.drag_delta(); - dragged_agent = Some((id, position + delta)); + if agent_response.dragged() && is_selected { + let delta = agent_response.drag_delta(); + dragged_agent = Some((id, position + delta)); + } } - } - // Handle click on empty space to deselect - let bg_response = ui.interact( - ui.max_rect(), - ui.id().with("scene_bg"), - Sense::click_and_drag(), - ); + // Handle click on empty space to deselect + let bg_response = ui.interact( + ui.max_rect(), + ui.id().with("scene_bg"), + Sense::click_and_drag(), + ); - if bg_response.clicked() && clicked_agent.is_none() { - bg_clicked = true; - } + if bg_response.clicked() && clicked_agent.is_none() { + bg_clicked = true; + } - if bg_response.drag_started() && clicked_agent.is_none() { - bg_drag_started = true; - } - }); + if bg_response.drag_started() && clicked_agent.is_none() { + bg_drag_started = true; + } + }); // Get the viewport rect for coordinate transforms let viewport_rect = scene_response.response.rect; diff --git a/crates/notedeck_dave/src/ui/session_picker.rs b/crates/notedeck_dave/src/ui/session_picker.rs @@ -114,7 +114,9 @@ impl SessionPicker { // Handle Escape key or Ctrl+B to go back // B key requires Ctrl to avoid intercepting TextEdit input - if ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B))) { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B))) + { return Some(SessionPickerAction::BackToDirectoryPicker); } diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -145,7 +145,7 @@ pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid } /// Get the tool name of the first pending permission request. -pub fn pending_permission_tool_name<'a>(session_manager: &'a SessionManager) -> Option<&'a str> { +pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> { let session = session_manager.get_active()?; let agentic = session.agentic.as_ref()?; let request_id = agentic.pending_permissions.keys().next()?; @@ -551,8 +551,7 @@ pub fn process_auto_steal_focus( if has_needs_input { // There are NeedsInput items - check if we need to steal focus let current_session = session_manager.active_id(); - let current_priority = - current_session.and_then(|id| focus_queue.get_session_priority(id)); + let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id)); let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput); if !already_on_needs_input { @@ -770,6 +769,7 @@ pub fn create_session_with_cwd( } /// Create a new session that resumes an existing Claude conversation. +#[allow(clippy::too_many_arguments)] pub fn create_resumed_session_with_cwd( session_manager: &mut SessionManager, directory_picker: &mut DirectoryPicker,