notedeck

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

update.rs (37408B)


      1 //! Helper functions for the Dave update loop.
      2 //!
      3 //! These are standalone functions with explicit inputs to reduce the complexity
      4 //! of the main Dave struct and make the code more testable and reusable.
      5 
      6 use crate::backend::{AiBackend, BackendType};
      7 use crate::config::AiMode;
      8 use crate::focus_queue::{FocusPriority, FocusQueue};
      9 use crate::messages::{
     10     AnswerSummary, AnswerSummaryEntry, AskUserQuestionInput, Message, PermissionResponse,
     11     QuestionAnswer,
     12 };
     13 use crate::session::{ChatSession, EditorJob, PermissionMessageState, SessionId, SessionManager};
     14 use crate::ui::{AgentScene, DirectoryPicker};
     15 use claude_agent_sdk_rs::PermissionMode;
     16 use std::path::PathBuf;
     17 use std::time::Instant;
     18 
     19 /// Timeout for confirming interrupt (in seconds)
     20 pub const INTERRUPT_CONFIRM_TIMEOUT_SECS: f32 = 1.5;
     21 
     22 // =============================================================================
     23 // Interrupt Handling
     24 // =============================================================================
     25 
     26 /// Handle an interrupt request - requires double-Escape to confirm.
     27 /// Returns the new pending_since state.
     28 pub fn handle_interrupt_request(
     29     session_manager: &SessionManager,
     30     backend: &dyn AiBackend,
     31     pending_since: Option<Instant>,
     32     ctx: &egui::Context,
     33 ) -> Option<Instant> {
     34     // Only allow interrupt if there's an active AI operation
     35     let has_active_operation = session_manager
     36         .get_active()
     37         .map(|s| s.incoming_tokens.is_some())
     38         .unwrap_or(false);
     39 
     40     if !has_active_operation {
     41         return None;
     42     }
     43 
     44     let now = Instant::now();
     45 
     46     if let Some(pending) = pending_since {
     47         if now.duration_since(pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS {
     48             // Second Escape within timeout - confirm interrupt
     49             if let Some(session) = session_manager.get_active() {
     50                 let session_id = format!("dave-session-{}", session.id);
     51                 backend.interrupt_session(session_id, ctx.clone());
     52             }
     53             None
     54         } else {
     55             // Timeout expired, treat as new first press
     56             Some(now)
     57         }
     58     } else {
     59         // First Escape press
     60         Some(now)
     61     }
     62 }
     63 
     64 /// Execute the actual interrupt on the active session.
     65 pub fn execute_interrupt(
     66     session_manager: &mut SessionManager,
     67     backend: &dyn AiBackend,
     68     ctx: &egui::Context,
     69 ) {
     70     if let Some(session) = session_manager.get_active_mut() {
     71         let session_id = format!("dave-session-{}", session.id);
     72         backend.interrupt_session(session_id, ctx.clone());
     73         session.incoming_tokens = None;
     74         if let Some(agentic) = &mut session.agentic {
     75             agentic.permissions.pending.clear();
     76         }
     77         tracing::debug!("Interrupted session {}", session.id);
     78     }
     79 }
     80 
     81 /// Check if interrupt confirmation has timed out.
     82 /// Returns None if timed out, otherwise returns the original value.
     83 pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant> {
     84     pending_since.filter(|pending| {
     85         Instant::now().duration_since(*pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS
     86     })
     87 }
     88 
     89 // =============================================================================
     90 // Plan Mode
     91 // =============================================================================
     92 
     93 /// Add the current pending permission's tool to the session's runtime allowlist.
     94 /// Returns the key that was added (for logging), or None if no pending permission.
     95 pub fn allow_always(session_manager: &mut SessionManager) -> Option<String> {
     96     let session = session_manager.get_active_mut()?;
     97     let agentic = session.agentic.as_mut()?;
     98 
     99     // Find the last pending (unresponded) permission request
    100     let (tool_name, tool_input) = session.chat.iter().rev().find_map(|msg| {
    101         if let crate::messages::Message::PermissionRequest(req) = msg {
    102             if req.response.is_none() {
    103                 return Some((req.tool_name.clone(), req.tool_input.clone()));
    104             }
    105         }
    106         None
    107     })?;
    108 
    109     let key = agentic.add_runtime_allow(&tool_name, &tool_input);
    110     if let Some(ref k) = key {
    111         tracing::info!("allow_always: added runtime allow for '{}'", k);
    112     }
    113     key
    114 }
    115 
    116 /// Cycle permission mode for the active session: Default → Plan → AcceptEdits → Default.
    117 /// Info needed to publish a permission mode command to a remote host.
    118 pub struct ModeCommandPublish {
    119     pub session_id: String,
    120     pub mode: &'static str,
    121 }
    122 
    123 pub fn cycle_permission_mode(
    124     session_manager: &mut SessionManager,
    125     backend: &dyn AiBackend,
    126     ctx: &egui::Context,
    127 ) -> Option<ModeCommandPublish> {
    128     let session = session_manager.get_active_mut()?;
    129     let is_remote = session.is_remote();
    130     let session_id = session.id;
    131     let agentic = session.agentic.as_mut()?;
    132 
    133     let new_mode = match agentic.permission_mode {
    134         PermissionMode::Default => PermissionMode::Plan,
    135         PermissionMode::Plan => PermissionMode::AcceptEdits,
    136         _ => PermissionMode::Default,
    137     };
    138     agentic.permission_mode = new_mode;
    139 
    140     let mode_str = crate::session::permission_mode_to_str(new_mode);
    141 
    142     let result = if is_remote {
    143         // Remote session: return info for caller to publish command event
    144         let event_sid = agentic.event_session_id().to_string();
    145         Some(ModeCommandPublish {
    146             session_id: event_sid,
    147             mode: mode_str,
    148         })
    149     } else {
    150         // Local session: apply directly and mark dirty for state event publish
    151         let backend_sid = format!("dave-session-{}", session_id);
    152         backend.set_permission_mode(backend_sid, new_mode, ctx.clone());
    153         session.state_dirty = true;
    154         None
    155     };
    156 
    157     tracing::debug!(
    158         "Cycled permission mode for session {} to {:?} (remote={})",
    159         session_id,
    160         new_mode,
    161         is_remote,
    162     );
    163 
    164     result
    165 }
    166 
    167 /// Exit plan mode for the active session (switch to Default mode).
    168 pub fn exit_plan_mode(
    169     session_manager: &mut SessionManager,
    170     backend: &dyn AiBackend,
    171     ctx: &egui::Context,
    172 ) {
    173     if let Some(session) = session_manager.get_active_mut() {
    174         if let Some(agentic) = &mut session.agentic {
    175             agentic.permission_mode = PermissionMode::Default;
    176             let session_id = format!("dave-session-{}", session.id);
    177             backend.set_permission_mode(session_id, PermissionMode::Default, ctx.clone());
    178             tracing::debug!("Exited plan mode for session {}", session.id);
    179         }
    180     }
    181 }
    182 
    183 // =============================================================================
    184 // Permission Handling
    185 // =============================================================================
    186 
    187 /// Get the first pending permission request ID for the active session.
    188 pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid::Uuid> {
    189     let session = session_manager.get_active()?;
    190     if session.is_remote() {
    191         // Remote: find first unresponded PermissionRequest in chat
    192         let responded = session.agentic.as_ref().map(|a| &a.permissions.responded);
    193         for msg in &session.chat {
    194             if let Message::PermissionRequest(req) = msg {
    195                 if req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) {
    196                     return Some(req.id);
    197                 }
    198             }
    199         }
    200         None
    201     } else {
    202         // Local: check oneshot senders
    203         session
    204             .agentic
    205             .as_ref()
    206             .and_then(|a| a.permissions.pending.keys().next().copied())
    207     }
    208 }
    209 
    210 /// Get the tool name of the first pending permission request.
    211 pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> {
    212     let request_id = first_pending_permission(session_manager)?;
    213     let session = session_manager.get_active()?;
    214 
    215     for msg in &session.chat {
    216         if let Message::PermissionRequest(req) = msg {
    217             if req.id == request_id {
    218                 return Some(&req.tool_name);
    219             }
    220         }
    221     }
    222 
    223     None
    224 }
    225 
    226 /// Check if the first pending permission is an AskUserQuestion tool call.
    227 pub fn has_pending_question(session_manager: &SessionManager) -> bool {
    228     pending_permission_tool_name(session_manager) == Some("AskUserQuestion")
    229 }
    230 
    231 /// Check if the first pending permission is an ExitPlanMode tool call.
    232 pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool {
    233     pending_permission_tool_name(session_manager) == Some("ExitPlanMode")
    234 }
    235 
    236 /// Data needed to publish a permission response to relays.
    237 pub struct PermissionPublish {
    238     pub perm_id: uuid::Uuid,
    239     pub allowed: bool,
    240     pub message: Option<String>,
    241 }
    242 
    243 /// Handle a permission response (from UI button or keybinding).
    244 pub fn handle_permission_response(
    245     session_manager: &mut SessionManager,
    246     request_id: uuid::Uuid,
    247     response: PermissionResponse,
    248 ) -> Option<PermissionPublish> {
    249     let session = session_manager.get_active_mut()?;
    250 
    251     let is_remote = session.is_remote();
    252 
    253     let response_type = match &response {
    254         PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed,
    255         PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied,
    256     };
    257 
    258     // Extract relay-publish info before we move `response`.
    259     let allowed = matches!(&response, PermissionResponse::Allow { .. });
    260     let message = match &response {
    261         PermissionResponse::Allow { message } => message.clone(),
    262         PermissionResponse::Deny { reason } => Some(reason.clone()),
    263     };
    264 
    265     // If Allow has a message, add it as a User message to the chat
    266     if let PermissionResponse::Allow { message: Some(msg) } = &response {
    267         if !msg.is_empty() {
    268             session.chat.push(Message::User(msg.clone()));
    269         }
    270     }
    271 
    272     // Clear permission message state (agentic only)
    273     if let Some(agentic) = &mut session.agentic {
    274         agentic.permission_message_state = PermissionMessageState::None;
    275     }
    276 
    277     // Resolve through the single unified path
    278     if let Some(agentic) = &mut session.agentic {
    279         agentic.permissions.resolve(
    280             &mut session.chat,
    281             request_id,
    282             response_type,
    283             None,
    284             is_remote,
    285             Some(response),
    286         );
    287 
    288         // Optimistically set remote status to Working so the phone doesn't
    289         // have to wait for the full round-trip (phone→relay→desktop→relay→phone)
    290         // before auto-steal can move on. The desktop will publish the real
    291         // status once it processes the permission response.
    292         if is_remote {
    293             agentic.remote_status = Some(crate::agent_status::AgentStatus::Working);
    294         }
    295     }
    296 
    297     Some(PermissionPublish {
    298         perm_id: request_id,
    299         allowed,
    300         message,
    301     })
    302 }
    303 
    304 /// Handle a user's response to an AskUserQuestion tool call.
    305 pub fn handle_question_response(
    306     session_manager: &mut SessionManager,
    307     request_id: uuid::Uuid,
    308     answers: Vec<QuestionAnswer>,
    309 ) -> Option<PermissionPublish> {
    310     let session = session_manager.get_active_mut()?;
    311 
    312     let is_remote = session.is_remote();
    313 
    314     // Find the original AskUserQuestion request to get the question labels
    315     let questions_input = session.chat.iter().find_map(|msg| {
    316         if let Message::PermissionRequest(req) = msg {
    317             if req.id == request_id && req.tool_name == "AskUserQuestion" {
    318                 serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
    319             } else {
    320                 None
    321             }
    322         } else {
    323             None
    324         }
    325     });
    326 
    327     // Format answers as JSON for the tool response, and build summary for display
    328     let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input {
    329         let mut answers_obj = serde_json::Map::new();
    330         let mut summary_entries = Vec::with_capacity(questions.questions.len());
    331 
    332         for (q_idx, (question, answer)) in
    333             questions.questions.iter().zip(answers.iter()).enumerate()
    334         {
    335             let mut answer_obj = serde_json::Map::new();
    336 
    337             // Map selected indices to option labels
    338             let selected_labels: Vec<String> = answer
    339                 .selected
    340                 .iter()
    341                 .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
    342                 .collect();
    343 
    344             answer_obj.insert(
    345                 "selected".to_string(),
    346                 serde_json::Value::Array(
    347                     selected_labels
    348                         .iter()
    349                         .cloned()
    350                         .map(serde_json::Value::String)
    351                         .collect(),
    352                 ),
    353             );
    354 
    355             // Build display text for summary
    356             let mut display_parts = selected_labels;
    357             if let Some(ref other) = answer.other_text {
    358                 if !other.is_empty() {
    359                     answer_obj.insert(
    360                         "other".to_string(),
    361                         serde_json::Value::String(other.clone()),
    362                     );
    363                     display_parts.push(format!("Other: {}", other));
    364                 }
    365             }
    366 
    367             // Use header as the key, fall back to question index
    368             let key = if !question.header.is_empty() {
    369                 question.header.clone()
    370             } else {
    371                 format!("question_{}", q_idx)
    372             };
    373             answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj));
    374 
    375             summary_entries.push(AnswerSummaryEntry {
    376                 header: key,
    377                 answer: display_parts.join(", "),
    378             });
    379         }
    380 
    381         (
    382             serde_json::json!({ "answers": answers_obj }).to_string(),
    383             Some(AnswerSummary {
    384                 entries: summary_entries,
    385             }),
    386         )
    387     } else {
    388         // Fallback: just serialize the answers directly
    389         (
    390             serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()),
    391             None,
    392         )
    393     };
    394 
    395     // Clean up transient answer state
    396     if let Some(agentic) = &mut session.agentic {
    397         agentic.question_answers.remove(&request_id);
    398         agentic.question_index.remove(&request_id);
    399 
    400         // Resolve through the single unified path
    401         let oneshot_response = PermissionResponse::Allow {
    402             message: Some(formatted_response.clone()),
    403         };
    404         agentic.permissions.resolve(
    405             &mut session.chat,
    406             request_id,
    407             crate::messages::PermissionResponseType::Allowed,
    408             answer_summary,
    409             is_remote,
    410             Some(oneshot_response),
    411         );
    412 
    413         // Optimistically set remote status to Working (same as permission response)
    414         if is_remote {
    415             agentic.remote_status = Some(crate::agent_status::AgentStatus::Working);
    416         }
    417     }
    418 
    419     Some(PermissionPublish {
    420         perm_id: request_id,
    421         allowed: true,
    422         message: Some(formatted_response),
    423     })
    424 }
    425 
    426 // =============================================================================
    427 // Agent Navigation
    428 // =============================================================================
    429 
    430 /// Switch to a session and optionally focus it in the scene.
    431 ///
    432 /// Handles the common pattern of: switch_to → scene.select → scene.focus_on → focus_requested.
    433 /// Used by navigation, focus queue, and auto-steal-focus operations.
    434 pub fn switch_and_focus_session(
    435     session_manager: &mut SessionManager,
    436     scene: &mut AgentScene,
    437     show_scene: bool,
    438     id: SessionId,
    439 ) {
    440     session_manager.switch_to(id);
    441     if show_scene {
    442         scene.select(id);
    443         if let Some(session) = session_manager.get(id) {
    444             if let Some(agentic) = &session.agentic {
    445                 scene.focus_on(agentic.scene_position);
    446             }
    447         }
    448     }
    449     if let Some(session) = session_manager.get_mut(id) {
    450         if !session.has_pending_permissions() {
    451             session.focus_requested = true;
    452         }
    453     }
    454 }
    455 
    456 /// Switch to agent by index in the visual display order (0-indexed).
    457 pub fn switch_to_agent_by_index(
    458     session_manager: &mut SessionManager,
    459     scene: &mut AgentScene,
    460     show_scene: bool,
    461     index: usize,
    462 ) {
    463     let ids = session_manager.visual_order();
    464     if let Some(&id) = ids.get(index) {
    465         switch_and_focus_session(session_manager, scene, show_scene, id);
    466     }
    467 }
    468 
    469 /// Cycle agents using a direction function that computes the next index.
    470 fn cycle_agent(
    471     session_manager: &mut SessionManager,
    472     scene: &mut AgentScene,
    473     show_scene: bool,
    474     index_fn: impl FnOnce(usize, usize) -> usize,
    475 ) {
    476     let ids = session_manager.visual_order();
    477     if ids.is_empty() {
    478         return;
    479     }
    480     let current_idx = session_manager
    481         .active_id()
    482         .and_then(|active| ids.iter().position(|&id| id == active))
    483         .unwrap_or(0);
    484     let next_idx = index_fn(current_idx, ids.len());
    485     if let Some(&id) = ids.get(next_idx) {
    486         switch_and_focus_session(session_manager, scene, show_scene, id);
    487     }
    488 }
    489 
    490 /// Cycle to the next agent.
    491 pub fn cycle_next_agent(
    492     session_manager: &mut SessionManager,
    493     scene: &mut AgentScene,
    494     show_scene: bool,
    495 ) {
    496     cycle_agent(session_manager, scene, show_scene, |idx, len| {
    497         (idx + 1) % len
    498     });
    499 }
    500 
    501 /// Cycle to the previous agent.
    502 pub fn cycle_prev_agent(
    503     session_manager: &mut SessionManager,
    504     scene: &mut AgentScene,
    505     show_scene: bool,
    506 ) {
    507     cycle_agent(session_manager, scene, show_scene, |idx, len| {
    508         if idx == 0 {
    509             len - 1
    510         } else {
    511             idx - 1
    512         }
    513     });
    514 }
    515 
    516 // =============================================================================
    517 // Focus Queue Operations
    518 // =============================================================================
    519 
    520 /// Navigate to the next item in the focus queue.
    521 /// Done items are automatically dismissed after switching to them.
    522 pub fn focus_queue_next(
    523     session_manager: &mut SessionManager,
    524     focus_queue: &mut FocusQueue,
    525     scene: &mut AgentScene,
    526     show_scene: bool,
    527 ) {
    528     if let Some(session_id) = focus_queue.next() {
    529         switch_and_focus_session(session_manager, scene, show_scene, session_id);
    530         dismiss_done(session_manager, focus_queue, session_id);
    531     }
    532 }
    533 
    534 /// Navigate to the previous item in the focus queue.
    535 /// Done items are automatically dismissed after switching to them.
    536 pub fn focus_queue_prev(
    537     session_manager: &mut SessionManager,
    538     focus_queue: &mut FocusQueue,
    539     scene: &mut AgentScene,
    540     show_scene: bool,
    541 ) {
    542     if let Some(session_id) = focus_queue.prev() {
    543         switch_and_focus_session(session_manager, scene, show_scene, session_id);
    544         dismiss_done(session_manager, focus_queue, session_id);
    545     }
    546 }
    547 
    548 /// Dismiss a Done session from the focus queue and clear its indicator.
    549 fn dismiss_done(
    550     session_manager: &mut SessionManager,
    551     focus_queue: &mut FocusQueue,
    552     session_id: SessionId,
    553 ) {
    554     if focus_queue.get_session_priority(session_id) == Some(FocusPriority::Done) {
    555         focus_queue.dequeue_done(session_id);
    556         if let Some(session) = session_manager.get_mut(session_id) {
    557             if session.indicator == Some(FocusPriority::Done) {
    558                 session.indicator = None;
    559                 session.state_dirty = true;
    560             }
    561         }
    562     }
    563 }
    564 
    565 /// Toggle Done status for the current focus queue item.
    566 pub fn focus_queue_toggle_done(focus_queue: &mut FocusQueue) {
    567     if let Some(entry) = focus_queue.current() {
    568         if entry.priority == FocusPriority::Done {
    569             focus_queue.dequeue(entry.session_id);
    570         }
    571     }
    572 }
    573 
    574 /// Toggle auto-steal focus mode.
    575 /// Returns the new auto_steal_focus state.
    576 pub fn toggle_auto_steal(
    577     session_manager: &mut SessionManager,
    578     scene: &mut AgentScene,
    579     show_scene: bool,
    580     auto_steal_focus: bool,
    581     home_session: &mut Option<SessionId>,
    582 ) -> bool {
    583     let new_state = !auto_steal_focus;
    584 
    585     if new_state {
    586         // Enabling: record current session as home
    587         *home_session = session_manager.active_id();
    588         tracing::debug!("Auto-steal focus enabled, home session: {:?}", home_session);
    589     } else {
    590         // Disabling: switch back to home session if set
    591         if let Some(home_id) = home_session.take() {
    592             switch_and_focus_session(session_manager, scene, show_scene, home_id);
    593             tracing::debug!("Auto-steal focus disabled, returned to home session");
    594         }
    595     }
    596 
    597     // Request focus on input after toggle
    598     if let Some(session) = session_manager.get_active_mut() {
    599         session.focus_requested = true;
    600     }
    601 
    602     new_state
    603 }
    604 
    605 /// Process auto-steal focus logic: switch to focus queue items as needed.
    606 /// Returns true if focus was stolen (switched to a NeedsInput or Done session),
    607 /// which can be used to raise the OS window.
    608 pub fn process_auto_steal_focus(
    609     session_manager: &mut SessionManager,
    610     focus_queue: &mut FocusQueue,
    611     scene: &mut AgentScene,
    612     show_scene: bool,
    613     auto_steal_focus: bool,
    614     home_session: &mut Option<SessionId>,
    615 ) -> bool {
    616     if !auto_steal_focus {
    617         return false;
    618     }
    619 
    620     let has_needs_input = focus_queue.has_needs_input();
    621     let has_done = focus_queue.has_done();
    622 
    623     if has_needs_input {
    624         // There are NeedsInput items - check if we need to steal focus
    625         let current_session = session_manager.active_id();
    626         let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id));
    627         let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput);
    628 
    629         if !already_on_needs_input {
    630             // Save current session before stealing (only if we haven't saved yet)
    631             if home_session.is_none() {
    632                 *home_session = current_session;
    633                 tracing::debug!("Auto-steal: saved home session {:?}", home_session);
    634             }
    635 
    636             // Jump to first NeedsInput item
    637             if let Some(idx) = focus_queue.first_needs_input_index() {
    638                 focus_queue.set_cursor(idx);
    639                 if let Some(entry) = focus_queue.current() {
    640                     switch_and_focus_session(session_manager, scene, show_scene, entry.session_id);
    641                     tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id);
    642                     return true;
    643                 }
    644             }
    645         }
    646     } else if has_done {
    647         // No NeedsInput but there are Done items - auto-focus those
    648         let current_session = session_manager.active_id();
    649         let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id));
    650         let already_on_done = current_priority == Some(FocusPriority::Done);
    651 
    652         if !already_on_done {
    653             // Save current session before stealing (only if we haven't saved yet)
    654             if home_session.is_none() {
    655                 *home_session = current_session;
    656                 tracing::debug!("Auto-steal: saved home session {:?}", home_session);
    657             }
    658 
    659             // Jump to first Done item (keep in queue; cleared externally
    660             // when the session's clearing condition is met)
    661             if let Some(idx) = focus_queue.first_done_index() {
    662                 focus_queue.set_cursor(idx);
    663                 if let Some(entry) = focus_queue.current() {
    664                     let sid = entry.session_id;
    665                     switch_and_focus_session(session_manager, scene, show_scene, sid);
    666                     tracing::debug!("Auto-steal: switched to Done session {:?}", sid);
    667                     return true;
    668                 }
    669             }
    670         }
    671     } else if let Some(home_id) = home_session.take() {
    672         // No more NeedsInput or Done items - return to saved session
    673         switch_and_focus_session(session_manager, scene, show_scene, home_id);
    674         tracing::debug!("Auto-steal: returned to home session {:?}", home_id);
    675     }
    676 
    677     false
    678 }
    679 
    680 // =============================================================================
    681 // External Editor
    682 // =============================================================================
    683 
    684 /// Open an external editor for composing the input text (non-blocking).
    685 ///
    686 /// Launches `$VISUAL` or `$EDITOR` (default: vim) in a **new** terminal
    687 /// window so it never hijacks the terminal notedeck was launched from.
    688 /// On macOS, uses `$TERM_PROGRAM` to detect the user's terminal; on
    689 /// Linux, checks `$TERMINAL` then probes common emulators.
    690 pub fn open_external_editor(session_manager: &mut SessionManager) {
    691     // Don't spawn another editor if one is already pending
    692     if session_manager.pending_editor.is_some() {
    693         tracing::warn!("External editor already in progress");
    694         return;
    695     }
    696 
    697     let Some(session) = session_manager.get_active_mut() else {
    698         return;
    699     };
    700     let session_id = session.id;
    701     let input_content = session.input.clone();
    702 
    703     // Create temp file with a unique name to avoid vim swap file conflicts
    704     let temp_path = std::env::temp_dir().join(format!(
    705         "notedeck_input_{}.txt",
    706         std::process::id()
    707             ^ (std::time::SystemTime::now()
    708                 .duration_since(std::time::UNIX_EPOCH)
    709                 .map(|d| d.as_millis() as u32)
    710                 .unwrap_or(0))
    711     ));
    712     if let Err(e) = std::fs::write(&temp_path, &input_content) {
    713         tracing::error!("Failed to write temp file for external editor: {}", e);
    714         return;
    715     }
    716 
    717     let editor = std::env::var("VISUAL")
    718         .or_else(|_| std::env::var("EDITOR"))
    719         .unwrap_or_else(|_| "vim".to_string());
    720 
    721     // Always open in a new terminal window so we never steal the
    722     // launching terminal's tty (which breaks when the app is disowned).
    723     let spawn_result = if cfg!(target_os = "macos") {
    724         spawn_macos_editor(&editor, &temp_path)
    725     } else {
    726         spawn_linux_editor(&editor, &temp_path)
    727     };
    728 
    729     match spawn_result {
    730         Ok(child) => {
    731             session_manager.pending_editor = Some(EditorJob {
    732                 child,
    733                 temp_path,
    734                 session_id,
    735             });
    736             tracing::debug!("External editor spawned for session {}", session_id);
    737         }
    738         Err(e) => {
    739             tracing::error!("Failed to spawn external editor: {}", e);
    740             let _ = std::fs::remove_file(&temp_path);
    741         }
    742     }
    743 }
    744 
    745 /// macOS: open the editor in a new terminal window.
    746 ///
    747 /// Uses `$TERM_PROGRAM` to detect the running terminal and launch a new
    748 /// window with the right CLI invocation. Falls back to `open -W -t`
    749 /// (system default text editor) if the terminal is unknown.
    750 fn spawn_macos_editor(
    751     editor: &str,
    752     file: &std::path::Path,
    753 ) -> std::io::Result<std::process::Child> {
    754     use std::process::{Command, Stdio};
    755 
    756     let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
    757     tracing::debug!("macOS TERM_PROGRAM={}, editor={}", term_program, editor);
    758 
    759     match term_program.as_str() {
    760         "WezTerm" => {
    761             let bin = find_macos_bin("wezterm", "WezTerm");
    762             Command::new(&bin)
    763                 .args(["start", "--always-new-process", "--"])
    764                 .arg(editor)
    765                 .arg(file)
    766                 .stdin(Stdio::null())
    767                 .stdout(Stdio::null())
    768                 .stderr(Stdio::null())
    769                 .spawn()
    770         }
    771         "kitty" => {
    772             let bin = find_macos_bin("kitty", "kitty");
    773             Command::new(&bin)
    774                 .arg(editor)
    775                 .arg(file)
    776                 .stdin(Stdio::null())
    777                 .stdout(Stdio::null())
    778                 .stderr(Stdio::null())
    779                 .spawn()
    780         }
    781         "Alacritty" | "alacritty" => {
    782             let bin = find_macos_bin("alacritty", "Alacritty");
    783             Command::new(&bin)
    784                 .arg("-e")
    785                 .arg(editor)
    786                 .arg(file)
    787                 .stdin(Stdio::null())
    788                 .stdout(Stdio::null())
    789                 .stderr(Stdio::null())
    790                 .spawn()
    791         }
    792         _ => {
    793             // Unknown terminal — open in system default text editor
    794             tracing::debug!(
    795                 "Unknown TERM_PROGRAM '{}', using `open -W -t`",
    796                 term_program
    797             );
    798             Command::new("open")
    799                 .arg("-W")
    800                 .arg("-t")
    801                 .arg(file)
    802                 .stdin(Stdio::null())
    803                 .stdout(Stdio::null())
    804                 .stderr(Stdio::null())
    805                 .spawn()
    806         }
    807     }
    808 }
    809 
    810 /// Find a binary on PATH or inside /Applications/<app>.app/Contents/MacOS/.
    811 fn find_macos_bin(bin_name: &str, app_name: &str) -> String {
    812     use std::process::Command;
    813 
    814     // Try PATH first
    815     if let Ok(output) = Command::new("which").arg(bin_name).output() {
    816         if output.status.success() {
    817             let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
    818             if !path.is_empty() {
    819                 return path;
    820             }
    821         }
    822     }
    823 
    824     // Check app bundle
    825     let bundle = format!("/Applications/{}.app/Contents/MacOS/{}", app_name, bin_name);
    826     if std::path::Path::new(&bundle).exists() {
    827         return bundle;
    828     }
    829 
    830     bin_name.to_string()
    831 }
    832 
    833 /// Linux: spawn a terminal emulator with the editor.
    834 fn spawn_linux_editor(
    835     editor: &str,
    836     file: &std::path::Path,
    837 ) -> std::io::Result<std::process::Child> {
    838     use std::process::Command;
    839 
    840     if let Ok(terminal) = std::env::var("TERMINAL") {
    841         return Command::new(&terminal)
    842             .arg("-e")
    843             .arg(editor)
    844             .arg(file)
    845             .spawn();
    846     }
    847 
    848     // Auto-detect. Each terminal has different exec syntax.
    849     let terminals: &[(&str, &[&str])] = &[
    850         ("wezterm", &["start", "--always-new-process", "--"]),
    851         ("alacritty", &["-e"]),
    852         ("kitty", &[]),
    853         ("gnome-terminal", &["--"]),
    854         ("konsole", &["-e"]),
    855         ("urxvtc", &["-e"]),
    856         ("urxvt", &["-e"]),
    857         ("xterm", &["-e"]),
    858     ];
    859 
    860     for (name, prefix_args) in terminals {
    861         let found = Command::new("which")
    862             .arg(name)
    863             .output()
    864             .map(|o| o.status.success())
    865             .unwrap_or(false);
    866 
    867         if found {
    868             tracing::debug!("Opening editor via {}: {} {}", name, editor, file.display());
    869             let mut cmd = Command::new(name);
    870             for arg in *prefix_args {
    871                 cmd.arg(arg);
    872             }
    873             cmd.arg(editor).arg(file);
    874             return cmd.spawn();
    875         }
    876     }
    877 
    878     Err(std::io::Error::new(
    879         std::io::ErrorKind::NotFound,
    880         "No terminal emulator found. Set $TERMINAL or $VISUAL.",
    881     ))
    882 }
    883 
    884 /// Poll for external editor completion (called each frame).
    885 pub fn poll_editor_job(session_manager: &mut SessionManager) {
    886     let Some(ref mut job) = session_manager.pending_editor else {
    887         return;
    888     };
    889 
    890     // Non-blocking check if child has exited
    891     match job.child.try_wait() {
    892         Ok(Some(status)) => {
    893             let session_id = job.session_id;
    894             let temp_path = job.temp_path.clone();
    895 
    896             if status.success() {
    897                 match std::fs::read_to_string(&temp_path) {
    898                     Ok(content) => {
    899                         if let Some(session) = session_manager.get_mut(session_id) {
    900                             session.input = content;
    901                             session.focus_requested = true;
    902                             tracing::debug!(
    903                                 "External editor completed, updated input for session {}",
    904                                 session_id
    905                             );
    906                         }
    907                     }
    908                     Err(e) => {
    909                         tracing::error!("Failed to read temp file after editing: {}", e);
    910                     }
    911                 }
    912             } else {
    913                 tracing::warn!("External editor exited with status: {}", status);
    914             }
    915 
    916             if let Err(e) = std::fs::remove_file(&temp_path) {
    917                 tracing::error!("Failed to remove temp file: {}", e);
    918             }
    919 
    920             session_manager.pending_editor = None;
    921         }
    922         Ok(None) => {
    923             // Editor still running
    924         }
    925         Err(e) => {
    926             tracing::error!("Failed to poll editor process: {}", e);
    927             let temp_path = job.temp_path.clone();
    928             let _ = std::fs::remove_file(&temp_path);
    929             session_manager.pending_editor = None;
    930         }
    931     }
    932 }
    933 
    934 // =============================================================================
    935 // Session Management
    936 // =============================================================================
    937 
    938 /// Create a new session with the given cwd.
    939 #[allow(clippy::too_many_arguments)]
    940 pub fn create_session_with_cwd(
    941     session_manager: &mut SessionManager,
    942     directory_picker: &mut DirectoryPicker,
    943     scene: &mut AgentScene,
    944     show_scene: bool,
    945     ai_mode: AiMode,
    946     cwd: PathBuf,
    947     hostname: &str,
    948     backend_type: BackendType,
    949     ndb: Option<&nostrdb::Ndb>,
    950 ) -> SessionId {
    951     directory_picker.add_recent(cwd.clone());
    952 
    953     let id = session_manager.new_session(cwd, ai_mode, backend_type);
    954     if let Some(session) = session_manager.get_mut(id) {
    955         session.details.hostname = hostname.to_string();
    956         session.focus_requested = true;
    957         if show_scene {
    958             scene.select(id);
    959             if let Some(agentic) = &session.agentic {
    960                 scene.focus_on(agentic.scene_position);
    961             }
    962         }
    963 
    964         // Set up ndb subscriptions so remote clients can send messages
    965         // to this session (e.g. to kickstart the backend remotely).
    966         if let (Some(ndb), Some(agentic)) = (ndb, &mut session.agentic) {
    967             let event_id = agentic.event_session_id().to_string();
    968             crate::setup_conversation_subscription(agentic, &event_id, ndb);
    969             crate::setup_conversation_action_subscription(agentic, &event_id, ndb);
    970         }
    971     }
    972     session_manager.rebuild_host_groups();
    973     id
    974 }
    975 
    976 /// Create a new session that resumes an existing Claude conversation.
    977 #[allow(clippy::too_many_arguments)]
    978 pub fn create_resumed_session_with_cwd(
    979     session_manager: &mut SessionManager,
    980     directory_picker: &mut DirectoryPicker,
    981     scene: &mut AgentScene,
    982     show_scene: bool,
    983     ai_mode: AiMode,
    984     cwd: PathBuf,
    985     resume_session_id: String,
    986     title: String,
    987     hostname: &str,
    988     backend_type: BackendType,
    989 ) -> SessionId {
    990     directory_picker.add_recent(cwd.clone());
    991 
    992     let id =
    993         session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode, backend_type);
    994     if let Some(session) = session_manager.get_mut(id) {
    995         session.details.hostname = hostname.to_string();
    996         session.focus_requested = true;
    997         if show_scene {
    998             scene.select(id);
    999             if let Some(agentic) = &session.agentic {
   1000                 scene.focus_on(agentic.scene_position);
   1001             }
   1002         }
   1003     }
   1004     session_manager.rebuild_host_groups();
   1005     id
   1006 }
   1007 
   1008 /// Clone the active agent, creating a new session with the same working directory.
   1009 pub fn clone_active_agent(
   1010     session_manager: &mut SessionManager,
   1011     directory_picker: &mut DirectoryPicker,
   1012     scene: &mut AgentScene,
   1013     show_scene: bool,
   1014     ai_mode: AiMode,
   1015     hostname: &str,
   1016 ) -> Option<SessionId> {
   1017     let active = session_manager.get_active()?;
   1018     let cwd = active.cwd().cloned()?;
   1019     let backend_type = active.backend_type;
   1020     Some(create_session_with_cwd(
   1021         session_manager,
   1022         directory_picker,
   1023         scene,
   1024         show_scene,
   1025         ai_mode,
   1026         cwd,
   1027         hostname,
   1028         backend_type,
   1029         None,
   1030     ))
   1031 }
   1032 
   1033 /// Delete a session and clean up backend resources.
   1034 pub fn delete_session(
   1035     session_manager: &mut SessionManager,
   1036     focus_queue: &mut FocusQueue,
   1037     backend: &dyn AiBackend,
   1038     directory_picker: &mut DirectoryPicker,
   1039     id: SessionId,
   1040 ) -> bool {
   1041     focus_queue.remove_session(id);
   1042     if session_manager.delete_session(id) {
   1043         let session_id = format!("dave-session-{}", id);
   1044         backend.cleanup_session(session_id);
   1045 
   1046         if session_manager.is_empty() {
   1047             directory_picker.open();
   1048         }
   1049         true
   1050     } else {
   1051         false
   1052     }
   1053 }
   1054 
   1055 // =============================================================================
   1056 // Send Action Handling
   1057 // =============================================================================
   1058 
   1059 /// Handle the /cd command if present in input.
   1060 /// Returns Some(Ok(path)) if cd succeeded, Some(Err(())) if cd failed, None if not a cd command.
   1061 pub fn handle_cd_command(session: &mut ChatSession) -> Option<Result<PathBuf, ()>> {
   1062     let input = session.input.trim().to_string();
   1063     if !input.starts_with("/cd ") {
   1064         return None;
   1065     }
   1066 
   1067     let path_str = input.strip_prefix("/cd ").unwrap().trim();
   1068     let path = PathBuf::from(path_str);
   1069     session.input.clear();
   1070 
   1071     if path.exists() && path.is_dir() {
   1072         if let Some(agentic) = &mut session.agentic {
   1073             agentic.cwd = path.clone();
   1074         }
   1075         session.chat.push(Message::System(format!(
   1076             "Working directory set to: {}",
   1077             path.display()
   1078         )));
   1079         Some(Ok(path))
   1080     } else {
   1081         session
   1082             .chat
   1083             .push(Message::Error(format!("Invalid directory: {}", path_str)));
   1084         Some(Err(()))
   1085     }
   1086 }