| 1 | //! Session resume picker view for the TUI. |
| 2 | |
| 3 | use std::cell::{Cell, RefCell}; |
| 4 | use std::collections::HashMap; |
| 5 | use std::path::{Path, PathBuf}; |
| 6 | |
| 7 | use chrono::{DateTime, Local}; |
| 8 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; |
| 9 | use ratatui::{ |
| 10 | buffer::Buffer, |
| 11 | layout::{Constraint, Direction, Layout, Rect}, |
| 12 | style::{Modifier, Style}, |
| 13 | text::{Line, Span}, |
| 14 | widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}, |
| 15 | }; |
| 16 | use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; |
| 17 | |
| 18 | use crate::localization::{Locale, MessageId, tr}; |
| 19 | use crate::palette; |
| 20 | use crate::session_manager::{ |
| 21 | SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title, |
| 22 | extract_user_prompt, strip_thinking_tags, |
| 23 | }; |
| 24 | use crate::session_projection::{MAX_PROJECTED_SESSIONS, SessionQuery, SessionSortMode}; |
| 25 | use crate::tui::menu_style; |
| 26 | use crate::tui::views::{ |
| 27 | ActionHint, action_footer_lines, render_modal_footer, render_panel_scroll_rail, |
| 28 | render_underwater_surface, |
| 29 | }; |
| 30 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 31 | |
| 32 | fn section_block(title: &str) -> Block<'static> { |
| 33 | Block::default() |
| 34 | .title(Line::from(vec![Span::styled( |
| 35 | title.to_string(), |
| 36 | Style::default() |
| 37 | .fg(palette::WHALE_ACTION) |
| 38 | .add_modifier(Modifier::BOLD), |
| 39 | )])) |
| 40 | .borders(Borders::TOP) |
| 41 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 42 | .style(Style::default().bg(palette::WHALE_BG)) |
| 43 | .padding(Padding::uniform(1)) |
| 44 | } |
| 45 | |
| 46 | pub struct SessionPickerView { |
| 47 | /// Every session loaded from disk. The picker filters from this set. |
| 48 | sessions: Vec<SessionMetadata>, |
| 49 | filtered: Vec<SessionMetadata>, |
| 50 | selected: usize, |
| 51 | list_scroll: Cell<usize>, |
| 52 | list_visible_rows: Cell<usize>, |
| 53 | history_scroll: Cell<usize>, |
| 54 | history_pinned_to_latest: Cell<bool>, |
| 55 | history_visible_rows: Cell<usize>, |
| 56 | search_input: String, |
| 57 | search_mode: bool, |
| 58 | sort_mode: SessionSortMode, |
| 59 | preview_cache: HashMap<String, Vec<String>>, |
| 60 | current_preview: Vec<String>, |
| 61 | confirm_delete: bool, |
| 62 | rename_mode: bool, |
| 63 | rename_input: String, |
| 64 | status: Option<String>, |
| 65 | /// Canonical workspace path used as the per-project scope filter |
| 66 | /// (#1395). `None` opts out of scoping (e.g. when the caller can't |
| 67 | /// resolve a workspace). |
| 68 | workspace_scope: Option<PathBuf>, |
| 69 | /// When `true`, the picker shows sessions from every workspace; when |
| 70 | /// `false`, only sessions whose recorded `workspace` matches the |
| 71 | /// canonicalised `workspace_scope`. |
| 72 | show_all_workspaces: bool, |
| 73 | /// When `true`, archived sessions are listed alongside active ones |
| 74 | /// (#2934 / #4397). Defaults to `false`: archiving is the user putting a |
| 75 | /// session away, and the browse default should honour that. |
| 76 | show_archived: bool, |
| 77 | /// Screen rows owned by the visible session list. Keeping this local to |
| 78 | /// the view gives mouse and keyboard the same selection/resume contract. |
| 79 | last_row_hitboxes: RefCell<Vec<(u16, usize)>>, |
| 80 | /// UI locale captured from the app at construction (#4057 wave 2). |
| 81 | locale: Locale, |
| 82 | } |
| 83 | |
| 84 | impl SessionPickerView { |
| 85 | /// Construct a picker scoped to `workspace`. Sessions belonging to |
| 86 | /// other workspaces are hidden by default — press `a` inside the |
| 87 | /// picker to expand to all workspaces (#1395). |
| 88 | pub fn new(workspace: &Path, locale: Locale) -> Self { |
| 89 | let sessions = SessionManager::default_location() |
| 90 | .and_then(|manager| manager.list_sessions()) |
| 91 | .unwrap_or_default(); |
| 92 | |
| 93 | let mut view = Self { |
| 94 | sessions, |
| 95 | filtered: Vec::new(), |
| 96 | selected: 0, |
| 97 | list_scroll: Cell::new(0), |
| 98 | list_visible_rows: Cell::new(8), |
| 99 | history_scroll: Cell::new(0), |
| 100 | history_pinned_to_latest: Cell::new(true), |
| 101 | history_visible_rows: Cell::new(12), |
| 102 | search_input: String::new(), |
| 103 | search_mode: false, |
| 104 | sort_mode: SessionSortMode::Recent, |
| 105 | preview_cache: HashMap::new(), |
| 106 | current_preview: Vec::new(), |
| 107 | confirm_delete: false, |
| 108 | rename_mode: false, |
| 109 | rename_input: String::new(), |
| 110 | status: None, |
| 111 | workspace_scope: Some(canonical_or_self(workspace.to_path_buf())), |
| 112 | show_all_workspaces: false, |
| 113 | show_archived: false, |
| 114 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 115 | locale, |
| 116 | }; |
| 117 | view.apply_sort_and_filter(); |
| 118 | view.refresh_preview(); |
| 119 | view |
| 120 | } |
| 121 | |
| 122 | /// As [`Self::new`], but with `session_id` preselected. |
| 123 | /// |
| 124 | /// This is how the sidebar Sessions rail hands off (#2934): the rail |
| 125 | /// navigates, the picker keeps ownership of preview, resume, rename, |
| 126 | /// archive, and delete. A row whose session is outside the current |
| 127 | /// workspace scope, or archived, widens the corresponding filter rather |
| 128 | /// than silently landing on the wrong row — but it never *resumes* |
| 129 | /// anything, so widening the view cannot cross a workspace boundary |
| 130 | /// behind the user's back. |
| 131 | pub fn new_selecting(workspace: &Path, locale: Locale, session_id: &str) -> Self { |
| 132 | let mut view = Self::new(workspace, locale); |
| 133 | if view.select_session_id(session_id) { |
| 134 | return view; |
| 135 | } |
| 136 | if !view.show_archived { |
| 137 | view.show_archived = true; |
| 138 | view.apply_sort_and_filter(); |
| 139 | if view.select_session_id(session_id) { |
| 140 | view.status = |
| 141 | Some(tr(view.locale, MessageId::SessionsShowingArchived).into_owned()); |
| 142 | return view; |
| 143 | } |
| 144 | } |
| 145 | if !view.show_all_workspaces { |
| 146 | view.show_all_workspaces = true; |
| 147 | view.apply_sort_and_filter(); |
| 148 | if view.select_session_id(session_id) { |
| 149 | view.status = |
| 150 | Some(tr(view.locale, MessageId::SessionsShowingAllWorkspaces).into_owned()); |
| 151 | return view; |
| 152 | } |
| 153 | } |
| 154 | // Not found at all: leave the default view rather than pretending. |
| 155 | view.status = Some(tr(view.locale, MessageId::SessionsNoResults).into_owned()); |
| 156 | view |
| 157 | } |
| 158 | |
| 159 | /// Move the selection onto `session_id` if it is in the filtered list. |
| 160 | fn select_session_id(&mut self, session_id: &str) -> bool { |
| 161 | let Some(index) = self.filtered.iter().position(|s| s.id == session_id) else { |
| 162 | return false; |
| 163 | }; |
| 164 | self.selected = index; |
| 165 | self.ensure_selected_visible(); |
| 166 | self.refresh_preview(); |
| 167 | true |
| 168 | } |
| 169 | |
| 170 | /// The query this picker's current view represents. |
| 171 | /// |
| 172 | /// Built here and handed to [`crate::session_projection::select_sessions`] |
| 173 | /// so the picker's list is literally the same selection the rail and |
| 174 | /// `/v1/sessions` compute — filter, workspace scope, fuzzy search, sort, |
| 175 | /// and tie-breaks included. The picker used to own private copies of all |
| 176 | /// five; that was the second backend. |
| 177 | fn view_query(&self) -> SessionQuery { |
| 178 | let mut query = SessionQuery::default() |
| 179 | .with_filter(self.archive_filter()) |
| 180 | .with_sort(self.sort_mode) |
| 181 | .with_search(self.search_input.trim().to_string()) |
| 182 | .with_limit(MAX_PROJECTED_SESSIONS); |
| 183 | if !self.show_all_workspaces |
| 184 | && let Some(scope) = self.workspace_scope.as_deref() |
| 185 | { |
| 186 | query = query.scoped_to(scope); |
| 187 | } |
| 188 | query |
| 189 | } |
| 190 | |
| 191 | /// Flip between current-workspace-only and all-workspaces view |
| 192 | /// (#1395). Used by the `a` keybinding inside the picker; also |
| 193 | /// callable from tests. |
| 194 | pub fn toggle_all_workspaces(&mut self) { |
| 195 | self.show_all_workspaces = !self.show_all_workspaces; |
| 196 | let label = if self.show_all_workspaces { |
| 197 | tr(self.locale, MessageId::SessionsShowingAllWorkspaces) |
| 198 | } else { |
| 199 | tr(self.locale, MessageId::SessionsScopedToWorkspace) |
| 200 | }; |
| 201 | self.status = Some(label.into_owned()); |
| 202 | self.selected = 0; |
| 203 | self.apply_sort_and_filter(); |
| 204 | } |
| 205 | |
| 206 | /// Which archive states the list currently admits. |
| 207 | fn archive_filter(&self) -> SessionListFilter { |
| 208 | if self.show_archived { |
| 209 | SessionListFilter::IncludeArchived |
| 210 | } else { |
| 211 | SessionListFilter::ActiveOnly |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /// Ids currently visible in the list, top to bottom. |
| 216 | /// |
| 217 | /// Exposed so the shared acceptance matrix can compare the picker's real |
| 218 | /// filtered view against the API's projection instead of comparing the |
| 219 | /// projection to itself. |
| 220 | #[cfg(test)] |
| 221 | pub fn visible_session_ids(&self) -> Vec<String> { |
| 222 | self.filtered.iter().map(|s| s.id.clone()).collect() |
| 223 | } |
| 224 | |
| 225 | /// The query behind [`Self::visible_session_ids`]. |
| 226 | #[cfg(test)] |
| 227 | pub fn view_query_for_test(&self) -> SessionQuery { |
| 228 | self.view_query() |
| 229 | } |
| 230 | |
| 231 | #[cfg(test)] |
| 232 | pub fn cycle_sort_for_test(&mut self) { |
| 233 | self.cycle_sort(); |
| 234 | } |
| 235 | |
| 236 | #[cfg(test)] |
| 237 | pub fn set_search_for_test(&mut self, query: &str) { |
| 238 | self.search_input = query.to_string(); |
| 239 | self.apply_sort_and_filter(); |
| 240 | } |
| 241 | |
| 242 | fn apply_sort_and_filter(&mut self) { |
| 243 | let query = self.view_query(); |
| 244 | self.filtered = crate::session_projection::select_sessions(&self.sessions, &query) |
| 245 | .into_iter() |
| 246 | .cloned() |
| 247 | .collect(); |
| 248 | |
| 249 | if self.selected >= self.filtered.len() { |
| 250 | self.selected = 0; |
| 251 | } |
| 252 | self.ensure_selected_visible(); |
| 253 | |
| 254 | self.refresh_preview(); |
| 255 | } |
| 256 | |
| 257 | fn move_selection(&mut self, delta: isize) { |
| 258 | self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta); |
| 259 | self.ensure_selected_visible(); |
| 260 | self.refresh_preview(); |
| 261 | } |
| 262 | |
| 263 | fn select_visible_shortcut(&mut self, c: char) -> bool { |
| 264 | let Some(slot) = c.to_digit(10) else { |
| 265 | return false; |
| 266 | }; |
| 267 | if !(1..=9).contains(&slot) { |
| 268 | return false; |
| 269 | } |
| 270 | let index = self.list_scroll.get().saturating_add(slot as usize - 1); |
| 271 | if index >= self.filtered.len() { |
| 272 | return false; |
| 273 | } |
| 274 | self.selected = index; |
| 275 | self.ensure_selected_visible(); |
| 276 | self.refresh_preview(); |
| 277 | if let Some(session) = self.selected_session() { |
| 278 | self.status = Some( |
| 279 | tr(self.locale, MessageId::SessionsOpenedHistory) |
| 280 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 281 | ); |
| 282 | } |
| 283 | true |
| 284 | } |
| 285 | |
| 286 | fn update_list_viewport(&self, visible_rows: usize) { |
| 287 | self.list_visible_rows.set(visible_rows.max(1)); |
| 288 | self.ensure_selected_visible(); |
| 289 | } |
| 290 | |
| 291 | fn update_history_viewport(&self, visible_rows: usize) { |
| 292 | self.history_visible_rows.set(visible_rows.max(1)); |
| 293 | self.ensure_history_scroll_in_bounds(); |
| 294 | } |
| 295 | |
| 296 | fn scroll_history(&self, delta: isize) { |
| 297 | let max_scroll = |
| 298 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 299 | let current = self.history_scroll.get(); |
| 300 | let next = if delta.is_negative() { |
| 301 | current.saturating_sub(delta.unsigned_abs()) |
| 302 | } else { |
| 303 | current.saturating_add(delta as usize) |
| 304 | }; |
| 305 | let next = next.min(max_scroll); |
| 306 | self.history_scroll.set(next); |
| 307 | self.history_pinned_to_latest.set(next == max_scroll); |
| 308 | } |
| 309 | |
| 310 | fn ensure_history_scroll_in_bounds(&self) { |
| 311 | let max_scroll = |
| 312 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 313 | if self.history_pinned_to_latest.get() { |
| 314 | self.history_scroll.set(max_scroll); |
| 315 | } else { |
| 316 | self.history_scroll |
| 317 | .set(self.history_scroll.get().min(max_scroll)); |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | fn scroll_history_to_latest(&self) { |
| 322 | let max_scroll = |
| 323 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 324 | self.history_scroll.set(max_scroll); |
| 325 | self.history_pinned_to_latest.set(true); |
| 326 | } |
| 327 | |
| 328 | fn ensure_selected_visible(&self) { |
| 329 | if self.filtered.is_empty() { |
| 330 | self.list_scroll.set(0); |
| 331 | return; |
| 332 | } |
| 333 | |
| 334 | let visible_rows = self.list_visible_rows.get().max(1); |
| 335 | let max_scroll = self.filtered.len().saturating_sub(visible_rows); |
| 336 | let mut scroll = self.list_scroll.get().min(max_scroll); |
| 337 | |
| 338 | if self.selected < scroll { |
| 339 | scroll = self.selected; |
| 340 | } else if self.selected >= scroll.saturating_add(visible_rows) { |
| 341 | scroll = self.selected.saturating_add(1).saturating_sub(visible_rows); |
| 342 | } |
| 343 | |
| 344 | self.list_scroll.set(scroll.min(max_scroll)); |
| 345 | } |
| 346 | |
| 347 | fn selected_session(&self) -> Option<&SessionMetadata> { |
| 348 | self.filtered.get(self.selected) |
| 349 | } |
| 350 | |
| 351 | fn cycle_sort(&mut self) { |
| 352 | self.sort_mode = self.sort_mode.next(); |
| 353 | self.apply_sort_and_filter(); |
| 354 | self.status = Some( |
| 355 | tr(self.locale, MessageId::SessionsSortStatus).replace("{sort}", &self.sort_label()), |
| 356 | ); |
| 357 | } |
| 358 | |
| 359 | fn sort_label(&self) -> String { |
| 360 | match self.sort_mode { |
| 361 | SessionSortMode::Recent => tr(self.locale, MessageId::SessionsSortRecent), |
| 362 | SessionSortMode::Name => tr(self.locale, MessageId::SessionsSortName), |
| 363 | SessionSortMode::Size => tr(self.locale, MessageId::SessionsSortSize), |
| 364 | } |
| 365 | .into_owned() |
| 366 | } |
| 367 | |
| 368 | fn enter_search(&mut self) { |
| 369 | self.search_mode = true; |
| 370 | self.search_input.clear(); |
| 371 | self.status = Some(tr(self.locale, MessageId::SessionsSearchPrompt).into_owned()); |
| 372 | } |
| 373 | |
| 374 | fn exit_search(&mut self) { |
| 375 | self.search_mode = false; |
| 376 | self.apply_sort_and_filter(); |
| 377 | self.status = None; |
| 378 | } |
| 379 | |
| 380 | fn delete_selected(&mut self) -> Option<ViewEvent> { |
| 381 | let session = self.selected_session().cloned()?; |
| 382 | let manager = SessionManager::default_location().ok()?; |
| 383 | if let Err(err) = manager.delete_session(&session.id) { |
| 384 | self.status = Some( |
| 385 | tr(self.locale, MessageId::SessionsDeleteFailed) |
| 386 | .replace("{error}", &err.to_string()), |
| 387 | ); |
| 388 | return None; |
| 389 | } |
| 390 | self.sessions.retain(|s| s.id != session.id); |
| 391 | self.apply_sort_and_filter(); |
| 392 | self.refresh_preview(); |
| 393 | self.status = Some( |
| 394 | tr(self.locale, MessageId::SessionsDeleted) |
| 395 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 396 | ); |
| 397 | Some(ViewEvent::SessionDeleted { |
| 398 | session_id: session.id, |
| 399 | title: session.title, |
| 400 | }) |
| 401 | } |
| 402 | |
| 403 | /// Archive or restore the selected session (#2934 / #4397). |
| 404 | /// |
| 405 | /// Writes through [`SessionManager::set_session_archived`] — the same |
| 406 | /// single writer `PATCH /v1/sessions/{id}` uses — so the picker and the |
| 407 | /// dashboard cannot disagree about what "archived" means. Emits a |
| 408 | /// `SessionRenamed` event carrying the saved metadata so the app-level |
| 409 | /// caches (and the sidebar rail) see the new lifecycle state without |
| 410 | /// re-reading disk. |
| 411 | fn toggle_archive_selected(&mut self) -> ViewAction { |
| 412 | let Some(session) = self.selected_session().cloned() else { |
| 413 | self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned()); |
| 414 | return ViewAction::None; |
| 415 | }; |
| 416 | let manager = match SessionManager::default_location() { |
| 417 | Ok(manager) => manager, |
| 418 | Err(err) => { |
| 419 | self.status = Some( |
| 420 | tr(self.locale, MessageId::SessionsOpenFailed) |
| 421 | .replace("{error}", &err.to_string()), |
| 422 | ); |
| 423 | return ViewAction::None; |
| 424 | } |
| 425 | }; |
| 426 | let archived = !session.archived; |
| 427 | let metadata = match manager.set_session_archived( |
| 428 | &session.id, |
| 429 | archived, |
| 430 | crate::session_manager::SessionMutator::Owner, |
| 431 | ) { |
| 432 | Ok(metadata) => metadata, |
| 433 | Err(err) => { |
| 434 | self.status = Some( |
| 435 | tr(self.locale, MessageId::SessionsArchiveFailed) |
| 436 | .replace("{error}", &err.to_string()), |
| 437 | ); |
| 438 | return ViewAction::None; |
| 439 | } |
| 440 | }; |
| 441 | |
| 442 | if let Some(local) = self.sessions.iter_mut().find(|s| s.id == session.id) { |
| 443 | local.archived = metadata.archived; |
| 444 | } |
| 445 | self.apply_sort_and_filter(); |
| 446 | let message_id = if archived { |
| 447 | MessageId::SessionsArchived |
| 448 | } else { |
| 449 | MessageId::SessionsRestored |
| 450 | }; |
| 451 | self.status = Some( |
| 452 | tr(self.locale, message_id) |
| 453 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 454 | ); |
| 455 | ViewAction::Emit(ViewEvent::SessionArchived { metadata }) |
| 456 | } |
| 457 | |
| 458 | /// Flip whether archived sessions appear in the list. |
| 459 | fn toggle_show_archived(&mut self) { |
| 460 | self.show_archived = !self.show_archived; |
| 461 | let label = if self.show_archived { |
| 462 | tr(self.locale, MessageId::SessionsShowingArchived) |
| 463 | } else { |
| 464 | tr(self.locale, MessageId::SessionsHidingArchived) |
| 465 | }; |
| 466 | self.status = Some(label.into_owned()); |
| 467 | self.selected = 0; |
| 468 | self.apply_sort_and_filter(); |
| 469 | } |
| 470 | |
| 471 | fn rename_selected(&mut self, new_title: &str) -> ViewAction { |
| 472 | let Some(session) = self.selected_session().cloned() else { |
| 473 | self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned()); |
| 474 | return ViewAction::None; |
| 475 | }; |
| 476 | if new_title.is_empty() || new_title.len() > 100 { |
| 477 | self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned()); |
| 478 | return ViewAction::None; |
| 479 | } |
| 480 | let manager = match SessionManager::default_location() { |
| 481 | Ok(m) => m, |
| 482 | Err(e) => { |
| 483 | self.status = Some( |
| 484 | tr(self.locale, MessageId::SessionsOpenFailed) |
| 485 | .replace("{error}", &e.to_string()), |
| 486 | ); |
| 487 | return ViewAction::None; |
| 488 | } |
| 489 | }; |
| 490 | let mut saved = match manager.load_session(&session.id) { |
| 491 | Ok(s) => s, |
| 492 | Err(e) => { |
| 493 | self.status = Some( |
| 494 | tr(self.locale, MessageId::SessionsLoadFailed) |
| 495 | .replace("{error}", &e.to_string()), |
| 496 | ); |
| 497 | return ViewAction::None; |
| 498 | } |
| 499 | }; |
| 500 | saved.metadata.title = new_title.to_string(); |
| 501 | if let Err(e) = manager.save_session(&saved) { |
| 502 | self.status = Some( |
| 503 | tr(self.locale, MessageId::SessionsRenameFailed).replace("{error}", &e.to_string()), |
| 504 | ); |
| 505 | return ViewAction::None; |
| 506 | } |
| 507 | // Update our local metadata cache. |
| 508 | if let Some(meta) = self.sessions.iter_mut().find(|s| s.id == session.id) { |
| 509 | meta.title = new_title.to_string(); |
| 510 | } |
| 511 | self.apply_sort_and_filter(); |
| 512 | self.refresh_preview(); |
| 513 | self.status = |
| 514 | Some(tr(self.locale, MessageId::SessionsRenamed).replace("{title}", new_title)); |
| 515 | ViewAction::Emit(ViewEvent::SessionRenamed { |
| 516 | metadata: Box::new(saved.metadata), |
| 517 | }) |
| 518 | } |
| 519 | |
| 520 | fn refresh_preview(&mut self) { |
| 521 | let Some(session) = self.selected_session() else { |
| 522 | self.current_preview = vec![tr(self.locale, MessageId::SessionsNoResults).into_owned()]; |
| 523 | self.scroll_history_to_latest(); |
| 524 | return; |
| 525 | }; |
| 526 | |
| 527 | if let Some(lines) = self.preview_cache.get(&session.id) { |
| 528 | self.current_preview = lines.clone(); |
| 529 | self.scroll_history_to_latest(); |
| 530 | return; |
| 531 | } |
| 532 | |
| 533 | let manager = match SessionManager::default_location() { |
| 534 | Ok(manager) => manager, |
| 535 | Err(_) => { |
| 536 | self.current_preview = |
| 537 | vec![tr(self.locale, MessageId::SessionsDirectoryFailed).into_owned()]; |
| 538 | self.scroll_history_to_latest(); |
| 539 | return; |
| 540 | } |
| 541 | }; |
| 542 | |
| 543 | let saved = match manager.load_session(&session.id) { |
| 544 | Ok(saved) => saved, |
| 545 | Err(_) => { |
| 546 | self.current_preview = |
| 547 | vec![tr(self.locale, MessageId::SessionsPreviewFailed).into_owned()]; |
| 548 | self.scroll_history_to_latest(); |
| 549 | return; |
| 550 | } |
| 551 | }; |
| 552 | |
| 553 | let preview = build_preview_lines(&saved, self.locale); |
| 554 | self.preview_cache |
| 555 | .insert(session.id.clone(), preview.clone()); |
| 556 | self.current_preview = preview; |
| 557 | self.scroll_history_to_latest(); |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | impl ModalView for SessionPickerView { |
| 562 | fn kind(&self) -> ModalKind { |
| 563 | ModalKind::SessionPicker |
| 564 | } |
| 565 | |
| 566 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 567 | self |
| 568 | } |
| 569 | |
| 570 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 571 | match mouse.kind { |
| 572 | MouseEventKind::ScrollUp => self.move_selection(-1), |
| 573 | MouseEventKind::ScrollDown => self.move_selection(1), |
| 574 | MouseEventKind::Down(MouseButton::Left) => { |
| 575 | let clicked = self |
| 576 | .last_row_hitboxes |
| 577 | .borrow() |
| 578 | .iter() |
| 579 | .find_map(|(y, index)| (*y == mouse.row).then_some(*index)); |
| 580 | if let Some(index) = clicked { |
| 581 | if self.selected == index { |
| 582 | if let Some(session) = self.filtered.get(index) { |
| 583 | return ViewAction::EmitAndClose(ViewEvent::SessionSelected { |
| 584 | session_id: session.id.clone(), |
| 585 | }); |
| 586 | } |
| 587 | } else { |
| 588 | self.selected = index; |
| 589 | self.ensure_selected_visible(); |
| 590 | self.refresh_preview(); |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | _ => {} |
| 595 | } |
| 596 | ViewAction::None |
| 597 | } |
| 598 | |
| 599 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 600 | if self.search_mode { |
| 601 | match key.code { |
| 602 | KeyCode::Enter => { |
| 603 | self.exit_search(); |
| 604 | } |
| 605 | KeyCode::Esc => { |
| 606 | self.exit_search(); |
| 607 | return ViewAction::None; |
| 608 | } |
| 609 | KeyCode::Backspace => { |
| 610 | self.search_input.pop(); |
| 611 | self.apply_sort_and_filter(); |
| 612 | return ViewAction::None; |
| 613 | } |
| 614 | KeyCode::Char(c) => { |
| 615 | self.search_input.push(c); |
| 616 | self.apply_sort_and_filter(); |
| 617 | return ViewAction::None; |
| 618 | } |
| 619 | _ => {} |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | if self.confirm_delete { |
| 624 | match key.code { |
| 625 | KeyCode::Char('y') | KeyCode::Char('Y') => { |
| 626 | self.confirm_delete = false; |
| 627 | if let Some(event) = self.delete_selected() { |
| 628 | return ViewAction::Emit(event); |
| 629 | } |
| 630 | return ViewAction::None; |
| 631 | } |
| 632 | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { |
| 633 | self.confirm_delete = false; |
| 634 | self.status = |
| 635 | Some(tr(self.locale, MessageId::SessionsDeleteCancelled).into_owned()); |
| 636 | return ViewAction::None; |
| 637 | } |
| 638 | _ => return ViewAction::None, |
| 639 | } |
| 640 | } |
| 641 | |
| 642 | if self.rename_mode { |
| 643 | match key.code { |
| 644 | KeyCode::Enter => { |
| 645 | self.rename_mode = false; |
| 646 | let new_title = self.rename_input.trim().to_string(); |
| 647 | self.rename_input.clear(); |
| 648 | return self.rename_selected(&new_title); |
| 649 | } |
| 650 | KeyCode::Esc => { |
| 651 | self.rename_mode = false; |
| 652 | self.rename_input.clear(); |
| 653 | self.status = |
| 654 | Some(tr(self.locale, MessageId::SessionsRenameCancelled).into_owned()); |
| 655 | return ViewAction::None; |
| 656 | } |
| 657 | KeyCode::Backspace => { |
| 658 | self.rename_input.pop(); |
| 659 | return ViewAction::None; |
| 660 | } |
| 661 | KeyCode::Char(c) if !c.is_control() => { |
| 662 | self.rename_input.push(c); |
| 663 | return ViewAction::None; |
| 664 | } |
| 665 | _ => return ViewAction::None, |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | match key.code { |
| 670 | KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, |
| 671 | KeyCode::Up | KeyCode::Char('k') => { |
| 672 | self.move_selection(-1); |
| 673 | ViewAction::None |
| 674 | } |
| 675 | KeyCode::Down | KeyCode::Char('j') => { |
| 676 | self.move_selection(1); |
| 677 | ViewAction::None |
| 678 | } |
| 679 | KeyCode::PageUp => { |
| 680 | let rows = self.history_visible_rows.get().max(1); |
| 681 | self.scroll_history(-(rows as isize)); |
| 682 | ViewAction::None |
| 683 | } |
| 684 | KeyCode::PageDown => { |
| 685 | let rows = self.history_visible_rows.get().max(1); |
| 686 | self.scroll_history(rows as isize); |
| 687 | ViewAction::None |
| 688 | } |
| 689 | KeyCode::Char('/') => { |
| 690 | self.enter_search(); |
| 691 | ViewAction::None |
| 692 | } |
| 693 | KeyCode::Char('s') | KeyCode::Char('S') => { |
| 694 | self.cycle_sort(); |
| 695 | ViewAction::None |
| 696 | } |
| 697 | // `a`/`A` toggles the per-workspace scope filter (#1395). The |
| 698 | // picker defaults to showing only sessions for the current |
| 699 | // workspace so Ctrl+R never restores a different project's |
| 700 | // history by surprise; press `a` to broaden to every saved |
| 701 | // session. |
| 702 | KeyCode::Char('a') | KeyCode::Char('A') => { |
| 703 | self.toggle_all_workspaces(); |
| 704 | ViewAction::None |
| 705 | } |
| 706 | KeyCode::Char('r') | KeyCode::Char('R') => { |
| 707 | self.rename_mode = true; |
| 708 | self.rename_input.clear(); |
| 709 | self.status = Some(tr(self.locale, MessageId::SessionsNewTitlePrompt).into_owned()); |
| 710 | ViewAction::None |
| 711 | } |
| 712 | KeyCode::Char('d') | KeyCode::Char('D') => { |
| 713 | self.confirm_delete = true; |
| 714 | self.status = Some(tr(self.locale, MessageId::SessionsDeletePrompt).into_owned()); |
| 715 | ViewAction::None |
| 716 | } |
| 717 | // `e` archives or restores the selected session, `x` toggles |
| 718 | // whether archived sessions are listed at all. Archive is |
| 719 | // deliberately undestructive and needs no confirmation — unlike |
| 720 | // `d`, nothing is lost and `e` puts it straight back. |
| 721 | KeyCode::Char('e') | KeyCode::Char('E') => self.toggle_archive_selected(), |
| 722 | KeyCode::Char('x') | KeyCode::Char('X') => { |
| 723 | self.toggle_show_archived(); |
| 724 | ViewAction::None |
| 725 | } |
| 726 | KeyCode::Char(c) if self.select_visible_shortcut(c) => ViewAction::None, |
| 727 | KeyCode::Enter => { |
| 728 | if let Some(session) = self.selected_session() { |
| 729 | ViewAction::EmitAndClose(ViewEvent::SessionSelected { |
| 730 | session_id: session.id.clone(), |
| 731 | }) |
| 732 | } else { |
| 733 | ViewAction::None |
| 734 | } |
| 735 | } |
| 736 | _ => ViewAction::None, |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 741 | let surface = |
| 742 | render_underwater_surface(area, buf, tr(self.locale, MessageId::SessionsSurfaceTitle)); |
| 743 | let full_hints = [ |
| 744 | ActionHint::new("Enter", tr(self.locale, MessageId::SessionsActionResume)), |
| 745 | ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)), |
| 746 | ActionHint::new("s", tr(self.locale, MessageId::SessionsActionSort)), |
| 747 | ActionHint::new("r", tr(self.locale, MessageId::SessionsActionRename)), |
| 748 | ActionHint::new("a", tr(self.locale, MessageId::SessionsActionAllWorkspaces)), |
| 749 | ActionHint::new("e", tr(self.locale, MessageId::SessionsActionArchive)), |
| 750 | ActionHint::new("x", tr(self.locale, MessageId::SessionsActionShowArchived)), |
| 751 | ActionHint::new("d", tr(self.locale, MessageId::SessionsActionDelete)), |
| 752 | ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)), |
| 753 | ]; |
| 754 | // The two bordered panes spend five rows on chrome before either can |
| 755 | // show a content row. When the body cannot afford that, this room |
| 756 | // keeps only the object it exists for — a selectable session list |
| 757 | // with a usable resume action — and trims the action rail to match. |
| 758 | let full_footer_rows = action_footer_lines(&full_hints, surface.width).len(); |
| 759 | let compact = usize::from(surface.height).saturating_sub(full_footer_rows) < 12; |
| 760 | if compact { |
| 761 | let content = render_modal_footer( |
| 762 | surface, |
| 763 | buf, |
| 764 | &[ |
| 765 | ActionHint::new("Enter", tr(self.locale, MessageId::SessionsActionResume)), |
| 766 | ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)), |
| 767 | ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)), |
| 768 | ], |
| 769 | ); |
| 770 | let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); |
| 771 | let footer_rows = usize::from(!self.filtered.is_empty()); |
| 772 | let visible_rows = usize::from(content.height) |
| 773 | .saturating_sub(header_rows + footer_rows) |
| 774 | .max(1); |
| 775 | self.update_list_viewport(visible_rows); |
| 776 | let list_scroll = self.list_scroll.get(); |
| 777 | let list_content = render_panel_scroll_rail( |
| 778 | content, |
| 779 | buf, |
| 780 | self.filtered.len().saturating_add(header_rows), |
| 781 | list_scroll, |
| 782 | visible_rows, |
| 783 | true, |
| 784 | ); |
| 785 | let list_lines = build_list_lines( |
| 786 | &self.filtered, |
| 787 | self.selected, |
| 788 | list_content.width, |
| 789 | list_scroll, |
| 790 | visible_rows, |
| 791 | self.search_mode, |
| 792 | &self.search_input, |
| 793 | &self.sort_label(), |
| 794 | self.confirm_delete, |
| 795 | self.rename_mode, |
| 796 | &self.rename_input, |
| 797 | self.status.as_deref(), |
| 798 | self.locale, |
| 799 | ); |
| 800 | *self.last_row_hitboxes.borrow_mut() = (0..visible_rows) |
| 801 | .filter_map(|row| { |
| 802 | let index = list_scroll.saturating_add(row); |
| 803 | (index < self.filtered.len()).then_some(( |
| 804 | list_content |
| 805 | .y |
| 806 | .saturating_add(header_rows as u16) |
| 807 | .saturating_add(row as u16), |
| 808 | index, |
| 809 | )) |
| 810 | }) |
| 811 | .collect(); |
| 812 | Paragraph::new(list_lines) |
| 813 | .wrap(Wrap { trim: false }) |
| 814 | .render(list_content, buf); |
| 815 | return; |
| 816 | } |
| 817 | let content = render_modal_footer(surface, buf, &full_hints); |
| 818 | let narrow = content.width < 95; |
| 819 | let chunks = Layout::default() |
| 820 | .direction(if narrow { |
| 821 | Direction::Vertical |
| 822 | } else { |
| 823 | Direction::Horizontal |
| 824 | }) |
| 825 | .constraints(if narrow { |
| 826 | [Constraint::Percentage(42), Constraint::Percentage(58)] |
| 827 | } else { |
| 828 | [Constraint::Percentage(64), Constraint::Percentage(36)] |
| 829 | }) |
| 830 | .split(content); |
| 831 | let (history_area, list_area) = if narrow { |
| 832 | (chunks[1], chunks[0]) |
| 833 | } else { |
| 834 | (chunks[0], chunks[1]) |
| 835 | }; |
| 836 | |
| 837 | let list_block = section_block(&tr(self.locale, MessageId::SessionsPaneTitle)); |
| 838 | let list_inner = list_block.inner(list_area); |
| 839 | let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); |
| 840 | let footer_rows = usize::from(!self.filtered.is_empty()); |
| 841 | let visible_rows = usize::from(list_inner.height) |
| 842 | .saturating_sub(header_rows + footer_rows) |
| 843 | .max(1); |
| 844 | self.update_list_viewport(visible_rows); |
| 845 | let list_scroll = self.list_scroll.get(); |
| 846 | list_block.render(list_area, buf); |
| 847 | let list_content = render_panel_scroll_rail( |
| 848 | list_inner, |
| 849 | buf, |
| 850 | self.filtered.len().saturating_add(header_rows), |
| 851 | list_scroll, |
| 852 | visible_rows, |
| 853 | true, |
| 854 | ); |
| 855 | |
| 856 | let list_lines = build_list_lines( |
| 857 | &self.filtered, |
| 858 | self.selected, |
| 859 | list_content.width, |
| 860 | list_scroll, |
| 861 | visible_rows, |
| 862 | self.search_mode, |
| 863 | &self.search_input, |
| 864 | &self.sort_label(), |
| 865 | self.confirm_delete, |
| 866 | self.rename_mode, |
| 867 | &self.rename_input, |
| 868 | self.status.as_deref(), |
| 869 | self.locale, |
| 870 | ); |
| 871 | *self.last_row_hitboxes.borrow_mut() = (0..visible_rows) |
| 872 | .filter_map(|row| { |
| 873 | let index = list_scroll.saturating_add(row); |
| 874 | (index < self.filtered.len()).then_some(( |
| 875 | list_content |
| 876 | .y |
| 877 | .saturating_add(header_rows as u16) |
| 878 | .saturating_add(row as u16), |
| 879 | index, |
| 880 | )) |
| 881 | }) |
| 882 | .collect(); |
| 883 | Paragraph::new(list_lines) |
| 884 | .wrap(Wrap { trim: false }) |
| 885 | .render(list_content, buf); |
| 886 | |
| 887 | let history_block = section_block(&tr(self.locale, MessageId::SessionsHistoryPaneTitle)); |
| 888 | let history_inner = history_block.inner(history_area); |
| 889 | self.update_history_viewport(history_inner.height as usize); |
| 890 | history_block.render(history_area, buf); |
| 891 | let history_content = render_panel_scroll_rail( |
| 892 | history_inner, |
| 893 | buf, |
| 894 | self.current_preview.len(), |
| 895 | self.history_scroll.get(), |
| 896 | history_inner.height as usize, |
| 897 | false, |
| 898 | ); |
| 899 | let visible_preview = visible_preview_lines( |
| 900 | &self.current_preview, |
| 901 | self.history_scroll.get(), |
| 902 | history_content.height as usize, |
| 903 | ); |
| 904 | let preview_lines = format_preview(&visible_preview); |
| 905 | |
| 906 | Paragraph::new(preview_lines) |
| 907 | .wrap(Wrap { trim: false }) |
| 908 | .render(history_content, buf); |
| 909 | } |
| 910 | } |
| 911 | |
| 912 | #[allow(clippy::too_many_arguments)] |
| 913 | fn build_list_lines( |
| 914 | sessions: &[SessionMetadata], |
| 915 | selected: usize, |
| 916 | width: u16, |
| 917 | scroll: usize, |
| 918 | visible_rows: usize, |
| 919 | search_mode: bool, |
| 920 | search_input: &str, |
| 921 | sort_label: &str, |
| 922 | confirm_delete: bool, |
| 923 | rename_mode: bool, |
| 924 | rename_input: &str, |
| 925 | status: Option<&str>, |
| 926 | locale: Locale, |
| 927 | ) -> Vec<Line<'static>> { |
| 928 | let mut lines = Vec::new(); |
| 929 | let header = if search_mode { |
| 930 | format!("/{search_input}") |
| 931 | } else if rename_mode { |
| 932 | format!( |
| 933 | "{}{rename_input}_", |
| 934 | tr(locale, MessageId::SessionsNewTitlePrompt) |
| 935 | ) |
| 936 | } else { |
| 937 | tr(locale, MessageId::SessionsScopeSortHeader).replace("{sort}", sort_label) |
| 938 | }; |
| 939 | lines.push(Line::from(Span::styled( |
| 940 | truncate(&header, width), |
| 941 | Style::default().fg(palette::TEXT_MUTED), |
| 942 | ))); |
| 943 | |
| 944 | if confirm_delete { |
| 945 | lines.push(Line::from(Span::styled( |
| 946 | tr(locale, MessageId::SessionsConfirmDelete), |
| 947 | Style::default() |
| 948 | .fg(palette::STATUS_WARNING) |
| 949 | .add_modifier(Modifier::BOLD), |
| 950 | ))); |
| 951 | } else if let Some(status) = status { |
| 952 | lines.push(Line::from(Span::styled( |
| 953 | truncate(status, width), |
| 954 | Style::default().fg(palette::WHALE_INFO), |
| 955 | ))); |
| 956 | } |
| 957 | |
| 958 | if sessions.is_empty() { |
| 959 | lines.push(Line::from(Span::styled( |
| 960 | tr(locale, MessageId::SessionsEmptyTitle), |
| 961 | Style::default().fg(palette::TEXT_MUTED), |
| 962 | ))); |
| 963 | lines.push(Line::from(Span::styled( |
| 964 | tr(locale, MessageId::SessionsEmptyHint), |
| 965 | Style::default().fg(palette::TEXT_HINT), |
| 966 | ))); |
| 967 | return lines; |
| 968 | } |
| 969 | |
| 970 | for (idx, session) in sessions.iter().enumerate().skip(scroll).take(visible_rows) { |
| 971 | let slot = idx.saturating_sub(scroll).saturating_add(1); |
| 972 | let prefix = if slot <= 9 { |
| 973 | format!("{slot}. ") |
| 974 | } else { |
| 975 | " ".to_string() |
| 976 | }; |
| 977 | let mut line = format!("{prefix}{}", format_session_line(session, locale)); |
| 978 | line = truncate(&line, width); |
| 979 | let style = if idx == selected { |
| 980 | menu_style::selected_row_style() |
| 981 | } else { |
| 982 | Style::default().fg(palette::TEXT_PRIMARY) |
| 983 | }; |
| 984 | lines.push(Line::from(Span::styled(line, style))); |
| 985 | } |
| 986 | |
| 987 | if sessions.len() > visible_rows { |
| 988 | let start = scroll.saturating_add(1); |
| 989 | let end = (scroll + visible_rows).min(sessions.len()); |
| 990 | lines.push(Line::from(Span::styled( |
| 991 | truncate( |
| 992 | &tr(locale, MessageId::SessionsShowingRange) |
| 993 | .replace("{start}", &start.to_string()) |
| 994 | .replace("{end}", &end.to_string()) |
| 995 | .replace("{total}", &sessions.len().to_string()), |
| 996 | width, |
| 997 | ), |
| 998 | Style::default().fg(palette::TEXT_DIM), |
| 999 | ))); |
| 1000 | } |
| 1001 | |
| 1002 | lines |
| 1003 | } |
| 1004 | |
| 1005 | fn format_session_line(session: &SessionMetadata, locale: Locale) -> String { |
| 1006 | let age = format_relative_time(&session.updated_at, locale); |
| 1007 | let updated = crate::session_manager::format_session_updated_at(&session.updated_at, &age); |
| 1008 | let raw_title = extract_title(&session.title); |
| 1009 | let title = if raw_title == "Session" { |
| 1010 | truncate(crate::session_manager::truncate_id(&session.id), 32) |
| 1011 | } else { |
| 1012 | truncate(raw_title, 32) |
| 1013 | }; |
| 1014 | let mode = session |
| 1015 | .mode |
| 1016 | .as_deref() |
| 1017 | .map(str::to_ascii_lowercase) |
| 1018 | .unwrap_or_else(|| tr(locale, MessageId::SessionsUnknownMode).into_owned()); |
| 1019 | let message_count = tr(locale, MessageId::SessionsMessageCountCompact) |
| 1020 | .replace("{count}", &session.message_count.to_string()); |
| 1021 | let fork_label = if session.parent_session_id.is_some() { |
| 1022 | format!(" | {}", tr(locale, MessageId::SessionsForkCompact)) |
| 1023 | } else { |
| 1024 | String::new() |
| 1025 | }; |
| 1026 | // Archived rows are labelled in text, not by colour alone, so the state |
| 1027 | // survives monochrome terminals and screen readers. |
| 1028 | let fork_label = if session.archived { |
| 1029 | format!( |
| 1030 | "{fork_label} | {}", |
| 1031 | tr(locale, MessageId::SessionsArchivedCompact) |
| 1032 | ) |
| 1033 | } else { |
| 1034 | fork_label |
| 1035 | }; |
| 1036 | format!( |
| 1037 | "{} | {} | {}{} | {} | {}", |
| 1038 | crate::session_manager::truncate_id(&session.id), |
| 1039 | title, |
| 1040 | message_count, |
| 1041 | fork_label, |
| 1042 | mode, |
| 1043 | updated |
| 1044 | ) |
| 1045 | } |
| 1046 | |
| 1047 | fn build_preview_lines(session: &SavedSession, locale: Locale) -> Vec<String> { |
| 1048 | let mut out = Vec::new(); |
| 1049 | out.push( |
| 1050 | tr(locale, MessageId::SessionsPreviewTitle) |
| 1051 | .replace("{title}", extract_title(&session.metadata.title)), |
| 1052 | ); |
| 1053 | out.push( |
| 1054 | tr(locale, MessageId::SessionsPreviewUpdated).replace( |
| 1055 | "{updated}", |
| 1056 | &session |
| 1057 | .metadata |
| 1058 | .updated_at |
| 1059 | .with_timezone(&Local) |
| 1060 | .format("%Y-%m-%d %H:%M") |
| 1061 | .to_string(), |
| 1062 | ), |
| 1063 | ); |
| 1064 | out.push( |
| 1065 | tr(locale, MessageId::SessionsPreviewMessagesModel) |
| 1066 | .replace("{count}", &session.metadata.message_count.to_string()) |
| 1067 | .replace("{model}", &session.metadata.model), |
| 1068 | ); |
| 1069 | if let Some(mode) = session.metadata.mode.as_deref() { |
| 1070 | out.push(tr(locale, MessageId::SessionsPreviewMode).replace("{mode}", mode)); |
| 1071 | } |
| 1072 | out.push("".to_string()); |
| 1073 | |
| 1074 | for message in &session.messages { |
| 1075 | let text = message_text_for_history(message, locale); |
| 1076 | if text.trim().is_empty() { |
| 1077 | continue; |
| 1078 | } |
| 1079 | out.push(format!("{}:", message.role.to_ascii_uppercase())); |
| 1080 | for line in text.lines() { |
| 1081 | out.push(format!(" {line}")); |
| 1082 | } |
| 1083 | out.push(String::new()); |
| 1084 | } |
| 1085 | if out.last().is_some_and(String::is_empty) { |
| 1086 | out.pop(); |
| 1087 | } |
| 1088 | out |
| 1089 | } |
| 1090 | |
| 1091 | fn message_text_for_history(message: &crate::models::Message, locale: Locale) -> String { |
| 1092 | let mut text = String::new(); |
| 1093 | for block in &message.content { |
| 1094 | let part = match block { |
| 1095 | crate::models::ContentBlock::Text { text: body, .. } => { |
| 1096 | if message.role.eq_ignore_ascii_case("user") { |
| 1097 | extract_user_prompt(body).to_string() |
| 1098 | } else { |
| 1099 | strip_thinking_tags(body) |
| 1100 | } |
| 1101 | } |
| 1102 | crate::models::ContentBlock::Thinking { .. } => String::new(), |
| 1103 | crate::models::ContentBlock::ToolUse { name, input, .. } => { |
| 1104 | tr(locale, MessageId::SessionsToolCall) |
| 1105 | .replace("{name}", name) |
| 1106 | .replace("{input}", &truncate(&input.to_string(), 180)) |
| 1107 | } |
| 1108 | crate::models::ContentBlock::ToolResult { |
| 1109 | content, is_error, .. |
| 1110 | } => { |
| 1111 | let id = if is_error.unwrap_or(false) { |
| 1112 | MessageId::SessionsToolError |
| 1113 | } else { |
| 1114 | MessageId::SessionsToolResult |
| 1115 | }; |
| 1116 | tr(locale, id).replace("{content}", &truncate(&content.replace('\n', " "), 220)) |
| 1117 | } |
| 1118 | crate::models::ContentBlock::ServerToolUse { name, input, .. } => { |
| 1119 | tr(locale, MessageId::SessionsServerTool) |
| 1120 | .replace("{name}", name) |
| 1121 | .replace("{input}", &truncate(&input.to_string(), 180)) |
| 1122 | } |
| 1123 | crate::models::ContentBlock::ToolSearchToolResult { content, .. } |
| 1124 | | crate::models::ContentBlock::CodeExecutionToolResult { content, .. } => { |
| 1125 | tr(locale, MessageId::SessionsToolResult) |
| 1126 | .replace("{content}", &truncate(&content.to_string(), 220)) |
| 1127 | } |
| 1128 | crate::models::ContentBlock::ImageUrl { .. } => { |
| 1129 | tr(locale, MessageId::SessionsImage).into_owned() |
| 1130 | } |
| 1131 | }; |
| 1132 | let part = part.trim(); |
| 1133 | if !part.is_empty() { |
| 1134 | if !text.is_empty() { |
| 1135 | text.push('\n'); |
| 1136 | } |
| 1137 | text.push_str(part); |
| 1138 | } |
| 1139 | } |
| 1140 | text |
| 1141 | } |
| 1142 | |
| 1143 | fn format_preview(lines: &[String]) -> Vec<Line<'static>> { |
| 1144 | let mut out = Vec::new(); |
| 1145 | for line in lines { |
| 1146 | out.push(Line::from(Span::styled( |
| 1147 | line.clone(), |
| 1148 | Style::default().fg(palette::TEXT_PRIMARY), |
| 1149 | ))); |
| 1150 | } |
| 1151 | out |
| 1152 | } |
| 1153 | |
| 1154 | fn preview_body_start(lines: &[String], visible_rows: usize) -> Option<usize> { |
| 1155 | let visible_rows = visible_rows.max(1); |
| 1156 | let body_start = lines |
| 1157 | .iter() |
| 1158 | .position(|line| line.is_empty()) |
| 1159 | .map(|idx| idx + 1)?; |
| 1160 | (body_start < visible_rows).then_some(body_start) |
| 1161 | } |
| 1162 | |
| 1163 | fn max_history_scroll_for(lines: &[String], visible_rows: usize) -> usize { |
| 1164 | let visible_rows = visible_rows.max(1); |
| 1165 | let Some(body_start) = preview_body_start(lines, visible_rows) else { |
| 1166 | return lines.len().saturating_sub(visible_rows); |
| 1167 | }; |
| 1168 | let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); |
| 1169 | lines |
| 1170 | .len() |
| 1171 | .saturating_sub(body_start) |
| 1172 | .saturating_sub(body_visible_rows) |
| 1173 | } |
| 1174 | |
| 1175 | fn visible_preview_lines(lines: &[String], scroll: usize, visible_rows: usize) -> Vec<String> { |
| 1176 | let visible_rows = visible_rows.max(1); |
| 1177 | let max_scroll = max_history_scroll_for(lines, visible_rows); |
| 1178 | let scroll = scroll.min(max_scroll); |
| 1179 | let Some(body_start) = preview_body_start(lines, visible_rows) else { |
| 1180 | return lines |
| 1181 | .iter() |
| 1182 | .skip(scroll) |
| 1183 | .take(visible_rows) |
| 1184 | .cloned() |
| 1185 | .collect(); |
| 1186 | }; |
| 1187 | |
| 1188 | let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); |
| 1189 | let mut out = Vec::with_capacity(visible_rows); |
| 1190 | out.extend(lines.iter().take(body_start).cloned()); |
| 1191 | out.extend( |
| 1192 | lines |
| 1193 | .iter() |
| 1194 | .skip(body_start + scroll) |
| 1195 | .take(body_visible_rows) |
| 1196 | .cloned(), |
| 1197 | ); |
| 1198 | out |
| 1199 | } |
| 1200 | |
| 1201 | /// Localized "2h ago" label. Shared with the sidebar Sessions rail so both |
| 1202 | /// surfaces age a session with the same words. |
| 1203 | pub(crate) fn format_relative_time(dt: &DateTime<chrono::Utc>, locale: Locale) -> String { |
| 1204 | let now = chrono::Utc::now(); |
| 1205 | let duration = now.signed_duration_since(*dt); |
| 1206 | if duration.num_minutes() < 1 { |
| 1207 | tr(locale, MessageId::SessionsTimeJustNow).into_owned() |
| 1208 | } else if duration.num_hours() < 1 { |
| 1209 | tr(locale, MessageId::SessionsTimeMinutesAgo) |
| 1210 | .replace("{count}", &duration.num_minutes().to_string()) |
| 1211 | } else if duration.num_days() < 1 { |
| 1212 | tr(locale, MessageId::SessionsTimeHoursAgo) |
| 1213 | .replace("{count}", &duration.num_hours().to_string()) |
| 1214 | } else { |
| 1215 | tr(locale, MessageId::SessionsTimeDaysAgo) |
| 1216 | .replace("{count}", &duration.num_days().to_string()) |
| 1217 | } |
| 1218 | } |
| 1219 | |
| 1220 | fn truncate(text: &str, width: u16) -> String { |
| 1221 | let max = width.max(1) as usize; |
| 1222 | if text.width() <= max { |
| 1223 | return text.to_string(); |
| 1224 | } |
| 1225 | let mut out = String::new(); |
| 1226 | let mut current = 0; |
| 1227 | for ch in text.chars() { |
| 1228 | let w = ch.width().unwrap_or(0); |
| 1229 | if current + w >= max.saturating_sub(3) { |
| 1230 | break; |
| 1231 | } |
| 1232 | out.push(ch); |
| 1233 | current += w; |
| 1234 | } |
| 1235 | out.push_str("..."); |
| 1236 | out |
| 1237 | } |
| 1238 | |
| 1239 | /// Best-effort canonicalisation of a path so two recordings of the same |
| 1240 | /// workspace match even when one is symlinked or relative. Falls back to |
| 1241 | /// the input path when canonicalisation fails (e.g. for a deleted dir or |
| 1242 | /// during tests with tmp paths that have already been cleaned up). |
| 1243 | fn canonical_or_self(path: PathBuf) -> PathBuf { |
| 1244 | std::fs::canonicalize(&path).unwrap_or(path) |
| 1245 | } |
| 1246 | |
| 1247 | #[cfg(test)] |
| 1248 | mod tests { |
| 1249 | use super::*; |
| 1250 | use chrono::Utc; |
| 1251 | use unicode_width::UnicodeWidthStr; |
| 1252 | |
| 1253 | fn test_session(idx: usize, title: &str) -> SessionMetadata { |
| 1254 | SessionMetadata { |
| 1255 | id: format!("session-{idx:02}"), |
| 1256 | title: title.to_string(), |
| 1257 | created_at: Utc::now(), |
| 1258 | updated_at: Utc::now(), |
| 1259 | message_count: idx + 1, |
| 1260 | total_tokens: 100, |
| 1261 | model: "deepseek-v4-pro".to_string(), |
| 1262 | model_provider: "deepseek".to_string(), |
| 1263 | model_provider_id: None, |
| 1264 | workspace: std::path::PathBuf::from("/tmp"), |
| 1265 | mode: Some("agent".to_string()), |
| 1266 | cost: crate::session_manager::SessionCostSnapshot::default(), |
| 1267 | parent_session_id: None, |
| 1268 | forked_from_message_count: None, |
| 1269 | cumulative_turn_secs: 0, |
| 1270 | archived: false, |
| 1271 | } |
| 1272 | } |
| 1273 | |
| 1274 | fn test_session_in(idx: usize, title: &str, workspace: &str) -> SessionMetadata { |
| 1275 | let mut s = test_session(idx, title); |
| 1276 | s.workspace = std::path::PathBuf::from(workspace); |
| 1277 | s |
| 1278 | } |
| 1279 | |
| 1280 | fn text_message(role: &str, text: &str) -> crate::models::Message { |
| 1281 | crate::models::Message { |
| 1282 | role: role.to_string(), |
| 1283 | content: vec![crate::models::ContentBlock::Text { |
| 1284 | text: text.to_string(), |
| 1285 | cache_control: None, |
| 1286 | }], |
| 1287 | } |
| 1288 | } |
| 1289 | |
| 1290 | fn saved_session_with_messages(messages: Vec<crate::models::Message>) -> SavedSession { |
| 1291 | let mut session = crate::session_manager::create_saved_session( |
| 1292 | &messages, |
| 1293 | "deepseek-v4-pro", |
| 1294 | std::path::Path::new("/tmp"), |
| 1295 | 100, |
| 1296 | None, |
| 1297 | ); |
| 1298 | session.metadata.title = "<turn_meta>{}</turn_meta>\nClean session title".to_string(); |
| 1299 | session |
| 1300 | } |
| 1301 | |
| 1302 | fn picker_with(sessions: Vec<SessionMetadata>, scope: Option<&str>) -> SessionPickerView { |
| 1303 | let workspace_scope = scope.map(PathBuf::from); |
| 1304 | let mut view = SessionPickerView { |
| 1305 | sessions: sessions.clone(), |
| 1306 | filtered: sessions, |
| 1307 | selected: 0, |
| 1308 | list_scroll: Cell::new(0), |
| 1309 | list_visible_rows: Cell::new(8), |
| 1310 | history_scroll: Cell::new(0), |
| 1311 | history_pinned_to_latest: Cell::new(true), |
| 1312 | history_visible_rows: Cell::new(12), |
| 1313 | search_input: String::new(), |
| 1314 | search_mode: false, |
| 1315 | sort_mode: SessionSortMode::Recent, |
| 1316 | preview_cache: HashMap::new(), |
| 1317 | current_preview: Vec::new(), |
| 1318 | confirm_delete: false, |
| 1319 | rename_mode: false, |
| 1320 | rename_input: String::new(), |
| 1321 | status: None, |
| 1322 | workspace_scope, |
| 1323 | show_all_workspaces: false, |
| 1324 | show_archived: false, |
| 1325 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 1326 | locale: Locale::En, |
| 1327 | }; |
| 1328 | view.apply_sort_and_filter(); |
| 1329 | view |
| 1330 | } |
| 1331 | |
| 1332 | #[test] |
| 1333 | fn rename_selected_persists_and_emits_saved_metadata() { |
| 1334 | let _lock = crate::test_support::lock_test_env(); |
| 1335 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1336 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1337 | let manager = SessionManager::default_location().expect("session manager"); |
| 1338 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1339 | saved.metadata.id = "session-01".to_string(); |
| 1340 | saved.metadata.title = "Before".to_string(); |
| 1341 | manager.save_session(&saved).expect("save session"); |
| 1342 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1343 | |
| 1344 | let action = view.rename_selected("After"); |
| 1345 | |
| 1346 | let ViewAction::Emit(ViewEvent::SessionRenamed { metadata }) = action else { |
| 1347 | panic!("expected SessionRenamed event"); |
| 1348 | }; |
| 1349 | assert_eq!(metadata.id, "session-01"); |
| 1350 | assert_eq!(metadata.title, "After"); |
| 1351 | assert_eq!(view.sessions[0].title, "After"); |
| 1352 | assert_eq!( |
| 1353 | manager |
| 1354 | .load_session("session-01") |
| 1355 | .expect("load renamed session") |
| 1356 | .metadata |
| 1357 | .title, |
| 1358 | "After" |
| 1359 | ); |
| 1360 | } |
| 1361 | |
| 1362 | #[test] |
| 1363 | fn archive_toggle_persists_hides_the_row_and_emits_an_archive_receipt() { |
| 1364 | let _lock = crate::test_support::lock_test_env(); |
| 1365 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1366 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1367 | let manager = SessionManager::default_location().expect("session manager"); |
| 1368 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1369 | saved.metadata.id = "session-01".to_string(); |
| 1370 | saved.metadata.title = "Finished work".to_string(); |
| 1371 | manager.save_session(&saved).expect("save session"); |
| 1372 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1373 | |
| 1374 | let action = view.toggle_archive_selected(); |
| 1375 | |
| 1376 | // The event is `SessionArchived`, not `SessionRenamed`: the receipt has |
| 1377 | // to describe what actually happened. |
| 1378 | let ViewAction::Emit(ViewEvent::SessionArchived { metadata }) = action else { |
| 1379 | panic!("expected SessionArchived event"); |
| 1380 | }; |
| 1381 | assert!(metadata.archived); |
| 1382 | assert!( |
| 1383 | manager |
| 1384 | .load_session("session-01") |
| 1385 | .expect("reload") |
| 1386 | .metadata |
| 1387 | .archived, |
| 1388 | "archive must be durable, not view-local" |
| 1389 | ); |
| 1390 | assert!( |
| 1391 | view.filtered.is_empty(), |
| 1392 | "an archived session leaves the default (active-only) list" |
| 1393 | ); |
| 1394 | |
| 1395 | // `x` brings archived rows back into view without un-archiving them. |
| 1396 | view.toggle_show_archived(); |
| 1397 | assert_eq!(view.filtered.len(), 1); |
| 1398 | assert!(view.filtered[0].archived); |
| 1399 | |
| 1400 | // And archiving is reversible from the same key. |
| 1401 | let restored = view.toggle_archive_selected(); |
| 1402 | let ViewAction::Emit(ViewEvent::SessionArchived { metadata }) = restored else { |
| 1403 | panic!("expected SessionArchived event"); |
| 1404 | }; |
| 1405 | assert!(!metadata.archived); |
| 1406 | assert!( |
| 1407 | !manager |
| 1408 | .load_session("session-01") |
| 1409 | .expect("reload") |
| 1410 | .metadata |
| 1411 | .archived |
| 1412 | ); |
| 1413 | } |
| 1414 | |
| 1415 | #[test] |
| 1416 | fn preselecting_a_row_lands_on_it_without_resuming_anything() { |
| 1417 | let _lock = crate::test_support::lock_test_env(); |
| 1418 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1419 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1420 | let manager = SessionManager::default_location().expect("session manager"); |
| 1421 | let workspace = tmp.path().join("workspace"); |
| 1422 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 1423 | for (id, title) in [("session-01", "First"), ("session-02", "Second")] { |
| 1424 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1425 | saved.metadata.id = id.to_string(); |
| 1426 | saved.metadata.title = title.to_string(); |
| 1427 | saved.metadata.workspace.clone_from(&workspace); |
| 1428 | manager.save_session(&saved).expect("save session"); |
| 1429 | } |
| 1430 | |
| 1431 | let view = SessionPickerView::new_selecting(&workspace, Locale::En, "session-02"); |
| 1432 | |
| 1433 | assert_eq!( |
| 1434 | view.selected_session().map(|s| s.id.as_str()), |
| 1435 | Some("session-02"), |
| 1436 | "the rail hands off a target row; the picker must land on it" |
| 1437 | ); |
| 1438 | } |
| 1439 | |
| 1440 | #[test] |
| 1441 | fn preselecting_an_archived_row_widens_the_archive_filter_only() { |
| 1442 | let _lock = crate::test_support::lock_test_env(); |
| 1443 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1444 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1445 | let manager = SessionManager::default_location().expect("session manager"); |
| 1446 | let workspace = tmp.path().join("workspace"); |
| 1447 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 1448 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1449 | saved.metadata.id = "session-01".to_string(); |
| 1450 | saved.metadata.title = "Put away".to_string(); |
| 1451 | saved.metadata.workspace.clone_from(&workspace); |
| 1452 | saved.metadata.archived = true; |
| 1453 | manager.save_session(&saved).expect("save session"); |
| 1454 | |
| 1455 | let view = SessionPickerView::new_selecting(&workspace, Locale::En, "session-01"); |
| 1456 | |
| 1457 | assert_eq!( |
| 1458 | view.selected_session().map(|s| s.id.as_str()), |
| 1459 | Some("session-01") |
| 1460 | ); |
| 1461 | assert!(view.show_archived, "archived rows had to be revealed"); |
| 1462 | assert!( |
| 1463 | !view.show_all_workspaces, |
| 1464 | "revealing an archived row must not also broaden the workspace scope" |
| 1465 | ); |
| 1466 | } |
| 1467 | |
| 1468 | fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String { |
| 1469 | (area.x..area.x.saturating_add(area.width)) |
| 1470 | .map(|x| buf[(x, y)].symbol()) |
| 1471 | .collect() |
| 1472 | } |
| 1473 | |
| 1474 | fn row_containing(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> { |
| 1475 | (area.y..area.y.saturating_add(area.height)) |
| 1476 | .find(|&y| buffer_row_text(buf, area, y).contains(needle)) |
| 1477 | } |
| 1478 | |
| 1479 | fn buffer_text(buf: &Buffer, area: Rect) -> String { |
| 1480 | let mut out = String::new(); |
| 1481 | for y in area.y..area.y.saturating_add(area.height) { |
| 1482 | for x in area.x..area.x.saturating_add(area.width) { |
| 1483 | out.push_str(buf[(x, y)].symbol()); |
| 1484 | } |
| 1485 | out.push('\n'); |
| 1486 | } |
| 1487 | out |
| 1488 | } |
| 1489 | |
| 1490 | #[test] |
| 1491 | fn workspace_scope_filters_sessions_to_current_project() { |
| 1492 | // #1395 reproduction: Ctrl+R in project B must not surface sessions |
| 1493 | // from project A. |
| 1494 | let sessions = vec![ |
| 1495 | test_session_in(1, "project-a chat", "/tmp/project-a"), |
| 1496 | test_session_in(2, "project-b chat", "/tmp/project-b"), |
| 1497 | test_session_in(3, "another project-a chat", "/tmp/project-a"), |
| 1498 | ]; |
| 1499 | let view = picker_with(sessions, Some("/tmp/project-b")); |
| 1500 | assert_eq!(view.filtered.len(), 1, "only project-b session should show"); |
| 1501 | assert_eq!(view.filtered[0].title, "project-b chat"); |
| 1502 | } |
| 1503 | |
| 1504 | #[test] |
| 1505 | fn workspace_scope_toggle_a_expands_to_all_workspaces() { |
| 1506 | let sessions = vec![ |
| 1507 | test_session_in(1, "a", "/tmp/project-a"), |
| 1508 | test_session_in(2, "b", "/tmp/project-b"), |
| 1509 | test_session_in(3, "c", "/tmp/project-c"), |
| 1510 | ]; |
| 1511 | let mut view = picker_with(sessions, Some("/tmp/project-b")); |
| 1512 | assert_eq!(view.filtered.len(), 1); |
| 1513 | |
| 1514 | view.toggle_all_workspaces(); |
| 1515 | assert_eq!(view.filtered.len(), 3, "after toggle, every session shows"); |
| 1516 | assert!(view.show_all_workspaces); |
| 1517 | assert!( |
| 1518 | view.status |
| 1519 | .as_deref() |
| 1520 | .map(|s| s.contains("every workspace")) |
| 1521 | .unwrap_or(false), |
| 1522 | "status should announce the new mode, got {:?}", |
| 1523 | view.status |
| 1524 | ); |
| 1525 | |
| 1526 | view.toggle_all_workspaces(); |
| 1527 | assert_eq!(view.filtered.len(), 1, "toggling back restores the scope"); |
| 1528 | } |
| 1529 | |
| 1530 | #[test] |
| 1531 | fn workspace_scope_none_means_show_all() { |
| 1532 | // An unscoped picker (no workspace) lists everything — matches the |
| 1533 | // pre-#1395 behaviour for any caller that opts out. |
| 1534 | let sessions = vec![ |
| 1535 | test_session_in(1, "a", "/tmp/project-a"), |
| 1536 | test_session_in(2, "b", "/tmp/project-b"), |
| 1537 | ]; |
| 1538 | let view = picker_with(sessions, None); |
| 1539 | assert_eq!(view.filtered.len(), 2); |
| 1540 | } |
| 1541 | |
| 1542 | #[test] |
| 1543 | fn build_list_lines_truncates_to_list_pane_width() { |
| 1544 | let sessions = vec![test_session( |
| 1545 | 1, |
| 1546 | "A very long title that should be truncated by the list pane width", |
| 1547 | )]; |
| 1548 | let width = 24; |
| 1549 | let lines = build_list_lines( |
| 1550 | &sessions, |
| 1551 | 0, |
| 1552 | width, |
| 1553 | 0, |
| 1554 | 5, |
| 1555 | false, |
| 1556 | "", |
| 1557 | "recent", |
| 1558 | false, |
| 1559 | false, |
| 1560 | "", |
| 1561 | None, |
| 1562 | Locale::En, |
| 1563 | ); |
| 1564 | |
| 1565 | for line in lines { |
| 1566 | let rendered_width: usize = line.spans.iter().map(|span| span.content.width()).sum(); |
| 1567 | assert!( |
| 1568 | rendered_width <= width as usize, |
| 1569 | "line width {rendered_width} exceeded pane width {width}" |
| 1570 | ); |
| 1571 | } |
| 1572 | } |
| 1573 | |
| 1574 | #[test] |
| 1575 | fn build_list_lines_selected_row_uses_muted_selection_highlight() { |
| 1576 | let sessions = vec![ |
| 1577 | test_session(1, "first session"), |
| 1578 | test_session(2, "second session"), |
| 1579 | ]; |
| 1580 | let lines = build_list_lines( |
| 1581 | &sessions, |
| 1582 | 1, |
| 1583 | 80, |
| 1584 | 0, |
| 1585 | 5, |
| 1586 | false, |
| 1587 | "", |
| 1588 | "recent", |
| 1589 | false, |
| 1590 | false, |
| 1591 | "", |
| 1592 | None, |
| 1593 | Locale::En, |
| 1594 | ); |
| 1595 | |
| 1596 | let selected_line = lines |
| 1597 | .iter() |
| 1598 | .find(|line| { |
| 1599 | line.spans |
| 1600 | .iter() |
| 1601 | .any(|span| span.content.contains("second session")) |
| 1602 | }) |
| 1603 | .expect("selected session should render"); |
| 1604 | let span = selected_line |
| 1605 | .spans |
| 1606 | .first() |
| 1607 | .expect("selected row should have a span"); |
| 1608 | |
| 1609 | assert_eq!(span.style.fg, Some(palette::SELECTION_TEXT)); |
| 1610 | assert_eq!(span.style.bg, Some(palette::SELECTION_BG)); |
| 1611 | assert_ne!(span.style.bg, Some(palette::WHALE_ACTION)); |
| 1612 | assert!(span.style.add_modifier.contains(Modifier::BOLD)); |
| 1613 | } |
| 1614 | |
| 1615 | #[test] |
| 1616 | fn session_picker_selected_row_renders_readable_selection_contrast() { |
| 1617 | let mut first = test_session(1, "first contrast fixture"); |
| 1618 | first.id = "alpha-contrast-fixture".to_string(); |
| 1619 | let mut second = test_session(2, "second contrast fixture"); |
| 1620 | second.id = "bravo-contrast-fixture".to_string(); |
| 1621 | let sessions = vec![first, second]; |
| 1622 | let mut view = picker_with(sessions, None); |
| 1623 | view.selected = 1; |
| 1624 | view.ensure_selected_visible(); |
| 1625 | view.current_preview = vec!["preview".to_string()]; |
| 1626 | let selected_id = crate::session_manager::truncate_id(&view.filtered[view.selected].id); |
| 1627 | let area = Rect::new(0, 0, 120, 28); |
| 1628 | let mut buf = Buffer::empty(area); |
| 1629 | |
| 1630 | view.render(area, &mut buf); |
| 1631 | |
| 1632 | let y = |
| 1633 | row_containing(&buf, area, selected_id).expect("selected session row should render"); |
| 1634 | let rendered_row = buffer_row_text(&buf, area, y); |
| 1635 | let highlighted_cells = (area.x..area.x.saturating_add(area.width)) |
| 1636 | .filter(|&x| { |
| 1637 | let cell = &buf[(x, y)]; |
| 1638 | !cell.symbol().trim().is_empty() |
| 1639 | && cell.bg == palette::SELECTION_BG |
| 1640 | && cell.fg == palette::SELECTION_TEXT |
| 1641 | }) |
| 1642 | .count(); |
| 1643 | |
| 1644 | assert!( |
| 1645 | highlighted_cells >= 4, |
| 1646 | "selected /sessions row should use readable selection text; got {highlighted_cells} highlighted cells on {rendered_row:?}" |
| 1647 | ); |
| 1648 | assert!( |
| 1649 | !(area.x..area.x.saturating_add(area.width)) |
| 1650 | .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION), |
| 1651 | "selected /sessions row should not use the bright accent background" |
| 1652 | ); |
| 1653 | } |
| 1654 | |
| 1655 | /// 40x12/60x16 regression: when two bordered panes cannot both show a |
| 1656 | /// content row, the picker keeps a single focused session list — with the |
| 1657 | /// selected session, its resume action, and truthful mouse hitboxes — |
| 1658 | /// instead of two empty headings over a wrapped footer. |
| 1659 | #[test] |
| 1660 | fn session_picker_compact_heights_keep_a_selectable_session() { |
| 1661 | let sessions = (0..6) |
| 1662 | .map(|idx| { |
| 1663 | let mut session = test_session(idx, "compact fixture session"); |
| 1664 | session.id = format!("compact-fixture-{idx:02}"); |
| 1665 | session |
| 1666 | }) |
| 1667 | .collect::<Vec<_>>(); |
| 1668 | let mut view = picker_with(sessions, None); |
| 1669 | view.selected = 4; |
| 1670 | view.ensure_selected_visible(); |
| 1671 | |
| 1672 | for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] { |
| 1673 | let area = Rect::new(0, 0, width, height); |
| 1674 | let mut buf = Buffer::empty(area); |
| 1675 | |
| 1676 | view.render(area, &mut buf); |
| 1677 | |
| 1678 | let dump = buffer_text(&buf, area); |
| 1679 | let selected_id = crate::session_manager::truncate_id(&view.filtered[view.selected].id); |
| 1680 | assert!( |
| 1681 | row_containing(&buf, area, selected_id).is_some(), |
| 1682 | "{label} should render the selected session row:\n{dump}" |
| 1683 | ); |
| 1684 | assert!( |
| 1685 | dump.contains("resume"), |
| 1686 | "{label} should keep the resume action visible:\n{dump}" |
| 1687 | ); |
| 1688 | let hitboxes = view.last_row_hitboxes.borrow(); |
| 1689 | assert!( |
| 1690 | !hitboxes.is_empty(), |
| 1691 | "{label} should register session hitboxes:\n{dump}" |
| 1692 | ); |
| 1693 | for (y, idx) in hitboxes.iter() { |
| 1694 | let row = buffer_row_text(&buf, area, *y); |
| 1695 | let id = crate::session_manager::truncate_id(&view.filtered[*idx].id); |
| 1696 | assert!( |
| 1697 | row.contains(id), |
| 1698 | "{label} hitbox at y={y} should map to session {id}; got {row:?}" |
| 1699 | ); |
| 1700 | } |
| 1701 | } |
| 1702 | } |
| 1703 | |
| 1704 | #[test] |
| 1705 | fn session_picker_visual_matrix_covers_narrow_and_medium_rendering() { |
| 1706 | let base_time = DateTime::parse_from_rfc3339("2026-06-25T10:30:00Z") |
| 1707 | .expect("visual matrix timestamp") |
| 1708 | .with_timezone(&Utc); |
| 1709 | let sessions = (0..12) |
| 1710 | .map(|idx| { |
| 1711 | let title = if idx == 6 { |
| 1712 | "selected visual matrix target with 中文内容 and suffix that must truncate" |
| 1713 | } else { |
| 1714 | "A very long terminal visual regression session title with 中文内容 and suffix that must truncate" |
| 1715 | }; |
| 1716 | let mut session = test_session(idx, title); |
| 1717 | session.id = format!("visual-matrix-{idx:02}"); |
| 1718 | session.created_at = base_time - chrono::Duration::seconds(idx as i64); |
| 1719 | session.updated_at = session.created_at; |
| 1720 | session |
| 1721 | }) |
| 1722 | .collect::<Vec<_>>(); |
| 1723 | let mut view = picker_with(sessions, None); |
| 1724 | view.selected = view |
| 1725 | .filtered |
| 1726 | .iter() |
| 1727 | .position(|session| session.id == "visual-matrix-06") |
| 1728 | .expect("visual matrix target session should be filtered"); |
| 1729 | view.ensure_selected_visible(); |
| 1730 | view.current_preview = vec![ |
| 1731 | "Title: terminal visual matrix".to_string(), |
| 1732 | "Updated: 2026-06-25 10:30".to_string(), |
| 1733 | "Messages: 3 | Model: deepseek-v4-pro".to_string(), |
| 1734 | String::new(), |
| 1735 | "USER: narrow panes should keep long CJK text readable 中文中文中文".to_string(), |
| 1736 | "ASSISTANT: overlays should keep borders and truncate rows predictably".to_string(), |
| 1737 | ]; |
| 1738 | |
| 1739 | for (width, height, label) in [(72, 20, "narrow"), (120, 28, "medium")] { |
| 1740 | let area = Rect::new(0, 0, width, height); |
| 1741 | let mut buf = Buffer::empty(area); |
| 1742 | |
| 1743 | view.render(area, &mut buf); |
| 1744 | |
| 1745 | let dump = buffer_text(&buf, area); |
| 1746 | assert!( |
| 1747 | dump.contains("sessions (1-9)"), |
| 1748 | "{label} sessions pane missing:\n{dump}" |
| 1749 | ); |
| 1750 | assert!( |
| 1751 | dump.contains("history (PgUp/PgDn)"), |
| 1752 | "{label} history pane missing:\n{dump}" |
| 1753 | ); |
| 1754 | assert!(dump.contains('─'), "{label} hairline missing:\n{dump}"); |
| 1755 | assert!( |
| 1756 | !dump.contains('┌') && !dump.contains('┘'), |
| 1757 | "{label} should use open hairlines, not boxed rooms:\n{dump}" |
| 1758 | ); |
| 1759 | assert!( |
| 1760 | !dump.contains("suffix that must truncate"), |
| 1761 | "{label} long title tail leaked instead of truncating:\n{dump}" |
| 1762 | ); |
| 1763 | assert!( |
| 1764 | dump.contains("..."), |
| 1765 | "{label} should show an explicit ellipsis for truncated rows:\n{dump}" |
| 1766 | ); |
| 1767 | assert!( |
| 1768 | !dump.contains('\u{fffd}'), |
| 1769 | "{label} render emitted replacement characters:\n{dump}" |
| 1770 | ); |
| 1771 | |
| 1772 | assert!( |
| 1773 | row_containing(&buf, area, "selected visual").is_some(), |
| 1774 | "{label} selected session row missing:\n{dump}" |
| 1775 | ); |
| 1776 | } |
| 1777 | } |
| 1778 | |
| 1779 | #[test] |
| 1780 | fn build_list_lines_includes_absolute_updated_timestamp() { |
| 1781 | let mut session = test_session(1, "last friday thread"); |
| 1782 | session.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") |
| 1783 | .expect("timestamp") |
| 1784 | .with_timezone(&Utc); |
| 1785 | let lines = build_list_lines( |
| 1786 | &[session], |
| 1787 | 0, |
| 1788 | 120, |
| 1789 | 0, |
| 1790 | 5, |
| 1791 | false, |
| 1792 | "", |
| 1793 | "recent", |
| 1794 | false, |
| 1795 | false, |
| 1796 | "", |
| 1797 | None, |
| 1798 | Locale::En, |
| 1799 | ); |
| 1800 | |
| 1801 | let rendered = lines |
| 1802 | .iter() |
| 1803 | .flat_map(|line| line.spans.iter()) |
| 1804 | .map(|span| span.content.as_ref()) |
| 1805 | .collect::<Vec<_>>() |
| 1806 | .join("\n"); |
| 1807 | assert!( |
| 1808 | rendered.contains("2026-06-01 12:34 UTC"), |
| 1809 | "session picker should include an absolute timestamp, got {rendered:?}" |
| 1810 | ); |
| 1811 | } |
| 1812 | |
| 1813 | #[test] |
| 1814 | fn build_list_lines_marks_fork_lineage() { |
| 1815 | let mut forked = test_session(1, "forked path"); |
| 1816 | forked.parent_session_id = Some("parent-session-abcdef".to_string()); |
| 1817 | forked.forked_from_message_count = Some(3); |
| 1818 | let lines = build_list_lines( |
| 1819 | &[forked], |
| 1820 | 0, |
| 1821 | 120, |
| 1822 | 0, |
| 1823 | 5, |
| 1824 | false, |
| 1825 | "", |
| 1826 | "recent", |
| 1827 | false, |
| 1828 | false, |
| 1829 | "", |
| 1830 | None, |
| 1831 | Locale::En, |
| 1832 | ); |
| 1833 | |
| 1834 | let rendered = lines |
| 1835 | .iter() |
| 1836 | .flat_map(|line| line.spans.iter()) |
| 1837 | .map(|span| span.content.as_ref()) |
| 1838 | .collect::<Vec<_>>() |
| 1839 | .join("\n"); |
| 1840 | assert!(rendered.contains("fork")); |
| 1841 | assert!(!rendered.contains("parent-session-abcdef")); |
| 1842 | } |
| 1843 | |
| 1844 | #[test] |
| 1845 | fn build_list_lines_numbers_visible_rows_for_shortcuts() { |
| 1846 | let sessions = vec![ |
| 1847 | test_session(1, "first session"), |
| 1848 | test_session(2, "second session"), |
| 1849 | ]; |
| 1850 | let lines = build_list_lines( |
| 1851 | &sessions, |
| 1852 | 0, |
| 1853 | 80, |
| 1854 | 0, |
| 1855 | 5, |
| 1856 | false, |
| 1857 | "", |
| 1858 | "recent", |
| 1859 | false, |
| 1860 | false, |
| 1861 | "", |
| 1862 | None, |
| 1863 | Locale::En, |
| 1864 | ); |
| 1865 | |
| 1866 | let rendered = lines |
| 1867 | .iter() |
| 1868 | .flat_map(|line| line.spans.iter()) |
| 1869 | .map(|span| span.content.as_ref()) |
| 1870 | .collect::<Vec<_>>() |
| 1871 | .join("\n"); |
| 1872 | assert!(rendered.contains("1. session-")); |
| 1873 | assert!(rendered.contains("2. session-")); |
| 1874 | } |
| 1875 | |
| 1876 | #[test] |
| 1877 | fn digit_shortcut_selects_visible_session_for_history() { |
| 1878 | let sessions = vec![ |
| 1879 | test_session(1, "first session"), |
| 1880 | test_session(2, "second session"), |
| 1881 | test_session(3, "third session"), |
| 1882 | ]; |
| 1883 | let mut view = picker_with(sessions, None); |
| 1884 | |
| 1885 | assert!(view.select_visible_shortcut('2')); |
| 1886 | assert_eq!(view.selected, 1); |
| 1887 | assert!( |
| 1888 | view.status |
| 1889 | .as_deref() |
| 1890 | .is_some_and(|status| status.contains("Opened history")) |
| 1891 | ); |
| 1892 | assert!(!view.select_visible_shortcut('9')); |
| 1893 | } |
| 1894 | |
| 1895 | #[test] |
| 1896 | fn history_scroll_pages_and_clamps() { |
| 1897 | let mut view = picker_with(vec![test_session(1, "first")], None); |
| 1898 | view.current_preview = (0..20).map(|idx| format!("line {idx}")).collect(); |
| 1899 | view.history_visible_rows.set(5); |
| 1900 | |
| 1901 | view.scroll_history(6); |
| 1902 | assert_eq!(view.history_scroll.get(), 6); |
| 1903 | view.scroll_history(100); |
| 1904 | assert_eq!(view.history_scroll.get(), 15); |
| 1905 | view.scroll_history(-200); |
| 1906 | assert_eq!(view.history_scroll.get(), 0); |
| 1907 | } |
| 1908 | |
| 1909 | #[test] |
| 1910 | fn history_preview_keeps_header_while_scrolling_transcript() { |
| 1911 | let lines = vec![ |
| 1912 | "Title: version".to_string(), |
| 1913 | "Updated: 2026-05-14 01:02".to_string(), |
| 1914 | "Messages: 100 | Model: auto".to_string(), |
| 1915 | "Mode: agent".to_string(), |
| 1916 | String::new(), |
| 1917 | "USER: oldest prompt".to_string(), |
| 1918 | "ASSISTANT: oldest answer".to_string(), |
| 1919 | "USER: middle prompt".to_string(), |
| 1920 | "ASSISTANT: middle answer".to_string(), |
| 1921 | "USER: newest prompt".to_string(), |
| 1922 | "ASSISTANT: newest answer".to_string(), |
| 1923 | ]; |
| 1924 | |
| 1925 | let max_scroll = max_history_scroll_for(&lines, 8); |
| 1926 | assert_eq!(max_scroll, 3); |
| 1927 | |
| 1928 | let rendered = visible_preview_lines(&lines, max_scroll, 8).join("\n"); |
| 1929 | assert!(rendered.contains("Title: version")); |
| 1930 | assert!(rendered.contains("Updated: 2026-05-14 01:02")); |
| 1931 | assert!(!rendered.contains("oldest prompt")); |
| 1932 | assert!(rendered.contains("newest prompt")); |
| 1933 | assert!(rendered.contains("newest answer")); |
| 1934 | } |
| 1935 | |
| 1936 | #[test] |
| 1937 | fn history_refresh_starts_at_latest_transcript_messages() { |
| 1938 | let mut view = picker_with(vec![test_session(1, "first")], None); |
| 1939 | view.current_preview = vec![ |
| 1940 | "Title: first".to_string(), |
| 1941 | "Updated: 2026-05-14 01:02".to_string(), |
| 1942 | "Messages: 10 | Model: auto".to_string(), |
| 1943 | String::new(), |
| 1944 | "line 0".to_string(), |
| 1945 | "line 1".to_string(), |
| 1946 | "line 2".to_string(), |
| 1947 | "line 3".to_string(), |
| 1948 | "line 4".to_string(), |
| 1949 | "line 5".to_string(), |
| 1950 | ]; |
| 1951 | view.history_visible_rows.set(6); |
| 1952 | |
| 1953 | view.scroll_history_to_latest(); |
| 1954 | |
| 1955 | assert_eq!(view.history_scroll.get(), 4); |
| 1956 | assert!(view.history_pinned_to_latest.get()); |
| 1957 | } |
| 1958 | |
| 1959 | #[test] |
| 1960 | fn build_preview_lines_shows_full_clean_history() { |
| 1961 | let messages = vec![ |
| 1962 | text_message( |
| 1963 | "user", |
| 1964 | "<turn_meta>{\"cache\":\"x\"}</turn_meta>\nFirst visible prompt", |
| 1965 | ), |
| 1966 | text_message( |
| 1967 | "assistant", |
| 1968 | "<thinking>hidden reasoning</thinking>\nFirst visible answer", |
| 1969 | ), |
| 1970 | text_message("user", "Second prompt"), |
| 1971 | text_message("assistant", "Second answer"), |
| 1972 | text_message("user", "Third prompt"), |
| 1973 | text_message("assistant", "Third answer"), |
| 1974 | text_message("user", "Fourth prompt beyond old six-message preview"), |
| 1975 | ]; |
| 1976 | let session = saved_session_with_messages(messages); |
| 1977 | let lines = build_preview_lines(&session, Locale::En).join("\n"); |
| 1978 | |
| 1979 | assert!(lines.contains("Title: Clean session title")); |
| 1980 | assert!(lines.contains("First visible prompt")); |
| 1981 | assert!(lines.contains("First visible answer")); |
| 1982 | assert!(lines.contains("Fourth prompt beyond old six-message preview")); |
| 1983 | assert!(!lines.contains("turn_meta")); |
| 1984 | assert!(!lines.contains("hidden reasoning")); |
| 1985 | } |
| 1986 | |
| 1987 | #[test] |
| 1988 | fn ensure_selected_visible_updates_scroll_window() { |
| 1989 | let sessions = (0..10) |
| 1990 | .map(|idx| test_session(idx, &format!("Session {idx}"))) |
| 1991 | .collect::<Vec<_>>(); |
| 1992 | |
| 1993 | let mut view = SessionPickerView { |
| 1994 | sessions: sessions.clone(), |
| 1995 | filtered: sessions, |
| 1996 | selected: 0, |
| 1997 | list_scroll: Cell::new(0), |
| 1998 | list_visible_rows: Cell::new(3), |
| 1999 | history_scroll: Cell::new(0), |
| 2000 | history_pinned_to_latest: Cell::new(true), |
| 2001 | history_visible_rows: Cell::new(12), |
| 2002 | search_input: String::new(), |
| 2003 | search_mode: false, |
| 2004 | sort_mode: SessionSortMode::Recent, |
| 2005 | preview_cache: HashMap::new(), |
| 2006 | current_preview: Vec::new(), |
| 2007 | confirm_delete: false, |
| 2008 | rename_mode: false, |
| 2009 | rename_input: String::new(), |
| 2010 | status: None, |
| 2011 | workspace_scope: None, |
| 2012 | show_all_workspaces: true, |
| 2013 | show_archived: false, |
| 2014 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 2015 | locale: Locale::En, |
| 2016 | }; |
| 2017 | |
| 2018 | view.selected = 6; |
| 2019 | view.ensure_selected_visible(); |
| 2020 | assert_eq!(view.list_scroll.get(), 4); |
| 2021 | |
| 2022 | view.selected = 1; |
| 2023 | view.ensure_selected_visible(); |
| 2024 | assert_eq!(view.list_scroll.get(), 1); |
| 2025 | |
| 2026 | view.selected = 9; |
| 2027 | view.ensure_selected_visible(); |
| 2028 | assert_eq!(view.list_scroll.get(), 7); |
| 2029 | } |
| 2030 | |
| 2031 | #[test] |
| 2032 | fn session_picker_is_usable_and_opaque_at_blocker_sizes() { |
| 2033 | use crate::tui::views::ViewStack; |
| 2034 | |
| 2035 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 2036 | for (w, h) in BLOCKER_SIZES { |
| 2037 | let sessions = vec![ |
| 2038 | test_session(1, "first session"), |
| 2039 | test_session(2, "second session"), |
| 2040 | ]; |
| 2041 | let mut view = picker_with(sessions, None); |
| 2042 | view.current_preview = vec![ |
| 2043 | "Title: preview".to_string(), |
| 2044 | "Updated: 2026-06-25 10:30".to_string(), |
| 2045 | String::new(), |
| 2046 | "USER: hello".to_string(), |
| 2047 | ]; |
| 2048 | |
| 2049 | let area = Rect::new(0, 0, w, h); |
| 2050 | let mut buf = Buffer::empty(area); |
| 2051 | for y in 0..h { |
| 2052 | for x in 0..w { |
| 2053 | buf[(x, y)].set_symbol("X"); |
| 2054 | } |
| 2055 | } |
| 2056 | let mut stack = ViewStack::new(); |
| 2057 | stack.push(view); |
| 2058 | stack.render(area, &mut buf); |
| 2059 | |
| 2060 | let rows: Vec<String> = (0..h) |
| 2061 | .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect()) |
| 2062 | .collect(); |
| 2063 | let text = rows.join("\n"); |
| 2064 | |
| 2065 | // Both panes and their key hints survive at every size. The long |
| 2066 | // in-pane action header truncates to the (sometimes narrow) list |
| 2067 | // pane width, so assert the pane titles, which carry the digit-jump |
| 2068 | // and paging shortcuts and always fit. |
| 2069 | assert!(text.contains("sessions"), "{w}x{h}: missing sessions pane"); |
| 2070 | assert!(text.contains("history"), "{w}x{h}: missing history pane"); |
| 2071 | assert!(text.contains("1-9"), "{w}x{h}: missing 1-9 shortcut hint"); |
| 2072 | assert!(text.contains("PgUp/PgDn"), "{w}x{h}: missing paging hint"); |
| 2073 | |
| 2074 | // Composited frame is fully opaque. |
| 2075 | assert!(!text.contains('X'), "{w}x{h}: background bleed-through"); |
| 2076 | assert_eq!( |
| 2077 | buf[(w / 2, h / 2)].bg, |
| 2078 | palette::WHALE_BG, |
| 2079 | "{w}x{h}: modal interior must be opaque" |
| 2080 | ); |
| 2081 | |
| 2082 | // No horizontal overflow. |
| 2083 | for (y, row) in rows.iter().enumerate() { |
| 2084 | assert!( |
| 2085 | UnicodeWidthStr::width(row.trim_end()) <= w as usize, |
| 2086 | "{w}x{h}: row {y} overflows width: {row:?}" |
| 2087 | ); |
| 2088 | } |
| 2089 | } |
| 2090 | } |
| 2091 | } |
| 2092 |