notedeck

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

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 }