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 }