notedeck

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

session_list.rs (9922B)


      1 use std::path::{Path, PathBuf};
      2 
      3 use egui::{Align, Color32, Layout, Sense};
      4 use notedeck_ui::app_images;
      5 
      6 use crate::agent_status::AgentStatus;
      7 use crate::config::AiMode;
      8 use crate::focus_queue::{FocusPriority, FocusQueue};
      9 use crate::session::{SessionId, SessionManager};
     10 use crate::ui::keybind_hint::paint_keybind_hint;
     11 
     12 /// Actions that can be triggered from the session list UI
     13 #[derive(Debug, Clone)]
     14 pub enum SessionListAction {
     15     NewSession,
     16     SwitchTo(SessionId),
     17     Delete(SessionId),
     18 }
     19 
     20 /// UI component for displaying the session list sidebar
     21 pub struct SessionListUi<'a> {
     22     session_manager: &'a SessionManager,
     23     focus_queue: &'a FocusQueue,
     24     ctrl_held: bool,
     25     ai_mode: AiMode,
     26 }
     27 
     28 impl<'a> SessionListUi<'a> {
     29     pub fn new(
     30         session_manager: &'a SessionManager,
     31         focus_queue: &'a FocusQueue,
     32         ctrl_held: bool,
     33         ai_mode: AiMode,
     34     ) -> Self {
     35         SessionListUi {
     36             session_manager,
     37             focus_queue,
     38             ctrl_held,
     39             ai_mode,
     40         }
     41     }
     42 
     43     pub fn ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
     44         let mut action: Option<SessionListAction> = None;
     45 
     46         ui.vertical(|ui| {
     47             // Header with New Agent button
     48             action = self.header_ui(ui);
     49 
     50             ui.add_space(8.0);
     51 
     52             // Scrollable list of sessions
     53             egui::ScrollArea::vertical()
     54                 .id_salt("session_list_scroll")
     55                 .auto_shrink([false; 2])
     56                 .show(ui, |ui| {
     57                     if let Some(session_action) = self.sessions_list_ui(ui) {
     58                         action = Some(session_action);
     59                     }
     60                 });
     61         });
     62 
     63         action
     64     }
     65 
     66     fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
     67         let mut action = None;
     68 
     69         // Header text and tooltip depend on mode
     70         let (header_text, new_tooltip) = match self.ai_mode {
     71             AiMode::Chat => ("Chats", "New Chat"),
     72             AiMode::Agentic => ("Agents", "New Agent"),
     73         };
     74 
     75         ui.horizontal(|ui| {
     76             ui.add_space(4.0);
     77             ui.label(egui::RichText::new(header_text).size(18.0).strong());
     78 
     79             ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
     80                 let icon = app_images::new_message_image()
     81                     .max_height(20.0)
     82                     .sense(Sense::click());
     83 
     84                 if ui
     85                     .add(icon)
     86                     .on_hover_cursor(egui::CursorIcon::PointingHand)
     87                     .on_hover_text(new_tooltip)
     88                     .clicked()
     89                 {
     90                     action = Some(SessionListAction::NewSession);
     91                 }
     92             });
     93         });
     94 
     95         action
     96     }
     97 
     98     fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
     99         let mut action = None;
    100         let active_id = self.session_manager.active_id();
    101 
    102         for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() {
    103             let is_active = Some(session.id) == active_id;
    104             // Show keyboard shortcut hint for first 9 sessions (1-9 keys), only when Ctrl held
    105             let shortcut_hint = if self.ctrl_held && index < 9 {
    106                 Some(index + 1)
    107             } else {
    108                 None
    109             };
    110 
    111             // Check if this session is in the focus queue
    112             let queue_priority = self.focus_queue.get_session_priority(session.id);
    113 
    114             // Get cwd from agentic data, fallback to empty path for Chat mode
    115             let empty_path = PathBuf::new();
    116             let cwd = session.cwd().unwrap_or(&empty_path);
    117 
    118             let response = self.session_item_ui(
    119                 ui,
    120                 &session.title,
    121                 cwd,
    122                 is_active,
    123                 shortcut_hint,
    124                 session.status(),
    125                 queue_priority,
    126             );
    127 
    128             if response.clicked() {
    129                 action = Some(SessionListAction::SwitchTo(session.id));
    130             }
    131 
    132             // Right-click context menu for delete
    133             response.context_menu(|ui| {
    134                 if ui.button("Delete").clicked() {
    135                     action = Some(SessionListAction::Delete(session.id));
    136                     ui.close_menu();
    137                 }
    138             });
    139         }
    140 
    141         action
    142     }
    143 
    144     #[allow(clippy::too_many_arguments)]
    145     fn session_item_ui(
    146         &self,
    147         ui: &mut egui::Ui,
    148         title: &str,
    149         cwd: &Path,
    150         is_active: bool,
    151         shortcut_hint: Option<usize>,
    152         status: AgentStatus,
    153         queue_priority: Option<FocusPriority>,
    154     ) -> egui::Response {
    155         // In Chat mode: shorter height (no CWD), no status bar
    156         // In Agentic mode: taller height with CWD and status bar
    157         let show_cwd = self.ai_mode == AiMode::Agentic;
    158         let show_status_bar = self.ai_mode == AiMode::Agentic;
    159 
    160         let item_height = if show_cwd { 48.0 } else { 32.0 };
    161         let desired_size = egui::vec2(ui.available_width(), item_height);
    162         let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
    163         let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0));
    164         let response = response
    165             .on_hover_cursor(egui::CursorIcon::PointingHand)
    166             .on_hover_text_at_pointer(hover_text);
    167 
    168         // Paint background: active > hovered > transparent
    169         let fill = if is_active {
    170             ui.visuals().widgets.active.bg_fill
    171         } else if response.hovered() {
    172             ui.visuals().widgets.hovered.weak_bg_fill
    173         } else {
    174             Color32::TRANSPARENT
    175         };
    176 
    177         let corner_radius = 8.0;
    178         ui.painter().rect_filled(rect, corner_radius, fill);
    179 
    180         // Status color indicator (left edge vertical bar) - only in Agentic mode
    181         let text_start_x = if show_status_bar {
    182             let status_color = status.color();
    183             let status_bar_rect = egui::Rect::from_min_size(
    184                 rect.left_top() + egui::vec2(2.0, 4.0),
    185                 egui::vec2(3.0, rect.height() - 8.0),
    186             );
    187             ui.painter().rect_filled(status_bar_rect, 1.5, status_color);
    188             12.0 // Left padding (room for status bar)
    189         } else {
    190             8.0 // Smaller padding in Chat mode (no status bar)
    191         };
    192 
    193         // Draw shortcut hint at the far right
    194         let mut right_offset = 8.0; // Start with normal right padding
    195 
    196         if let Some(num) = shortcut_hint {
    197             let hint_text = format!("{}", num);
    198             let hint_size = 18.0;
    199             let hint_center = rect.right_center() - egui::vec2(8.0 + hint_size / 2.0, 0.0);
    200             paint_keybind_hint(ui, hint_center, &hint_text, hint_size);
    201             right_offset = 8.0 + hint_size + 6.0; // padding + hint width + spacing
    202         }
    203 
    204         // Draw focus queue indicator dot to the left of the shortcut hint
    205         let text_end_x = if let Some(priority) = queue_priority {
    206             let dot_radius = 5.0;
    207             let dot_center = rect.right_center() - egui::vec2(right_offset + dot_radius + 4.0, 0.0);
    208             ui.painter()
    209                 .circle_filled(dot_center, dot_radius, priority.color());
    210             right_offset + dot_radius * 2.0 + 8.0 // Space reserved for the dot
    211         } else {
    212             right_offset
    213         };
    214 
    215         // Calculate text position - offset title upward only if showing CWD
    216         let title_y_offset = if show_cwd { -7.0 } else { 0.0 };
    217         let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset);
    218         let max_text_width = rect.width() - text_start_x - text_end_x;
    219 
    220         // Draw title text (with clipping to avoid overlapping the dot)
    221         let font_id = egui::FontId::proportional(14.0);
    222         let text_color = ui.visuals().text_color();
    223         let galley = ui
    224             .painter()
    225             .layout_no_wrap(title.to_string(), font_id.clone(), text_color);
    226 
    227         if galley.size().x > max_text_width {
    228             // Text is too long, use ellipsis
    229             let clip_rect = egui::Rect::from_min_size(
    230                 text_pos - egui::vec2(0.0, galley.size().y / 2.0),
    231                 egui::vec2(max_text_width, galley.size().y),
    232             );
    233             ui.painter().with_clip_rect(clip_rect).galley(
    234                 text_pos - egui::vec2(0.0, galley.size().y / 2.0),
    235                 galley,
    236                 text_color,
    237             );
    238         } else {
    239             ui.painter().text(
    240                 text_pos,
    241                 egui::Align2::LEFT_CENTER,
    242                 title,
    243                 font_id,
    244                 text_color,
    245             );
    246         }
    247 
    248         // Draw cwd below title - only in Agentic mode
    249         if show_cwd {
    250             let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0);
    251             cwd_ui(ui, cwd, cwd_pos, max_text_width);
    252         }
    253 
    254         response
    255     }
    256 }
    257 
    258 /// Draw cwd text (monospace, weak+small) with clipping
    259 fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) {
    260     let cwd_text = cwd_path.to_string_lossy();
    261     let cwd_font = egui::FontId::monospace(10.0);
    262     let cwd_color = ui.visuals().weak_text_color();
    263 
    264     let cwd_galley = ui
    265         .painter()
    266         .layout_no_wrap(cwd_text.to_string(), cwd_font.clone(), cwd_color);
    267 
    268     if cwd_galley.size().x > max_width {
    269         let clip_rect = egui::Rect::from_min_size(
    270             pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
    271             egui::vec2(max_width, cwd_galley.size().y),
    272         );
    273         ui.painter().with_clip_rect(clip_rect).galley(
    274             pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
    275             cwd_galley,
    276             cwd_color,
    277         );
    278     } else {
    279         ui.painter().text(
    280             pos,
    281             egui::Align2::LEFT_CENTER,
    282             &cwd_text,
    283             cwd_font,
    284             cwd_color,
    285         );
    286     }
    287 }