lib.rs (35115B)
1 mod agent_status; 2 mod auto_accept; 3 mod avatar; 4 mod backend; 5 mod config; 6 pub mod file_update; 7 mod focus_queue; 8 pub mod ipc; 9 pub(crate) mod mesh; 10 mod messages; 11 mod quaternion; 12 pub mod session; 13 pub mod session_discovery; 14 mod tools; 15 mod ui; 16 mod update; 17 mod vec3; 18 19 use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend}; 20 use chrono::{Duration, Local}; 21 use egui_wgpu::RenderState; 22 use enostr::KeypairUnowned; 23 use focus_queue::FocusQueue; 24 use nostrdb::Transaction; 25 use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse}; 26 use std::collections::{HashMap, HashSet}; 27 use std::path::PathBuf; 28 use std::string::ToString; 29 use std::sync::Arc; 30 use std::time::Instant; 31 32 pub use avatar::DaveAvatar; 33 pub use config::{AiMode, AiProvider, DaveSettings, ModelConfig}; 34 pub use messages::{ 35 AskUserQuestionInput, DaveApiResponse, Message, PermissionResponse, PermissionResponseType, 36 QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, ToolResult, 37 }; 38 pub use quaternion::Quaternion; 39 pub use session::{ChatSession, SessionId, SessionManager}; 40 pub use session_discovery::{discover_sessions, format_relative_time, ResumableSession}; 41 pub use tools::{ 42 PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse, 43 ToolResponses, 44 }; 45 pub use ui::{ 46 check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, 47 DirectoryPicker, DirectoryPickerAction, KeyAction, KeyActionResult, OverlayResult, SceneAction, 48 SceneResponse, SceneViewAction, SendActionResult, SessionListAction, SessionListUi, 49 SessionPicker, SessionPickerAction, SettingsPanelAction, UiActionResult, 50 }; 51 pub use vec3::Vec3; 52 53 /// Represents which full-screen overlay (if any) is currently active 54 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 55 pub enum DaveOverlay { 56 #[default] 57 None, 58 Settings, 59 DirectoryPicker, 60 SessionPicker, 61 } 62 63 pub struct Dave { 64 /// AI interaction mode (Chat vs Agentic) 65 ai_mode: AiMode, 66 /// Manages multiple chat sessions 67 session_manager: SessionManager, 68 /// A 3d representation of dave. 69 avatar: Option<DaveAvatar>, 70 /// Shared tools available to all sessions 71 tools: Arc<HashMap<String, Tool>>, 72 /// AI backend (OpenAI, Claude, etc.) 73 backend: Box<dyn AiBackend>, 74 /// Model configuration 75 model_config: ModelConfig, 76 /// Whether to show session list on mobile 77 show_session_list: bool, 78 /// User settings 79 settings: DaveSettings, 80 /// Settings panel UI state 81 settings_panel: DaveSettingsPanel, 82 /// RTS-style scene view 83 scene: AgentScene, 84 /// Whether to show scene view (vs classic chat view) 85 show_scene: bool, 86 /// Tracks when first Escape was pressed for interrupt confirmation 87 interrupt_pending_since: Option<Instant>, 88 /// Focus queue for agents needing attention 89 focus_queue: FocusQueue, 90 /// Auto-steal focus mode: automatically cycle through focus queue items 91 auto_steal_focus: bool, 92 /// The session ID to return to after processing all NeedsInput items 93 home_session: Option<SessionId>, 94 /// Directory picker for selecting working directory when creating sessions 95 directory_picker: DirectoryPicker, 96 /// Session picker for resuming existing Claude sessions 97 session_picker: SessionPicker, 98 /// Current overlay taking over the UI (if any) 99 active_overlay: DaveOverlay, 100 /// IPC listener for external spawn-agent commands 101 ipc_listener: Option<ipc::IpcListener>, 102 } 103 104 /// Calculate an anonymous user_id from a keypair 105 fn calculate_user_id(keypair: KeypairUnowned) -> String { 106 use sha2::{Digest, Sha256}; 107 // pubkeys have degraded privacy, don't do that 108 let key_input = keypair 109 .secret_key 110 .map(|sk| sk.as_secret_bytes()) 111 .unwrap_or(keypair.pubkey.bytes()); 112 let hex_key = hex::encode(key_input); 113 let input = format!("{hex_key}notedeck_dave_user_id"); 114 hex::encode(Sha256::digest(input)) 115 } 116 117 impl Dave { 118 pub fn avatar_mut(&mut self) -> Option<&mut DaveAvatar> { 119 self.avatar.as_mut() 120 } 121 122 fn _system_prompt() -> Message { 123 let now = Local::now(); 124 let yesterday = now - Duration::hours(24); 125 let date = now.format("%Y-%m-%d %H:%M:%S"); 126 let timestamp = now.timestamp(); 127 let yesterday_timestamp = yesterday.timestamp(); 128 129 Message::System(format!( 130 r#" 131 You are an AI agent for the nostr protocol called Dave, created by Damus. nostr is a decentralized social media and internet communications protocol. You are embedded in a nostr browser called 'Damus Notedeck'. 132 133 - The current date is {date} ({timestamp} unix timestamp if needed for queries). 134 135 - Yesterday (-24hrs) was {yesterday_timestamp}. You can use this in combination with `since` queries for pulling notes for summarizing notes the user might have missed while they were away. 136 137 # Response Guidelines 138 139 - You *MUST* call the present_notes tool with a list of comma-separated note id references when referring to notes so that the UI can display them. Do *NOT* include note id references in the text response, but you *SHOULD* use ^1, ^2, etc to reference note indices passed to present_notes. 140 - When a user asks for a digest instead of specific query terms, make sure to include both since and until to pull notes for the correct range. 141 - When tasked with open-ended queries such as looking for interesting notes or summarizing the day, make sure to add enough notes to the context (limit: 100-200) so that it returns enough data for summarization. 142 "# 143 )) 144 } 145 146 pub fn new(render_state: Option<&RenderState>, ndb: nostrdb::Ndb, ctx: egui::Context) -> Self { 147 let model_config = ModelConfig::default(); 148 //let model_config = ModelConfig::ollama(); 149 150 // Determine AI mode from backend type 151 let ai_mode = model_config.ai_mode(); 152 153 // Create backend based on configuration 154 let backend: Box<dyn AiBackend> = match model_config.backend { 155 BackendType::OpenAI => { 156 use async_openai::Client; 157 let client = Client::with_config(model_config.to_api()); 158 Box::new(OpenAiBackend::new(client, ndb.clone())) 159 } 160 BackendType::Claude => { 161 let api_key = model_config 162 .anthropic_api_key 163 .as_ref() 164 .expect("Claude backend requires ANTHROPIC_API_KEY or CLAUDE_API_KEY"); 165 Box::new(ClaudeBackend::new(api_key.clone())) 166 } 167 }; 168 169 let avatar = render_state.map(DaveAvatar::new); 170 let mut tools: HashMap<String, Tool> = HashMap::new(); 171 for tool in tools::dave_tools() { 172 tools.insert(tool.name().to_string(), tool); 173 } 174 175 let settings = DaveSettings::from_model_config(&model_config); 176 177 let directory_picker = DirectoryPicker::new(); 178 179 // Create IPC listener for external spawn-agent commands 180 let ipc_listener = ipc::create_listener(ctx); 181 182 // In Chat mode, create a default session immediately and skip directory picker 183 // In Agentic mode, show directory picker on startup 184 let (session_manager, active_overlay) = match ai_mode { 185 AiMode::Chat => { 186 let mut manager = SessionManager::new(); 187 // Create a default session with current directory 188 manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode); 189 (manager, DaveOverlay::None) 190 } 191 AiMode::Agentic => (SessionManager::new(), DaveOverlay::DirectoryPicker), 192 }; 193 194 Dave { 195 ai_mode, 196 backend, 197 avatar, 198 session_manager, 199 tools: Arc::new(tools), 200 model_config, 201 show_session_list: false, 202 settings, 203 settings_panel: DaveSettingsPanel::new(), 204 scene: AgentScene::new(), 205 show_scene: false, // Default to list view 206 interrupt_pending_since: None, 207 focus_queue: FocusQueue::new(), 208 auto_steal_focus: false, 209 home_session: None, 210 directory_picker, 211 session_picker: SessionPicker::new(), 212 active_overlay, 213 ipc_listener, 214 } 215 } 216 217 /// Get current settings for persistence 218 pub fn settings(&self) -> &DaveSettings { 219 &self.settings 220 } 221 222 /// Apply new settings. Note: Provider changes require app restart to take effect. 223 pub fn apply_settings(&mut self, settings: DaveSettings) { 224 self.model_config = ModelConfig::from_settings(&settings); 225 self.settings = settings; 226 } 227 228 /// Process incoming tokens from the ai backend for ALL sessions 229 /// Returns a set of session IDs that need to send tool responses 230 fn process_events(&mut self, app_ctx: &AppContext) -> HashSet<SessionId> { 231 // Track which sessions need to send tool responses 232 let mut needs_send: HashSet<SessionId> = HashSet::new(); 233 let active_id = self.session_manager.active_id(); 234 235 // Get all session IDs to process 236 let session_ids = self.session_manager.session_ids(); 237 238 for session_id in session_ids { 239 // Take the receiver out to avoid borrow conflicts 240 let recvr = { 241 let Some(session) = self.session_manager.get_mut(session_id) else { 242 continue; 243 }; 244 session.incoming_tokens.take() 245 }; 246 247 let Some(recvr) = recvr else { 248 continue; 249 }; 250 251 while let Ok(res) = recvr.try_recv() { 252 // Nudge avatar only for active session 253 if active_id == Some(session_id) { 254 if let Some(avatar) = &mut self.avatar { 255 avatar.random_nudge(); 256 } 257 } 258 259 let Some(session) = self.session_manager.get_mut(session_id) else { 260 break; 261 }; 262 263 match res { 264 DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), 265 266 DaveApiResponse::Token(token) => match session.chat.last_mut() { 267 Some(Message::Assistant(msg)) => msg.push_str(&token), 268 Some(_) => session.chat.push(Message::Assistant(token)), 269 None => {} 270 }, 271 272 DaveApiResponse::ToolCalls(toolcalls) => { 273 tracing::info!("got tool calls: {:?}", toolcalls); 274 session.chat.push(Message::ToolCalls(toolcalls.clone())); 275 276 let txn = Transaction::new(app_ctx.ndb).unwrap(); 277 for call in &toolcalls { 278 // execute toolcall 279 match call.calls() { 280 ToolCalls::PresentNotes(present) => { 281 session.chat.push(Message::ToolResponse(ToolResponse::new( 282 call.id().to_owned(), 283 ToolResponses::PresentNotes(present.note_ids.len() as i32), 284 ))); 285 286 needs_send.insert(session_id); 287 } 288 289 ToolCalls::Invalid(invalid) => { 290 session.chat.push(Message::tool_error( 291 call.id().to_string(), 292 invalid.error.clone(), 293 )); 294 295 needs_send.insert(session_id); 296 } 297 298 ToolCalls::Query(search_call) => { 299 let resp = search_call.execute(&txn, app_ctx.ndb); 300 session.chat.push(Message::ToolResponse(ToolResponse::new( 301 call.id().to_owned(), 302 ToolResponses::Query(resp), 303 ))); 304 305 needs_send.insert(session_id); 306 } 307 } 308 } 309 } 310 311 DaveApiResponse::PermissionRequest(pending) => { 312 tracing::info!( 313 "Permission request for tool '{}': {:?}", 314 pending.request.tool_name, 315 pending.request.tool_input 316 ); 317 318 // Store the response sender for later (agentic only) 319 if let Some(agentic) = &mut session.agentic { 320 agentic 321 .pending_permissions 322 .insert(pending.request.id, pending.response_tx); 323 } 324 325 // Add the request to chat for UI display 326 session 327 .chat 328 .push(Message::PermissionRequest(pending.request)); 329 } 330 331 DaveApiResponse::ToolResult(result) => { 332 tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); 333 session.chat.push(Message::ToolResult(result)); 334 } 335 336 DaveApiResponse::SessionInfo(info) => { 337 tracing::debug!( 338 "Session info: model={:?}, tools={}, agents={}", 339 info.model, 340 info.tools.len(), 341 info.agents.len() 342 ); 343 if let Some(agentic) = &mut session.agentic { 344 agentic.session_info = Some(info); 345 } 346 } 347 348 DaveApiResponse::SubagentSpawned(subagent) => { 349 tracing::debug!( 350 "Subagent spawned: {} ({}) - {}", 351 subagent.task_id, 352 subagent.subagent_type, 353 subagent.description 354 ); 355 let task_id = subagent.task_id.clone(); 356 let idx = session.chat.len(); 357 session.chat.push(Message::Subagent(subagent)); 358 if let Some(agentic) = &mut session.agentic { 359 agentic.subagent_indices.insert(task_id, idx); 360 } 361 } 362 363 DaveApiResponse::SubagentOutput { task_id, output } => { 364 session.update_subagent_output(&task_id, &output); 365 } 366 367 DaveApiResponse::SubagentCompleted { task_id, result } => { 368 tracing::debug!("Subagent completed: {}", task_id); 369 session.complete_subagent(&task_id, &result); 370 } 371 372 DaveApiResponse::CompactionStarted => { 373 tracing::debug!("Compaction started for session {}", session_id); 374 if let Some(agentic) = &mut session.agentic { 375 agentic.is_compacting = true; 376 } 377 } 378 379 DaveApiResponse::CompactionComplete(info) => { 380 tracing::debug!( 381 "Compaction completed for session {}: pre_tokens={}", 382 session_id, 383 info.pre_tokens 384 ); 385 if let Some(agentic) = &mut session.agentic { 386 agentic.is_compacting = false; 387 agentic.last_compaction = Some(info.clone()); 388 } 389 session.chat.push(Message::CompactionComplete(info)); 390 } 391 } 392 } 393 394 // Check if channel is disconnected (stream ended) 395 match recvr.try_recv() { 396 Err(std::sync::mpsc::TryRecvError::Disconnected) => { 397 // Stream ended, clear task state 398 if let Some(session) = self.session_manager.get_mut(session_id) { 399 session.task_handle = None; 400 // Don't restore incoming_tokens - leave it None 401 } 402 } 403 _ => { 404 // Channel still open, put receiver back 405 if let Some(session) = self.session_manager.get_mut(session_id) { 406 session.incoming_tokens = Some(recvr); 407 } 408 } 409 } 410 } 411 412 needs_send 413 } 414 415 fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { 416 // Check overlays first - they take over the entire UI 417 match self.active_overlay { 418 DaveOverlay::Settings => { 419 match ui::settings_overlay_ui(&mut self.settings_panel, &self.settings, ui) { 420 OverlayResult::ApplySettings(new_settings) => { 421 self.apply_settings(new_settings.clone()); 422 self.active_overlay = DaveOverlay::None; 423 return DaveResponse::new(DaveAction::UpdateSettings(new_settings)); 424 } 425 OverlayResult::Close => { 426 self.active_overlay = DaveOverlay::None; 427 } 428 _ => {} 429 } 430 return DaveResponse::default(); 431 } 432 DaveOverlay::DirectoryPicker => { 433 let has_sessions = !self.session_manager.is_empty(); 434 match ui::directory_picker_overlay_ui(&mut self.directory_picker, has_sessions, ui) 435 { 436 OverlayResult::DirectorySelected(path) => { 437 self.create_session_with_cwd(path); 438 self.active_overlay = DaveOverlay::None; 439 } 440 OverlayResult::ShowSessionPicker(path) => { 441 self.session_picker.open(path); 442 self.active_overlay = DaveOverlay::SessionPicker; 443 } 444 OverlayResult::Close => { 445 self.active_overlay = DaveOverlay::None; 446 } 447 _ => {} 448 } 449 return DaveResponse::default(); 450 } 451 DaveOverlay::SessionPicker => { 452 match ui::session_picker_overlay_ui(&mut self.session_picker, ui) { 453 OverlayResult::ResumeSession { 454 cwd, 455 session_id, 456 title, 457 } => { 458 self.create_resumed_session_with_cwd(cwd, session_id, title); 459 self.session_picker.close(); 460 self.active_overlay = DaveOverlay::None; 461 } 462 OverlayResult::NewSession { cwd } => { 463 self.create_session_with_cwd(cwd); 464 self.session_picker.close(); 465 self.active_overlay = DaveOverlay::None; 466 } 467 OverlayResult::BackToDirectoryPicker => { 468 self.session_picker.close(); 469 self.active_overlay = DaveOverlay::DirectoryPicker; 470 } 471 _ => {} 472 } 473 return DaveResponse::default(); 474 } 475 DaveOverlay::None => {} 476 } 477 478 // Normal routing 479 if is_narrow(ui.ctx()) { 480 self.narrow_ui(app_ctx, ui) 481 } else if self.show_scene { 482 self.scene_ui(app_ctx, ui) 483 } else { 484 self.desktop_ui(app_ctx, ui) 485 } 486 } 487 488 /// Scene view with RTS-style agent visualization and chat side panel 489 fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { 490 let is_interrupt_pending = self.is_interrupt_pending(); 491 let (dave_response, view_action) = ui::scene_ui( 492 &mut self.session_manager, 493 &mut self.scene, 494 &self.focus_queue, 495 &self.model_config, 496 is_interrupt_pending, 497 self.auto_steal_focus, 498 app_ctx, 499 ui, 500 ); 501 502 // Handle view actions 503 match view_action { 504 SceneViewAction::ToggleToListView => { 505 self.show_scene = false; 506 } 507 SceneViewAction::SpawnAgent => { 508 return DaveResponse::new(DaveAction::NewChat); 509 } 510 SceneViewAction::DeleteSelected(ids) => { 511 for id in ids { 512 self.delete_session(id); 513 } 514 if let Some(session) = self.session_manager.sessions_ordered().first() { 515 self.scene.select(session.id); 516 } else { 517 self.scene.clear_selection(); 518 } 519 } 520 SceneViewAction::None => {} 521 } 522 523 dave_response 524 } 525 526 /// Desktop layout with sidebar for session list 527 fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { 528 let is_interrupt_pending = self.is_interrupt_pending(); 529 let (chat_response, session_action, toggle_scene) = ui::desktop_ui( 530 &mut self.session_manager, 531 &self.focus_queue, 532 &self.model_config, 533 is_interrupt_pending, 534 self.auto_steal_focus, 535 self.ai_mode, 536 app_ctx, 537 ui, 538 ); 539 540 if toggle_scene { 541 self.show_scene = true; 542 } 543 544 if let Some(action) = session_action { 545 match action { 546 SessionListAction::NewSession => return DaveResponse::new(DaveAction::NewChat), 547 SessionListAction::SwitchTo(id) => { 548 self.session_manager.switch_to(id); 549 } 550 SessionListAction::Delete(id) => { 551 self.delete_session(id); 552 } 553 } 554 } 555 556 chat_response 557 } 558 559 /// Narrow/mobile layout - shows either session list or chat 560 fn narrow_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { 561 let is_interrupt_pending = self.is_interrupt_pending(); 562 let (dave_response, session_action) = ui::narrow_ui( 563 &mut self.session_manager, 564 &self.focus_queue, 565 &self.model_config, 566 is_interrupt_pending, 567 self.auto_steal_focus, 568 self.ai_mode, 569 self.show_session_list, 570 app_ctx, 571 ui, 572 ); 573 574 if let Some(action) = session_action { 575 match action { 576 SessionListAction::NewSession => { 577 self.handle_new_chat(); 578 self.show_session_list = false; 579 } 580 SessionListAction::SwitchTo(id) => { 581 self.session_manager.switch_to(id); 582 self.show_session_list = false; 583 } 584 SessionListAction::Delete(id) => { 585 self.delete_session(id); 586 } 587 } 588 } 589 590 dave_response 591 } 592 593 fn handle_new_chat(&mut self) { 594 // Show the directory picker overlay 595 self.active_overlay = DaveOverlay::DirectoryPicker; 596 } 597 598 /// Create a new session with the given cwd (called after directory picker selection) 599 fn create_session_with_cwd(&mut self, cwd: PathBuf) { 600 update::create_session_with_cwd( 601 &mut self.session_manager, 602 &mut self.directory_picker, 603 &mut self.scene, 604 self.show_scene, 605 self.ai_mode, 606 cwd, 607 ); 608 } 609 610 /// Create a new session that resumes an existing Claude conversation 611 fn create_resumed_session_with_cwd( 612 &mut self, 613 cwd: PathBuf, 614 resume_session_id: String, 615 title: String, 616 ) { 617 update::create_resumed_session_with_cwd( 618 &mut self.session_manager, 619 &mut self.directory_picker, 620 &mut self.scene, 621 self.show_scene, 622 self.ai_mode, 623 cwd, 624 resume_session_id, 625 title, 626 ); 627 } 628 629 /// Clone the active agent, creating a new session with the same working directory 630 fn clone_active_agent(&mut self) { 631 update::clone_active_agent( 632 &mut self.session_manager, 633 &mut self.directory_picker, 634 &mut self.scene, 635 self.show_scene, 636 self.ai_mode, 637 ); 638 } 639 640 /// Poll for IPC spawn-agent commands from external tools 641 fn poll_ipc_commands(&mut self) { 642 let Some(listener) = self.ipc_listener.as_ref() else { 643 return; 644 }; 645 646 // Drain all pending connections (non-blocking) 647 while let Some(mut pending) = listener.try_recv() { 648 // Create the session and get its ID 649 let id = self 650 .session_manager 651 .new_session(pending.cwd.clone(), self.ai_mode); 652 self.directory_picker.add_recent(pending.cwd); 653 654 // Focus on new session 655 if let Some(session) = self.session_manager.get_mut(id) { 656 session.focus_requested = true; 657 if self.show_scene { 658 self.scene.select(id); 659 if let Some(agentic) = &session.agentic { 660 self.scene.focus_on(agentic.scene_position); 661 } 662 } 663 } 664 665 // Close directory picker if open 666 if self.active_overlay == DaveOverlay::DirectoryPicker { 667 self.active_overlay = DaveOverlay::None; 668 } 669 670 // Send success response back to the client 671 #[cfg(unix)] 672 { 673 let response = ipc::SpawnResponse::ok(id); 674 let _ = ipc::send_response(&mut pending.stream, &response); 675 } 676 677 tracing::info!("Spawned agent via IPC (session {})", id); 678 } 679 } 680 681 /// Delete a session and clean up backend resources 682 fn delete_session(&mut self, id: SessionId) { 683 update::delete_session( 684 &mut self.session_manager, 685 &mut self.focus_queue, 686 self.backend.as_ref(), 687 &mut self.directory_picker, 688 id, 689 ); 690 } 691 692 /// Handle an interrupt request - requires double-Escape to confirm 693 fn handle_interrupt_request(&mut self, ctx: &egui::Context) { 694 self.interrupt_pending_since = update::handle_interrupt_request( 695 &self.session_manager, 696 self.backend.as_ref(), 697 self.interrupt_pending_since, 698 ctx, 699 ); 700 } 701 702 /// Check if interrupt confirmation has timed out and clear it 703 fn check_interrupt_timeout(&mut self) { 704 self.interrupt_pending_since = 705 update::check_interrupt_timeout(self.interrupt_pending_since); 706 } 707 708 /// Returns true if an interrupt is pending confirmation 709 pub fn is_interrupt_pending(&self) -> bool { 710 self.interrupt_pending_since.is_some() 711 } 712 713 /// Get the first pending permission request ID for the active session 714 fn first_pending_permission(&self) -> Option<uuid::Uuid> { 715 update::first_pending_permission(&self.session_manager) 716 } 717 718 /// Check if the first pending permission is an AskUserQuestion tool call 719 fn has_pending_question(&self) -> bool { 720 update::has_pending_question(&self.session_manager) 721 } 722 723 /// Handle a keybinding action 724 fn handle_key_action(&mut self, key_action: KeyAction, ui: &egui::Ui) { 725 match ui::handle_key_action( 726 key_action, 727 &mut self.session_manager, 728 &mut self.scene, 729 &mut self.focus_queue, 730 self.backend.as_ref(), 731 self.show_scene, 732 self.auto_steal_focus, 733 &mut self.home_session, 734 &mut self.active_overlay, 735 ui.ctx(), 736 ) { 737 KeyActionResult::ToggleView => { 738 self.show_scene = !self.show_scene; 739 } 740 KeyActionResult::HandleInterrupt => { 741 self.handle_interrupt_request(ui.ctx()); 742 } 743 KeyActionResult::CloneAgent => { 744 self.clone_active_agent(); 745 } 746 KeyActionResult::DeleteSession(id) => { 747 self.delete_session(id); 748 } 749 KeyActionResult::SetAutoSteal(new_state) => { 750 self.auto_steal_focus = new_state; 751 } 752 KeyActionResult::None => {} 753 } 754 } 755 756 /// Handle the Send action, including tentative permission states 757 fn handle_send_action(&mut self, ctx: &AppContext, ui: &egui::Ui) { 758 match ui::handle_send_action(&mut self.session_manager, self.backend.as_ref(), ui.ctx()) { 759 SendActionResult::SendMessage => { 760 self.handle_user_send(ctx, ui); 761 } 762 SendActionResult::Handled => {} 763 } 764 } 765 766 /// Handle a UI action from DaveUi 767 fn handle_ui_action( 768 &mut self, 769 action: DaveAction, 770 ctx: &AppContext, 771 ui: &egui::Ui, 772 ) -> Option<AppAction> { 773 match ui::handle_ui_action( 774 action, 775 &mut self.session_manager, 776 self.backend.as_ref(), 777 &mut self.active_overlay, 778 &mut self.show_session_list, 779 ui.ctx(), 780 ) { 781 UiActionResult::AppAction(app_action) => Some(app_action), 782 UiActionResult::SendAction => { 783 self.handle_send_action(ctx, ui); 784 None 785 } 786 UiActionResult::Handled => None, 787 } 788 } 789 790 /// Handle a user send action triggered by the ui 791 fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) { 792 // Check for /cd command first (agentic only) 793 let cd_result = self 794 .session_manager 795 .get_active_mut() 796 .and_then(update::handle_cd_command); 797 798 // If /cd command was processed, add to recent directories 799 if let Some(Ok(path)) = cd_result { 800 self.directory_picker.add_recent(path); 801 return; 802 } else if cd_result.is_some() { 803 // Error case - already handled above 804 return; 805 } 806 807 // Normal message handling 808 if let Some(session) = self.session_manager.get_active_mut() { 809 session.chat.push(Message::User(session.input.clone())); 810 session.input.clear(); 811 session.update_title_from_last_message(); 812 } 813 self.send_user_message(app_ctx, ui.ctx()); 814 } 815 816 fn send_user_message(&mut self, app_ctx: &AppContext, ctx: &egui::Context) { 817 let Some(active_id) = self.session_manager.active_id() else { 818 return; 819 }; 820 self.send_user_message_for(active_id, app_ctx, ctx); 821 } 822 823 /// Send a message for a specific session by ID 824 fn send_user_message_for(&mut self, sid: SessionId, app_ctx: &AppContext, ctx: &egui::Context) { 825 let Some(session) = self.session_manager.get_mut(sid) else { 826 return; 827 }; 828 829 let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair()); 830 let session_id = format!("dave-session-{}", session.id); 831 let messages = session.chat.clone(); 832 let cwd = session.agentic.as_ref().map(|a| a.cwd.clone()); 833 let resume_session_id = session 834 .agentic 835 .as_ref() 836 .and_then(|a| a.resume_session_id.clone()); 837 let tools = self.tools.clone(); 838 let model_name = self.model_config.model().to_owned(); 839 let ctx = ctx.clone(); 840 841 // Use backend to stream request 842 let (rx, task_handle) = self.backend.stream_request( 843 messages, 844 tools, 845 model_name, 846 user_id, 847 session_id, 848 cwd, 849 resume_session_id, 850 ctx, 851 ); 852 session.incoming_tokens = Some(rx); 853 session.task_handle = task_handle; 854 } 855 } 856 857 impl notedeck::App for Dave { 858 fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { 859 let mut app_action: Option<AppAction> = None; 860 861 // Poll for external spawn-agent commands via IPC 862 self.poll_ipc_commands(); 863 864 // Poll for external editor completion 865 update::poll_editor_job(&mut self.session_manager); 866 867 // Handle global keybindings (when no text input has focus) 868 let has_pending_permission = self.first_pending_permission().is_some(); 869 let has_pending_question = self.has_pending_question(); 870 let in_tentative_state = self 871 .session_manager 872 .get_active() 873 .and_then(|s| s.agentic.as_ref()) 874 .map(|a| a.permission_message_state != crate::session::PermissionMessageState::None) 875 .unwrap_or(false); 876 if let Some(key_action) = check_keybindings( 877 ui.ctx(), 878 has_pending_permission, 879 has_pending_question, 880 in_tentative_state, 881 self.ai_mode, 882 ) { 883 self.handle_key_action(key_action, ui); 884 } 885 886 // Check if interrupt confirmation has timed out 887 self.check_interrupt_timeout(); 888 889 // Process incoming AI responses for all sessions 890 let sessions_needing_send = self.process_events(ctx); 891 892 // Update all session statuses after processing events 893 self.session_manager.update_all_statuses(); 894 895 // Update focus queue based on status changes 896 let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); 897 self.focus_queue.update_from_statuses(status_iter); 898 899 // Process auto-steal focus mode 900 update::process_auto_steal_focus( 901 &mut self.session_manager, 902 &mut self.focus_queue, 903 &mut self.scene, 904 self.show_scene, 905 self.auto_steal_focus, 906 &mut self.home_session, 907 ); 908 909 // Render UI and handle actions 910 if let Some(action) = self.ui(ctx, ui).action { 911 if let Some(returned_action) = self.handle_ui_action(action, ctx, ui) { 912 app_action = Some(returned_action); 913 } 914 } 915 916 // Send continuation messages for all sessions that have tool responses 917 for session_id in sessions_needing_send { 918 self.send_user_message_for(session_id, ctx, ui.ctx()); 919 } 920 921 AppResponse::action(app_action) 922 } 923 }