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