notedeck

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

update.rs (29437B)


      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;
      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.pending_permissions.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 /// Toggle plan mode for the active session.
     94 pub fn toggle_plan_mode(
     95     session_manager: &mut SessionManager,
     96     backend: &dyn AiBackend,
     97     ctx: &egui::Context,
     98 ) {
     99     if let Some(session) = session_manager.get_active_mut() {
    100         if let Some(agentic) = &mut session.agentic {
    101             let new_mode = match agentic.permission_mode {
    102                 PermissionMode::Plan => PermissionMode::Default,
    103                 _ => PermissionMode::Plan,
    104             };
    105             agentic.permission_mode = new_mode;
    106 
    107             let session_id = format!("dave-session-{}", session.id);
    108             backend.set_permission_mode(session_id, new_mode, ctx.clone());
    109 
    110             tracing::debug!(
    111                 "Toggled plan mode for session {} to {:?}",
    112                 session.id,
    113                 new_mode
    114             );
    115         }
    116     }
    117 }
    118 
    119 /// Exit plan mode for the active session (switch to Default mode).
    120 pub fn exit_plan_mode(
    121     session_manager: &mut SessionManager,
    122     backend: &dyn AiBackend,
    123     ctx: &egui::Context,
    124 ) {
    125     if let Some(session) = session_manager.get_active_mut() {
    126         if let Some(agentic) = &mut session.agentic {
    127             agentic.permission_mode = PermissionMode::Default;
    128             let session_id = format!("dave-session-{}", session.id);
    129             backend.set_permission_mode(session_id, PermissionMode::Default, ctx.clone());
    130             tracing::debug!("Exited plan mode for session {}", session.id);
    131         }
    132     }
    133 }
    134 
    135 // =============================================================================
    136 // Permission Handling
    137 // =============================================================================
    138 
    139 /// Get the first pending permission request ID for the active session.
    140 pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid::Uuid> {
    141     session_manager
    142         .get_active()
    143         .and_then(|session| session.agentic.as_ref())
    144         .and_then(|agentic| agentic.pending_permissions.keys().next().copied())
    145 }
    146 
    147 /// Get the tool name of the first pending permission request.
    148 pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> {
    149     let session = session_manager.get_active()?;
    150     let agentic = session.agentic.as_ref()?;
    151     let request_id = agentic.pending_permissions.keys().next()?;
    152 
    153     for msg in &session.chat {
    154         if let Message::PermissionRequest(req) = msg {
    155             if &req.id == request_id {
    156                 return Some(&req.tool_name);
    157             }
    158         }
    159     }
    160 
    161     None
    162 }
    163 
    164 /// Check if the first pending permission is an AskUserQuestion tool call.
    165 pub fn has_pending_question(session_manager: &SessionManager) -> bool {
    166     pending_permission_tool_name(session_manager) == Some("AskUserQuestion")
    167 }
    168 
    169 /// Check if the first pending permission is an ExitPlanMode tool call.
    170 pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool {
    171     pending_permission_tool_name(session_manager) == Some("ExitPlanMode")
    172 }
    173 
    174 /// Handle a permission response (from UI button or keybinding).
    175 pub fn handle_permission_response(
    176     session_manager: &mut SessionManager,
    177     request_id: uuid::Uuid,
    178     response: PermissionResponse,
    179 ) {
    180     let Some(session) = session_manager.get_active_mut() else {
    181         return;
    182     };
    183 
    184     // Record the response type in the message for UI display
    185     let response_type = match &response {
    186         PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed,
    187         PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied,
    188     };
    189 
    190     // If Allow has a message, add it as a User message to the chat
    191     if let PermissionResponse::Allow { message: Some(msg) } = &response {
    192         if !msg.is_empty() {
    193             session.chat.push(Message::User(msg.clone()));
    194         }
    195     }
    196 
    197     // Clear permission message state (agentic only)
    198     if let Some(agentic) = &mut session.agentic {
    199         agentic.permission_message_state = PermissionMessageState::None;
    200     }
    201 
    202     for msg in &mut session.chat {
    203         if let Message::PermissionRequest(req) = msg {
    204             if req.id == request_id {
    205                 req.response = Some(response_type);
    206                 break;
    207             }
    208         }
    209     }
    210 
    211     if let Some(agentic) = &mut session.agentic {
    212         if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
    213             if sender.send(response).is_err() {
    214                 tracing::error!(
    215                     "Failed to send permission response for request {}",
    216                     request_id
    217                 );
    218             }
    219         } else {
    220             tracing::warn!("No pending permission found for request {}", request_id);
    221         }
    222     }
    223 }
    224 
    225 /// Handle a user's response to an AskUserQuestion tool call.
    226 pub fn handle_question_response(
    227     session_manager: &mut SessionManager,
    228     request_id: uuid::Uuid,
    229     answers: Vec<QuestionAnswer>,
    230 ) {
    231     let Some(session) = session_manager.get_active_mut() else {
    232         return;
    233     };
    234 
    235     // Find the original AskUserQuestion request to get the question labels
    236     let questions_input = session.chat.iter().find_map(|msg| {
    237         if let Message::PermissionRequest(req) = msg {
    238             if req.id == request_id && req.tool_name == "AskUserQuestion" {
    239                 serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
    240             } else {
    241                 None
    242             }
    243         } else {
    244             None
    245         }
    246     });
    247 
    248     // Format answers as JSON for the tool response, and build summary for display
    249     let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input {
    250         let mut answers_obj = serde_json::Map::new();
    251         let mut summary_entries = Vec::with_capacity(questions.questions.len());
    252 
    253         for (q_idx, (question, answer)) in
    254             questions.questions.iter().zip(answers.iter()).enumerate()
    255         {
    256             let mut answer_obj = serde_json::Map::new();
    257 
    258             // Map selected indices to option labels
    259             let selected_labels: Vec<String> = answer
    260                 .selected
    261                 .iter()
    262                 .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
    263                 .collect();
    264 
    265             answer_obj.insert(
    266                 "selected".to_string(),
    267                 serde_json::Value::Array(
    268                     selected_labels
    269                         .iter()
    270                         .cloned()
    271                         .map(serde_json::Value::String)
    272                         .collect(),
    273                 ),
    274             );
    275 
    276             // Build display text for summary
    277             let mut display_parts = selected_labels;
    278             if let Some(ref other) = answer.other_text {
    279                 if !other.is_empty() {
    280                     answer_obj.insert(
    281                         "other".to_string(),
    282                         serde_json::Value::String(other.clone()),
    283                     );
    284                     display_parts.push(format!("Other: {}", other));
    285                 }
    286             }
    287 
    288             // Use header as the key, fall back to question index
    289             let key = if !question.header.is_empty() {
    290                 question.header.clone()
    291             } else {
    292                 format!("question_{}", q_idx)
    293             };
    294             answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj));
    295 
    296             summary_entries.push(AnswerSummaryEntry {
    297                 header: key,
    298                 answer: display_parts.join(", "),
    299             });
    300         }
    301 
    302         (
    303             serde_json::json!({ "answers": answers_obj }).to_string(),
    304             Some(AnswerSummary {
    305                 entries: summary_entries,
    306             }),
    307         )
    308     } else {
    309         // Fallback: just serialize the answers directly
    310         (
    311             serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()),
    312             None,
    313         )
    314     };
    315 
    316     // Mark the request as allowed in the UI and store the summary for display
    317     for msg in &mut session.chat {
    318         if let Message::PermissionRequest(req) = msg {
    319             if req.id == request_id {
    320                 req.response = Some(crate::messages::PermissionResponseType::Allowed);
    321                 req.answer_summary = answer_summary.clone();
    322                 break;
    323             }
    324         }
    325     }
    326 
    327     // Clean up transient answer state and send response (agentic only)
    328     if let Some(agentic) = &mut session.agentic {
    329         agentic.question_answers.remove(&request_id);
    330         agentic.question_index.remove(&request_id);
    331 
    332         // Send the response through the permission channel
    333         if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
    334             let response = PermissionResponse::Allow {
    335                 message: Some(formatted_response),
    336             };
    337             if sender.send(response).is_err() {
    338                 tracing::error!(
    339                     "Failed to send question response for request {}",
    340                     request_id
    341                 );
    342             }
    343         } else {
    344             tracing::warn!("No pending permission found for request {}", request_id);
    345         }
    346     }
    347 }
    348 
    349 // =============================================================================
    350 // Agent Navigation
    351 // =============================================================================
    352 
    353 /// Switch to agent by index in the ordered list (0-indexed).
    354 pub fn switch_to_agent_by_index(
    355     session_manager: &mut SessionManager,
    356     scene: &mut AgentScene,
    357     show_scene: bool,
    358     index: usize,
    359 ) {
    360     let ids = session_manager.session_ids();
    361     if let Some(&id) = ids.get(index) {
    362         session_manager.switch_to(id);
    363         if show_scene {
    364             scene.select(id);
    365         }
    366         if let Some(session) = session_manager.get_mut(id) {
    367             if !session.has_pending_permissions() {
    368                 session.focus_requested = true;
    369             }
    370         }
    371     }
    372 }
    373 
    374 /// Cycle to the next agent.
    375 pub fn cycle_next_agent(
    376     session_manager: &mut SessionManager,
    377     scene: &mut AgentScene,
    378     show_scene: bool,
    379 ) {
    380     let ids = session_manager.session_ids();
    381     if ids.is_empty() {
    382         return;
    383     }
    384     let current_idx = session_manager
    385         .active_id()
    386         .and_then(|active| ids.iter().position(|&id| id == active))
    387         .unwrap_or(0);
    388     let next_idx = (current_idx + 1) % ids.len();
    389     if let Some(&id) = ids.get(next_idx) {
    390         session_manager.switch_to(id);
    391         if show_scene {
    392             scene.select(id);
    393         }
    394         if let Some(session) = session_manager.get_mut(id) {
    395             if !session.has_pending_permissions() {
    396                 session.focus_requested = true;
    397             }
    398         }
    399     }
    400 }
    401 
    402 /// Cycle to the previous agent.
    403 pub fn cycle_prev_agent(
    404     session_manager: &mut SessionManager,
    405     scene: &mut AgentScene,
    406     show_scene: bool,
    407 ) {
    408     let ids = session_manager.session_ids();
    409     if ids.is_empty() {
    410         return;
    411     }
    412     let current_idx = session_manager
    413         .active_id()
    414         .and_then(|active| ids.iter().position(|&id| id == active))
    415         .unwrap_or(0);
    416     let prev_idx = if current_idx == 0 {
    417         ids.len() - 1
    418     } else {
    419         current_idx - 1
    420     };
    421     if let Some(&id) = ids.get(prev_idx) {
    422         session_manager.switch_to(id);
    423         if show_scene {
    424             scene.select(id);
    425         }
    426         if let Some(session) = session_manager.get_mut(id) {
    427             if !session.has_pending_permissions() {
    428                 session.focus_requested = true;
    429             }
    430         }
    431     }
    432 }
    433 
    434 // =============================================================================
    435 // Focus Queue Operations
    436 // =============================================================================
    437 
    438 /// Navigate to the next item in the focus queue.
    439 pub fn focus_queue_next(
    440     session_manager: &mut SessionManager,
    441     focus_queue: &mut FocusQueue,
    442     scene: &mut AgentScene,
    443     show_scene: bool,
    444 ) {
    445     if let Some(session_id) = focus_queue.next() {
    446         session_manager.switch_to(session_id);
    447         if show_scene {
    448             scene.select(session_id);
    449             if let Some(session) = session_manager.get(session_id) {
    450                 if let Some(agentic) = &session.agentic {
    451                     scene.focus_on(agentic.scene_position);
    452                 }
    453             }
    454         }
    455         if let Some(session) = session_manager.get_mut(session_id) {
    456             if !session.has_pending_permissions() {
    457                 session.focus_requested = true;
    458             }
    459         }
    460     }
    461 }
    462 
    463 /// Navigate to the previous item in the focus queue.
    464 pub fn focus_queue_prev(
    465     session_manager: &mut SessionManager,
    466     focus_queue: &mut FocusQueue,
    467     scene: &mut AgentScene,
    468     show_scene: bool,
    469 ) {
    470     if let Some(session_id) = focus_queue.prev() {
    471         session_manager.switch_to(session_id);
    472         if show_scene {
    473             scene.select(session_id);
    474             if let Some(session) = session_manager.get(session_id) {
    475                 if let Some(agentic) = &session.agentic {
    476                     scene.focus_on(agentic.scene_position);
    477                 }
    478             }
    479         }
    480         if let Some(session) = session_manager.get_mut(session_id) {
    481             if !session.has_pending_permissions() {
    482                 session.focus_requested = true;
    483             }
    484         }
    485     }
    486 }
    487 
    488 /// Toggle Done status for the current focus queue item.
    489 pub fn focus_queue_toggle_done(focus_queue: &mut FocusQueue) {
    490     if let Some(entry) = focus_queue.current() {
    491         if entry.priority == FocusPriority::Done {
    492             focus_queue.dequeue(entry.session_id);
    493         }
    494     }
    495 }
    496 
    497 /// Toggle auto-steal focus mode.
    498 /// Returns the new auto_steal_focus state.
    499 pub fn toggle_auto_steal(
    500     session_manager: &mut SessionManager,
    501     scene: &mut AgentScene,
    502     show_scene: bool,
    503     auto_steal_focus: bool,
    504     home_session: &mut Option<SessionId>,
    505 ) -> bool {
    506     let new_state = !auto_steal_focus;
    507 
    508     if new_state {
    509         // Enabling: record current session as home
    510         *home_session = session_manager.active_id();
    511         tracing::debug!("Auto-steal focus enabled, home session: {:?}", home_session);
    512     } else {
    513         // Disabling: switch back to home session if set
    514         if let Some(home_id) = home_session.take() {
    515             session_manager.switch_to(home_id);
    516             if show_scene {
    517                 scene.select(home_id);
    518                 if let Some(session) = session_manager.get(home_id) {
    519                     if let Some(agentic) = &session.agentic {
    520                         scene.focus_on(agentic.scene_position);
    521                     }
    522                 }
    523             }
    524             tracing::debug!("Auto-steal focus disabled, returned to home session");
    525         }
    526     }
    527 
    528     // Request focus on input after toggle
    529     if let Some(session) = session_manager.get_active_mut() {
    530         session.focus_requested = true;
    531     }
    532 
    533     new_state
    534 }
    535 
    536 /// Process auto-steal focus logic: switch to focus queue items as needed.
    537 pub fn process_auto_steal_focus(
    538     session_manager: &mut SessionManager,
    539     focus_queue: &mut FocusQueue,
    540     scene: &mut AgentScene,
    541     show_scene: bool,
    542     auto_steal_focus: bool,
    543     home_session: &mut Option<SessionId>,
    544 ) {
    545     if !auto_steal_focus {
    546         return;
    547     }
    548 
    549     let has_needs_input = focus_queue.has_needs_input();
    550 
    551     if has_needs_input {
    552         // There are NeedsInput items - check if we need to steal focus
    553         let current_session = session_manager.active_id();
    554         let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id));
    555         let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput);
    556 
    557         if !already_on_needs_input {
    558             // Save current session before stealing (only if we haven't saved yet)
    559             if home_session.is_none() {
    560                 *home_session = current_session;
    561                 tracing::debug!("Auto-steal: saved home session {:?}", home_session);
    562             }
    563 
    564             // Jump to first NeedsInput item
    565             if let Some(idx) = focus_queue.first_needs_input_index() {
    566                 focus_queue.set_cursor(idx);
    567                 if let Some(entry) = focus_queue.current() {
    568                     session_manager.switch_to(entry.session_id);
    569                     if show_scene {
    570                         scene.select(entry.session_id);
    571                         if let Some(session) = session_manager.get(entry.session_id) {
    572                             if let Some(agentic) = &session.agentic {
    573                                 scene.focus_on(agentic.scene_position);
    574                             }
    575                         }
    576                     }
    577                     tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id);
    578                 }
    579             }
    580         }
    581     } else if let Some(home_id) = home_session.take() {
    582         // No more NeedsInput items - return to saved session
    583         session_manager.switch_to(home_id);
    584         if show_scene {
    585             scene.select(home_id);
    586             if let Some(session) = session_manager.get(home_id) {
    587                 if let Some(agentic) = &session.agentic {
    588                     scene.focus_on(agentic.scene_position);
    589                 }
    590             }
    591         }
    592         tracing::debug!("Auto-steal: returned to home session {:?}", home_id);
    593     }
    594 }
    595 
    596 // =============================================================================
    597 // External Editor
    598 // =============================================================================
    599 
    600 /// Try to find a common terminal emulator.
    601 pub fn find_terminal() -> Option<String> {
    602     use std::process::Command;
    603     let terminals = [
    604         "alacritty",
    605         "kitty",
    606         "gnome-terminal",
    607         "konsole",
    608         "urxvtc",
    609         "urxvt",
    610         "xterm",
    611     ];
    612     for term in terminals {
    613         if Command::new("which")
    614             .arg(term)
    615             .output()
    616             .map(|o| o.status.success())
    617             .unwrap_or(false)
    618         {
    619             return Some(term.to_string());
    620         }
    621     }
    622     None
    623 }
    624 
    625 /// Open an external editor for composing the input text (non-blocking).
    626 pub fn open_external_editor(session_manager: &mut SessionManager) {
    627     use std::process::Command;
    628 
    629     // Don't spawn another editor if one is already pending
    630     if session_manager.pending_editor.is_some() {
    631         tracing::warn!("External editor already in progress");
    632         return;
    633     }
    634 
    635     let Some(session) = session_manager.get_active_mut() else {
    636         return;
    637     };
    638     let session_id = session.id;
    639     let input_content = session.input.clone();
    640 
    641     // Create temp file with current input content
    642     let temp_path = std::env::temp_dir().join("notedeck_input.txt");
    643     if let Err(e) = std::fs::write(&temp_path, &input_content) {
    644         tracing::error!("Failed to write temp file for external editor: {}", e);
    645         return;
    646     }
    647 
    648     // Try $VISUAL first (GUI editors), then fall back to terminal + $EDITOR
    649     let visual = std::env::var("VISUAL").ok();
    650     let editor = std::env::var("EDITOR").ok();
    651 
    652     let spawn_result = if let Some(visual_editor) = visual {
    653         // $VISUAL is set - use it directly (assumes GUI editor)
    654         tracing::debug!("Opening external editor via $VISUAL: {}", visual_editor);
    655         Command::new(&visual_editor).arg(&temp_path).spawn()
    656     } else {
    657         // Fall back to terminal + $EDITOR
    658         let editor_cmd = editor.unwrap_or_else(|| "vim".to_string());
    659         let terminal = std::env::var("TERMINAL")
    660             .ok()
    661             .or_else(find_terminal)
    662             .unwrap_or_else(|| "xterm".to_string());
    663 
    664         tracing::debug!(
    665             "Opening external editor via terminal: {} -e {} {}",
    666             terminal,
    667             editor_cmd,
    668             temp_path.display()
    669         );
    670         Command::new(&terminal)
    671             .arg("-e")
    672             .arg(&editor_cmd)
    673             .arg(&temp_path)
    674             .spawn()
    675     };
    676 
    677     match spawn_result {
    678         Ok(child) => {
    679             session_manager.pending_editor = Some(EditorJob {
    680                 child,
    681                 temp_path,
    682                 session_id,
    683             });
    684             tracing::debug!("External editor spawned for session {}", session_id);
    685         }
    686         Err(e) => {
    687             tracing::error!("Failed to spawn external editor: {}", e);
    688             let _ = std::fs::remove_file(&temp_path);
    689         }
    690     }
    691 }
    692 
    693 /// Poll for external editor completion (called each frame).
    694 pub fn poll_editor_job(session_manager: &mut SessionManager) {
    695     let Some(ref mut job) = session_manager.pending_editor else {
    696         return;
    697     };
    698 
    699     // Non-blocking check if child has exited
    700     match job.child.try_wait() {
    701         Ok(Some(status)) => {
    702             let session_id = job.session_id;
    703             let temp_path = job.temp_path.clone();
    704 
    705             if status.success() {
    706                 match std::fs::read_to_string(&temp_path) {
    707                     Ok(content) => {
    708                         if let Some(session) = session_manager.get_mut(session_id) {
    709                             session.input = content;
    710                             session.focus_requested = true;
    711                             tracing::debug!(
    712                                 "External editor completed, updated input for session {}",
    713                                 session_id
    714                             );
    715                         }
    716                     }
    717                     Err(e) => {
    718                         tracing::error!("Failed to read temp file after editing: {}", e);
    719                     }
    720                 }
    721             } else {
    722                 tracing::warn!("External editor exited with status: {}", status);
    723             }
    724 
    725             if let Err(e) = std::fs::remove_file(&temp_path) {
    726                 tracing::error!("Failed to remove temp file: {}", e);
    727             }
    728 
    729             session_manager.pending_editor = None;
    730         }
    731         Ok(None) => {
    732             // Editor still running
    733         }
    734         Err(e) => {
    735             tracing::error!("Failed to poll editor process: {}", e);
    736             let temp_path = job.temp_path.clone();
    737             let _ = std::fs::remove_file(&temp_path);
    738             session_manager.pending_editor = None;
    739         }
    740     }
    741 }
    742 
    743 // =============================================================================
    744 // Session Management
    745 // =============================================================================
    746 
    747 /// Create a new session with the given cwd.
    748 pub fn create_session_with_cwd(
    749     session_manager: &mut SessionManager,
    750     directory_picker: &mut DirectoryPicker,
    751     scene: &mut AgentScene,
    752     show_scene: bool,
    753     ai_mode: AiMode,
    754     cwd: PathBuf,
    755 ) -> SessionId {
    756     directory_picker.add_recent(cwd.clone());
    757 
    758     let id = session_manager.new_session(cwd, ai_mode);
    759     if let Some(session) = session_manager.get_mut(id) {
    760         session.focus_requested = true;
    761         if show_scene {
    762             scene.select(id);
    763             if let Some(agentic) = &session.agentic {
    764                 scene.focus_on(agentic.scene_position);
    765             }
    766         }
    767     }
    768     id
    769 }
    770 
    771 /// Create a new session that resumes an existing Claude conversation.
    772 #[allow(clippy::too_many_arguments)]
    773 pub fn create_resumed_session_with_cwd(
    774     session_manager: &mut SessionManager,
    775     directory_picker: &mut DirectoryPicker,
    776     scene: &mut AgentScene,
    777     show_scene: bool,
    778     ai_mode: AiMode,
    779     cwd: PathBuf,
    780     resume_session_id: String,
    781     title: String,
    782 ) -> SessionId {
    783     directory_picker.add_recent(cwd.clone());
    784 
    785     let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode);
    786     if let Some(session) = session_manager.get_mut(id) {
    787         session.focus_requested = true;
    788         if show_scene {
    789             scene.select(id);
    790             if let Some(agentic) = &session.agentic {
    791                 scene.focus_on(agentic.scene_position);
    792             }
    793         }
    794     }
    795     id
    796 }
    797 
    798 /// Clone the active agent, creating a new session with the same working directory.
    799 pub fn clone_active_agent(
    800     session_manager: &mut SessionManager,
    801     directory_picker: &mut DirectoryPicker,
    802     scene: &mut AgentScene,
    803     show_scene: bool,
    804     ai_mode: AiMode,
    805 ) -> Option<SessionId> {
    806     let cwd = session_manager
    807         .get_active()
    808         .and_then(|s| s.cwd().cloned())?;
    809     Some(create_session_with_cwd(
    810         session_manager,
    811         directory_picker,
    812         scene,
    813         show_scene,
    814         ai_mode,
    815         cwd,
    816     ))
    817 }
    818 
    819 /// Delete a session and clean up backend resources.
    820 pub fn delete_session(
    821     session_manager: &mut SessionManager,
    822     focus_queue: &mut FocusQueue,
    823     backend: &dyn AiBackend,
    824     directory_picker: &mut DirectoryPicker,
    825     id: SessionId,
    826 ) -> bool {
    827     focus_queue.remove_session(id);
    828     if session_manager.delete_session(id) {
    829         let session_id = format!("dave-session-{}", id);
    830         backend.cleanup_session(session_id);
    831 
    832         if session_manager.is_empty() {
    833             directory_picker.open();
    834         }
    835         true
    836     } else {
    837         false
    838     }
    839 }
    840 
    841 // =============================================================================
    842 // Send Action Handling
    843 // =============================================================================
    844 
    845 /// Handle the /cd command if present in input.
    846 /// Returns Some(Ok(path)) if cd succeeded, Some(Err(())) if cd failed, None if not a cd command.
    847 pub fn handle_cd_command(session: &mut ChatSession) -> Option<Result<PathBuf, ()>> {
    848     let input = session.input.trim().to_string();
    849     if !input.starts_with("/cd ") {
    850         return None;
    851     }
    852 
    853     let path_str = input.strip_prefix("/cd ").unwrap().trim();
    854     let path = PathBuf::from(path_str);
    855     session.input.clear();
    856 
    857     if path.exists() && path.is_dir() {
    858         if let Some(agentic) = &mut session.agentic {
    859             agentic.cwd = path.clone();
    860         }
    861         session.chat.push(Message::System(format!(
    862             "Working directory set to: {}",
    863             path.display()
    864         )));
    865         Some(Ok(path))
    866     } else {
    867         session
    868             .chat
    869             .push(Message::Error(format!("Invalid directory: {}", path_str)));
    870         Some(Err(()))
    871     }
    872 }