notedeck

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

session_list.rs (16661B)


      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::backend::BackendType;
      8 use crate::config::AiMode;
      9 use crate::focus_queue::{FocusPriority, FocusQueue};
     10 use crate::session::{SessionId, SessionManager};
     11 use crate::ui::keybind_hint::paint_keybind_hint;
     12 
     13 /// Actions that can be triggered from the session list UI
     14 #[derive(Debug, Clone)]
     15 pub enum SessionListAction {
     16     NewSession,
     17     SwitchTo(SessionId),
     18     Delete(SessionId),
     19     Rename(SessionId, String),
     20     DismissDone(SessionId),
     21 }
     22 
     23 /// UI component for displaying the session list sidebar
     24 pub struct SessionListUi<'a> {
     25     session_manager: &'a SessionManager,
     26     focus_queue: &'a FocusQueue,
     27     ctrl_held: bool,
     28 }
     29 
     30 impl<'a> SessionListUi<'a> {
     31     pub fn new(
     32         session_manager: &'a SessionManager,
     33         focus_queue: &'a FocusQueue,
     34         ctrl_held: bool,
     35     ) -> Self {
     36         SessionListUi {
     37             session_manager,
     38             focus_queue,
     39             ctrl_held,
     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         ui.horizontal(|ui| {
     70             ui.add_space(4.0);
     71             ui.label(egui::RichText::new("Sessions").size(18.0).strong());
     72 
     73             ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
     74                 let icon = app_images::new_message_image()
     75                     .max_height(20.0)
     76                     .sense(Sense::click());
     77 
     78                 if ui
     79                     .add(icon)
     80                     .on_hover_cursor(egui::CursorIcon::PointingHand)
     81                     .on_hover_text("New Chat")
     82                     .clicked()
     83                 {
     84                     action = Some(SessionListAction::NewSession);
     85                 }
     86             });
     87         });
     88 
     89         action
     90     }
     91 
     92     fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
     93         let mut action = None;
     94         let active_id = self.session_manager.active_id();
     95         let mut visual_index: usize = 0;
     96 
     97         // Agents grouped by hostname (pre-computed, no per-frame allocation)
     98         for (hostname, ids) in self.session_manager.host_groups() {
     99             let label = if hostname.is_empty() {
    100                 "Local"
    101             } else {
    102                 hostname
    103             };
    104             ui.label(
    105                 egui::RichText::new(label)
    106                     .size(12.0)
    107                     .color(ui.visuals().weak_text_color()),
    108             );
    109             ui.add_space(4.0);
    110             for &id in ids {
    111                 if let Some(session) = self.session_manager.get(id) {
    112                     if let Some(a) = self.render_session_item(ui, session, visual_index, active_id)
    113                     {
    114                         action = Some(a);
    115                     }
    116                     visual_index += 1;
    117                 }
    118             }
    119             ui.add_space(8.0);
    120         }
    121 
    122         // Chats section (pre-computed IDs)
    123         let chat_ids = self.session_manager.chat_ids();
    124         if !chat_ids.is_empty() {
    125             ui.label(
    126                 egui::RichText::new("Chats")
    127                     .size(12.0)
    128                     .color(ui.visuals().weak_text_color()),
    129             );
    130             ui.add_space(4.0);
    131             for &id in chat_ids {
    132                 if let Some(session) = self.session_manager.get(id) {
    133                     if let Some(a) = self.render_session_item(ui, session, visual_index, active_id)
    134                     {
    135                         action = Some(a);
    136                     }
    137                     visual_index += 1;
    138                 }
    139             }
    140         }
    141 
    142         action
    143     }
    144 
    145     fn render_session_item(
    146         &self,
    147         ui: &mut egui::Ui,
    148         session: &crate::session::ChatSession,
    149         index: usize,
    150         active_id: Option<SessionId>,
    151     ) -> Option<SessionListAction> {
    152         let is_active = Some(session.id) == active_id;
    153         let shortcut_hint = if self.ctrl_held && index < 9 {
    154             Some(index + 1)
    155         } else {
    156             None
    157         };
    158         let queue_priority = self.focus_queue.get_session_priority(session.id);
    159         let empty_path = PathBuf::new();
    160         let cwd = session.cwd().unwrap_or(&empty_path);
    161 
    162         let rename_id = egui::Id::new("session_rename_state");
    163         let mut renaming: Option<(SessionId, String)> =
    164             ui.data(|d| d.get_temp::<(SessionId, String)>(rename_id));
    165         let is_renaming = renaming
    166             .as_ref()
    167             .map(|(id, _)| *id == session.id)
    168             .unwrap_or(false);
    169 
    170         let display_title = if is_renaming {
    171             ""
    172         } else {
    173             session.details.display_title()
    174         };
    175         let (response, dot_action) = self.session_item_ui(
    176             ui,
    177             session.id,
    178             display_title,
    179             cwd,
    180             &session.details.home_dir,
    181             is_active,
    182             shortcut_hint,
    183             session.status(),
    184             queue_priority,
    185             session.ai_mode,
    186             session.backend_type,
    187         );
    188 
    189         let mut action = dot_action;
    190 
    191         if is_renaming {
    192             let outcome = renaming
    193                 .as_mut()
    194                 .and_then(|(_, buf)| inline_rename_ui(ui, &response, buf));
    195             match outcome {
    196                 Some(RenameOutcome::Confirmed(title)) => {
    197                     action = Some(SessionListAction::Rename(session.id, title));
    198                     ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>());
    199                 }
    200                 Some(RenameOutcome::Cancelled) => {
    201                     ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>());
    202                 }
    203                 None => {
    204                     if let Some(r) = renaming {
    205                         ui.data_mut(|d| d.insert_temp(rename_id, r));
    206                     }
    207                 }
    208             }
    209         } else if response.clicked() {
    210             action = Some(SessionListAction::SwitchTo(session.id));
    211         }
    212 
    213         // Long-press to rename (mobile)
    214         if !is_renaming {
    215             let press_id = egui::Id::new("session_long_press");
    216             if response.is_pointer_button_down_on() {
    217                 let now = ui.input(|i| i.time);
    218                 let start: Option<PressStart> = ui.data(|d| d.get_temp(press_id));
    219                 if start.is_none() {
    220                     ui.data_mut(|d| d.insert_temp(press_id, PressStart(now)));
    221                 } else if let Some(s) = start {
    222                     if now - s.0 > 0.5 {
    223                         let rename_state =
    224                             (session.id, session.details.display_title().to_string());
    225                         ui.data_mut(|d| d.insert_temp(rename_id, rename_state));
    226                         ui.data_mut(|d| d.remove_by_type::<PressStart>());
    227                     }
    228                 }
    229             } else {
    230                 ui.data_mut(|d| d.remove_by_type::<PressStart>());
    231             }
    232         }
    233 
    234         response.context_menu(|ui| {
    235             if ui.button("Rename").clicked() {
    236                 let rename_state = (session.id, session.details.display_title().to_string());
    237                 ui.ctx()
    238                     .data_mut(|d| d.insert_temp(rename_id, rename_state));
    239                 ui.close_menu();
    240             }
    241             if ui.button("Delete").clicked() {
    242                 action = Some(SessionListAction::Delete(session.id));
    243                 ui.close_menu();
    244             }
    245         });
    246         action
    247     }
    248 
    249     #[allow(clippy::too_many_arguments)]
    250     fn session_item_ui(
    251         &self,
    252         ui: &mut egui::Ui,
    253         session_id: SessionId,
    254         title: &str,
    255         cwd: &Path,
    256         home_dir: &str,
    257         is_active: bool,
    258         shortcut_hint: Option<usize>,
    259         status: AgentStatus,
    260         queue_priority: Option<FocusPriority>,
    261         session_ai_mode: AiMode,
    262         backend_type: BackendType,
    263     ) -> (egui::Response, Option<SessionListAction>) {
    264         let mut dot_action = None;
    265         // Per-session: Chat sessions get shorter height (no CWD), no status bar
    266         // Agentic sessions get taller height with CWD and status bar
    267         let show_cwd = session_ai_mode == AiMode::Agentic;
    268         let show_status_bar = session_ai_mode == AiMode::Agentic;
    269 
    270         let item_height = if show_cwd { 48.0 } else { 32.0 };
    271         let desired_size = egui::vec2(ui.available_width(), item_height);
    272         let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
    273         let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0));
    274         let response = response
    275             .on_hover_cursor(egui::CursorIcon::PointingHand)
    276             .on_hover_text_at_pointer(hover_text);
    277 
    278         // Paint background: active > hovered > transparent
    279         let fill = if is_active {
    280             ui.visuals().widgets.active.bg_fill
    281         } else if response.hovered() {
    282             ui.visuals().widgets.hovered.weak_bg_fill
    283         } else {
    284             Color32::TRANSPARENT
    285         };
    286 
    287         let corner_radius = 8.0;
    288         ui.painter().rect_filled(rect, corner_radius, fill);
    289 
    290         // Status color indicator (left edge vertical bar) - only in Agentic mode
    291         let mut text_start_x = if show_status_bar {
    292             let status_color = status.color();
    293             let status_bar_rect = egui::Rect::from_min_size(
    294                 rect.left_top() + egui::vec2(2.0, 4.0),
    295                 egui::vec2(3.0, rect.height() - 8.0),
    296             );
    297             ui.painter().rect_filled(status_bar_rect, 1.5, status_color);
    298             12.0 // Left padding (room for status bar)
    299         } else {
    300             8.0 // Smaller padding in Chat mode (no status bar)
    301         };
    302 
    303         // Backend icon (only for agentic backends)
    304         if backend_type.is_agentic() {
    305             let icon_size = 14.0;
    306             let icon_rect = egui::Rect::from_center_size(
    307                 rect.left_center() + egui::vec2(text_start_x + icon_size / 2.0, 0.0),
    308                 egui::vec2(icon_size, icon_size),
    309             );
    310             let icon = crate::ui::backend_icon(backend_type);
    311             icon.paint_at(ui, icon_rect);
    312             text_start_x += icon_size + 4.0;
    313         }
    314 
    315         // Draw shortcut hint at the far right
    316         let mut right_offset = 8.0; // Start with normal right padding
    317 
    318         if let Some(num) = shortcut_hint {
    319             let hint_text = format!("{}", num);
    320             let hint_size = 18.0;
    321             let hint_center = rect.right_center() - egui::vec2(8.0 + hint_size / 2.0, 0.0);
    322             paint_keybind_hint(ui, hint_center, &hint_text, hint_size);
    323             right_offset = 8.0 + hint_size + 6.0; // padding + hint width + spacing
    324         }
    325 
    326         // Draw focus queue indicator dot to the left of the shortcut hint
    327         let text_end_x = if let Some(priority) = queue_priority {
    328             let dot_radius = 5.0;
    329             let dot_center = rect.right_center() - egui::vec2(right_offset + dot_radius + 4.0, 0.0);
    330             ui.painter()
    331                 .circle_filled(dot_center, dot_radius, priority.color());
    332 
    333             // Make the dot clickable to dismiss Done indicators
    334             if priority == FocusPriority::Done {
    335                 let dot_rect = egui::Rect::from_center_size(
    336                     dot_center,
    337                     egui::vec2(dot_radius * 4.0, dot_radius * 4.0),
    338                 );
    339                 let dot_response = ui.interact(
    340                     dot_rect,
    341                     ui.id().with(("dismiss_dot", session_id)),
    342                     egui::Sense::click(),
    343                 );
    344                 if dot_response.clicked() {
    345                     dot_action = Some(SessionListAction::DismissDone(session_id));
    346                 }
    347                 if dot_response.hovered() {
    348                     ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
    349                 }
    350             }
    351 
    352             right_offset + dot_radius * 2.0 + 8.0 // Space reserved for the dot
    353         } else {
    354             right_offset
    355         };
    356 
    357         // Calculate text position - offset title upward only if showing CWD
    358         let title_y_offset = if show_cwd { -7.0 } else { 0.0 };
    359         let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset);
    360         let max_text_width = rect.width() - text_start_x - text_end_x;
    361 
    362         // Draw title text (with clipping to avoid overlapping the dot)
    363         let font_id = egui::FontId::proportional(14.0);
    364         let text_color = ui.visuals().text_color();
    365         let galley = ui
    366             .painter()
    367             .layout_no_wrap(title.to_string(), font_id.clone(), text_color);
    368 
    369         if galley.size().x > max_text_width {
    370             // Text is too long, use ellipsis
    371             let clip_rect = egui::Rect::from_min_size(
    372                 text_pos - egui::vec2(0.0, galley.size().y / 2.0),
    373                 egui::vec2(max_text_width, galley.size().y),
    374             );
    375             ui.painter().with_clip_rect(clip_rect).galley(
    376                 text_pos - egui::vec2(0.0, galley.size().y / 2.0),
    377                 galley,
    378                 text_color,
    379             );
    380         } else {
    381             ui.painter().text(
    382                 text_pos,
    383                 egui::Align2::LEFT_CENTER,
    384                 title,
    385                 font_id,
    386                 text_color,
    387             );
    388         }
    389 
    390         // Draw cwd below title - only in Agentic mode
    391         if show_cwd {
    392             let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0);
    393             cwd_ui(ui, cwd, home_dir, cwd_pos, max_text_width);
    394         }
    395 
    396         (response, dot_action)
    397     }
    398 }
    399 
    400 #[derive(Clone, Copy)]
    401 struct PressStart(f64);
    402 
    403 enum RenameOutcome {
    404     Confirmed(String),
    405     Cancelled,
    406 }
    407 
    408 fn inline_rename_ui(
    409     ui: &mut egui::Ui,
    410     response: &egui::Response,
    411     buf: &mut String,
    412 ) -> Option<RenameOutcome> {
    413     let edit_rect = response.rect.shrink2(egui::vec2(8.0, 4.0));
    414     let edit = egui::Area::new(egui::Id::new("rename_textedit"))
    415         .fixed_pos(edit_rect.min)
    416         .order(egui::Order::Foreground)
    417         .show(ui.ctx(), |ui| {
    418             ui.set_width(edit_rect.width());
    419             ui.add(
    420                 egui::TextEdit::singleline(buf)
    421                     .font(egui::FontId::proportional(14.0))
    422                     .frame(false),
    423             )
    424         })
    425         .inner;
    426 
    427     if !edit.has_focus() && !edit.lost_focus() {
    428         edit.request_focus();
    429     }
    430 
    431     if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
    432         Some(RenameOutcome::Confirmed(buf.clone()))
    433     } else if edit.lost_focus() {
    434         if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
    435             Some(RenameOutcome::Cancelled)
    436         } else {
    437             Some(RenameOutcome::Confirmed(buf.clone()))
    438         }
    439     } else {
    440         None
    441     }
    442 }
    443 
    444 /// Draw cwd text (monospace, weak+small) with clipping.
    445 fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, home_dir: &str, pos: egui::Pos2, max_width: f32) {
    446     let display_text = if home_dir.is_empty() {
    447         crate::path_utils::abbreviate_path(cwd_path)
    448     } else {
    449         crate::path_utils::abbreviate_with_home(cwd_path, home_dir)
    450     };
    451     let cwd_font = egui::FontId::monospace(10.0);
    452     let cwd_color = ui.visuals().weak_text_color();
    453 
    454     let cwd_galley = ui
    455         .painter()
    456         .layout_no_wrap(display_text.clone(), cwd_font.clone(), cwd_color);
    457 
    458     if cwd_galley.size().x > max_width {
    459         let clip_rect = egui::Rect::from_min_size(
    460             pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
    461             egui::vec2(max_width, cwd_galley.size().y),
    462         );
    463         ui.painter().with_clip_rect(clip_rect).galley(
    464             pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
    465             cwd_galley,
    466             cwd_color,
    467         );
    468     } else {
    469         ui.painter().text(
    470             pos,
    471             egui::Align2::LEFT_CENTER,
    472             &display_text,
    473             cwd_font,
    474             cwd_color,
    475         );
    476     }
    477 }