commit c16e6cc51fa32b866bd747aba65a67883242b914
parent cb081aec46f8c5f9a9a3804d19518d33f4f6f15a
Author: William Casarin <jb55@jb55.com>
Date: Mon, 9 Feb 2026 11:54:40 -0800
dave: extract standalone helper functions into update.rs module
Split lib.rs (~2042 lines) into lib.rs (~1462 lines) + update.rs (872 lines).
Functions use explicit inputs instead of &self methods for better testability.
Extracted categories:
- Interrupt handling (handle_interrupt_request, execute_interrupt, check_timeout)
- Plan mode (toggle_plan_mode, exit_plan_mode)
- Permission handling (first_pending_permission, handle_permission_response, etc)
- Agent navigation (switch_to_agent_by_index, cycle_next/prev_agent)
- Focus queue ops (focus_queue_next/prev, toggle_auto_steal, process_auto_steal)
- External editor (open_external_editor, poll_editor_job, find_terminal)
- Session management (create_session_with_cwd, delete_session, clone_active_agent)
- Send handling (handle_cd_command)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
2 files changed, 967 insertions(+), 674 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -13,14 +13,14 @@ pub mod session;
pub mod session_discovery;
mod tools;
mod ui;
+mod update;
mod vec3;
use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend};
use chrono::{Duration, Local};
-use claude_agent_sdk_rs::PermissionMode;
use egui_wgpu::RenderState;
use enostr::KeypairUnowned;
-use focus_queue::{FocusPriority, FocusQueue};
+use focus_queue::FocusQueue;
use nostrdb::Transaction;
use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse};
use std::collections::HashMap;
@@ -858,21 +858,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Create a new session with the given cwd (called after directory picker selection)
fn create_session_with_cwd(&mut self, cwd: PathBuf) {
- // Add to recent directories
- self.directory_picker.add_recent(cwd.clone());
-
- let id = self.session_manager.new_session(cwd, self.ai_mode);
- // Request focus on the new session's input
- if let Some(session) = self.session_manager.get_mut(id) {
- session.focus_requested = true;
- // Also update scene selection and camera if in scene view
- if self.show_scene {
- self.scene.select(id);
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
+ update::create_session_with_cwd(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ cwd,
+ );
}
/// Create a new session that resumes an existing Claude conversation
@@ -882,34 +875,27 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
resume_session_id: String,
title: String,
) {
- // Add to recent directories
- self.directory_picker.add_recent(cwd.clone());
-
- let id =
- self.session_manager
- .new_resumed_session(cwd, resume_session_id, title, self.ai_mode);
- // Request focus on the new session's input
- if let Some(session) = self.session_manager.get_mut(id) {
- session.focus_requested = true;
- // Also update scene selection and camera if in scene view
- if self.show_scene {
- self.scene.select(id);
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
+ update::create_resumed_session_with_cwd(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ cwd,
+ resume_session_id,
+ title,
+ );
}
/// Clone the active agent, creating a new session with the same working directory
fn clone_active_agent(&mut self) {
- if let Some(cwd) = self
- .session_manager
- .get_active()
- .and_then(|s| s.cwd().cloned())
- {
- self.create_session_with_cwd(cwd);
- }
+ update::clone_active_agent(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ );
}
/// Poll for IPC spawn-agent commands from external tools
@@ -955,67 +941,28 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Delete a session and clean up backend resources
fn delete_session(&mut self, id: SessionId) {
- // Remove from focus queue first
- self.focus_queue.remove_session(id);
- if self.session_manager.delete_session(id) {
- // Clean up backend resources (e.g., close persistent connections)
- let session_id = format!("dave-session-{}", id);
- self.backend.cleanup_session(session_id);
-
- // If no sessions remain, open the directory picker for a new session
- if self.session_manager.is_empty() {
- self.directory_picker.open();
- }
- }
+ update::delete_session(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ self.backend.as_ref(),
+ &mut self.directory_picker,
+ id,
+ );
}
- /// Timeout for confirming interrupt (in seconds)
- const INTERRUPT_CONFIRM_TIMEOUT_SECS: f32 = 1.5;
-
/// Handle an interrupt request - requires double-Escape to confirm
- fn handle_interrupt_request(&mut self, ui: &egui::Ui) {
- // Only allow interrupt if there's an active AI operation
- let has_active_operation = self
- .session_manager
- .get_active()
- .map(|s| s.incoming_tokens.is_some())
- .unwrap_or(false);
-
- if !has_active_operation {
- // No active operation, just clear any pending state
- self.interrupt_pending_since = None;
- return;
- }
-
- let now = Instant::now();
-
- if let Some(pending_since) = self.interrupt_pending_since {
- // Check if we're within the confirmation timeout
- if now.duration_since(pending_since).as_secs_f32()
- < Self::INTERRUPT_CONFIRM_TIMEOUT_SECS
- {
- // Second Escape within timeout - confirm interrupt
- self.handle_interrupt(ui);
- self.interrupt_pending_since = None;
- } else {
- // Timeout expired, treat as new first press
- self.interrupt_pending_since = Some(now);
- }
- } else {
- // First Escape press - start pending state
- self.interrupt_pending_since = Some(now);
- }
+ fn handle_interrupt_request(&mut self, ctx: &egui::Context) {
+ self.interrupt_pending_since = update::handle_interrupt_request(
+ &self.session_manager,
+ self.backend.as_ref(),
+ self.interrupt_pending_since,
+ ctx,
+ );
}
/// Check if interrupt confirmation has timed out and clear it
fn check_interrupt_timeout(&mut self) {
- if let Some(pending_since) = self.interrupt_pending_since {
- if Instant::now().duration_since(pending_since).as_secs_f32()
- >= Self::INTERRUPT_CONFIRM_TIMEOUT_SECS
- {
- self.interrupt_pending_since = None;
- }
- }
+ self.interrupt_pending_since = update::check_interrupt_timeout(self.interrupt_pending_since);
}
/// Returns true if an interrupt is pending confirmation
@@ -1024,624 +971,121 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
/// Handle an interrupt action - stop the current AI operation
- fn handle_interrupt(&mut self, ui: &egui::Ui) {
- if let Some(session) = self.session_manager.get_active_mut() {
- let session_id = format!("dave-session-{}", session.id);
- // Send interrupt to backend
- self.backend.interrupt_session(session_id, ui.ctx().clone());
- // Clear the incoming token receiver so we stop processing
- session.incoming_tokens = None;
- // Clear pending permissions since we're interrupting
- if let Some(agentic) = &mut session.agentic {
- agentic.pending_permissions.clear();
- }
- tracing::debug!("Interrupted session {}", session.id);
- }
+ fn handle_interrupt(&mut self, ctx: &egui::Context) {
+ update::execute_interrupt(&mut self.session_manager, self.backend.as_ref(), ctx);
}
/// Toggle plan mode for the active session
fn toggle_plan_mode(&mut self, ctx: &egui::Context) {
- if let Some(session) = self.session_manager.get_active_mut() {
- if let Some(agentic) = &mut session.agentic {
- // Toggle between Plan and Default modes
- let new_mode = match agentic.permission_mode {
- PermissionMode::Plan => PermissionMode::Default,
- _ => PermissionMode::Plan,
- };
- agentic.permission_mode = new_mode;
-
- // Notify the backend
- let session_id = format!("dave-session-{}", session.id);
- self.backend
- .set_permission_mode(session_id, new_mode, ctx.clone());
-
- tracing::debug!(
- "Toggled plan mode for session {} to {:?}",
- session.id,
- new_mode
- );
- }
- }
+ update::toggle_plan_mode(&mut self.session_manager, self.backend.as_ref(), ctx);
}
/// Exit plan mode for the active session (switch to Default mode)
fn exit_plan_mode(&mut self, ctx: &egui::Context) {
- if let Some(session) = self.session_manager.get_active_mut() {
- if let Some(agentic) = &mut session.agentic {
- agentic.permission_mode = PermissionMode::Default;
- let session_id = format!("dave-session-{}", session.id);
- self.backend
- .set_permission_mode(session_id, PermissionMode::Default, ctx.clone());
- tracing::debug!("Exited plan mode for session {}", session.id);
- }
- }
+ update::exit_plan_mode(&mut self.session_manager, self.backend.as_ref(), ctx);
}
/// Get the first pending permission request ID for the active session
fn first_pending_permission(&self) -> Option<uuid::Uuid> {
- self.session_manager
- .get_active()
- .and_then(|session| session.agentic.as_ref())
- .and_then(|agentic| agentic.pending_permissions.keys().next().copied())
+ update::first_pending_permission(&self.session_manager)
}
/// Check if the first pending permission is an AskUserQuestion tool call
fn has_pending_question(&self) -> bool {
- self.pending_permission_tool_name() == Some("AskUserQuestion")
+ update::has_pending_question(&self.session_manager)
}
/// Check if the first pending permission is an ExitPlanMode tool call
fn has_pending_exit_plan_mode(&self) -> bool {
- self.pending_permission_tool_name() == Some("ExitPlanMode")
- }
-
- /// Get the tool name of the first pending permission request
- fn pending_permission_tool_name(&self) -> Option<&str> {
- let session = self.session_manager.get_active()?;
- let agentic = session.agentic.as_ref()?;
- let request_id = agentic.pending_permissions.keys().next()?;
-
- for msg in &session.chat {
- if let Message::PermissionRequest(req) = msg {
- if &req.id == request_id {
- return Some(&req.tool_name);
- }
- }
- }
-
- None
+ update::has_pending_exit_plan_mode(&self.session_manager)
}
/// Handle a permission response (from UI button or keybinding)
fn handle_permission_response(&mut self, request_id: uuid::Uuid, response: PermissionResponse) {
- if let Some(session) = self.session_manager.get_active_mut() {
- // Record the response type in the message for UI display
- let response_type = match &response {
- PermissionResponse::Allow { .. } => messages::PermissionResponseType::Allowed,
- PermissionResponse::Deny { .. } => messages::PermissionResponseType::Denied,
- };
-
- // If Allow has a message, add it as a User message to the chat
- // (SDK doesn't support message field on Allow, so we inject it as context)
- if let PermissionResponse::Allow { message: Some(msg) } = &response {
- if !msg.is_empty() {
- session.chat.push(Message::User(msg.clone()));
- }
- }
-
- // Clear permission message state (agentic only)
- if let Some(agentic) = &mut session.agentic {
- agentic.permission_message_state = crate::session::PermissionMessageState::None;
- }
-
- for msg in &mut session.chat {
- if let Message::PermissionRequest(req) = msg {
- if req.id == request_id {
- req.response = Some(response_type);
- break;
- }
- }
- }
-
- if let Some(agentic) = &mut session.agentic {
- if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
- if sender.send(response).is_err() {
- tracing::error!(
- "Failed to send permission response for request {}",
- request_id
- );
- }
- } else {
- tracing::warn!("No pending permission found for request {}", request_id);
- }
- }
- }
+ update::handle_permission_response(&mut self.session_manager, request_id, response);
}
/// Handle a user's response to an AskUserQuestion tool call
fn handle_question_response(&mut self, request_id: uuid::Uuid, answers: Vec<QuestionAnswer>) {
- use messages::{AnswerSummary, AnswerSummaryEntry};
-
- if let Some(session) = self.session_manager.get_active_mut() {
- // Find the original AskUserQuestion request to get the question labels
- let questions_input = session.chat.iter().find_map(|msg| {
- if let Message::PermissionRequest(req) = msg {
- if req.id == request_id && req.tool_name == "AskUserQuestion" {
- serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
- } else {
- None
- }
- } else {
- None
- }
- });
-
- // Format answers as JSON for the tool response, and build summary for display
- let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input
- {
- let mut answers_obj = serde_json::Map::new();
- let mut summary_entries = Vec::with_capacity(questions.questions.len());
-
- for (q_idx, (question, answer)) in
- questions.questions.iter().zip(answers.iter()).enumerate()
- {
- let mut answer_obj = serde_json::Map::new();
-
- // Map selected indices to option labels
- let selected_labels: Vec<String> = answer
- .selected
- .iter()
- .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
- .collect();
-
- answer_obj.insert(
- "selected".to_string(),
- serde_json::Value::Array(
- selected_labels
- .iter()
- .cloned()
- .map(serde_json::Value::String)
- .collect(),
- ),
- );
-
- // Build display text for summary
- let mut display_parts = selected_labels;
- if let Some(ref other) = answer.other_text {
- if !other.is_empty() {
- answer_obj.insert(
- "other".to_string(),
- serde_json::Value::String(other.clone()),
- );
- display_parts.push(format!("Other: {}", other));
- }
- }
-
- // Use header as the key, fall back to question index
- let key = if !question.header.is_empty() {
- question.header.clone()
- } else {
- format!("question_{}", q_idx)
- };
- answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj));
-
- summary_entries.push(AnswerSummaryEntry {
- header: key,
- answer: display_parts.join(", "),
- });
- }
-
- (
- serde_json::json!({ "answers": answers_obj }).to_string(),
- Some(AnswerSummary {
- entries: summary_entries,
- }),
- )
- } else {
- // Fallback: just serialize the answers directly
- (
- serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()),
- None,
- )
- };
-
- // Mark the request as allowed in the UI and store the summary for display
- for msg in &mut session.chat {
- if let Message::PermissionRequest(req) = msg {
- if req.id == request_id {
- req.response = Some(messages::PermissionResponseType::Allowed);
- req.answer_summary = answer_summary.clone();
- break;
- }
- }
- }
-
- // Clean up transient answer state and send response (agentic only)
- if let Some(agentic) = &mut session.agentic {
- agentic.question_answers.remove(&request_id);
- agentic.question_index.remove(&request_id);
-
- // Send the response through the permission channel
- // AskUserQuestion responses are sent as Allow with the formatted answers as the message
- if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
- let response = PermissionResponse::Allow {
- message: Some(formatted_response),
- };
- if sender.send(response).is_err() {
- tracing::error!(
- "Failed to send question response for request {}",
- request_id
- );
- }
- } else {
- tracing::warn!("No pending permission found for request {}", request_id);
- }
- }
- }
+ update::handle_question_response(&mut self.session_manager, request_id, answers);
}
/// Switch to agent by index in the ordered list (0-indexed)
fn switch_to_agent_by_index(&mut self, index: usize) {
- let ids = self.session_manager.session_ids();
- if let Some(&id) = ids.get(index) {
- self.session_manager.switch_to(id);
- // Also update scene selection if in scene view
- if self.show_scene {
- self.scene.select(id);
- }
- // Focus input if no permission request is pending
- if let Some(session) = self.session_manager.get_mut(id) {
- if !session.has_pending_permissions() {
- session.focus_requested = true;
- }
- }
- }
+ update::switch_to_agent_by_index(
+ &mut self.session_manager,
+ &mut self.scene,
+ self.show_scene,
+ index,
+ );
}
/// Cycle to the next agent
fn cycle_next_agent(&mut self) {
- let ids = self.session_manager.session_ids();
- if ids.is_empty() {
- return;
- }
- let current_idx = self
- .session_manager
- .active_id()
- .and_then(|active| ids.iter().position(|&id| id == active))
- .unwrap_or(0);
- let next_idx = (current_idx + 1) % ids.len();
- if let Some(&id) = ids.get(next_idx) {
- self.session_manager.switch_to(id);
- if self.show_scene {
- self.scene.select(id);
- }
- // Focus input if no permission request is pending
- if let Some(session) = self.session_manager.get_mut(id) {
- if !session.has_pending_permissions() {
- session.focus_requested = true;
- }
- }
- }
+ update::cycle_next_agent(&mut self.session_manager, &mut self.scene, self.show_scene);
}
/// Cycle to the previous agent
fn cycle_prev_agent(&mut self) {
- let ids = self.session_manager.session_ids();
- if ids.is_empty() {
- return;
- }
- let current_idx = self
- .session_manager
- .active_id()
- .and_then(|active| ids.iter().position(|&id| id == active))
- .unwrap_or(0);
- let prev_idx = if current_idx == 0 {
- ids.len() - 1
- } else {
- current_idx - 1
- };
- if let Some(&id) = ids.get(prev_idx) {
- self.session_manager.switch_to(id);
- if self.show_scene {
- self.scene.select(id);
- }
- // Focus input if no permission request is pending
- if let Some(session) = self.session_manager.get_mut(id) {
- if !session.has_pending_permissions() {
- session.focus_requested = true;
- }
- }
- }
+ update::cycle_prev_agent(&mut self.session_manager, &mut self.scene, self.show_scene);
}
/// Navigate to the next item in the focus queue
fn focus_queue_next(&mut self) {
- if let Some(session_id) = self.focus_queue.next() {
- self.session_manager.switch_to(session_id);
- if self.show_scene {
- self.scene.select(session_id);
- if let Some(session) = self.session_manager.get(session_id) {
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
- // Focus input if no permission request is pending
- if let Some(session) = self.session_manager.get_mut(session_id) {
- if !session.has_pending_permissions() {
- session.focus_requested = true;
- }
- }
- }
+ update::focus_queue_next(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ &mut self.scene,
+ self.show_scene,
+ );
}
/// Navigate to the previous item in the focus queue
fn focus_queue_prev(&mut self) {
- if let Some(session_id) = self.focus_queue.prev() {
- self.session_manager.switch_to(session_id);
- if self.show_scene {
- self.scene.select(session_id);
- if let Some(session) = self.session_manager.get(session_id) {
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
- // Focus input if no permission request is pending
- if let Some(session) = self.session_manager.get_mut(session_id) {
- if !session.has_pending_permissions() {
- session.focus_requested = true;
- }
- }
- }
+ update::focus_queue_prev(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ &mut self.scene,
+ self.show_scene,
+ );
}
/// Toggle Done status for the current focus queue item.
- /// If the item is Done, remove it from the queue.
fn focus_queue_toggle_done(&mut self) {
- if let Some(entry) = self.focus_queue.current() {
- if entry.priority == FocusPriority::Done {
- self.focus_queue.dequeue(entry.session_id);
- }
- }
+ update::focus_queue_toggle_done(&mut self.focus_queue);
}
/// Toggle auto-steal focus mode
fn toggle_auto_steal(&mut self) {
- self.auto_steal_focus = !self.auto_steal_focus;
-
- if self.auto_steal_focus {
- // Enabling: record current session as home
- self.home_session = self.session_manager.active_id();
- tracing::debug!(
- "Auto-steal focus enabled, home session: {:?}",
- self.home_session
- );
- } else {
- // Disabling: switch back to home session if set
- if let Some(home_id) = self.home_session.take() {
- self.session_manager.switch_to(home_id);
- if self.show_scene {
- self.scene.select(home_id);
- if let Some(session) = self.session_manager.get(home_id) {
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
- tracing::debug!("Auto-steal focus disabled, returned to home session");
- }
- }
-
- // Request focus on input after toggle
- if let Some(session) = self.session_manager.get_active_mut() {
- session.focus_requested = true;
- }
+ self.auto_steal_focus = update::toggle_auto_steal(
+ &mut self.session_manager,
+ &mut self.scene,
+ self.show_scene,
+ self.auto_steal_focus,
+ &mut self.home_session,
+ );
}
/// Open an external editor for composing the input text (non-blocking)
fn open_external_editor(&mut self) {
- use crate::session::EditorJob;
- use std::process::Command;
-
- // Don't spawn another editor if one is already pending
- if self.session_manager.pending_editor.is_some() {
- tracing::warn!("External editor already in progress");
- return;
- }
-
- let Some(session) = self.session_manager.get_active_mut() else {
- return;
- };
- let session_id = session.id;
- let input_content = session.input.clone();
-
- // Create temp file with current input content
- let temp_path = std::env::temp_dir().join("notedeck_input.txt");
- if let Err(e) = std::fs::write(&temp_path, &input_content) {
- tracing::error!("Failed to write temp file for external editor: {}", e);
- return;
- }
-
- // Try $VISUAL first (GUI editors), then fall back to terminal + $EDITOR
- let visual = std::env::var("VISUAL").ok();
- let editor = std::env::var("EDITOR").ok();
-
- let spawn_result = if let Some(visual_editor) = visual {
- // $VISUAL is set - use it directly (assumes GUI editor)
- tracing::debug!("Opening external editor via $VISUAL: {}", visual_editor);
- Command::new(&visual_editor).arg(&temp_path).spawn()
- } else {
- // Fall back to terminal + $EDITOR
- let editor_cmd = editor.unwrap_or_else(|| "vim".to_string());
- let terminal = std::env::var("TERMINAL")
- .ok()
- .or_else(Self::find_terminal)
- .unwrap_or_else(|| "xterm".to_string());
-
- tracing::debug!(
- "Opening external editor via terminal: {} -e {} {}",
- terminal,
- editor_cmd,
- temp_path.display()
- );
- Command::new(&terminal)
- .arg("-e")
- .arg(&editor_cmd)
- .arg(&temp_path)
- .spawn()
- };
-
- match spawn_result {
- Ok(child) => {
- self.session_manager.pending_editor = Some(EditorJob {
- child,
- temp_path,
- session_id,
- });
- tracing::debug!("External editor spawned for session {}", session_id);
- }
- Err(e) => {
- tracing::error!("Failed to spawn external editor: {}", e);
- // Clean up temp file on spawn failure
- let _ = std::fs::remove_file(&temp_path);
- }
- }
+ update::open_external_editor(&mut self.session_manager);
}
/// Poll for external editor completion (called each frame)
fn poll_editor_job(&mut self) {
- let Some(ref mut job) = self.session_manager.pending_editor else {
- return;
- };
-
- // Non-blocking check if child has exited
- match job.child.try_wait() {
- Ok(Some(status)) => {
- // Editor has exited
- let session_id = job.session_id;
- let temp_path = job.temp_path.clone();
-
- if status.success() {
- // Read the edited content back
- match std::fs::read_to_string(&temp_path) {
- Ok(content) => {
- if let Some(session) = self.session_manager.get_mut(session_id) {
- session.input = content;
- session.focus_requested = true;
- tracing::debug!(
- "External editor completed, updated input for session {}",
- session_id
- );
- }
- }
- Err(e) => {
- tracing::error!("Failed to read temp file after editing: {}", e);
- }
- }
- } else {
- tracing::warn!("External editor exited with status: {}", status);
- }
-
- // Clean up temp file
- if let Err(e) = std::fs::remove_file(&temp_path) {
- tracing::error!("Failed to remove temp file: {}", e);
- }
-
- // Clear the pending editor
- self.session_manager.pending_editor = None;
- }
- Ok(None) => {
- // Editor still running, nothing to do
- }
- Err(e) => {
- tracing::error!("Failed to poll editor process: {}", e);
- // Clean up on error
- let temp_path = job.temp_path.clone();
- let _ = std::fs::remove_file(&temp_path);
- self.session_manager.pending_editor = None;
- }
- }
- }
-
- /// Try to find a common terminal emulator
- fn find_terminal() -> Option<String> {
- use std::process::Command;
- let terminals = [
- "alacritty",
- "kitty",
- "gnome-terminal",
- "konsole",
- "urxvtc",
- "urxvt",
- "xterm",
- ];
- for term in terminals {
- if Command::new("which")
- .arg(term)
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false)
- {
- return Some(term.to_string());
- }
- }
- None
+ update::poll_editor_job(&mut self.session_manager);
}
/// Process auto-steal focus logic: switch to focus queue items as needed
fn process_auto_steal_focus(&mut self) {
- if !self.auto_steal_focus {
- return;
- }
-
- let has_needs_input = self.focus_queue.has_needs_input();
-
- if has_needs_input {
- // There are NeedsInput items - check if we need to steal focus
- let current_session = self.session_manager.active_id();
- let current_priority =
- current_session.and_then(|id| self.focus_queue.get_session_priority(id));
- let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput);
-
- if !already_on_needs_input {
- // Save current session before stealing (only if we haven't saved yet)
- if self.home_session.is_none() {
- self.home_session = current_session;
- tracing::debug!("Auto-steal: saved home session {:?}", self.home_session);
- }
-
- // Jump to first NeedsInput item
- if let Some(idx) = self.focus_queue.first_needs_input_index() {
- self.focus_queue.set_cursor(idx);
- if let Some(entry) = self.focus_queue.current() {
- self.session_manager.switch_to(entry.session_id);
- if self.show_scene {
- self.scene.select(entry.session_id);
- if let Some(session) = self.session_manager.get(entry.session_id) {
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
- tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id);
- }
- }
- }
- } else if let Some(home_id) = self.home_session.take() {
- // No more NeedsInput items - return to saved session
- self.session_manager.switch_to(home_id);
- if self.show_scene {
- self.scene.select(home_id);
- if let Some(session) = self.session_manager.get(home_id) {
- if let Some(agentic) = &session.agentic {
- self.scene.focus_on(agentic.scene_position);
- }
- }
- }
- tracing::debug!("Auto-steal: returned to home session {:?}", home_id);
- }
- // If no NeedsInput and no home_session saved, do nothing - allow free navigation
+ update::process_auto_steal_focus(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ &mut self.scene,
+ self.show_scene,
+ self.auto_steal_focus,
+ &mut self.home_session,
+ );
}
/// Handle a keybinding action
@@ -1718,7 +1162,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.clone_active_agent();
}
KeyAction::Interrupt => {
- self.handle_interrupt_request(ui);
+ self.handle_interrupt_request(ui.ctx());
}
KeyAction::ToggleView => {
self.show_scene = !self.show_scene;
@@ -1844,7 +1288,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.handle_permission_response(request_id, response);
}
DaveAction::Interrupt => {
- self.handle_interrupt(ui);
+ self.handle_interrupt(ui.ctx());
}
DaveAction::TentativeAccept => {
// Enter tentative accept mode (from Shift+click)
@@ -1900,33 +1344,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Handle a user send action triggered by the ui
fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) {
// Check for /cd command first (agentic only)
- let cd_result = if let Some(session) = self.session_manager.get_active_mut() {
- let input = session.input.trim().to_string();
- if input.starts_with("/cd ") {
- let path_str = input.strip_prefix("/cd ").unwrap().trim();
- let path = PathBuf::from(path_str);
- session.input.clear();
- if path.exists() && path.is_dir() {
- if let Some(agentic) = &mut session.agentic {
- agentic.cwd = path.clone();
- }
- session.chat.push(Message::System(format!(
- "Working directory set to: {}",
- path.display()
- )));
- Some(Ok(path))
- } else {
- session
- .chat
- .push(Message::Error(format!("Invalid directory: {}", path_str)));
- Some(Err(()))
- }
- } else {
- None
- }
- } else {
- None
- };
+ let cd_result = self
+ .session_manager
+ .get_active_mut()
+ .and_then(update::handle_cd_command);
// If /cd command was processed, add to recent directories
if let Some(Ok(path)) = cd_result {
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -0,0 +1,872 @@
+//! Helper functions for the Dave update loop.
+//!
+//! These are standalone functions with explicit inputs to reduce the complexity
+//! of the main Dave struct and make the code more testable and reusable.
+
+use crate::backend::AiBackend;
+use crate::config::AiMode;
+use crate::focus_queue::{FocusPriority, FocusQueue};
+use crate::messages::{
+ AnswerSummary, AnswerSummaryEntry, AskUserQuestionInput, Message, PermissionResponse,
+ QuestionAnswer,
+};
+use crate::session::{ChatSession, EditorJob, PermissionMessageState, SessionId, SessionManager};
+use crate::ui::{AgentScene, DirectoryPicker};
+use claude_agent_sdk_rs::PermissionMode;
+use std::path::PathBuf;
+use std::time::Instant;
+
+/// Timeout for confirming interrupt (in seconds)
+pub const INTERRUPT_CONFIRM_TIMEOUT_SECS: f32 = 1.5;
+
+// =============================================================================
+// Interrupt Handling
+// =============================================================================
+
+/// Handle an interrupt request - requires double-Escape to confirm.
+/// Returns the new pending_since state.
+pub fn handle_interrupt_request(
+ session_manager: &SessionManager,
+ backend: &dyn AiBackend,
+ pending_since: Option<Instant>,
+ ctx: &egui::Context,
+) -> Option<Instant> {
+ // Only allow interrupt if there's an active AI operation
+ let has_active_operation = session_manager
+ .get_active()
+ .map(|s| s.incoming_tokens.is_some())
+ .unwrap_or(false);
+
+ if !has_active_operation {
+ return None;
+ }
+
+ let now = Instant::now();
+
+ if let Some(pending) = pending_since {
+ if now.duration_since(pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS {
+ // Second Escape within timeout - confirm interrupt
+ if let Some(session) = session_manager.get_active() {
+ let session_id = format!("dave-session-{}", session.id);
+ backend.interrupt_session(session_id, ctx.clone());
+ }
+ None
+ } else {
+ // Timeout expired, treat as new first press
+ Some(now)
+ }
+ } else {
+ // First Escape press
+ Some(now)
+ }
+}
+
+/// Execute the actual interrupt on the active session.
+pub fn execute_interrupt(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ let session_id = format!("dave-session-{}", session.id);
+ backend.interrupt_session(session_id, ctx.clone());
+ session.incoming_tokens = None;
+ if let Some(agentic) = &mut session.agentic {
+ agentic.pending_permissions.clear();
+ }
+ tracing::debug!("Interrupted session {}", session.id);
+ }
+}
+
+/// Check if interrupt confirmation has timed out.
+/// Returns None if timed out, otherwise returns the original value.
+pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant> {
+ pending_since.filter(|pending| {
+ Instant::now().duration_since(*pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS
+ })
+}
+
+// =============================================================================
+// Plan Mode
+// =============================================================================
+
+/// Toggle plan mode for the active session.
+pub fn toggle_plan_mode(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ let new_mode = match agentic.permission_mode {
+ PermissionMode::Plan => PermissionMode::Default,
+ _ => PermissionMode::Plan,
+ };
+ agentic.permission_mode = new_mode;
+
+ let session_id = format!("dave-session-{}", session.id);
+ backend.set_permission_mode(session_id, new_mode, ctx.clone());
+
+ tracing::debug!(
+ "Toggled plan mode for session {} to {:?}",
+ session.id,
+ new_mode
+ );
+ }
+ }
+}
+
+/// Exit plan mode for the active session (switch to Default mode).
+pub fn exit_plan_mode(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_mode = PermissionMode::Default;
+ let session_id = format!("dave-session-{}", session.id);
+ backend.set_permission_mode(session_id, PermissionMode::Default, ctx.clone());
+ tracing::debug!("Exited plan mode for session {}", session.id);
+ }
+ }
+}
+
+// =============================================================================
+// Permission Handling
+// =============================================================================
+
+/// Get the first pending permission request ID for the active session.
+pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid::Uuid> {
+ session_manager
+ .get_active()
+ .and_then(|session| session.agentic.as_ref())
+ .and_then(|agentic| agentic.pending_permissions.keys().next().copied())
+}
+
+/// Get the tool name of the first pending permission request.
+pub fn pending_permission_tool_name<'a>(session_manager: &'a SessionManager) -> Option<&'a str> {
+ let session = session_manager.get_active()?;
+ let agentic = session.agentic.as_ref()?;
+ let request_id = agentic.pending_permissions.keys().next()?;
+
+ for msg in &session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if &req.id == request_id {
+ return Some(&req.tool_name);
+ }
+ }
+ }
+
+ None
+}
+
+/// Check if the first pending permission is an AskUserQuestion tool call.
+pub fn has_pending_question(session_manager: &SessionManager) -> bool {
+ pending_permission_tool_name(session_manager) == Some("AskUserQuestion")
+}
+
+/// Check if the first pending permission is an ExitPlanMode tool call.
+pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool {
+ pending_permission_tool_name(session_manager) == Some("ExitPlanMode")
+}
+
+/// Handle a permission response (from UI button or keybinding).
+pub fn handle_permission_response(
+ session_manager: &mut SessionManager,
+ request_id: uuid::Uuid,
+ response: PermissionResponse,
+) {
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+
+ // Record the response type in the message for UI display
+ let response_type = match &response {
+ PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed,
+ PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied,
+ };
+
+ // If Allow has a message, add it as a User message to the chat
+ if let PermissionResponse::Allow { message: Some(msg) } = &response {
+ if !msg.is_empty() {
+ session.chat.push(Message::User(msg.clone()));
+ }
+ }
+
+ // Clear permission message state (agentic only)
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::None;
+ }
+
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(response_type);
+ break;
+ }
+ }
+ }
+
+ if let Some(agentic) = &mut session.agentic {
+ if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send permission response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!("No pending permission found for request {}", request_id);
+ }
+ }
+}
+
+/// Handle a user's response to an AskUserQuestion tool call.
+pub fn handle_question_response(
+ session_manager: &mut SessionManager,
+ request_id: uuid::Uuid,
+ answers: Vec<QuestionAnswer>,
+) {
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+
+ // Find the original AskUserQuestion request to get the question labels
+ let questions_input = session.chat.iter().find_map(|msg| {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id && req.tool_name == "AskUserQuestion" {
+ serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ });
+
+ // Format answers as JSON for the tool response, and build summary for display
+ let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input {
+ let mut answers_obj = serde_json::Map::new();
+ let mut summary_entries = Vec::with_capacity(questions.questions.len());
+
+ for (q_idx, (question, answer)) in
+ questions.questions.iter().zip(answers.iter()).enumerate()
+ {
+ let mut answer_obj = serde_json::Map::new();
+
+ // Map selected indices to option labels
+ let selected_labels: Vec<String> = answer
+ .selected
+ .iter()
+ .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
+ .collect();
+
+ answer_obj.insert(
+ "selected".to_string(),
+ serde_json::Value::Array(
+ selected_labels
+ .iter()
+ .cloned()
+ .map(serde_json::Value::String)
+ .collect(),
+ ),
+ );
+
+ // Build display text for summary
+ let mut display_parts = selected_labels;
+ if let Some(ref other) = answer.other_text {
+ if !other.is_empty() {
+ answer_obj.insert(
+ "other".to_string(),
+ serde_json::Value::String(other.clone()),
+ );
+ display_parts.push(format!("Other: {}", other));
+ }
+ }
+
+ // Use header as the key, fall back to question index
+ let key = if !question.header.is_empty() {
+ question.header.clone()
+ } else {
+ format!("question_{}", q_idx)
+ };
+ answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj));
+
+ summary_entries.push(AnswerSummaryEntry {
+ header: key,
+ answer: display_parts.join(", "),
+ });
+ }
+
+ (
+ serde_json::json!({ "answers": answers_obj }).to_string(),
+ Some(AnswerSummary {
+ entries: summary_entries,
+ }),
+ )
+ } else {
+ // Fallback: just serialize the answers directly
+ (
+ serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()),
+ None,
+ )
+ };
+
+ // Mark the request as allowed in the UI and store the summary for display
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(crate::messages::PermissionResponseType::Allowed);
+ req.answer_summary = answer_summary.clone();
+ break;
+ }
+ }
+ }
+
+ // Clean up transient answer state and send response (agentic only)
+ if let Some(agentic) = &mut session.agentic {
+ agentic.question_answers.remove(&request_id);
+ agentic.question_index.remove(&request_id);
+
+ // Send the response through the permission channel
+ if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
+ let response = PermissionResponse::Allow {
+ message: Some(formatted_response),
+ };
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send question response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!("No pending permission found for request {}", request_id);
+ }
+ }
+}
+
+// =============================================================================
+// Agent Navigation
+// =============================================================================
+
+/// Switch to agent by index in the ordered list (0-indexed).
+pub fn switch_to_agent_by_index(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ index: usize,
+) {
+ let ids = session_manager.session_ids();
+ if let Some(&id) = ids.get(index) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Cycle to the next agent.
+pub fn cycle_next_agent(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ let ids = session_manager.session_ids();
+ if ids.is_empty() {
+ return;
+ }
+ let current_idx = session_manager
+ .active_id()
+ .and_then(|active| ids.iter().position(|&id| id == active))
+ .unwrap_or(0);
+ let next_idx = (current_idx + 1) % ids.len();
+ if let Some(&id) = ids.get(next_idx) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Cycle to the previous agent.
+pub fn cycle_prev_agent(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ let ids = session_manager.session_ids();
+ if ids.is_empty() {
+ return;
+ }
+ let current_idx = session_manager
+ .active_id()
+ .and_then(|active| ids.iter().position(|&id| id == active))
+ .unwrap_or(0);
+ let prev_idx = if current_idx == 0 {
+ ids.len() - 1
+ } else {
+ current_idx - 1
+ };
+ if let Some(&id) = ids.get(prev_idx) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+// =============================================================================
+// Focus Queue Operations
+// =============================================================================
+
+/// Navigate to the next item in the focus queue.
+pub fn focus_queue_next(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ if let Some(session_id) = focus_queue.next() {
+ session_manager.switch_to(session_id);
+ if show_scene {
+ scene.select(session_id);
+ if let Some(session) = session_manager.get(session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ if let Some(session) = session_manager.get_mut(session_id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Navigate to the previous item in the focus queue.
+pub fn focus_queue_prev(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ if let Some(session_id) = focus_queue.prev() {
+ session_manager.switch_to(session_id);
+ if show_scene {
+ scene.select(session_id);
+ if let Some(session) = session_manager.get(session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ if let Some(session) = session_manager.get_mut(session_id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Toggle Done status for the current focus queue item.
+pub fn focus_queue_toggle_done(focus_queue: &mut FocusQueue) {
+ if let Some(entry) = focus_queue.current() {
+ if entry.priority == FocusPriority::Done {
+ focus_queue.dequeue(entry.session_id);
+ }
+ }
+}
+
+/// Toggle auto-steal focus mode.
+/// Returns the new auto_steal_focus state.
+pub fn toggle_auto_steal(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ auto_steal_focus: bool,
+ home_session: &mut Option<SessionId>,
+) -> bool {
+ let new_state = !auto_steal_focus;
+
+ if new_state {
+ // Enabling: record current session as home
+ *home_session = session_manager.active_id();
+ tracing::debug!("Auto-steal focus enabled, home session: {:?}", home_session);
+ } else {
+ // Disabling: switch back to home session if set
+ if let Some(home_id) = home_session.take() {
+ session_manager.switch_to(home_id);
+ if show_scene {
+ scene.select(home_id);
+ if let Some(session) = session_manager.get(home_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal focus disabled, returned to home session");
+ }
+ }
+
+ // Request focus on input after toggle
+ if let Some(session) = session_manager.get_active_mut() {
+ session.focus_requested = true;
+ }
+
+ new_state
+}
+
+/// Process auto-steal focus logic: switch to focus queue items as needed.
+pub fn process_auto_steal_focus(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ auto_steal_focus: bool,
+ home_session: &mut Option<SessionId>,
+) {
+ if !auto_steal_focus {
+ return;
+ }
+
+ let has_needs_input = focus_queue.has_needs_input();
+
+ if has_needs_input {
+ // There are NeedsInput items - check if we need to steal focus
+ let current_session = session_manager.active_id();
+ let current_priority =
+ current_session.and_then(|id| focus_queue.get_session_priority(id));
+ let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput);
+
+ if !already_on_needs_input {
+ // Save current session before stealing (only if we haven't saved yet)
+ if home_session.is_none() {
+ *home_session = current_session;
+ tracing::debug!("Auto-steal: saved home session {:?}", home_session);
+ }
+
+ // Jump to first NeedsInput item
+ if let Some(idx) = focus_queue.first_needs_input_index() {
+ focus_queue.set_cursor(idx);
+ if let Some(entry) = focus_queue.current() {
+ session_manager.switch_to(entry.session_id);
+ if show_scene {
+ scene.select(entry.session_id);
+ if let Some(session) = session_manager.get(entry.session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id);
+ }
+ }
+ }
+ } else if let Some(home_id) = home_session.take() {
+ // No more NeedsInput items - return to saved session
+ session_manager.switch_to(home_id);
+ if show_scene {
+ scene.select(home_id);
+ if let Some(session) = session_manager.get(home_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal: returned to home session {:?}", home_id);
+ }
+}
+
+// =============================================================================
+// External Editor
+// =============================================================================
+
+/// Try to find a common terminal emulator.
+pub fn find_terminal() -> Option<String> {
+ use std::process::Command;
+ let terminals = [
+ "alacritty",
+ "kitty",
+ "gnome-terminal",
+ "konsole",
+ "urxvtc",
+ "urxvt",
+ "xterm",
+ ];
+ for term in terminals {
+ if Command::new("which")
+ .arg(term)
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+ {
+ return Some(term.to_string());
+ }
+ }
+ None
+}
+
+/// Open an external editor for composing the input text (non-blocking).
+pub fn open_external_editor(session_manager: &mut SessionManager) {
+ use std::process::Command;
+
+ // Don't spawn another editor if one is already pending
+ if session_manager.pending_editor.is_some() {
+ tracing::warn!("External editor already in progress");
+ return;
+ }
+
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+ let session_id = session.id;
+ let input_content = session.input.clone();
+
+ // Create temp file with current input content
+ let temp_path = std::env::temp_dir().join("notedeck_input.txt");
+ if let Err(e) = std::fs::write(&temp_path, &input_content) {
+ tracing::error!("Failed to write temp file for external editor: {}", e);
+ return;
+ }
+
+ // Try $VISUAL first (GUI editors), then fall back to terminal + $EDITOR
+ let visual = std::env::var("VISUAL").ok();
+ let editor = std::env::var("EDITOR").ok();
+
+ let spawn_result = if let Some(visual_editor) = visual {
+ // $VISUAL is set - use it directly (assumes GUI editor)
+ tracing::debug!("Opening external editor via $VISUAL: {}", visual_editor);
+ Command::new(&visual_editor).arg(&temp_path).spawn()
+ } else {
+ // Fall back to terminal + $EDITOR
+ let editor_cmd = editor.unwrap_or_else(|| "vim".to_string());
+ let terminal = std::env::var("TERMINAL")
+ .ok()
+ .or_else(find_terminal)
+ .unwrap_or_else(|| "xterm".to_string());
+
+ tracing::debug!(
+ "Opening external editor via terminal: {} -e {} {}",
+ terminal,
+ editor_cmd,
+ temp_path.display()
+ );
+ Command::new(&terminal)
+ .arg("-e")
+ .arg(&editor_cmd)
+ .arg(&temp_path)
+ .spawn()
+ };
+
+ match spawn_result {
+ Ok(child) => {
+ session_manager.pending_editor = Some(EditorJob {
+ child,
+ temp_path,
+ session_id,
+ });
+ tracing::debug!("External editor spawned for session {}", session_id);
+ }
+ Err(e) => {
+ tracing::error!("Failed to spawn external editor: {}", e);
+ let _ = std::fs::remove_file(&temp_path);
+ }
+ }
+}
+
+/// Poll for external editor completion (called each frame).
+pub fn poll_editor_job(session_manager: &mut SessionManager) {
+ let Some(ref mut job) = session_manager.pending_editor else {
+ return;
+ };
+
+ // Non-blocking check if child has exited
+ match job.child.try_wait() {
+ Ok(Some(status)) => {
+ let session_id = job.session_id;
+ let temp_path = job.temp_path.clone();
+
+ if status.success() {
+ match std::fs::read_to_string(&temp_path) {
+ Ok(content) => {
+ if let Some(session) = session_manager.get_mut(session_id) {
+ session.input = content;
+ session.focus_requested = true;
+ tracing::debug!(
+ "External editor completed, updated input for session {}",
+ session_id
+ );
+ }
+ }
+ Err(e) => {
+ tracing::error!("Failed to read temp file after editing: {}", e);
+ }
+ }
+ } else {
+ tracing::warn!("External editor exited with status: {}", status);
+ }
+
+ if let Err(e) = std::fs::remove_file(&temp_path) {
+ tracing::error!("Failed to remove temp file: {}", e);
+ }
+
+ session_manager.pending_editor = None;
+ }
+ Ok(None) => {
+ // Editor still running
+ }
+ Err(e) => {
+ tracing::error!("Failed to poll editor process: {}", e);
+ let temp_path = job.temp_path.clone();
+ let _ = std::fs::remove_file(&temp_path);
+ session_manager.pending_editor = None;
+ }
+ }
+}
+
+// =============================================================================
+// Session Management
+// =============================================================================
+
+/// Create a new session with the given cwd.
+pub fn create_session_with_cwd(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+ cwd: PathBuf,
+) -> SessionId {
+ directory_picker.add_recent(cwd.clone());
+
+ let id = session_manager.new_session(cwd, ai_mode);
+ if let Some(session) = session_manager.get_mut(id) {
+ session.focus_requested = true;
+ if show_scene {
+ scene.select(id);
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ id
+}
+
+/// Create a new session that resumes an existing Claude conversation.
+pub fn create_resumed_session_with_cwd(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+) -> SessionId {
+ directory_picker.add_recent(cwd.clone());
+
+ let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode);
+ if let Some(session) = session_manager.get_mut(id) {
+ session.focus_requested = true;
+ if show_scene {
+ scene.select(id);
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ id
+}
+
+/// Clone the active agent, creating a new session with the same working directory.
+pub fn clone_active_agent(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+) -> Option<SessionId> {
+ let cwd = session_manager
+ .get_active()
+ .and_then(|s| s.cwd().cloned())?;
+ Some(create_session_with_cwd(
+ session_manager,
+ directory_picker,
+ scene,
+ show_scene,
+ ai_mode,
+ cwd,
+ ))
+}
+
+/// Delete a session and clean up backend resources.
+pub fn delete_session(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ backend: &dyn AiBackend,
+ directory_picker: &mut DirectoryPicker,
+ id: SessionId,
+) -> bool {
+ focus_queue.remove_session(id);
+ if session_manager.delete_session(id) {
+ let session_id = format!("dave-session-{}", id);
+ backend.cleanup_session(session_id);
+
+ if session_manager.is_empty() {
+ directory_picker.open();
+ }
+ true
+ } else {
+ false
+ }
+}
+
+// =============================================================================
+// Send Action Handling
+// =============================================================================
+
+/// Handle the /cd command if present in input.
+/// Returns Some(Ok(path)) if cd succeeded, Some(Err(())) if cd failed, None if not a cd command.
+pub fn handle_cd_command(session: &mut ChatSession) -> Option<Result<PathBuf, ()>> {
+ let input = session.input.trim().to_string();
+ if !input.starts_with("/cd ") {
+ return None;
+ }
+
+ let path_str = input.strip_prefix("/cd ").unwrap().trim();
+ let path = PathBuf::from(path_str);
+ session.input.clear();
+
+ if path.exists() && path.is_dir() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.cwd = path.clone();
+ }
+ session.chat.push(Message::System(format!(
+ "Working directory set to: {}",
+ path.display()
+ )));
+ Some(Ok(path))
+ } else {
+ session
+ .chat
+ .push(Message::Error(format!("Invalid directory: {}", path_str)));
+ Some(Err(()))
+ }
+}