notedeck

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

commit 5ebe62feee2bb25587c99ae9f623a24d899dac1c
parent 315af1c4839489d37ea426625f4365cf1c879e68
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  9 Feb 2026 10:44:25 -0800

dave: separate agentic-only fields into AgenticSessionData

Move 12 agentic-mode specific fields from ChatSession into a new
AgenticSessionData struct, accessed via Option<AgenticSessionData>.

This makes explicit which session state is only used in Agentic mode
(Claude backend) vs shared between Chat and Agentic modes.

Agentic-only fields moved:
- pending_permissions, scene_position, permission_mode
- permission_message_state, question_answers, question_index
- cwd, session_info, subagent_indices
- is_compacting, last_compaction, resume_session_id

Added helper methods: agentic(), agentic_mut(), is_agentic(),
has_pending_permissions(), is_plan_mode(), cwd()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/notedeck_dave/src/session.rs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/notedeck_dave/src/ui/scene.rs | 17++++++++++++-----
Mcrates/notedeck_dave/src/ui/session_list.rs | 8++++++--
4 files changed, 334 insertions(+), 175 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -321,10 +321,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending.request.tool_input ); - // Store the response sender for later - session - .pending_permissions - .insert(pending.request.id, pending.response_tx); + // Store the response sender for later (agentic only) + if let Some(agentic) = &mut session.agentic { + agentic + .pending_permissions + .insert(pending.request.id, pending.response_tx); + } // Add the request to chat for UI display session @@ -344,7 +346,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr info.tools.len(), info.agents.len() ); - session.session_info = Some(info); + if let Some(agentic) = &mut session.agentic { + agentic.session_info = Some(info); + } } DaveApiResponse::SubagentSpawned(subagent) => { @@ -357,7 +361,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let task_id = subagent.task_id.clone(); let idx = session.chat.len(); session.chat.push(Message::Subagent(subagent)); - session.subagent_indices.insert(task_id, idx); + if let Some(agentic) = &mut session.agentic { + agentic.subagent_indices.insert(task_id, idx); + } } DaveApiResponse::SubagentOutput { task_id, output } => { @@ -371,7 +377,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveApiResponse::CompactionStarted => { tracing::debug!("Compaction started for session {}", session_id); - session.is_compacting = true; + if let Some(agentic) = &mut session.agentic { + agentic.is_compacting = true; + } } DaveApiResponse::CompactionComplete(info) => { @@ -380,8 +388,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_id, info.pre_tokens ); - session.is_compacting = false; - session.last_compaction = Some(info.clone()); + if let Some(agentic) = &mut session.agentic { + agentic.is_compacting = false; + agentic.last_compaction = Some(info.clone()); + } session.chat.push(Message::CompactionComplete(info)); } } @@ -589,11 +599,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr == crate::agent_status::AgentStatus::Working; // Render chat UI for selected session - let has_pending_permission = - !session.pending_permissions.is_empty(); - let plan_mode_active = - session.permission_mode == PermissionMode::Plan; - let response = DaveUi::new( + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + let mut ui_builder = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -605,12 +613,18 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .question_answers(&mut session.question_answers) - .question_index(&mut session.question_index) - .is_compacting(session.is_compacting) - .auto_steal_focus(auto_steal_focus) - .ui(app_ctx, ui); + .auto_steal_focus(auto_steal_focus); + + // Add agentic-specific UI state if available + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + let response = ui_builder.ui(app_ctx, ui); if response.action.is_some() { dave_response = response; @@ -652,7 +666,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } SceneAction::AgentMoved { id, position } => { if let Some(session) = self.session_manager.get_mut(id) { - session.scene_position = position; + if let Some(agentic) = &mut session.agentic { + agentic.scene_position = position; + } } } } @@ -712,9 +728,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { if let Some(session) = self.session_manager.get_active_mut() { let is_working = session.status() == crate::agent_status::AgentStatus::Working; - let has_pending_permission = !session.pending_permissions.is_empty(); - let plan_mode_active = session.permission_mode == PermissionMode::Plan; - DaveUi::new( + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + let mut ui_builder = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -725,12 +741,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .question_answers(&mut session.question_answers) - .question_index(&mut session.question_index) - .is_compacting(session.is_compacting) - .auto_steal_focus(auto_steal_focus) - .ui(app_ctx, ui) + .auto_steal_focus(auto_steal_focus); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + ui_builder.ui(app_ctx, ui) } else { DaveResponse::default() } @@ -787,9 +808,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let auto_steal_focus = self.auto_steal_focus; if let Some(session) = self.session_manager.get_active_mut() { let is_working = session.status() == crate::agent_status::AgentStatus::Working; - let has_pending_permission = !session.pending_permissions.is_empty(); - let plan_mode_active = session.permission_mode == PermissionMode::Plan; - DaveUi::new( + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + let mut ui_builder = DaveUi::new( self.model_config.trial, &session.chat, &mut session.input, @@ -800,12 +821,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .interrupt_pending(interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .permission_message_state(session.permission_message_state) - .question_answers(&mut session.question_answers) - .question_index(&mut session.question_index) - .is_compacting(session.is_compacting) - .auto_steal_focus(auto_steal_focus) - .ui(app_ctx, ui) + .auto_steal_focus(auto_steal_focus); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + } + + ui_builder.ui(app_ctx, ui) } else { DaveResponse::default() } @@ -829,7 +855,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Also update scene selection and camera if in scene view if self.show_scene { self.scene.select(id); - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } } @@ -853,14 +881,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Also update scene selection and camera if in scene view if self.show_scene { self.scene.select(id); - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } } /// 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().map(|s| s.cwd.clone()) { + if let Some(cwd) = self.session_manager.get_active().and_then(|s| s.cwd().cloned()) { self.create_session_with_cwd(cwd); } } @@ -882,7 +912,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.focus_requested = true; if self.show_scene { self.scene.select(id); - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } @@ -981,7 +1013,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Clear the incoming token receiver so we stop processing session.incoming_tokens = None; // Clear pending permissions since we're interrupting - session.pending_permissions.clear(); + if let Some(agentic) = &mut session.agentic { + agentic.pending_permissions.clear(); + } tracing::debug!("Interrupted session {}", session.id); } } @@ -989,34 +1023,38 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// 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() { - // Toggle between Plan and Default modes - let new_mode = match session.permission_mode { - PermissionMode::Plan => PermissionMode::Default, - _ => PermissionMode::Plan, - }; - session.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 - ); + 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 + ); + } } } /// 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() { - session.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); + 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); + } } } @@ -1024,7 +1062,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr fn first_pending_permission(&self) -> Option<uuid::Uuid> { self.session_manager .get_active() - .and_then(|session| session.pending_permissions.keys().next().copied()) + .and_then(|session| session.agentic.as_ref()) + .and_then(|agentic| agentic.pending_permissions.keys().next().copied()) } /// Check if the first pending permission is an AskUserQuestion tool call @@ -1040,7 +1079,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// 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 request_id = session.pending_permissions.keys().next()?; + 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 { @@ -1070,8 +1110,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - // Clear permission message state - session.permission_message_state = crate::session::PermissionMessageState::None; + // 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 { @@ -1082,15 +1124,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - if let Some(sender) = session.pending_permissions.remove(&request_id) { - if sender.send(response).is_err() { - tracing::error!( - "Failed to send permission response for request {}", - request_id - ); + 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); } - } else { - tracing::warn!("No pending permission found for request {}", request_id); } } } @@ -1193,24 +1237,26 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - // Clean up transient answer state - session.question_answers.remove(&request_id); - session.question_index.remove(&request_id); + // 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) = session.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 - ); + // 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); } - } else { - tracing::warn!("No pending permission found for request {}", request_id); } } } @@ -1226,7 +1272,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } // Focus input if no permission request is pending if let Some(session) = self.session_manager.get_mut(id) { - if session.pending_permissions.is_empty() { + if !session.has_pending_permissions() { session.focus_requested = true; } } @@ -1252,7 +1298,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } // Focus input if no permission request is pending if let Some(session) = self.session_manager.get_mut(id) { - if session.pending_permissions.is_empty() { + if !session.has_pending_permissions() { session.focus_requested = true; } } @@ -1282,7 +1328,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } // Focus input if no permission request is pending if let Some(session) = self.session_manager.get_mut(id) { - if session.pending_permissions.is_empty() { + if !session.has_pending_permissions() { session.focus_requested = true; } } @@ -1296,12 +1342,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if self.show_scene { self.scene.select(session_id); if let Some(session) = self.session_manager.get(session_id) { - self.scene.focus_on(session.scene_position); + 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.pending_permissions.is_empty() { + if !session.has_pending_permissions() { session.focus_requested = true; } } @@ -1315,12 +1363,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if self.show_scene { self.scene.select(session_id); if let Some(session) = self.session_manager.get(session_id) { - self.scene.focus_on(session.scene_position); + 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.pending_permissions.is_empty() { + if !session.has_pending_permissions() { session.focus_requested = true; } } @@ -1355,7 +1405,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if self.show_scene { self.scene.select(home_id); if let Some(session) = self.session_manager.get(home_id) { - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } tracing::debug!("Auto-steal focus disabled, returned to home session"); @@ -1495,7 +1547,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if self.show_scene { self.scene.select(entry.session_id); if let Some(session) = self.session_manager.get(entry.session_id) { - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id); @@ -1508,7 +1562,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if self.show_scene { self.scene.select(home_id); if let Some(session) = self.session_manager.get(home_id) { - self.scene.focus_on(session.scene_position); + if let Some(agentic) = &session.agentic { + self.scene.focus_on(agentic.scene_position); + } } } tracing::debug!("Auto-steal: returned to home session {:?}", home_id); @@ -1518,7 +1574,7 @@ 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 + // 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 ") { @@ -1526,7 +1582,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let path = PathBuf::from(path_str); session.input.clear(); if path.exists() && path.is_dir() { - session.cwd = path.clone(); + if let Some(agentic) = &mut session.agentic { + agentic.cwd = path.clone(); + } session.chat.push(Message::System(format!( "Working directory set to: {}", path.display() @@ -1571,8 +1629,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair()); let session_id = format!("dave-session-{}", session.id); let messages = session.chat.clone(); - let cwd = Some(session.cwd.clone()); - let resume_session_id = session.resume_session_id.clone(); + let cwd = session.agentic.as_ref().map(|a| a.cwd.clone()); + let resume_session_id = session + .agentic + .as_ref() + .and_then(|a| a.resume_session_id.clone()); let tools = self.tools.clone(); let model_name = self.model_config.model().to_owned(); let ctx = ctx.clone(); @@ -1614,7 +1675,8 @@ impl notedeck::App for Dave { let in_tentative_state = self .session_manager .get_active() - .map(|s| s.permission_message_state != crate::session::PermissionMessageState::None) + .and_then(|s| s.agentic.as_ref()) + .map(|a| a.permission_message_state != crate::session::PermissionMessageState::None) .unwrap_or(false); if let Some(key_action) = check_keybindings( ui.ctx(), @@ -1653,24 +1715,30 @@ impl notedeck::App for Dave { KeyAction::TentativeAccept => { // Enter tentative accept mode - user will type message, then Enter to send if let Some(session) = self.session_manager.get_active_mut() { - session.permission_message_state = - crate::session::PermissionMessageState::TentativeAccept; + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = + crate::session::PermissionMessageState::TentativeAccept; + } session.focus_requested = true; } } KeyAction::TentativeDeny => { // Enter tentative deny mode - user will type message, then Enter to send if let Some(session) = self.session_manager.get_active_mut() { - session.permission_message_state = - crate::session::PermissionMessageState::TentativeDeny; + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = + crate::session::PermissionMessageState::TentativeDeny; + } session.focus_requested = true; } } KeyAction::CancelTentative => { // Cancel tentative mode if let Some(session) = self.session_manager.get_active_mut() { - session.permission_message_state = - crate::session::PermissionMessageState::None; + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = + crate::session::PermissionMessageState::None; + } } } KeyAction::SwitchToAgent(index) => { @@ -1756,7 +1824,8 @@ impl notedeck::App for Dave { let tentative_state = self .session_manager .get_active() - .map(|s| s.permission_message_state) + .and_then(|s| s.agentic.as_ref()) + .map(|a| a.permission_message_state) .unwrap_or(crate::session::PermissionMessageState::None); match tentative_state { @@ -1829,16 +1898,20 @@ impl notedeck::App for Dave { DaveAction::TentativeAccept => { // Enter tentative accept mode (from Shift+click) if let Some(session) = self.session_manager.get_active_mut() { - session.permission_message_state = - crate::session::PermissionMessageState::TentativeAccept; + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = + crate::session::PermissionMessageState::TentativeAccept; + } session.focus_requested = true; } } DaveAction::TentativeDeny => { // Enter tentative deny mode (from Shift+click) if let Some(session) = self.session_manager.get_active_mut() { - session.permission_message_state = - crate::session::PermissionMessageState::TentativeDeny; + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = + crate::session::PermissionMessageState::TentativeDeny; + } session.focus_requested = true; } } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -25,24 +25,12 @@ pub enum PermissionMessageState { TentativeDeny, } -/// A single chat session with Dave -pub struct ChatSession { - pub id: SessionId, - pub title: String, - pub chat: Vec<Message>, - pub input: String, - pub incoming_tokens: Option<Receiver<DaveApiResponse>>, +/// Agentic-mode specific session data (Claude backend only) +pub struct AgenticSessionData { /// Pending permission requests waiting for user response pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>, - /// Handle to the background task processing this session's AI requests. - /// Aborted on drop to clean up the subprocess. - pub task_handle: Option<tokio::task::JoinHandle<()>>, /// Position in the RTS scene (in scene coordinates) pub scene_position: egui::Vec2, - /// Cached status for the agent (derived from session state) - cached_status: AgentStatus, - /// Whether this session's input should be focused on the next frame - pub focus_requested: bool, /// Permission mode for Claude (Default or Plan) pub permission_mode: PermissionMode, /// State for permission response message (tentative accept/deny) @@ -64,8 +52,75 @@ pub struct ChatSession { /// Claude session ID to resume (UUID from Claude CLI's session storage) /// When set, the backend will use --resume to continue this session pub resume_session_id: Option<String>, +} + +impl AgenticSessionData { + pub fn new(id: SessionId, cwd: PathBuf) -> Self { + // Arrange sessions in a grid pattern + let col = (id as i32 - 1) % 4; + let row = (id as i32 - 1) / 4; + let x = col as f32 * 150.0 - 225.0; // Center around origin + let y = row as f32 * 150.0 - 75.0; + + AgenticSessionData { + pending_permissions: HashMap::new(), + scene_position: egui::Vec2::new(x, y), + permission_mode: PermissionMode::Default, + permission_message_state: PermissionMessageState::None, + question_answers: HashMap::new(), + question_index: HashMap::new(), + cwd, + session_info: None, + subagent_indices: HashMap::new(), + is_compacting: false, + last_compaction: None, + resume_session_id: None, + } + } + + /// Update a subagent's output (appending new content, keeping only the tail) + pub fn update_subagent_output(&mut self, chat: &mut [Message], task_id: &str, new_output: &str) { + if let Some(&idx) = self.subagent_indices.get(task_id) { + if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) { + subagent.output.push_str(new_output); + // Keep only the most recent content up to max_output_size + if subagent.output.len() > subagent.max_output_size { + let keep_from = subagent.output.len() - subagent.max_output_size; + subagent.output = subagent.output[keep_from..].to_string(); + } + } + } + } + + /// Mark a subagent as completed + pub fn complete_subagent(&mut self, chat: &mut [Message], task_id: &str, result: &str) { + if let Some(&idx) = self.subagent_indices.get(task_id) { + if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) { + subagent.status = SubagentStatus::Completed; + subagent.output = result.to_string(); + } + } + } +} + +/// A single chat session with Dave +pub struct ChatSession { + pub id: SessionId, + pub title: String, + pub chat: Vec<Message>, + pub input: String, + pub incoming_tokens: Option<Receiver<DaveApiResponse>>, + /// Handle to the background task processing this session's AI requests. + /// Aborted on drop to clean up the subprocess. + pub task_handle: Option<tokio::task::JoinHandle<()>>, + /// Cached status for the agent (derived from session state) + cached_status: AgentStatus, + /// Whether this session's input should be focused on the next frame + pub focus_requested: bool, /// AI interaction mode for this session (Chat vs Agentic) pub ai_mode: AiMode, + /// Agentic-mode specific data (None in Chat mode) + pub agentic: Option<AgenticSessionData>, } impl Drop for ChatSession { @@ -78,11 +133,10 @@ impl Drop for ChatSession { impl ChatSession { pub fn new(id: SessionId, cwd: PathBuf, ai_mode: AiMode) -> Self { - // Arrange sessions in a grid pattern - let col = (id as i32 - 1) % 4; - let row = (id as i32 - 1) / 4; - let x = col as f32 * 150.0 - 225.0; // Center around origin - let y = row as f32 * 150.0 - 75.0; + let agentic = match ai_mode { + AiMode::Agentic => Some(AgenticSessionData::new(id, cwd)), + AiMode::Chat => None, + }; ChatSession { id, @@ -90,22 +144,11 @@ impl ChatSession { chat: vec![], input: String::new(), incoming_tokens: None, - pending_permissions: HashMap::new(), task_handle: None, - scene_position: egui::Vec2::new(x, y), cached_status: AgentStatus::Idle, focus_requested: false, - permission_mode: PermissionMode::Default, - permission_message_state: PermissionMessageState::None, - question_answers: HashMap::new(), - question_index: HashMap::new(), - cwd, - session_info: None, - subagent_indices: HashMap::new(), - is_compacting: false, - last_compaction: None, - resume_session_id: None, ai_mode, + agentic, } } @@ -118,32 +161,64 @@ impl ChatSession { ai_mode: AiMode, ) -> Self { let mut session = Self::new(id, cwd, ai_mode); - session.resume_session_id = Some(resume_session_id); + if let Some(ref mut agentic) = session.agentic { + agentic.resume_session_id = Some(resume_session_id); + } session.title = title; session } + // === Helper methods for accessing agentic data === + + /// Get agentic data, panics if not in agentic mode (use in agentic-only code paths) + pub fn agentic(&self) -> &AgenticSessionData { + self.agentic + .as_ref() + .expect("agentic data only available in Agentic mode") + } + + /// Get mutable agentic data + pub fn agentic_mut(&mut self) -> &mut AgenticSessionData { + self.agentic + .as_mut() + .expect("agentic data only available in Agentic mode") + } + + /// Check if session has agentic capabilities + pub fn is_agentic(&self) -> bool { + self.agentic.is_some() + } + + /// Check if session has pending permission requests + pub fn has_pending_permissions(&self) -> bool { + self.agentic + .as_ref() + .is_some_and(|a| !a.pending_permissions.is_empty()) + } + + /// Check if session is in plan mode + pub fn is_plan_mode(&self) -> bool { + self.agentic + .as_ref() + .is_some_and(|a| a.permission_mode == PermissionMode::Plan) + } + + /// Get the working directory (agentic only) + pub fn cwd(&self) -> Option<&PathBuf> { + self.agentic.as_ref().map(|a| &a.cwd) + } + /// Update a subagent's output (appending new content, keeping only the tail) pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) { - if let Some(&idx) = self.subagent_indices.get(task_id) { - if let Some(Message::Subagent(subagent)) = self.chat.get_mut(idx) { - subagent.output.push_str(new_output); - // Keep only the most recent content up to max_output_size - if subagent.output.len() > subagent.max_output_size { - let keep_from = subagent.output.len() - subagent.max_output_size; - subagent.output = subagent.output[keep_from..].to_string(); - } - } + if let Some(ref mut agentic) = self.agentic { + agentic.update_subagent_output(&mut self.chat, task_id, new_output); } } /// Mark a subagent as completed pub fn complete_subagent(&mut self, task_id: &str, result: &str) { - if let Some(&idx) = self.subagent_indices.get(task_id) { - if let Some(Message::Subagent(subagent)) = self.chat.get_mut(idx) { - subagent.status = SubagentStatus::Completed; - subagent.output = result.to_string(); - } + if let Some(ref mut agentic) = self.agentic { + agentic.complete_subagent(&mut self.chat, task_id, result); } } @@ -177,8 +252,8 @@ impl ChatSession { /// Derive status from the current session state fn derive_status(&self) -> AgentStatus { - // Check for pending permission requests (needs input) - if !self.pending_permissions.is_empty() { + // Check for pending permission requests (needs input) - agentic only + if self.has_pending_permissions() { return AgentStatus::NeedsInput; } diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -155,9 +155,14 @@ impl AgentScene { for (keybind_idx, session) in session_manager.sessions_ordered().into_iter().enumerate() { + // Scene view only makes sense for agentic sessions + let Some(agentic) = &session.agentic else { + continue; + }; + let id = session.id; let keybind_number = keybind_idx + 1; // 1-indexed for display - let position = session.scene_position; + let position = agentic.scene_position; let status = session.status(); let title = &session.title; let is_selected = selected_ids.contains(&id); @@ -170,7 +175,7 @@ impl AgentScene { position, status, title, - &session.cwd, + &agentic.cwd, is_selected, ctrl_held, queue_priority, @@ -262,9 +267,11 @@ impl AgentScene { self.selected.clear(); for session in session_manager.iter() { - let agent_pos = Pos2::new(session.scene_position.x, session.scene_position.y); - if selection_rect.contains(agent_pos) { - self.selected.push(session.id); + if let Some(agentic) = &session.agentic { + let agent_pos = Pos2::new(agentic.scene_position.x, agentic.scene_position.y); + if selection_rect.contains(agent_pos) { + self.selected.push(session.id); + } } } diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use egui::{Align, Color32, Layout, Sense}; use notedeck_ui::app_images; @@ -111,10 +111,14 @@ impl<'a> SessionListUi<'a> { // Check if this session is in the focus queue let queue_priority = self.focus_queue.get_session_priority(session.id); + // Get cwd from agentic data, fallback to empty path for Chat mode + let empty_path = PathBuf::new(); + let cwd = session.cwd().unwrap_or(&empty_path); + let response = self.session_item_ui( ui, &session.title, - &session.cwd, + cwd, is_active, shortcut_hint, session.status(),