notedeck

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

session.rs (15544B)


      1 use std::collections::HashMap;
      2 use std::path::PathBuf;
      3 use std::sync::mpsc::Receiver;
      4 
      5 use crate::agent_status::AgentStatus;
      6 use crate::config::AiMode;
      7 use crate::messages::{
      8     CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus,
      9 };
     10 use crate::{DaveApiResponse, Message};
     11 use claude_agent_sdk_rs::PermissionMode;
     12 use tokio::sync::oneshot;
     13 use uuid::Uuid;
     14 
     15 pub type SessionId = u32;
     16 
     17 /// State for permission response with message
     18 #[derive(Default, Clone, Copy, PartialEq)]
     19 pub enum PermissionMessageState {
     20     #[default]
     21     None,
     22     /// User pressed Shift+1, waiting for message then will Allow
     23     TentativeAccept,
     24     /// User pressed Shift+2, waiting for message then will Deny
     25     TentativeDeny,
     26 }
     27 
     28 /// Agentic-mode specific session data (Claude backend only)
     29 pub struct AgenticSessionData {
     30     /// Pending permission requests waiting for user response
     31     pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>,
     32     /// Position in the RTS scene (in scene coordinates)
     33     pub scene_position: egui::Vec2,
     34     /// Permission mode for Claude (Default or Plan)
     35     pub permission_mode: PermissionMode,
     36     /// State for permission response message (tentative accept/deny)
     37     pub permission_message_state: PermissionMessageState,
     38     /// State for pending AskUserQuestion responses (keyed by request UUID)
     39     pub question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
     40     /// Current question index for multi-question AskUserQuestion (keyed by request UUID)
     41     pub question_index: HashMap<Uuid, usize>,
     42     /// Working directory for claude-code subprocess
     43     pub cwd: PathBuf,
     44     /// Session info from Claude Code CLI (tools, model, agents, etc.)
     45     pub session_info: Option<SessionInfo>,
     46     /// Indices of subagent messages in chat (keyed by task_id)
     47     pub subagent_indices: HashMap<String, usize>,
     48     /// Whether conversation compaction is in progress
     49     pub is_compacting: bool,
     50     /// Info from the last completed compaction (for display)
     51     pub last_compaction: Option<CompactionInfo>,
     52     /// Claude session ID to resume (UUID from Claude CLI's session storage)
     53     /// When set, the backend will use --resume to continue this session
     54     pub resume_session_id: Option<String>,
     55 }
     56 
     57 impl AgenticSessionData {
     58     pub fn new(id: SessionId, cwd: PathBuf) -> Self {
     59         // Arrange sessions in a grid pattern
     60         let col = (id as i32 - 1) % 4;
     61         let row = (id as i32 - 1) / 4;
     62         let x = col as f32 * 150.0 - 225.0; // Center around origin
     63         let y = row as f32 * 150.0 - 75.0;
     64 
     65         AgenticSessionData {
     66             pending_permissions: HashMap::new(),
     67             scene_position: egui::Vec2::new(x, y),
     68             permission_mode: PermissionMode::Default,
     69             permission_message_state: PermissionMessageState::None,
     70             question_answers: HashMap::new(),
     71             question_index: HashMap::new(),
     72             cwd,
     73             session_info: None,
     74             subagent_indices: HashMap::new(),
     75             is_compacting: false,
     76             last_compaction: None,
     77             resume_session_id: None,
     78         }
     79     }
     80 
     81     /// Update a subagent's output (appending new content, keeping only the tail)
     82     pub fn update_subagent_output(
     83         &mut self,
     84         chat: &mut [Message],
     85         task_id: &str,
     86         new_output: &str,
     87     ) {
     88         if let Some(&idx) = self.subagent_indices.get(task_id) {
     89             if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) {
     90                 subagent.output.push_str(new_output);
     91                 // Keep only the most recent content up to max_output_size
     92                 if subagent.output.len() > subagent.max_output_size {
     93                     let keep_from = subagent.output.len() - subagent.max_output_size;
     94                     subagent.output = subagent.output[keep_from..].to_string();
     95                 }
     96             }
     97         }
     98     }
     99 
    100     /// Mark a subagent as completed
    101     pub fn complete_subagent(&mut self, chat: &mut [Message], task_id: &str, result: &str) {
    102         if let Some(&idx) = self.subagent_indices.get(task_id) {
    103             if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) {
    104                 subagent.status = SubagentStatus::Completed;
    105                 subagent.output = result.to_string();
    106             }
    107         }
    108     }
    109 }
    110 
    111 /// A single chat session with Dave
    112 pub struct ChatSession {
    113     pub id: SessionId,
    114     pub title: String,
    115     pub chat: Vec<Message>,
    116     pub input: String,
    117     pub incoming_tokens: Option<Receiver<DaveApiResponse>>,
    118     /// Handle to the background task processing this session's AI requests.
    119     /// Aborted on drop to clean up the subprocess.
    120     pub task_handle: Option<tokio::task::JoinHandle<()>>,
    121     /// Cached status for the agent (derived from session state)
    122     cached_status: AgentStatus,
    123     /// Whether this session's input should be focused on the next frame
    124     pub focus_requested: bool,
    125     /// AI interaction mode for this session (Chat vs Agentic)
    126     pub ai_mode: AiMode,
    127     /// Agentic-mode specific data (None in Chat mode)
    128     pub agentic: Option<AgenticSessionData>,
    129 }
    130 
    131 impl Drop for ChatSession {
    132     fn drop(&mut self) {
    133         if let Some(handle) = self.task_handle.take() {
    134             handle.abort();
    135         }
    136     }
    137 }
    138 
    139 impl ChatSession {
    140     pub fn new(id: SessionId, cwd: PathBuf, ai_mode: AiMode) -> Self {
    141         let agentic = match ai_mode {
    142             AiMode::Agentic => Some(AgenticSessionData::new(id, cwd)),
    143             AiMode::Chat => None,
    144         };
    145 
    146         ChatSession {
    147             id,
    148             title: "New Chat".to_string(),
    149             chat: vec![],
    150             input: String::new(),
    151             incoming_tokens: None,
    152             task_handle: None,
    153             cached_status: AgentStatus::Idle,
    154             focus_requested: false,
    155             ai_mode,
    156             agentic,
    157         }
    158     }
    159 
    160     /// Create a new session that resumes an existing Claude conversation
    161     pub fn new_resumed(
    162         id: SessionId,
    163         cwd: PathBuf,
    164         resume_session_id: String,
    165         title: String,
    166         ai_mode: AiMode,
    167     ) -> Self {
    168         let mut session = Self::new(id, cwd, ai_mode);
    169         if let Some(ref mut agentic) = session.agentic {
    170             agentic.resume_session_id = Some(resume_session_id);
    171         }
    172         session.title = title;
    173         session
    174     }
    175 
    176     // === Helper methods for accessing agentic data ===
    177 
    178     /// Get agentic data, panics if not in agentic mode (use in agentic-only code paths)
    179     pub fn agentic(&self) -> &AgenticSessionData {
    180         self.agentic
    181             .as_ref()
    182             .expect("agentic data only available in Agentic mode")
    183     }
    184 
    185     /// Get mutable agentic data
    186     pub fn agentic_mut(&mut self) -> &mut AgenticSessionData {
    187         self.agentic
    188             .as_mut()
    189             .expect("agentic data only available in Agentic mode")
    190     }
    191 
    192     /// Check if session has agentic capabilities
    193     pub fn is_agentic(&self) -> bool {
    194         self.agentic.is_some()
    195     }
    196 
    197     /// Check if session has pending permission requests
    198     pub fn has_pending_permissions(&self) -> bool {
    199         self.agentic
    200             .as_ref()
    201             .is_some_and(|a| !a.pending_permissions.is_empty())
    202     }
    203 
    204     /// Check if session is in plan mode
    205     pub fn is_plan_mode(&self) -> bool {
    206         self.agentic
    207             .as_ref()
    208             .is_some_and(|a| a.permission_mode == PermissionMode::Plan)
    209     }
    210 
    211     /// Get the working directory (agentic only)
    212     pub fn cwd(&self) -> Option<&PathBuf> {
    213         self.agentic.as_ref().map(|a| &a.cwd)
    214     }
    215 
    216     /// Update a subagent's output (appending new content, keeping only the tail)
    217     pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) {
    218         if let Some(ref mut agentic) = self.agentic {
    219             agentic.update_subagent_output(&mut self.chat, task_id, new_output);
    220         }
    221     }
    222 
    223     /// Mark a subagent as completed
    224     pub fn complete_subagent(&mut self, task_id: &str, result: &str) {
    225         if let Some(ref mut agentic) = self.agentic {
    226             agentic.complete_subagent(&mut self.chat, task_id, result);
    227         }
    228     }
    229 
    230     /// Update the session title from the last message (user or assistant)
    231     pub fn update_title_from_last_message(&mut self) {
    232         for msg in self.chat.iter().rev() {
    233             let text = match msg {
    234                 Message::User(text) | Message::Assistant(text) => text,
    235                 _ => continue,
    236             };
    237             // Use first ~30 chars of last message as title
    238             let title: String = text.chars().take(30).collect();
    239             self.title = if text.len() > 30 {
    240                 format!("{}...", title)
    241             } else {
    242                 title
    243             };
    244             break;
    245         }
    246     }
    247 
    248     /// Get the current status of this session/agent
    249     pub fn status(&self) -> AgentStatus {
    250         self.cached_status
    251     }
    252 
    253     /// Update the cached status based on current session state
    254     pub fn update_status(&mut self) {
    255         self.cached_status = self.derive_status();
    256     }
    257 
    258     /// Derive status from the current session state
    259     fn derive_status(&self) -> AgentStatus {
    260         // Check for pending permission requests (needs input) - agentic only
    261         if self.has_pending_permissions() {
    262             return AgentStatus::NeedsInput;
    263         }
    264 
    265         // Check for error in last message
    266         if let Some(Message::Error(_)) = self.chat.last() {
    267             return AgentStatus::Error;
    268         }
    269 
    270         // Check if actively working (has task handle and receiving tokens)
    271         if self.task_handle.is_some() && self.incoming_tokens.is_some() {
    272             return AgentStatus::Working;
    273         }
    274 
    275         // Check if done (has messages and no active task)
    276         if !self.chat.is_empty() && self.task_handle.is_none() {
    277             // Check if the last meaningful message was from assistant
    278             for msg in self.chat.iter().rev() {
    279                 match msg {
    280                     Message::Assistant(_) => return AgentStatus::Done,
    281                     Message::User(_) => return AgentStatus::Idle, // Waiting for response
    282                     Message::Error(_) => return AgentStatus::Error,
    283                     _ => continue,
    284                 }
    285             }
    286         }
    287 
    288         AgentStatus::Idle
    289     }
    290 }
    291 
    292 /// Tracks a pending external editor process
    293 pub struct EditorJob {
    294     /// The spawned editor process
    295     pub child: std::process::Child,
    296     /// Path to the temp file being edited
    297     pub temp_path: PathBuf,
    298     /// Session ID that initiated the editor
    299     pub session_id: SessionId,
    300 }
    301 
    302 /// Manages multiple chat sessions
    303 pub struct SessionManager {
    304     sessions: HashMap<SessionId, ChatSession>,
    305     order: Vec<SessionId>, // Sorted by recency (most recent first)
    306     active: Option<SessionId>,
    307     next_id: SessionId,
    308     /// Pending external editor job (only one at a time)
    309     pub pending_editor: Option<EditorJob>,
    310 }
    311 
    312 impl Default for SessionManager {
    313     fn default() -> Self {
    314         Self::new()
    315     }
    316 }
    317 
    318 impl SessionManager {
    319     pub fn new() -> Self {
    320         SessionManager {
    321             sessions: HashMap::new(),
    322             order: Vec::new(),
    323             active: None,
    324             next_id: 1,
    325             pending_editor: None,
    326         }
    327     }
    328 
    329     /// Create a new session with the given cwd and make it active
    330     pub fn new_session(&mut self, cwd: PathBuf, ai_mode: AiMode) -> SessionId {
    331         let id = self.next_id;
    332         self.next_id += 1;
    333 
    334         let session = ChatSession::new(id, cwd, ai_mode);
    335         self.sessions.insert(id, session);
    336         self.order.insert(0, id); // Most recent first
    337         self.active = Some(id);
    338 
    339         id
    340     }
    341 
    342     /// Create a new session that resumes an existing Claude conversation
    343     pub fn new_resumed_session(
    344         &mut self,
    345         cwd: PathBuf,
    346         resume_session_id: String,
    347         title: String,
    348         ai_mode: AiMode,
    349     ) -> SessionId {
    350         let id = self.next_id;
    351         self.next_id += 1;
    352 
    353         let session = ChatSession::new_resumed(id, cwd, resume_session_id, title, ai_mode);
    354         self.sessions.insert(id, session);
    355         self.order.insert(0, id); // Most recent first
    356         self.active = Some(id);
    357 
    358         id
    359     }
    360 
    361     /// Get a reference to the active session
    362     pub fn get_active(&self) -> Option<&ChatSession> {
    363         self.active.and_then(|id| self.sessions.get(&id))
    364     }
    365 
    366     /// Get a mutable reference to the active session
    367     pub fn get_active_mut(&mut self) -> Option<&mut ChatSession> {
    368         self.active.and_then(|id| self.sessions.get_mut(&id))
    369     }
    370 
    371     /// Get the active session ID
    372     pub fn active_id(&self) -> Option<SessionId> {
    373         self.active
    374     }
    375 
    376     /// Switch to a different session
    377     pub fn switch_to(&mut self, id: SessionId) -> bool {
    378         if self.sessions.contains_key(&id) {
    379             self.active = Some(id);
    380             true
    381         } else {
    382             false
    383         }
    384     }
    385 
    386     /// Delete a session
    387     /// Returns true if the session was deleted, false if it didn't exist.
    388     /// If the last session is deleted, active will be None and the caller
    389     /// should open the directory picker to create a new session.
    390     pub fn delete_session(&mut self, id: SessionId) -> bool {
    391         if self.sessions.remove(&id).is_some() {
    392             self.order.retain(|&x| x != id);
    393 
    394             // If we deleted the active session, switch to another
    395             if self.active == Some(id) {
    396                 self.active = self.order.first().copied();
    397             }
    398             true
    399         } else {
    400             false
    401         }
    402     }
    403 
    404     /// Get sessions in order of recency (most recent first)
    405     pub fn sessions_ordered(&self) -> Vec<&ChatSession> {
    406         self.order
    407             .iter()
    408             .filter_map(|id| self.sessions.get(id))
    409             .collect()
    410     }
    411 
    412     /// Update the recency of a session (move to front of order)
    413     pub fn touch(&mut self, id: SessionId) {
    414         if self.sessions.contains_key(&id) {
    415             self.order.retain(|&x| x != id);
    416             self.order.insert(0, id);
    417         }
    418     }
    419 
    420     /// Get the number of sessions
    421     pub fn len(&self) -> usize {
    422         self.sessions.len()
    423     }
    424 
    425     /// Check if there are no sessions
    426     pub fn is_empty(&self) -> bool {
    427         self.sessions.is_empty()
    428     }
    429 
    430     /// Get a reference to a session by ID
    431     pub fn get(&self, id: SessionId) -> Option<&ChatSession> {
    432         self.sessions.get(&id)
    433     }
    434 
    435     /// Get a mutable reference to a session by ID
    436     pub fn get_mut(&mut self, id: SessionId) -> Option<&mut ChatSession> {
    437         self.sessions.get_mut(&id)
    438     }
    439 
    440     /// Iterate over all sessions
    441     pub fn iter(&self) -> impl Iterator<Item = &ChatSession> {
    442         self.sessions.values()
    443     }
    444 
    445     /// Iterate over all sessions mutably
    446     pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut ChatSession> {
    447         self.sessions.values_mut()
    448     }
    449 
    450     /// Update status for all sessions
    451     pub fn update_all_statuses(&mut self) {
    452         for session in self.sessions.values_mut() {
    453             session.update_status();
    454         }
    455     }
    456 
    457     /// Get the first session that needs attention (NeedsInput status)
    458     pub fn find_needs_attention(&self) -> Option<SessionId> {
    459         for session in self.sessions.values() {
    460             if session.status() == AgentStatus::NeedsInput {
    461                 return Some(session.id);
    462             }
    463         }
    464         None
    465     }
    466 
    467     /// Get all session IDs
    468     pub fn session_ids(&self) -> Vec<SessionId> {
    469         self.order.clone()
    470     }
    471 }