| 1 | //! Session resume picker view for the TUI. |
| 2 | |
| 3 | use std::cell::{Cell, RefCell}; |
| 4 | use std::collections::VecDeque; |
| 5 | use std::path::{Path, PathBuf}; |
| 6 | use std::sync::{Arc, Mutex}; |
| 7 | |
| 8 | use chrono::{DateTime, Local}; |
| 9 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; |
| 10 | use ratatui::{ |
| 11 | buffer::Buffer, |
| 12 | layout::{Constraint, Direction, Layout, Rect}, |
| 13 | style::{Modifier, Style}, |
| 14 | text::{Line, Span}, |
| 15 | widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}, |
| 16 | }; |
| 17 | use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; |
| 18 | |
| 19 | use crate::session_manager::{ |
| 20 | SavedSession, SessionListFilter, SessionManager, SessionMetadata, extract_title, |
| 21 | extract_user_prompt, strip_thinking_tags, |
| 22 | }; |
| 23 | use crate::session_projection::{MAX_PROJECTED_SESSIONS, SessionQuery, SessionSortMode}; |
| 24 | use crate::tui::menu_style; |
| 25 | use crate::tui::views::{ |
| 26 | ActionHint, action_footer_lines, render_modal_footer, render_panel_scroll_rail, |
| 27 | render_underwater_surface, |
| 28 | }; |
| 29 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 30 | use codewhale_localization::{Locale, MessageId, tr}; |
| 31 | use codewhale_models::Role; |
| 32 | use codewhale_palette as palette; |
| 33 | |
| 34 | fn section_block(title: &str) -> Block<'static> { |
| 35 | Block::default() |
| 36 | .title(Line::from(vec![Span::styled( |
| 37 | title.to_string(), |
| 38 | Style::default() |
| 39 | .fg(palette::WHALE_ACTION) |
| 40 | .add_modifier(Modifier::BOLD), |
| 41 | )])) |
| 42 | .borders(Borders::TOP) |
| 43 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 44 | .style(Style::default().bg(palette::WHALE_BG)) |
| 45 | .padding(Padding::uniform(1)) |
| 46 | } |
| 47 | |
| 48 | /// Previews kept in memory. Each entry is a whole rendered transcript, so the |
| 49 | /// cache is bounded: scrolling a long list must not retain every session. |
| 50 | const PREVIEW_CACHE_CAPACITY: usize = 16; |
| 51 | |
| 52 | /// Small least-recently-used cache of rendered previews keyed by session id. |
| 53 | #[derive(Default)] |
| 54 | struct PreviewCache { |
| 55 | /// Oldest first; a hit moves its entry to the back. |
| 56 | entries: VecDeque<(String, Vec<String>)>, |
| 57 | } |
| 58 | |
| 59 | impl PreviewCache { |
| 60 | fn get(&mut self, id: &str) -> Option<&Vec<String>> { |
| 61 | let index = self.entries.iter().position(|(key, _)| key == id)?; |
| 62 | let entry = self.entries.remove(index)?; |
| 63 | self.entries.push_back(entry); |
| 64 | self.entries.back().map(|(_, lines)| lines) |
| 65 | } |
| 66 | |
| 67 | fn remove(&mut self, id: &str) { |
| 68 | self.entries.retain(|(key, _)| key != id); |
| 69 | } |
| 70 | |
| 71 | fn insert(&mut self, id: String, lines: Vec<String>) { |
| 72 | self.entries.retain(|(key, _)| *key != id); |
| 73 | self.entries.push_back((id, lines)); |
| 74 | while self.entries.len() > PREVIEW_CACHE_CAPACITY { |
| 75 | self.entries.pop_front(); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | #[cfg(test)] |
| 80 | fn len(&self) -> usize { |
| 81 | self.entries.len() |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// Outcome of reading one session from disk for the preview pane. |
| 86 | struct PreviewLoad { |
| 87 | lines: Vec<String>, |
| 88 | /// Only a successful load is cached; a failure is retried on reselect. |
| 89 | cacheable: bool, |
| 90 | } |
| 91 | |
| 92 | /// A preview load running off the event loop. The result is only applied if |
| 93 | /// it still matches the selection that requested it. |
| 94 | struct PendingPreview { |
| 95 | session_id: String, |
| 96 | generation: u64, |
| 97 | cell: Arc<Mutex<Option<PreviewLoad>>>, |
| 98 | } |
| 99 | |
| 100 | /// One session-store listing, or why the store could not be listed. |
| 101 | type SessionListing = Result<Vec<SessionMetadata>, String>; |
| 102 | |
| 103 | /// The session-store listing running off the event loop (U08-09). The |
| 104 | /// store's metadata scan grows with every saved session, so the picker opens |
| 105 | /// at once and fills in when the listing lands. |
| 106 | struct PendingSessionList { |
| 107 | cell: Arc<Mutex<Option<SessionListing>>>, |
| 108 | /// Row to land on once the list arrives (`new_selecting`). |
| 109 | select: Option<String>, |
| 110 | } |
| 111 | |
| 112 | /// What the list pane says in place of rows it does not have yet. |
| 113 | #[derive(Debug, Clone, Copy)] |
| 114 | enum ListNotice<'a> { |
| 115 | Loading, |
| 116 | Failed(&'a str), |
| 117 | } |
| 118 | |
| 119 | pub struct SessionPickerView { |
| 120 | /// Every session loaded from disk. The picker filters from this set. |
| 121 | sessions: Vec<SessionMetadata>, |
| 122 | /// The store listing still in flight, if any. |
| 123 | pending_list: Option<PendingSessionList>, |
| 124 | /// The store could not be listed. Shown instead of "no saved sessions", |
| 125 | /// which would claim an empty store nobody observed. |
| 126 | load_error: Option<String>, |
| 127 | filtered: Vec<SessionMetadata>, |
| 128 | selected: usize, |
| 129 | list_scroll: Cell<usize>, |
| 130 | list_visible_rows: Cell<usize>, |
| 131 | history_scroll: Cell<usize>, |
| 132 | history_pinned_to_latest: Cell<bool>, |
| 133 | history_visible_rows: Cell<usize>, |
| 134 | search_input: String, |
| 135 | search_mode: bool, |
| 136 | sort_mode: SessionSortMode, |
| 137 | preview_cache: PreviewCache, |
| 138 | current_preview: Vec<String>, |
| 139 | /// Bumped on every preview refresh; a background load tagged with an |
| 140 | /// older generation is stale and dropped. |
| 141 | preview_generation: u64, |
| 142 | pending_preview: Option<PendingPreview>, |
| 143 | confirm_delete: bool, |
| 144 | rename_mode: bool, |
| 145 | rename_input: String, |
| 146 | status: Option<String>, |
| 147 | /// Canonical workspace path used as the per-project scope filter |
| 148 | /// (#1395). `None` opts out of scoping (e.g. when the caller can't |
| 149 | /// resolve a workspace). |
| 150 | workspace_scope: Option<PathBuf>, |
| 151 | /// When `true`, the picker shows sessions from every workspace; when |
| 152 | /// `false`, only sessions whose recorded `workspace` matches the |
| 153 | /// canonicalised `workspace_scope`. |
| 154 | show_all_workspaces: bool, |
| 155 | /// When `true`, archived sessions are listed alongside active ones |
| 156 | /// (#2934 / #4397). Defaults to `false`: archiving is the user putting a |
| 157 | /// session away, and the browse default should honour that. |
| 158 | show_archived: bool, |
| 159 | /// Hide empty auto-created sessions — the same filter the launch list |
| 160 | /// and `--continue` already apply (#6014). The picker exists to resume |
| 161 | /// work, and a session with no messages has none to resume. |
| 162 | hide_empty_sessions: bool, |
| 163 | /// The session the caller is running in, when it hands one over. Rows |
| 164 | /// mark it `current` so the user can tell which session they are inside |
| 165 | /// (#6014). Never inferred from disk state. |
| 166 | current_session_id: Option<String>, |
| 167 | /// Screen rows owned by the visible session list. Keeping this local to |
| 168 | /// the view gives mouse and keyboard the same selection/resume contract. |
| 169 | last_row_hitboxes: RefCell<Vec<(u16, usize)>>, |
| 170 | /// UI locale captured from the app at construction (#4057 wave 2). |
| 171 | locale: Locale, |
| 172 | } |
| 173 | |
| 174 | impl SessionPickerView { |
| 175 | /// Construct a picker scoped to `workspace`. Sessions belonging to |
| 176 | /// other workspaces are hidden by default — press `a` inside the |
| 177 | /// picker to expand to all workspaces (#1395). |
| 178 | pub fn new(workspace: &Path, locale: Locale) -> Self { |
| 179 | Self::load(workspace, locale, None) |
| 180 | } |
| 181 | |
| 182 | /// Open over the session store. Listing it is blocking disk I/O that |
| 183 | /// grows with the store, so inside a runtime it runs on the blocking pool |
| 184 | /// and lands through `tick`; outside one (unit tests, headless callers) |
| 185 | /// there is no loop to stall, so it loads inline. |
| 186 | fn load(workspace: &Path, locale: Locale, select: Option<String>) -> Self { |
| 187 | let mut view = Self::from_session_list(workspace, locale, Vec::new()); |
| 188 | if tokio::runtime::Handle::try_current().is_err() { |
| 189 | view.apply_session_list(list_session_store(), select); |
| 190 | return view; |
| 191 | } |
| 192 | let cell = Arc::new(Mutex::new(None)); |
| 193 | let slot = Arc::clone(&cell); |
| 194 | crate::utils::spawn_blocking_supervised("session-picker-list", move || { |
| 195 | let listed = list_session_store(); |
| 196 | if let Ok(mut guard) = slot.lock() { |
| 197 | *guard = Some(listed); |
| 198 | } |
| 199 | }); |
| 200 | view.pending_list = Some(PendingSessionList { cell, select }); |
| 201 | view |
| 202 | } |
| 203 | |
| 204 | /// Install a finished store listing. A failure is shown as a failure — |
| 205 | /// never as an empty store — and the list stays empty. |
| 206 | fn apply_session_list(&mut self, listed: SessionListing, select: Option<String>) { |
| 207 | match listed { |
| 208 | Ok(sessions) => { |
| 209 | self.sessions = sessions; |
| 210 | self.load_error = None; |
| 211 | self.apply_sort_and_filter(); |
| 212 | if let Some(session_id) = select { |
| 213 | self.select_with_fallback(&session_id); |
| 214 | } |
| 215 | } |
| 216 | Err(error) => { |
| 217 | self.sessions.clear(); |
| 218 | self.load_error = |
| 219 | Some(tr(self.locale, MessageId::SessionsOpenFailed).replace("{error}", &error)); |
| 220 | self.apply_sort_and_filter(); |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// Apply the store listing once it lands. Returns whether the view changed. |
| 226 | fn poll_session_list(&mut self) -> bool { |
| 227 | let Some(pending) = self.pending_list.as_ref() else { |
| 228 | return false; |
| 229 | }; |
| 230 | let Some(listed) = pending.cell.lock().ok().and_then(|mut guard| guard.take()) else { |
| 231 | return false; |
| 232 | }; |
| 233 | let select = self.pending_list.take().and_then(|pending| pending.select); |
| 234 | self.apply_session_list(listed, select); |
| 235 | true |
| 236 | } |
| 237 | |
| 238 | /// Construct a picker scoped to `workspace` over an explicit session list. |
| 239 | /// |
| 240 | /// Test seam for acceptance coverage (#5929): production always resolves |
| 241 | /// the store via [`Self::new`], but redirecting `CODEWHALE_HOME` to point |
| 242 | /// that resolution at a temp directory is process-global state — a |
| 243 | /// concurrent test that resolves the store without the env barrier can |
| 244 | /// observe the redirect mid-test and write sessions into it, so the two |
| 245 | /// listings under comparison drift apart. Handing the picker the list from |
| 246 | /// a private store removes the shared-state window without touching what |
| 247 | /// the picker does with the list. |
| 248 | #[cfg(test)] |
| 249 | pub fn new_with_sessions( |
| 250 | workspace: &Path, |
| 251 | locale: Locale, |
| 252 | sessions: Vec<SessionMetadata>, |
| 253 | ) -> Self { |
| 254 | Self::from_session_list(workspace, locale, sessions) |
| 255 | } |
| 256 | |
| 257 | /// Shared constructor: everything except where the session list came from. |
| 258 | fn from_session_list(workspace: &Path, locale: Locale, sessions: Vec<SessionMetadata>) -> Self { |
| 259 | let mut view = Self { |
| 260 | sessions, |
| 261 | pending_list: None, |
| 262 | load_error: None, |
| 263 | filtered: Vec::new(), |
| 264 | selected: 0, |
| 265 | list_scroll: Cell::new(0), |
| 266 | list_visible_rows: Cell::new(8), |
| 267 | history_scroll: Cell::new(0), |
| 268 | history_pinned_to_latest: Cell::new(true), |
| 269 | history_visible_rows: Cell::new(12), |
| 270 | search_input: String::new(), |
| 271 | search_mode: false, |
| 272 | sort_mode: SessionSortMode::Recent, |
| 273 | preview_cache: PreviewCache::default(), |
| 274 | current_preview: Vec::new(), |
| 275 | preview_generation: 0, |
| 276 | pending_preview: None, |
| 277 | confirm_delete: false, |
| 278 | rename_mode: false, |
| 279 | rename_input: String::new(), |
| 280 | status: None, |
| 281 | workspace_scope: Some(canonical_or_self(workspace.to_path_buf())), |
| 282 | show_all_workspaces: false, |
| 283 | show_archived: false, |
| 284 | hide_empty_sessions: true, |
| 285 | current_session_id: None, |
| 286 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 287 | locale, |
| 288 | }; |
| 289 | view.apply_sort_and_filter(); |
| 290 | view.refresh_preview(); |
| 291 | view |
| 292 | } |
| 293 | |
| 294 | /// As [`Self::new`], but with `session_id` preselected. |
| 295 | /// |
| 296 | /// This is how the sidebar Sessions rail hands off (#2934): the rail |
| 297 | /// navigates, the picker keeps ownership of preview, resume, rename, |
| 298 | /// archive, and delete. A row whose session is outside the current |
| 299 | /// workspace scope, or archived, widens the corresponding filter rather |
| 300 | /// than silently landing on the wrong row — but it never *resumes* |
| 301 | /// anything, so widening the view cannot cross a workspace boundary |
| 302 | /// behind the user's back. |
| 303 | pub fn new_selecting(workspace: &Path, locale: Locale, session_id: &str) -> Self { |
| 304 | Self::load(workspace, locale, Some(session_id.to_string())) |
| 305 | } |
| 306 | |
| 307 | /// Land the selection on `session_id`, widening one browse filter at a |
| 308 | /// time when the row is outside the default view. |
| 309 | fn select_with_fallback(&mut self, session_id: &str) { |
| 310 | if self.select_session_id(session_id) { |
| 311 | return; |
| 312 | } |
| 313 | if !self.show_archived { |
| 314 | self.show_archived = true; |
| 315 | self.apply_sort_and_filter(); |
| 316 | if self.select_session_id(session_id) { |
| 317 | self.status = |
| 318 | Some(tr(self.locale, MessageId::SessionsShowingArchived).into_owned()); |
| 319 | return; |
| 320 | } |
| 321 | } |
| 322 | if !self.show_all_workspaces { |
| 323 | self.show_all_workspaces = true; |
| 324 | self.apply_sort_and_filter(); |
| 325 | if self.select_session_id(session_id) { |
| 326 | self.status = |
| 327 | Some(tr(self.locale, MessageId::SessionsShowingAllWorkspaces).into_owned()); |
| 328 | return; |
| 329 | } |
| 330 | } |
| 331 | // Last resort: an empty auto-created session is hidden by default |
| 332 | // but a rail handoff may still target it — widen that filter too |
| 333 | // rather than report "no results" for a session that exists (#6014). |
| 334 | if self.hide_empty_sessions { |
| 335 | self.hide_empty_sessions = false; |
| 336 | self.apply_sort_and_filter(); |
| 337 | if self.select_session_id(session_id) { |
| 338 | return; |
| 339 | } |
| 340 | } |
| 341 | // Not found at all: leave the default view rather than pretending. |
| 342 | self.status = Some(tr(self.locale, MessageId::SessionsNoResults).into_owned()); |
| 343 | } |
| 344 | |
| 345 | /// Mark `session_id` as the session the caller is running in. Rows mark |
| 346 | /// it `current` (#6014); `None` leaves every row unmarked. |
| 347 | #[must_use] |
| 348 | pub fn with_current_session(mut self, session_id: Option<&str>) -> Self { |
| 349 | self.current_session_id = session_id.map(str::to_string); |
| 350 | self |
| 351 | } |
| 352 | |
| 353 | /// Move the selection onto `session_id` if it is in the filtered list. |
| 354 | fn select_session_id(&mut self, session_id: &str) -> bool { |
| 355 | let Some(index) = self.filtered.iter().position(|s| s.id == session_id) else { |
| 356 | return false; |
| 357 | }; |
| 358 | self.selected = index; |
| 359 | self.ensure_selected_visible(); |
| 360 | self.refresh_preview(); |
| 361 | true |
| 362 | } |
| 363 | |
| 364 | /// The query this picker's current view represents. |
| 365 | /// |
| 366 | /// Built here and handed to [`crate::session_projection::select_sessions`] |
| 367 | /// so the picker's list is literally the same selection the rail and |
| 368 | /// `/v1/sessions` compute — filter, workspace scope, fuzzy search, sort, |
| 369 | /// and tie-breaks included. The picker used to own private copies of all |
| 370 | /// five; that was the second backend. |
| 371 | fn view_query(&self) -> SessionQuery { |
| 372 | let mut query = SessionQuery::default() |
| 373 | .with_filter(self.archive_filter()) |
| 374 | .with_sort(self.sort_mode) |
| 375 | .with_search(self.search_input.trim().to_string()) |
| 376 | .with_limit(MAX_PROJECTED_SESSIONS); |
| 377 | if self.hide_empty_sessions { |
| 378 | query = query.without_empty_auto_created(); |
| 379 | } |
| 380 | if !self.show_all_workspaces |
| 381 | && let Some(scope) = self.workspace_scope.as_deref() |
| 382 | { |
| 383 | query = query.scoped_to(scope); |
| 384 | } |
| 385 | query |
| 386 | } |
| 387 | |
| 388 | /// Flip between current-workspace-only and all-workspaces view |
| 389 | /// (#1395). Used by the `a` keybinding inside the picker; also |
| 390 | /// callable from tests. |
| 391 | pub fn toggle_all_workspaces(&mut self) { |
| 392 | self.show_all_workspaces = !self.show_all_workspaces; |
| 393 | let label = if self.show_all_workspaces { |
| 394 | tr(self.locale, MessageId::SessionsShowingAllWorkspaces) |
| 395 | } else { |
| 396 | tr(self.locale, MessageId::SessionsScopedToWorkspace) |
| 397 | }; |
| 398 | self.status = Some(label.into_owned()); |
| 399 | self.selected = 0; |
| 400 | self.apply_sort_and_filter(); |
| 401 | } |
| 402 | |
| 403 | /// Which archive states the list currently admits. |
| 404 | fn archive_filter(&self) -> SessionListFilter { |
| 405 | if self.show_archived { |
| 406 | SessionListFilter::IncludeArchived |
| 407 | } else { |
| 408 | SessionListFilter::ActiveOnly |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | /// Ids currently visible in the list, top to bottom. |
| 413 | /// |
| 414 | /// Exposed so the shared acceptance matrix can compare the picker's real |
| 415 | /// filtered view against the API's projection instead of comparing the |
| 416 | /// projection to itself. |
| 417 | #[cfg(test)] |
| 418 | pub fn visible_session_ids(&self) -> Vec<String> { |
| 419 | self.filtered.iter().map(|s| s.id.clone()).collect() |
| 420 | } |
| 421 | |
| 422 | /// The query behind [`Self::visible_session_ids`]. |
| 423 | #[cfg(test)] |
| 424 | pub fn view_query_for_test(&self) -> SessionQuery { |
| 425 | self.view_query() |
| 426 | } |
| 427 | |
| 428 | #[cfg(test)] |
| 429 | pub fn cycle_sort_for_test(&mut self) { |
| 430 | self.cycle_sort(); |
| 431 | } |
| 432 | |
| 433 | #[cfg(test)] |
| 434 | pub fn set_search_for_test(&mut self, query: &str) { |
| 435 | self.search_input = query.to_string(); |
| 436 | self.apply_sort_and_filter(); |
| 437 | } |
| 438 | |
| 439 | fn apply_sort_and_filter(&mut self) { |
| 440 | let query = self.view_query(); |
| 441 | self.filtered = crate::session_projection::select_sessions(&self.sessions, &query) |
| 442 | .into_iter() |
| 443 | .cloned() |
| 444 | .collect(); |
| 445 | |
| 446 | if self.selected >= self.filtered.len() { |
| 447 | self.selected = 0; |
| 448 | } |
| 449 | self.ensure_selected_visible(); |
| 450 | |
| 451 | self.refresh_preview(); |
| 452 | } |
| 453 | |
| 454 | /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning |
| 455 | /// whether it was consumed. `Prev`/`Next` wrap (existing behavior); |
| 456 | /// paging and Home/End clamp — a paging key asks to travel, not to |
| 457 | /// teleport. The horizontal axis has nowhere to go on this surface. |
| 458 | fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { |
| 459 | if self.filtered.is_empty() { |
| 460 | return false; |
| 461 | } |
| 462 | let page = self.list_visible_rows.get().max(1); |
| 463 | let Some(next) = |
| 464 | crate::tui::list_nav::apply(self.selected, self.filtered.len(), page, motion) |
| 465 | else { |
| 466 | return false; |
| 467 | }; |
| 468 | self.selected = next; |
| 469 | self.ensure_selected_visible(); |
| 470 | self.refresh_preview(); |
| 471 | true |
| 472 | } |
| 473 | |
| 474 | fn select_visible_shortcut(&mut self, c: char) -> bool { |
| 475 | let Some(slot) = c.to_digit(10) else { |
| 476 | return false; |
| 477 | }; |
| 478 | if !(1..=9).contains(&slot) { |
| 479 | return false; |
| 480 | } |
| 481 | let index = self.list_scroll.get().saturating_add(slot as usize - 1); |
| 482 | if index >= self.filtered.len() { |
| 483 | return false; |
| 484 | } |
| 485 | self.selected = index; |
| 486 | self.ensure_selected_visible(); |
| 487 | self.refresh_preview(); |
| 488 | if let Some(session) = self.selected_session() { |
| 489 | self.status = Some( |
| 490 | tr(self.locale, MessageId::SessionsOpenedHistory) |
| 491 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 492 | ); |
| 493 | } |
| 494 | true |
| 495 | } |
| 496 | |
| 497 | fn update_list_viewport(&self, visible_rows: usize) { |
| 498 | self.list_visible_rows.set(visible_rows.max(1)); |
| 499 | self.ensure_selected_visible(); |
| 500 | } |
| 501 | |
| 502 | fn update_history_viewport(&self, visible_rows: usize) { |
| 503 | self.history_visible_rows.set(visible_rows.max(1)); |
| 504 | self.ensure_history_scroll_in_bounds(); |
| 505 | } |
| 506 | |
| 507 | fn scroll_history(&self, delta: isize) { |
| 508 | let max_scroll = |
| 509 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 510 | let current = self.history_scroll.get(); |
| 511 | let next = if delta.is_negative() { |
| 512 | current.saturating_sub(delta.unsigned_abs()) |
| 513 | } else { |
| 514 | current.saturating_add(delta as usize) |
| 515 | }; |
| 516 | let next = next.min(max_scroll); |
| 517 | self.history_scroll.set(next); |
| 518 | self.history_pinned_to_latest.set(next == max_scroll); |
| 519 | } |
| 520 | |
| 521 | fn ensure_history_scroll_in_bounds(&self) { |
| 522 | let max_scroll = |
| 523 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 524 | if self.history_pinned_to_latest.get() { |
| 525 | self.history_scroll.set(max_scroll); |
| 526 | } else { |
| 527 | self.history_scroll |
| 528 | .set(self.history_scroll.get().min(max_scroll)); |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | fn scroll_history_to_latest(&self) { |
| 533 | let max_scroll = |
| 534 | max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); |
| 535 | self.history_scroll.set(max_scroll); |
| 536 | self.history_pinned_to_latest.set(true); |
| 537 | } |
| 538 | |
| 539 | fn ensure_selected_visible(&self) { |
| 540 | if self.filtered.is_empty() { |
| 541 | self.list_scroll.set(0); |
| 542 | return; |
| 543 | } |
| 544 | |
| 545 | let visible_rows = self.list_visible_rows.get().max(1); |
| 546 | let max_scroll = self.filtered.len().saturating_sub(visible_rows); |
| 547 | let mut scroll = self.list_scroll.get().min(max_scroll); |
| 548 | |
| 549 | if self.selected < scroll { |
| 550 | scroll = self.selected; |
| 551 | } else if self.selected >= scroll.saturating_add(visible_rows) { |
| 552 | scroll = self.selected.saturating_add(1).saturating_sub(visible_rows); |
| 553 | } |
| 554 | |
| 555 | self.list_scroll.set(scroll.min(max_scroll)); |
| 556 | } |
| 557 | |
| 558 | fn selected_session(&self) -> Option<&SessionMetadata> { |
| 559 | self.filtered.get(self.selected) |
| 560 | } |
| 561 | |
| 562 | fn cycle_sort(&mut self) { |
| 563 | self.sort_mode = self.sort_mode.next(); |
| 564 | self.apply_sort_and_filter(); |
| 565 | self.status = Some( |
| 566 | tr(self.locale, MessageId::SessionsSortStatus).replace("{sort}", &self.sort_label()), |
| 567 | ); |
| 568 | } |
| 569 | |
| 570 | /// What the list pane shows in place of rows it has not got: the listing |
| 571 | /// is still in flight, or the store could not be listed. |
| 572 | fn list_notice(&self) -> Option<ListNotice<'_>> { |
| 573 | if self.pending_list.is_some() { |
| 574 | Some(ListNotice::Loading) |
| 575 | } else { |
| 576 | self.load_error.as_deref().map(ListNotice::Failed) |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | fn sort_label(&self) -> String { |
| 581 | match self.sort_mode { |
| 582 | SessionSortMode::Recent => tr(self.locale, MessageId::SessionsSortRecent), |
| 583 | SessionSortMode::Name => tr(self.locale, MessageId::SessionsSortName), |
| 584 | SessionSortMode::Size => tr(self.locale, MessageId::SessionsSortSize), |
| 585 | } |
| 586 | .into_owned() |
| 587 | } |
| 588 | |
| 589 | fn enter_search(&mut self) { |
| 590 | self.search_mode = true; |
| 591 | self.search_input.clear(); |
| 592 | self.status = Some(tr(self.locale, MessageId::SessionsSearchPrompt).into_owned()); |
| 593 | } |
| 594 | |
| 595 | fn exit_search(&mut self) { |
| 596 | self.search_mode = false; |
| 597 | self.apply_sort_and_filter(); |
| 598 | self.status = None; |
| 599 | } |
| 600 | |
| 601 | fn delete_selected(&mut self) -> Option<ViewEvent> { |
| 602 | let session = self.selected_session().cloned()?; |
| 603 | let manager = SessionManager::default_location().ok()?; |
| 604 | // A deleted document the live session still owns refuses every later |
| 605 | // save, so the conversation on screen would silently stop persisting. |
| 606 | let refusal = if self.current_session_id.as_deref() == Some(session.id.as_str()) { |
| 607 | Some(MessageId::SessionsDeleteOpenHere) |
| 608 | } else if manager.is_session_live_anywhere(&session.id) { |
| 609 | Some(MessageId::SessionsDeleteOpenElsewhere) |
| 610 | } else { |
| 611 | None |
| 612 | }; |
| 613 | if let Some(reason) = refusal { |
| 614 | self.status = Some(tr(self.locale, reason).into_owned()); |
| 615 | return None; |
| 616 | } |
| 617 | if let Err(err) = manager.delete_session(&session.id) { |
| 618 | self.status = Some( |
| 619 | tr(self.locale, MessageId::SessionsDeleteFailed) |
| 620 | .replace("{error}", &err.to_string()), |
| 621 | ); |
| 622 | return None; |
| 623 | } |
| 624 | self.sessions.retain(|s| s.id != session.id); |
| 625 | self.apply_sort_and_filter(); |
| 626 | self.refresh_preview(); |
| 627 | self.status = Some( |
| 628 | tr(self.locale, MessageId::SessionsDeleted) |
| 629 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 630 | ); |
| 631 | Some(ViewEvent::SessionDeleted { |
| 632 | session_id: session.id, |
| 633 | title: session.title, |
| 634 | }) |
| 635 | } |
| 636 | |
| 637 | /// Archive or restore the selected session (#2934 / #4397). |
| 638 | /// |
| 639 | /// Writes through [`SessionManager::set_session_archived`] — the same |
| 640 | /// single writer `PATCH /v1/sessions/{id}` uses — so the picker and the |
| 641 | /// dashboard cannot disagree about what "archived" means. Emits a |
| 642 | /// `SessionRenamed` event carrying the saved metadata so the app-level |
| 643 | /// caches (and the sidebar rail) see the new lifecycle state without |
| 644 | /// re-reading disk. |
| 645 | fn toggle_archive_selected(&mut self) -> ViewAction { |
| 646 | let Some(session) = self.selected_session().cloned() else { |
| 647 | self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned()); |
| 648 | return ViewAction::None; |
| 649 | }; |
| 650 | let manager = match SessionManager::default_location() { |
| 651 | Ok(manager) => manager, |
| 652 | Err(err) => { |
| 653 | self.status = Some( |
| 654 | tr(self.locale, MessageId::SessionsOpenFailed) |
| 655 | .replace("{error}", &err.to_string()), |
| 656 | ); |
| 657 | return ViewAction::None; |
| 658 | } |
| 659 | }; |
| 660 | let archived = !session.archived; |
| 661 | let metadata = match manager.set_session_archived( |
| 662 | &session.id, |
| 663 | archived, |
| 664 | crate::session_manager::SessionMutator::Owner, |
| 665 | ) { |
| 666 | Ok(metadata) => metadata, |
| 667 | Err(err) => { |
| 668 | self.status = Some( |
| 669 | tr(self.locale, MessageId::SessionsArchiveFailed) |
| 670 | .replace("{error}", &err.to_string()), |
| 671 | ); |
| 672 | return ViewAction::None; |
| 673 | } |
| 674 | }; |
| 675 | |
| 676 | if let Some(local) = self.sessions.iter_mut().find(|s| s.id == session.id) { |
| 677 | local.archived = metadata.archived; |
| 678 | } |
| 679 | self.apply_sort_and_filter(); |
| 680 | let message_id = if archived { |
| 681 | MessageId::SessionsArchived |
| 682 | } else { |
| 683 | MessageId::SessionsRestored |
| 684 | }; |
| 685 | self.status = Some( |
| 686 | tr(self.locale, message_id) |
| 687 | .replace("{id}", crate::session_manager::truncate_id(&session.id)), |
| 688 | ); |
| 689 | ViewAction::Emit(ViewEvent::SessionArchived { |
| 690 | metadata: Box::new(metadata), |
| 691 | }) |
| 692 | } |
| 693 | |
| 694 | /// Flip whether archived sessions appear in the list. |
| 695 | fn toggle_show_archived(&mut self) { |
| 696 | self.show_archived = !self.show_archived; |
| 697 | let label = if self.show_archived { |
| 698 | tr(self.locale, MessageId::SessionsShowingArchived) |
| 699 | } else { |
| 700 | tr(self.locale, MessageId::SessionsHidingArchived) |
| 701 | }; |
| 702 | self.status = Some(label.into_owned()); |
| 703 | self.selected = 0; |
| 704 | self.apply_sort_and_filter(); |
| 705 | } |
| 706 | |
| 707 | fn rename_selected(&mut self, new_title: &str) -> ViewAction { |
| 708 | let Some(session) = self.selected_session().cloned() else { |
| 709 | self.status = Some(tr(self.locale, MessageId::SessionsNoSelection).into_owned()); |
| 710 | return ViewAction::None; |
| 711 | }; |
| 712 | if new_title.is_empty() || new_title.len() > 100 { |
| 713 | self.status = Some(tr(self.locale, MessageId::SessionsTitleLength).into_owned()); |
| 714 | return ViewAction::None; |
| 715 | } |
| 716 | let manager = match SessionManager::default_location() { |
| 717 | Ok(m) => m, |
| 718 | Err(e) => { |
| 719 | self.status = Some( |
| 720 | tr(self.locale, MessageId::SessionsOpenFailed) |
| 721 | .replace("{error}", &e.to_string()), |
| 722 | ); |
| 723 | return ViewAction::None; |
| 724 | } |
| 725 | }; |
| 726 | let mut saved = match manager.load_session(&session.id) { |
| 727 | Ok(s) => s, |
| 728 | Err(e) => { |
| 729 | self.status = Some( |
| 730 | tr(self.locale, MessageId::SessionsLoadFailed) |
| 731 | .replace("{error}", &e.to_string()), |
| 732 | ); |
| 733 | return ViewAction::None; |
| 734 | } |
| 735 | }; |
| 736 | saved.metadata.title = new_title.to_string(); |
| 737 | if let Err(e) = manager.save_session(&saved) { |
| 738 | self.status = Some( |
| 739 | tr(self.locale, MessageId::SessionsRenameFailed).replace("{error}", &e.to_string()), |
| 740 | ); |
| 741 | return ViewAction::None; |
| 742 | } |
| 743 | // Update our local metadata cache. The cached preview renders the old |
| 744 | // title, so it is dropped rather than shown again (U08-09). |
| 745 | if let Some(meta) = self.sessions.iter_mut().find(|s| s.id == session.id) { |
| 746 | meta.title = new_title.to_string(); |
| 747 | } |
| 748 | self.preview_cache.remove(&session.id); |
| 749 | self.apply_sort_and_filter(); |
| 750 | self.refresh_preview(); |
| 751 | self.status = |
| 752 | Some(tr(self.locale, MessageId::SessionsRenamed).replace("{title}", new_title)); |
| 753 | ViewAction::Emit(ViewEvent::SessionRenamed { |
| 754 | metadata: Box::new(saved.metadata), |
| 755 | }) |
| 756 | } |
| 757 | |
| 758 | fn refresh_preview(&mut self) { |
| 759 | // Any load still in flight belongs to the previous selection. |
| 760 | self.preview_generation = self.preview_generation.wrapping_add(1); |
| 761 | self.pending_preview = None; |
| 762 | |
| 763 | let Some(session) = self.selected_session() else { |
| 764 | self.current_preview = vec![tr(self.locale, MessageId::SessionsNoResults).into_owned()]; |
| 765 | self.scroll_history_to_latest(); |
| 766 | return; |
| 767 | }; |
| 768 | let session_id = session.id.clone(); |
| 769 | |
| 770 | if let Some(lines) = self.preview_cache.get(&session_id) { |
| 771 | self.current_preview = lines.clone(); |
| 772 | self.scroll_history_to_latest(); |
| 773 | return; |
| 774 | } |
| 775 | |
| 776 | // Reading and parsing a saved session is blocking disk I/O that grows |
| 777 | // with the transcript; arrowing through the list must not stall the |
| 778 | // event loop on it. Outside a runtime (unit tests, headless callers) |
| 779 | // there is no loop to stall, so load inline. |
| 780 | if tokio::runtime::Handle::try_current().is_err() { |
| 781 | let load = load_preview(&session_id, self.locale); |
| 782 | self.apply_preview_load(session_id, load); |
| 783 | return; |
| 784 | } |
| 785 | |
| 786 | if let Some(session) = self.selected_session() { |
| 787 | self.current_preview = loading_preview_lines(session, self.locale); |
| 788 | } |
| 789 | self.scroll_history_to_latest(); |
| 790 | |
| 791 | let cell = Arc::new(Mutex::new(None)); |
| 792 | let slot = Arc::clone(&cell); |
| 793 | let locale = self.locale; |
| 794 | let id = session_id.clone(); |
| 795 | crate::utils::spawn_blocking_supervised("session-picker-preview", move || { |
| 796 | let load = load_preview(&id, locale); |
| 797 | if let Ok(mut guard) = slot.lock() { |
| 798 | *guard = Some(load); |
| 799 | } |
| 800 | }); |
| 801 | self.pending_preview = Some(PendingPreview { |
| 802 | session_id, |
| 803 | generation: self.preview_generation, |
| 804 | cell, |
| 805 | }); |
| 806 | } |
| 807 | |
| 808 | /// Apply a background preview load if it has landed and still belongs to |
| 809 | /// the current selection. Called from `tick`; returns whether the visible |
| 810 | /// preview changed, so the host knows to repaint. |
| 811 | fn poll_preview(&mut self) -> bool { |
| 812 | let Some(pending) = self.pending_preview.as_ref() else { |
| 813 | return false; |
| 814 | }; |
| 815 | let landed = pending.cell.lock().ok().and_then(|mut guard| guard.take()); |
| 816 | let Some(load) = landed else { |
| 817 | return false; |
| 818 | }; |
| 819 | let Some(pending) = self.pending_preview.take() else { |
| 820 | return false; |
| 821 | }; |
| 822 | let still_selected = self |
| 823 | .selected_session() |
| 824 | .is_some_and(|session| session.id == pending.session_id); |
| 825 | if pending.generation != self.preview_generation || !still_selected { |
| 826 | // Stale: keep a good result for later, never show it now. |
| 827 | if load.cacheable { |
| 828 | self.preview_cache.insert(pending.session_id, load.lines); |
| 829 | } |
| 830 | return false; |
| 831 | } |
| 832 | self.apply_preview_load(pending.session_id, load); |
| 833 | true |
| 834 | } |
| 835 | |
| 836 | fn apply_preview_load(&mut self, session_id: String, load: PreviewLoad) { |
| 837 | if load.cacheable { |
| 838 | self.preview_cache.insert(session_id, load.lines.clone()); |
| 839 | } |
| 840 | self.current_preview = load.lines; |
| 841 | self.scroll_history_to_latest(); |
| 842 | } |
| 843 | } |
| 844 | |
| 845 | /// List the saved-session store. Blocking; runs on the blocking pool when a |
| 846 | /// runtime is available. |
| 847 | fn list_session_store() -> SessionListing { |
| 848 | SessionManager::default_location() |
| 849 | .and_then(|manager| manager.list_sessions()) |
| 850 | .map_err(|error| error.to_string()) |
| 851 | } |
| 852 | |
| 853 | /// Read one saved session and render its preview. Blocking; runs on the |
| 854 | /// blocking pool when a runtime is available. |
| 855 | fn load_preview(session_id: &str, locale: Locale) -> PreviewLoad { |
| 856 | let manager = match SessionManager::default_location() { |
| 857 | Ok(manager) => manager, |
| 858 | Err(_) => { |
| 859 | return PreviewLoad { |
| 860 | lines: vec![tr(locale, MessageId::SessionsDirectoryFailed).into_owned()], |
| 861 | cacheable: false, |
| 862 | }; |
| 863 | } |
| 864 | }; |
| 865 | match manager.load_session(session_id) { |
| 866 | Ok(saved) => PreviewLoad { |
| 867 | lines: build_preview_lines(&saved, locale), |
| 868 | cacheable: true, |
| 869 | }, |
| 870 | Err(_) => PreviewLoad { |
| 871 | lines: vec![tr(locale, MessageId::SessionsPreviewFailed).into_owned()], |
| 872 | cacheable: false, |
| 873 | }, |
| 874 | } |
| 875 | } |
| 876 | |
| 877 | /// What the preview pane shows while the transcript loads: the header facts |
| 878 | /// the list row already knows, then an ellipsis where the transcript goes. |
| 879 | /// Built from existing localized strings so no locale falls back to English. |
| 880 | fn loading_preview_lines(session: &SessionMetadata, locale: Locale) -> Vec<String> { |
| 881 | vec![ |
| 882 | tr(locale, MessageId::SessionsPreviewId).replace("{id}", &session.id), |
| 883 | tr(locale, MessageId::SessionsPreviewTitle).replace("{title}", &session.title), |
| 884 | String::new(), |
| 885 | "\u{2026}".to_string(), |
| 886 | ] |
| 887 | } |
| 888 | |
| 889 | impl ModalView for SessionPickerView { |
| 890 | fn kind(&self) -> ModalKind { |
| 891 | ModalKind::SessionPicker |
| 892 | } |
| 893 | |
| 894 | fn tick(&mut self) -> ViewAction { |
| 895 | let listed = self.poll_session_list(); |
| 896 | if self.poll_preview() || listed { |
| 897 | ViewAction::Redraw |
| 898 | } else { |
| 899 | ViewAction::None |
| 900 | } |
| 901 | } |
| 902 | |
| 903 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 904 | self |
| 905 | } |
| 906 | |
| 907 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 908 | match mouse.kind { |
| 909 | MouseEventKind::ScrollUp => { |
| 910 | self.apply_motion(crate::tui::list_nav::Motion::Prev); |
| 911 | } |
| 912 | MouseEventKind::ScrollDown => { |
| 913 | self.apply_motion(crate::tui::list_nav::Motion::Next); |
| 914 | } |
| 915 | MouseEventKind::Down(MouseButton::Left) => { |
| 916 | let clicked = self |
| 917 | .last_row_hitboxes |
| 918 | .borrow() |
| 919 | .iter() |
| 920 | .find_map(|(y, index)| (*y == mouse.row).then_some(*index)); |
| 921 | if let Some(index) = clicked { |
| 922 | if self.selected == index { |
| 923 | if let Some(session) = self.filtered.get(index) { |
| 924 | return ViewAction::EmitAndClose(ViewEvent::SessionSelected { |
| 925 | session_id: session.id.clone(), |
| 926 | }); |
| 927 | } |
| 928 | } else { |
| 929 | self.selected = index; |
| 930 | self.ensure_selected_visible(); |
| 931 | self.refresh_preview(); |
| 932 | } |
| 933 | } |
| 934 | } |
| 935 | _ => {} |
| 936 | } |
| 937 | ViewAction::None |
| 938 | } |
| 939 | |
| 940 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 941 | if self.search_mode { |
| 942 | match key.code { |
| 943 | KeyCode::Enter => { |
| 944 | self.exit_search(); |
| 945 | } |
| 946 | KeyCode::Esc => { |
| 947 | self.exit_search(); |
| 948 | return ViewAction::None; |
| 949 | } |
| 950 | KeyCode::Backspace => { |
| 951 | self.search_input.pop(); |
| 952 | self.apply_sort_and_filter(); |
| 953 | return ViewAction::None; |
| 954 | } |
| 955 | KeyCode::Char(c) => { |
| 956 | self.search_input.push(c); |
| 957 | self.apply_sort_and_filter(); |
| 958 | return ViewAction::None; |
| 959 | } |
| 960 | _ => {} |
| 961 | } |
| 962 | } |
| 963 | |
| 964 | if self.confirm_delete { |
| 965 | match key.code { |
| 966 | KeyCode::Char('y') | KeyCode::Char('Y') => { |
| 967 | self.confirm_delete = false; |
| 968 | if let Some(event) = self.delete_selected() { |
| 969 | return ViewAction::Emit(event); |
| 970 | } |
| 971 | return ViewAction::None; |
| 972 | } |
| 973 | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { |
| 974 | self.confirm_delete = false; |
| 975 | self.status = |
| 976 | Some(tr(self.locale, MessageId::SessionsDeleteCancelled).into_owned()); |
| 977 | return ViewAction::None; |
| 978 | } |
| 979 | _ => return ViewAction::None, |
| 980 | } |
| 981 | } |
| 982 | |
| 983 | if self.rename_mode { |
| 984 | match key.code { |
| 985 | KeyCode::Enter => { |
| 986 | self.rename_mode = false; |
| 987 | let new_title = self.rename_input.trim().to_string(); |
| 988 | self.rename_input.clear(); |
| 989 | return self.rename_selected(&new_title); |
| 990 | } |
| 991 | KeyCode::Esc => { |
| 992 | self.rename_mode = false; |
| 993 | self.rename_input.clear(); |
| 994 | self.status = |
| 995 | Some(tr(self.locale, MessageId::SessionsRenameCancelled).into_owned()); |
| 996 | return ViewAction::None; |
| 997 | } |
| 998 | KeyCode::Backspace => { |
| 999 | self.rename_input.pop(); |
| 1000 | return ViewAction::None; |
| 1001 | } |
| 1002 | KeyCode::Char(c) if !c.is_control() => { |
| 1003 | self.rename_input.push(c); |
| 1004 | return ViewAction::None; |
| 1005 | } |
| 1006 | _ => return ViewAction::None, |
| 1007 | } |
| 1008 | } |
| 1009 | |
| 1010 | // Shift-modified paging belongs to the preview pane (#6014) and must |
| 1011 | // be claimed before the shared vocabulary sees the bare keys. |
| 1012 | if key.modifiers.contains(KeyModifiers::SHIFT) { |
| 1013 | match key.code { |
| 1014 | KeyCode::PageUp => { |
| 1015 | let rows = self.history_visible_rows.get().max(1); |
| 1016 | self.scroll_history(-(rows as isize)); |
| 1017 | return ViewAction::None; |
| 1018 | } |
| 1019 | KeyCode::PageDown => { |
| 1020 | let rows = self.history_visible_rows.get().max(1); |
| 1021 | self.scroll_history(rows as isize); |
| 1022 | return ViewAction::None; |
| 1023 | } |
| 1024 | _ => {} |
| 1025 | } |
| 1026 | } |
| 1027 | // Movement keys come from the shared vocabulary (#6290); this match |
| 1028 | // owns only the picker's own verbs. |
| 1029 | if let Some(motion) = crate::tui::list_nav::motion(&key) |
| 1030 | && self.apply_motion(motion) |
| 1031 | { |
| 1032 | return ViewAction::None; |
| 1033 | } |
| 1034 | |
| 1035 | match key.code { |
| 1036 | KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, |
| 1037 | KeyCode::Char('/') => { |
| 1038 | self.enter_search(); |
| 1039 | ViewAction::None |
| 1040 | } |
| 1041 | KeyCode::Char('s') | KeyCode::Char('S') => { |
| 1042 | self.cycle_sort(); |
| 1043 | ViewAction::None |
| 1044 | } |
| 1045 | // `a`/`A` toggles the per-workspace scope filter (#1395). The |
| 1046 | // picker defaults to showing only sessions for the current |
| 1047 | // workspace so Ctrl+R never restores a different project's |
| 1048 | // history by surprise; press `a` to broaden to every saved |
| 1049 | // session. |
| 1050 | KeyCode::Char('a') | KeyCode::Char('A') => { |
| 1051 | self.toggle_all_workspaces(); |
| 1052 | ViewAction::None |
| 1053 | } |
| 1054 | KeyCode::Char('r') | KeyCode::Char('R') => { |
| 1055 | self.rename_mode = true; |
| 1056 | self.rename_input.clear(); |
| 1057 | self.status = Some(tr(self.locale, MessageId::SessionsNewTitlePrompt).into_owned()); |
| 1058 | ViewAction::None |
| 1059 | } |
| 1060 | KeyCode::Char('d') | KeyCode::Char('D') => { |
| 1061 | self.confirm_delete = true; |
| 1062 | self.status = Some(tr(self.locale, MessageId::SessionsDeletePrompt).into_owned()); |
| 1063 | ViewAction::None |
| 1064 | } |
| 1065 | // `e` archives or restores the selected session, `x` toggles |
| 1066 | // whether archived sessions are listed at all. Archive is |
| 1067 | // deliberately undestructive and needs no confirmation — unlike |
| 1068 | // `d`, nothing is lost and `e` puts it straight back. |
| 1069 | KeyCode::Char('e') | KeyCode::Char('E') => self.toggle_archive_selected(), |
| 1070 | KeyCode::Char('x') | KeyCode::Char('X') => { |
| 1071 | self.toggle_show_archived(); |
| 1072 | ViewAction::None |
| 1073 | } |
| 1074 | KeyCode::Char(c) if self.select_visible_shortcut(c) => ViewAction::None, |
| 1075 | KeyCode::Enter => { |
| 1076 | if let Some(session) = self.selected_session() { |
| 1077 | ViewAction::EmitAndClose(ViewEvent::SessionSelected { |
| 1078 | session_id: session.id.clone(), |
| 1079 | }) |
| 1080 | } else { |
| 1081 | ViewAction::None |
| 1082 | } |
| 1083 | } |
| 1084 | _ => ViewAction::None, |
| 1085 | } |
| 1086 | } |
| 1087 | |
| 1088 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 1089 | let surface = |
| 1090 | render_underwater_surface(area, buf, tr(self.locale, MessageId::SessionsSurfaceTitle)); |
| 1091 | let full_hints = [ |
| 1092 | ActionHint::new("Enter", tr(self.locale, MessageId::SessionsActionResume)), |
| 1093 | ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)), |
| 1094 | ActionHint::new("s", tr(self.locale, MessageId::SessionsActionSort)), |
| 1095 | ActionHint::new("r", tr(self.locale, MessageId::SessionsActionRename)), |
| 1096 | ActionHint::new("a", tr(self.locale, MessageId::SessionsActionAllWorkspaces)), |
| 1097 | ActionHint::new("e", tr(self.locale, MessageId::SessionsActionArchive)), |
| 1098 | ActionHint::new("x", tr(self.locale, MessageId::SessionsActionShowArchived)), |
| 1099 | ActionHint::new("d", tr(self.locale, MessageId::SessionsActionDelete)), |
| 1100 | ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)), |
| 1101 | ]; |
| 1102 | // The two bordered panes spend five rows on chrome before either can |
| 1103 | // show a content row. When the body cannot afford that, this room |
| 1104 | // keeps only the object it exists for — a selectable session list |
| 1105 | // with a usable resume action — and trims the action rail to match. |
| 1106 | let full_footer_rows = action_footer_lines(&full_hints, surface.width).len(); |
| 1107 | let compact = usize::from(surface.height).saturating_sub(full_footer_rows) < 12; |
| 1108 | if compact { |
| 1109 | let content = render_modal_footer( |
| 1110 | surface, |
| 1111 | buf, |
| 1112 | &[ |
| 1113 | ActionHint::new("Enter", tr(self.locale, MessageId::SessionsActionResume)), |
| 1114 | ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)), |
| 1115 | ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)), |
| 1116 | ], |
| 1117 | ); |
| 1118 | let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); |
| 1119 | let footer_rows = usize::from(!self.filtered.is_empty()); |
| 1120 | let visible_rows = usize::from(content.height) |
| 1121 | .saturating_sub(header_rows + footer_rows) |
| 1122 | .max(1); |
| 1123 | self.update_list_viewport(visible_rows); |
| 1124 | let list_scroll = self.list_scroll.get(); |
| 1125 | let list_content = render_panel_scroll_rail( |
| 1126 | content, |
| 1127 | buf, |
| 1128 | self.filtered.len().saturating_add(header_rows), |
| 1129 | list_scroll, |
| 1130 | visible_rows, |
| 1131 | true, |
| 1132 | ); |
| 1133 | let list_lines = build_list_lines( |
| 1134 | &self.filtered, |
| 1135 | self.selected, |
| 1136 | list_content.width, |
| 1137 | list_scroll, |
| 1138 | visible_rows, |
| 1139 | self.search_mode, |
| 1140 | &self.search_input, |
| 1141 | &self.sort_label(), |
| 1142 | self.confirm_delete, |
| 1143 | self.rename_mode, |
| 1144 | &self.rename_input, |
| 1145 | self.status.as_deref(), |
| 1146 | self.list_notice(), |
| 1147 | self.current_session_id.as_deref(), |
| 1148 | self.locale, |
| 1149 | ); |
| 1150 | *self.last_row_hitboxes.borrow_mut() = (0..visible_rows) |
| 1151 | .filter_map(|row| { |
| 1152 | let index = list_scroll.saturating_add(row); |
| 1153 | (index < self.filtered.len()).then_some(( |
| 1154 | list_content |
| 1155 | .y |
| 1156 | .saturating_add(header_rows as u16) |
| 1157 | .saturating_add(row as u16), |
| 1158 | index, |
| 1159 | )) |
| 1160 | }) |
| 1161 | .collect(); |
| 1162 | Paragraph::new(list_lines) |
| 1163 | .wrap(Wrap { trim: false }) |
| 1164 | .render(list_content, buf); |
| 1165 | return; |
| 1166 | } |
| 1167 | let content = render_modal_footer(surface, buf, &full_hints); |
| 1168 | let narrow = content.width < 95; |
| 1169 | let chunks = Layout::default() |
| 1170 | .direction(if narrow { |
| 1171 | Direction::Vertical |
| 1172 | } else { |
| 1173 | Direction::Horizontal |
| 1174 | }) |
| 1175 | .constraints(if narrow { |
| 1176 | [Constraint::Percentage(42), Constraint::Percentage(58)] |
| 1177 | } else { |
| 1178 | // 36% was too thin for session rows — the list exists to be |
| 1179 | // scanned, so give it a fairer share on wide terminals |
| 1180 | // (#6014). |
| 1181 | [Constraint::Percentage(56), Constraint::Percentage(44)] |
| 1182 | }) |
| 1183 | .split(content); |
| 1184 | let (history_area, list_area) = if narrow { |
| 1185 | (chunks[1], chunks[0]) |
| 1186 | } else { |
| 1187 | (chunks[0], chunks[1]) |
| 1188 | }; |
| 1189 | |
| 1190 | let list_block = section_block(&tr(self.locale, MessageId::SessionsPaneTitle)); |
| 1191 | let list_inner = list_block.inner(list_area); |
| 1192 | let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); |
| 1193 | let footer_rows = usize::from(!self.filtered.is_empty()); |
| 1194 | let visible_rows = usize::from(list_inner.height) |
| 1195 | .saturating_sub(header_rows + footer_rows) |
| 1196 | .max(1); |
| 1197 | self.update_list_viewport(visible_rows); |
| 1198 | let list_scroll = self.list_scroll.get(); |
| 1199 | list_block.render(list_area, buf); |
| 1200 | let list_content = render_panel_scroll_rail( |
| 1201 | list_inner, |
| 1202 | buf, |
| 1203 | self.filtered.len().saturating_add(header_rows), |
| 1204 | list_scroll, |
| 1205 | visible_rows, |
| 1206 | true, |
| 1207 | ); |
| 1208 | |
| 1209 | let list_lines = build_list_lines( |
| 1210 | &self.filtered, |
| 1211 | self.selected, |
| 1212 | list_content.width, |
| 1213 | list_scroll, |
| 1214 | visible_rows, |
| 1215 | self.search_mode, |
| 1216 | &self.search_input, |
| 1217 | &self.sort_label(), |
| 1218 | self.confirm_delete, |
| 1219 | self.rename_mode, |
| 1220 | &self.rename_input, |
| 1221 | self.status.as_deref(), |
| 1222 | self.list_notice(), |
| 1223 | self.current_session_id.as_deref(), |
| 1224 | self.locale, |
| 1225 | ); |
| 1226 | *self.last_row_hitboxes.borrow_mut() = (0..visible_rows) |
| 1227 | .filter_map(|row| { |
| 1228 | let index = list_scroll.saturating_add(row); |
| 1229 | (index < self.filtered.len()).then_some(( |
| 1230 | list_content |
| 1231 | .y |
| 1232 | .saturating_add(header_rows as u16) |
| 1233 | .saturating_add(row as u16), |
| 1234 | index, |
| 1235 | )) |
| 1236 | }) |
| 1237 | .collect(); |
| 1238 | Paragraph::new(list_lines) |
| 1239 | .wrap(Wrap { trim: false }) |
| 1240 | .render(list_content, buf); |
| 1241 | |
| 1242 | let history_block = section_block(&tr(self.locale, MessageId::SessionsHistoryPaneTitle)); |
| 1243 | let history_inner = history_block.inner(history_area); |
| 1244 | self.update_history_viewport(history_inner.height as usize); |
| 1245 | history_block.render(history_area, buf); |
| 1246 | let history_content = render_panel_scroll_rail( |
| 1247 | history_inner, |
| 1248 | buf, |
| 1249 | self.current_preview.len(), |
| 1250 | self.history_scroll.get(), |
| 1251 | history_inner.height as usize, |
| 1252 | false, |
| 1253 | ); |
| 1254 | let visible_preview = visible_preview_lines( |
| 1255 | &self.current_preview, |
| 1256 | self.history_scroll.get(), |
| 1257 | history_content.height as usize, |
| 1258 | ); |
| 1259 | let preview_lines = format_preview(&visible_preview); |
| 1260 | |
| 1261 | Paragraph::new(preview_lines) |
| 1262 | .wrap(Wrap { trim: false }) |
| 1263 | .render(history_content, buf); |
| 1264 | } |
| 1265 | } |
| 1266 | |
| 1267 | #[allow(clippy::too_many_arguments)] |
| 1268 | fn build_list_lines( |
| 1269 | sessions: &[SessionMetadata], |
| 1270 | selected: usize, |
| 1271 | width: u16, |
| 1272 | scroll: usize, |
| 1273 | visible_rows: usize, |
| 1274 | search_mode: bool, |
| 1275 | search_input: &str, |
| 1276 | sort_label: &str, |
| 1277 | confirm_delete: bool, |
| 1278 | rename_mode: bool, |
| 1279 | rename_input: &str, |
| 1280 | status: Option<&str>, |
| 1281 | notice: Option<ListNotice<'_>>, |
| 1282 | current_session_id: Option<&str>, |
| 1283 | locale: Locale, |
| 1284 | ) -> Vec<Line<'static>> { |
| 1285 | let mut lines = Vec::new(); |
| 1286 | let header = if search_mode { |
| 1287 | format!("/{search_input}") |
| 1288 | } else if rename_mode { |
| 1289 | format!( |
| 1290 | "{}{rename_input}_", |
| 1291 | tr(locale, MessageId::SessionsNewTitlePrompt) |
| 1292 | ) |
| 1293 | } else { |
| 1294 | tr(locale, MessageId::SessionsScopeSortHeader).replace("{sort}", sort_label) |
| 1295 | }; |
| 1296 | lines.push(Line::from(Span::styled( |
| 1297 | truncate(&header, width), |
| 1298 | Style::default().fg(palette::TEXT_MUTED), |
| 1299 | ))); |
| 1300 | |
| 1301 | if confirm_delete { |
| 1302 | lines.push(Line::from(Span::styled( |
| 1303 | tr(locale, MessageId::SessionsConfirmDelete), |
| 1304 | Style::default() |
| 1305 | .fg(palette::STATUS_WARNING) |
| 1306 | .add_modifier(Modifier::BOLD), |
| 1307 | ))); |
| 1308 | } else if let Some(status) = status { |
| 1309 | lines.push(Line::from(Span::styled( |
| 1310 | truncate(status, width), |
| 1311 | Style::default().fg(palette::WHALE_ACTION), |
| 1312 | ))); |
| 1313 | } |
| 1314 | |
| 1315 | if sessions.is_empty() { |
| 1316 | match notice { |
| 1317 | Some(ListNotice::Loading) => { |
| 1318 | lines.push(Line::from(Span::styled( |
| 1319 | "\u{2026}", |
| 1320 | Style::default().fg(palette::TEXT_MUTED), |
| 1321 | ))); |
| 1322 | return lines; |
| 1323 | } |
| 1324 | Some(ListNotice::Failed(error)) => { |
| 1325 | lines.push(Line::from(Span::styled( |
| 1326 | truncate(error, width), |
| 1327 | Style::default().fg(palette::STATUS_ERROR), |
| 1328 | ))); |
| 1329 | return lines; |
| 1330 | } |
| 1331 | None => {} |
| 1332 | } |
| 1333 | lines.push(Line::from(Span::styled( |
| 1334 | tr(locale, MessageId::SessionsEmptyTitle), |
| 1335 | Style::default().fg(palette::TEXT_MUTED), |
| 1336 | ))); |
| 1337 | lines.push(Line::from(Span::styled( |
| 1338 | tr(locale, MessageId::SessionsEmptyHint), |
| 1339 | Style::default().fg(palette::TEXT_HINT), |
| 1340 | ))); |
| 1341 | return lines; |
| 1342 | } |
| 1343 | |
| 1344 | for (idx, session) in sessions.iter().enumerate().skip(scroll).take(visible_rows) { |
| 1345 | let slot = idx.saturating_sub(scroll).saturating_add(1); |
| 1346 | let prefix = if slot <= 9 { |
| 1347 | format!("{slot}. ") |
| 1348 | } else { |
| 1349 | " ".to_string() |
| 1350 | }; |
| 1351 | // The row for the session the user is inside gets a text label, not |
| 1352 | // colour alone — the same monochrome-safe convention as the |
| 1353 | // archived/fork labels (#6014). |
| 1354 | let is_current = current_session_id == Some(session.id.as_str()); |
| 1355 | let mut line = format!( |
| 1356 | "{prefix}{}", |
| 1357 | format_session_line(session, is_current, locale) |
| 1358 | ); |
| 1359 | line = truncate(&line, width); |
| 1360 | let style = if idx == selected { |
| 1361 | menu_style::selected_row_style() |
| 1362 | } else if is_current { |
| 1363 | Style::default().fg(palette::WHALE_ACTION) |
| 1364 | } else { |
| 1365 | Style::default().fg(palette::TEXT_PRIMARY) |
| 1366 | }; |
| 1367 | lines.push(Line::from(Span::styled(line, style))); |
| 1368 | } |
| 1369 | |
| 1370 | if sessions.len() > visible_rows { |
| 1371 | let start = scroll.saturating_add(1); |
| 1372 | let end = (scroll + visible_rows).min(sessions.len()); |
| 1373 | lines.push(Line::from(Span::styled( |
| 1374 | truncate( |
| 1375 | &tr(locale, MessageId::SessionsShowingRange) |
| 1376 | .replace("{start}", &start.to_string()) |
| 1377 | .replace("{end}", &end.to_string()) |
| 1378 | .replace("{total}", &sessions.len().to_string()), |
| 1379 | width, |
| 1380 | ), |
| 1381 | Style::default().fg(palette::TEXT_DIM), |
| 1382 | ))); |
| 1383 | } |
| 1384 | |
| 1385 | lines |
| 1386 | } |
| 1387 | |
| 1388 | pub(super) fn format_message_count(count: usize, locale: Locale) -> String { |
| 1389 | if locale == Locale::En && count == 1 { |
| 1390 | "1 msg".to_string() |
| 1391 | } else { |
| 1392 | tr(locale, MessageId::SessionsMessageCountCompact).replace("{count}", &count.to_string()) |
| 1393 | } |
| 1394 | } |
| 1395 | |
| 1396 | fn format_session_line(session: &SessionMetadata, is_current: bool, locale: Locale) -> String { |
| 1397 | let age = format_relative_time(&session.updated_at, locale); |
| 1398 | let updated = crate::session_manager::format_session_updated_at(&session.updated_at, &age); |
| 1399 | let raw_title = extract_title(&session.title); |
| 1400 | let title = if raw_title == "Session" { |
| 1401 | truncate(crate::session_manager::truncate_id(&session.id), 32) |
| 1402 | } else { |
| 1403 | truncate(raw_title, 32) |
| 1404 | }; |
| 1405 | let mode = session |
| 1406 | .mode |
| 1407 | .as_deref() |
| 1408 | .map(str::to_ascii_lowercase) |
| 1409 | .unwrap_or_else(|| tr(locale, MessageId::SessionsUnknownMode).into_owned()); |
| 1410 | let message_count = format_message_count(session.message_count, locale); |
| 1411 | let fork_label = if session.parent_session_id.is_some() { |
| 1412 | format!(" | {}", tr(locale, MessageId::SessionsForkCompact)) |
| 1413 | } else { |
| 1414 | String::new() |
| 1415 | }; |
| 1416 | // Archived rows are labelled in text, not by colour alone, so the state |
| 1417 | // survives monochrome terminals and screen readers. |
| 1418 | let fork_label = if session.archived { |
| 1419 | format!( |
| 1420 | "{fork_label} | {}", |
| 1421 | tr(locale, MessageId::SessionsArchivedCompact) |
| 1422 | ) |
| 1423 | } else { |
| 1424 | fork_label |
| 1425 | }; |
| 1426 | let current_label = if is_current { |
| 1427 | format!(" | {}", tr(locale, MessageId::SessionsCurrentCompact)) |
| 1428 | } else { |
| 1429 | String::new() |
| 1430 | }; |
| 1431 | format!( |
| 1432 | "{} | {} | {}{}{} | {} | {}", |
| 1433 | crate::session_manager::truncate_id(&session.id), |
| 1434 | title, |
| 1435 | message_count, |
| 1436 | current_label, |
| 1437 | fork_label, |
| 1438 | mode, |
| 1439 | updated |
| 1440 | ) |
| 1441 | } |
| 1442 | |
| 1443 | fn build_preview_lines(session: &SavedSession, locale: Locale) -> Vec<String> { |
| 1444 | // Runtime control traffic is persisted with `role = "user"` because strict |
| 1445 | // chat templates reject any other role mid-conversation. Both surfaces that |
| 1446 | // already render a session drop it on this same predicate -- the live |
| 1447 | // transcript in `history_cells_from_message` and the read-only pane in |
| 1448 | // `session_peek::build_peek`. Showing it here attributed the runtime's own |
| 1449 | // bookkeeping to the person: an Operate session previewed as `USER:` |
| 1450 | // followed by the whole `<codewhale:runtime_event kind="operate_contract">` |
| 1451 | // envelope. |
| 1452 | // |
| 1453 | // Render the turns first so the header can count exactly what the body |
| 1454 | // draws. Filtering alone is not enough: a message can survive the filter |
| 1455 | // and still draw nothing, because `message_text_for_history` yields an |
| 1456 | // empty string for a thinking-only assistant turn (and for a user turn |
| 1457 | // that `extract_user_prompt` reduces to nothing). Counting the filtered |
| 1458 | // vector instead of the rendered turns reintroduced the same |
| 1459 | // unaccountable total this function exists to remove. |
| 1460 | let mut rendered_turns = 0usize; |
| 1461 | let mut body: Vec<String> = Vec::new(); |
| 1462 | for message in session |
| 1463 | .messages |
| 1464 | .iter() |
| 1465 | .filter(|message| !crate::runtime_handoff::is_internal_runtime_handoff(message)) |
| 1466 | { |
| 1467 | let text = message_text_for_history(message, locale); |
| 1468 | if text.trim().is_empty() { |
| 1469 | continue; |
| 1470 | } |
| 1471 | rendered_turns += 1; |
| 1472 | body.push(format!("{}:", message.role.as_str().to_ascii_uppercase())); |
| 1473 | for line in text.lines() { |
| 1474 | body.push(format!(" {line}")); |
| 1475 | } |
| 1476 | body.push(String::new()); |
| 1477 | } |
| 1478 | |
| 1479 | let mut out = vec![ |
| 1480 | tr(locale, MessageId::SessionsPreviewTitle) |
| 1481 | .replace("{title}", extract_title(&session.metadata.title)), |
| 1482 | ]; |
| 1483 | // The full id is the handle `codewhale exec --session` and `/resume` |
| 1484 | // need — the list row only has room for the truncated form (#6014). |
| 1485 | out.push(tr(locale, MessageId::SessionsPreviewId).replace("{id}", &session.metadata.id)); |
| 1486 | out.push( |
| 1487 | tr(locale, MessageId::SessionsPreviewUpdated).replace( |
| 1488 | "{updated}", |
| 1489 | &session |
| 1490 | .metadata |
| 1491 | .updated_at |
| 1492 | .with_timezone(&Local) |
| 1493 | .format("%Y-%m-%d %H:%M") |
| 1494 | .to_string(), |
| 1495 | ), |
| 1496 | ); |
| 1497 | // Count what the preview actually shows. `session_peek` already reports |
| 1498 | // the conversation this way, for the same reason: a total the pane cannot |
| 1499 | // account for is not a useful number. The persisted |
| 1500 | // `metadata.message_count` stays untouched -- it is an index quantity |
| 1501 | // (`forked_from_message_count` stores it as a position, and the |
| 1502 | // empty-session tests key on it), not a display one. |
| 1503 | // |
| 1504 | // Known limitation: the list row still shows the persisted total, because |
| 1505 | // rows are rendered from the session index without loading messages. For |
| 1506 | // an Operate session the row can therefore read higher than the preview. |
| 1507 | out.push( |
| 1508 | tr(locale, MessageId::SessionsPreviewMessagesModel) |
| 1509 | .replace("{count}", &rendered_turns.to_string()) |
| 1510 | .replace("{model}", &session.metadata.model), |
| 1511 | ); |
| 1512 | if let Some(mode) = session.metadata.mode.as_deref() { |
| 1513 | out.push(tr(locale, MessageId::SessionsPreviewMode).replace("{mode}", mode)); |
| 1514 | } |
| 1515 | out.push("".to_string()); |
| 1516 | |
| 1517 | out.extend(body); |
| 1518 | if out.last().is_some_and(String::is_empty) { |
| 1519 | out.pop(); |
| 1520 | } |
| 1521 | out |
| 1522 | } |
| 1523 | |
| 1524 | fn message_text_for_history(message: &codewhale_models::Message, locale: Locale) -> String { |
| 1525 | let mut text = String::new(); |
| 1526 | for block in &message.content { |
| 1527 | let part = match block { |
| 1528 | codewhale_models::ContentBlock::Text { text: body, .. } => { |
| 1529 | if message.role == Role::User { |
| 1530 | extract_user_prompt(body).to_string() |
| 1531 | } else { |
| 1532 | strip_thinking_tags(body) |
| 1533 | } |
| 1534 | } |
| 1535 | codewhale_models::ContentBlock::Thinking { .. } => String::new(), |
| 1536 | codewhale_models::ContentBlock::ToolUse { name, input, .. } => { |
| 1537 | tr(locale, MessageId::SessionsToolCall) |
| 1538 | .replace("{name}", name) |
| 1539 | .replace("{input}", &truncate(&input.to_string(), 180)) |
| 1540 | } |
| 1541 | codewhale_models::ContentBlock::ToolResult { |
| 1542 | content, is_error, .. |
| 1543 | } => { |
| 1544 | let id = if is_error.unwrap_or(false) { |
| 1545 | MessageId::SessionsToolError |
| 1546 | } else { |
| 1547 | MessageId::SessionsToolResult |
| 1548 | }; |
| 1549 | tr(locale, id).replace("{content}", &truncate(&content.replace('\n', " "), 220)) |
| 1550 | } |
| 1551 | codewhale_models::ContentBlock::ServerToolUse { name, input, .. } => { |
| 1552 | tr(locale, MessageId::SessionsServerTool) |
| 1553 | .replace("{name}", name) |
| 1554 | .replace("{input}", &truncate(&input.to_string(), 180)) |
| 1555 | } |
| 1556 | codewhale_models::ContentBlock::ToolSearchToolResult { content, .. } |
| 1557 | | codewhale_models::ContentBlock::CodeExecutionToolResult { content, .. } => { |
| 1558 | tr(locale, MessageId::SessionsToolResult) |
| 1559 | .replace("{content}", &truncate(&content.to_string(), 220)) |
| 1560 | } |
| 1561 | codewhale_models::ContentBlock::ImageUrl { .. } => { |
| 1562 | tr(locale, MessageId::SessionsImage).into_owned() |
| 1563 | } |
| 1564 | }; |
| 1565 | let part = part.trim(); |
| 1566 | if !part.is_empty() { |
| 1567 | if !text.is_empty() { |
| 1568 | text.push('\n'); |
| 1569 | } |
| 1570 | text.push_str(part); |
| 1571 | } |
| 1572 | } |
| 1573 | text |
| 1574 | } |
| 1575 | |
| 1576 | fn format_preview(lines: &[String]) -> Vec<Line<'static>> { |
| 1577 | let mut out = Vec::new(); |
| 1578 | for line in lines { |
| 1579 | out.push(Line::from(Span::styled( |
| 1580 | line.clone(), |
| 1581 | Style::default().fg(palette::TEXT_PRIMARY), |
| 1582 | ))); |
| 1583 | } |
| 1584 | out |
| 1585 | } |
| 1586 | |
| 1587 | fn preview_body_start(lines: &[String], visible_rows: usize) -> Option<usize> { |
| 1588 | let visible_rows = visible_rows.max(1); |
| 1589 | let body_start = lines |
| 1590 | .iter() |
| 1591 | .position(|line| line.is_empty()) |
| 1592 | .map(|idx| idx + 1)?; |
| 1593 | (body_start < visible_rows).then_some(body_start) |
| 1594 | } |
| 1595 | |
| 1596 | fn max_history_scroll_for(lines: &[String], visible_rows: usize) -> usize { |
| 1597 | let visible_rows = visible_rows.max(1); |
| 1598 | let Some(body_start) = preview_body_start(lines, visible_rows) else { |
| 1599 | return lines.len().saturating_sub(visible_rows); |
| 1600 | }; |
| 1601 | let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); |
| 1602 | lines |
| 1603 | .len() |
| 1604 | .saturating_sub(body_start) |
| 1605 | .saturating_sub(body_visible_rows) |
| 1606 | } |
| 1607 | |
| 1608 | fn visible_preview_lines(lines: &[String], scroll: usize, visible_rows: usize) -> Vec<String> { |
| 1609 | let visible_rows = visible_rows.max(1); |
| 1610 | let max_scroll = max_history_scroll_for(lines, visible_rows); |
| 1611 | let scroll = scroll.min(max_scroll); |
| 1612 | let Some(body_start) = preview_body_start(lines, visible_rows) else { |
| 1613 | return lines |
| 1614 | .iter() |
| 1615 | .skip(scroll) |
| 1616 | .take(visible_rows) |
| 1617 | .cloned() |
| 1618 | .collect(); |
| 1619 | }; |
| 1620 | |
| 1621 | let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); |
| 1622 | let mut out = Vec::with_capacity(visible_rows); |
| 1623 | out.extend(lines.iter().take(body_start).cloned()); |
| 1624 | out.extend( |
| 1625 | lines |
| 1626 | .iter() |
| 1627 | .skip(body_start + scroll) |
| 1628 | .take(body_visible_rows) |
| 1629 | .cloned(), |
| 1630 | ); |
| 1631 | out |
| 1632 | } |
| 1633 | |
| 1634 | /// Localized "2h ago" label. Shared with the sidebar Sessions rail so both |
| 1635 | /// surfaces age a session with the same words. |
| 1636 | pub(crate) fn format_relative_time(dt: &DateTime<chrono::Utc>, locale: Locale) -> String { |
| 1637 | let now = chrono::Utc::now(); |
| 1638 | let duration = now.signed_duration_since(*dt); |
| 1639 | if duration.num_minutes() < 1 { |
| 1640 | tr(locale, MessageId::SessionsTimeJustNow).into_owned() |
| 1641 | } else if duration.num_hours() < 1 { |
| 1642 | tr(locale, MessageId::SessionsTimeMinutesAgo) |
| 1643 | .replace("{count}", &duration.num_minutes().to_string()) |
| 1644 | } else if duration.num_days() < 1 { |
| 1645 | tr(locale, MessageId::SessionsTimeHoursAgo) |
| 1646 | .replace("{count}", &duration.num_hours().to_string()) |
| 1647 | } else { |
| 1648 | tr(locale, MessageId::SessionsTimeDaysAgo) |
| 1649 | .replace("{count}", &duration.num_days().to_string()) |
| 1650 | } |
| 1651 | } |
| 1652 | |
| 1653 | fn truncate(text: &str, width: u16) -> String { |
| 1654 | let max = width.max(1) as usize; |
| 1655 | if text.width() <= max { |
| 1656 | return text.to_string(); |
| 1657 | } |
| 1658 | let mut out = String::new(); |
| 1659 | let mut current = 0; |
| 1660 | for ch in text.chars() { |
| 1661 | let w = ch.width().unwrap_or(0); |
| 1662 | if current + w >= max.saturating_sub(3) { |
| 1663 | break; |
| 1664 | } |
| 1665 | out.push(ch); |
| 1666 | current += w; |
| 1667 | } |
| 1668 | out.push_str("..."); |
| 1669 | out |
| 1670 | } |
| 1671 | |
| 1672 | /// Best-effort canonicalisation of a path so two recordings of the same |
| 1673 | /// workspace match even when one is symlinked or relative. Falls back to |
| 1674 | /// the input path when canonicalisation fails (e.g. for a deleted dir or |
| 1675 | /// during tests with tmp paths that have already been cleaned up). |
| 1676 | fn canonical_or_self(path: PathBuf) -> PathBuf { |
| 1677 | std::fs::canonicalize(&path).unwrap_or(path) |
| 1678 | } |
| 1679 | |
| 1680 | #[cfg(test)] |
| 1681 | mod tests { |
| 1682 | use super::*; |
| 1683 | use chrono::Utc; |
| 1684 | use unicode_width::UnicodeWidthStr; |
| 1685 | |
| 1686 | fn test_session(idx: usize, title: &str) -> SessionMetadata { |
| 1687 | SessionMetadata { |
| 1688 | id: format!("session-{idx:02}"), |
| 1689 | title: title.to_string(), |
| 1690 | created_at: Utc::now(), |
| 1691 | updated_at: Utc::now(), |
| 1692 | message_count: idx + 1, |
| 1693 | total_tokens: 100, |
| 1694 | model: "deepseek-v4-pro".to_string(), |
| 1695 | model_provider: "deepseek".to_string(), |
| 1696 | model_provider_id: None, |
| 1697 | workspace: std::path::PathBuf::from("/tmp"), |
| 1698 | mode: Some("agent".to_string()), |
| 1699 | cost: crate::session_manager::SessionCostSnapshot::default(), |
| 1700 | parent_session_id: None, |
| 1701 | forked_from_message_count: None, |
| 1702 | runtime_store: None, |
| 1703 | cumulative_turn_secs: 0, |
| 1704 | archived: false, |
| 1705 | spawn_depth: 0, |
| 1706 | } |
| 1707 | } |
| 1708 | |
| 1709 | fn test_session_in(idx: usize, title: &str, workspace: &str) -> SessionMetadata { |
| 1710 | let mut s = test_session(idx, title); |
| 1711 | s.workspace = std::path::PathBuf::from(workspace); |
| 1712 | s |
| 1713 | } |
| 1714 | |
| 1715 | fn text_message(role: &str, text: &str) -> codewhale_models::Message { |
| 1716 | codewhale_models::Message { |
| 1717 | role: Role::from(role), |
| 1718 | content: vec![codewhale_models::ContentBlock::Text { |
| 1719 | text: text.to_string(), |
| 1720 | cache_control: None, |
| 1721 | }], |
| 1722 | } |
| 1723 | } |
| 1724 | |
| 1725 | fn saved_session_with_messages(messages: Vec<codewhale_models::Message>) -> SavedSession { |
| 1726 | let mut session = crate::session_manager::create_saved_session( |
| 1727 | &messages, |
| 1728 | "deepseek-v4-pro", |
| 1729 | std::path::Path::new("/tmp"), |
| 1730 | 100, |
| 1731 | None, |
| 1732 | ); |
| 1733 | session.metadata.title = "<turn_meta>{}</turn_meta>\nClean session title".to_string(); |
| 1734 | session |
| 1735 | } |
| 1736 | |
| 1737 | fn picker_with(sessions: Vec<SessionMetadata>, scope: Option<&str>) -> SessionPickerView { |
| 1738 | let workspace_scope = scope.map(PathBuf::from); |
| 1739 | let mut view = SessionPickerView { |
| 1740 | sessions: sessions.clone(), |
| 1741 | pending_list: None, |
| 1742 | load_error: None, |
| 1743 | filtered: sessions, |
| 1744 | selected: 0, |
| 1745 | list_scroll: Cell::new(0), |
| 1746 | list_visible_rows: Cell::new(8), |
| 1747 | history_scroll: Cell::new(0), |
| 1748 | history_pinned_to_latest: Cell::new(true), |
| 1749 | history_visible_rows: Cell::new(12), |
| 1750 | search_input: String::new(), |
| 1751 | search_mode: false, |
| 1752 | sort_mode: SessionSortMode::Recent, |
| 1753 | preview_cache: PreviewCache::default(), |
| 1754 | current_preview: Vec::new(), |
| 1755 | preview_generation: 0, |
| 1756 | pending_preview: None, |
| 1757 | confirm_delete: false, |
| 1758 | rename_mode: false, |
| 1759 | rename_input: String::new(), |
| 1760 | status: None, |
| 1761 | workspace_scope, |
| 1762 | show_all_workspaces: false, |
| 1763 | show_archived: false, |
| 1764 | hide_empty_sessions: true, |
| 1765 | current_session_id: None, |
| 1766 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 1767 | locale: Locale::En, |
| 1768 | }; |
| 1769 | view.apply_sort_and_filter(); |
| 1770 | view |
| 1771 | } |
| 1772 | |
| 1773 | /// Deleting the session open in this window would leave it unable to |
| 1774 | /// save anything afterwards, with nothing on screen saying so. |
| 1775 | #[test] |
| 1776 | fn delete_refuses_the_session_open_here() { |
| 1777 | let _lock = crate::test_support::lock_test_env(); |
| 1778 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1779 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1780 | let manager = SessionManager::default_location().expect("session manager"); |
| 1781 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1782 | saved.metadata.id = "session-open-here".to_string(); |
| 1783 | manager.save_session(&saved).expect("save session"); |
| 1784 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1785 | view.current_session_id = Some("session-open-here".to_string()); |
| 1786 | |
| 1787 | assert!(view.delete_selected().is_none(), "no deletion event"); |
| 1788 | |
| 1789 | assert_eq!( |
| 1790 | view.status.as_deref(), |
| 1791 | Some(tr(view.locale, MessageId::SessionsDeleteOpenHere).as_ref()) |
| 1792 | ); |
| 1793 | assert_eq!(view.sessions.len(), 1, "the row stays listed"); |
| 1794 | assert!( |
| 1795 | manager.load_session("session-open-here").is_ok(), |
| 1796 | "the document is not deleted" |
| 1797 | ); |
| 1798 | } |
| 1799 | |
| 1800 | /// A session another Codewhale window holds open would be written back |
| 1801 | /// by that window's next autosave, so deleting it here is refused. |
| 1802 | #[test] |
| 1803 | fn delete_refuses_a_session_open_in_another_window() { |
| 1804 | let _lock = crate::test_support::lock_test_env(); |
| 1805 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1806 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1807 | let manager = SessionManager::default_location().expect("session manager"); |
| 1808 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1809 | saved.metadata.id = "session-open-elsewhere".to_string(); |
| 1810 | manager.save_session(&saved).expect("save session"); |
| 1811 | let _lease = manager.hold_live_lease_elsewhere("session-open-elsewhere"); |
| 1812 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1813 | |
| 1814 | assert!(view.delete_selected().is_none(), "no deletion event"); |
| 1815 | |
| 1816 | assert_eq!( |
| 1817 | view.status.as_deref(), |
| 1818 | Some(tr(view.locale, MessageId::SessionsDeleteOpenElsewhere).as_ref()) |
| 1819 | ); |
| 1820 | assert!( |
| 1821 | manager.load_session("session-open-elsewhere").is_ok(), |
| 1822 | "the document is not deleted" |
| 1823 | ); |
| 1824 | } |
| 1825 | |
| 1826 | #[test] |
| 1827 | fn rename_selected_persists_and_emits_saved_metadata() { |
| 1828 | let _lock = crate::test_support::lock_test_env(); |
| 1829 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1830 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1831 | let manager = SessionManager::default_location().expect("session manager"); |
| 1832 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1833 | saved.metadata.id = "session-01".to_string(); |
| 1834 | saved.metadata.title = "Before".to_string(); |
| 1835 | manager.save_session(&saved).expect("save session"); |
| 1836 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1837 | assert!( |
| 1838 | view.current_preview |
| 1839 | .iter() |
| 1840 | .any(|line| line == "Title: Before"), |
| 1841 | "{:?}", |
| 1842 | view.current_preview |
| 1843 | ); |
| 1844 | |
| 1845 | let action = view.rename_selected("After"); |
| 1846 | // U08-09: the cached preview rendered the old title; it must not be |
| 1847 | // shown again after the rename. |
| 1848 | assert!( |
| 1849 | view.current_preview |
| 1850 | .iter() |
| 1851 | .any(|line| line == "Title: After"), |
| 1852 | "{:?}", |
| 1853 | view.current_preview |
| 1854 | ); |
| 1855 | |
| 1856 | let ViewAction::Emit(ViewEvent::SessionRenamed { metadata }) = action else { |
| 1857 | panic!("expected SessionRenamed event"); |
| 1858 | }; |
| 1859 | assert_eq!(metadata.id, "session-01"); |
| 1860 | assert_eq!(metadata.title, "After"); |
| 1861 | assert_eq!(view.sessions[0].title, "After"); |
| 1862 | assert_eq!( |
| 1863 | manager |
| 1864 | .load_session("session-01") |
| 1865 | .expect("load renamed session") |
| 1866 | .metadata |
| 1867 | .title, |
| 1868 | "After" |
| 1869 | ); |
| 1870 | } |
| 1871 | |
| 1872 | #[test] |
| 1873 | fn archive_toggle_persists_hides_the_row_and_emits_an_archive_receipt() { |
| 1874 | let _lock = crate::test_support::lock_test_env(); |
| 1875 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1876 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1877 | let manager = SessionManager::default_location().expect("session manager"); |
| 1878 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1879 | saved.metadata.id = "session-01".to_string(); |
| 1880 | saved.metadata.title = "Finished work".to_string(); |
| 1881 | manager.save_session(&saved).expect("save session"); |
| 1882 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 1883 | |
| 1884 | let action = view.toggle_archive_selected(); |
| 1885 | |
| 1886 | // The event is `SessionArchived`, not `SessionRenamed`: the receipt has |
| 1887 | // to describe what actually happened. |
| 1888 | let ViewAction::Emit(ViewEvent::SessionArchived { metadata }) = action else { |
| 1889 | panic!("expected SessionArchived event"); |
| 1890 | }; |
| 1891 | assert!(metadata.archived); |
| 1892 | assert!( |
| 1893 | manager |
| 1894 | .load_session("session-01") |
| 1895 | .expect("reload") |
| 1896 | .metadata |
| 1897 | .archived, |
| 1898 | "archive must be durable, not view-local" |
| 1899 | ); |
| 1900 | assert!( |
| 1901 | view.filtered.is_empty(), |
| 1902 | "an archived session leaves the default (active-only) list" |
| 1903 | ); |
| 1904 | |
| 1905 | // `x` brings archived rows back into view without un-archiving them. |
| 1906 | view.toggle_show_archived(); |
| 1907 | assert_eq!(view.filtered.len(), 1); |
| 1908 | assert!(view.filtered[0].archived); |
| 1909 | |
| 1910 | // And archiving is reversible from the same key. |
| 1911 | let restored = view.toggle_archive_selected(); |
| 1912 | let ViewAction::Emit(ViewEvent::SessionArchived { metadata }) = restored else { |
| 1913 | panic!("expected SessionArchived event"); |
| 1914 | }; |
| 1915 | assert!(!metadata.archived); |
| 1916 | assert!( |
| 1917 | !manager |
| 1918 | .load_session("session-01") |
| 1919 | .expect("reload") |
| 1920 | .metadata |
| 1921 | .archived |
| 1922 | ); |
| 1923 | } |
| 1924 | |
| 1925 | #[test] |
| 1926 | fn preselecting_a_row_lands_on_it_without_resuming_anything() { |
| 1927 | let _lock = crate::test_support::lock_test_env(); |
| 1928 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1929 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1930 | let manager = SessionManager::default_location().expect("session manager"); |
| 1931 | let workspace = tmp.path().join("workspace"); |
| 1932 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 1933 | for (id, title) in [("session-01", "First"), ("session-02", "Second")] { |
| 1934 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1935 | saved.metadata.id = id.to_string(); |
| 1936 | saved.metadata.title = title.to_string(); |
| 1937 | saved.metadata.workspace.clone_from(&workspace); |
| 1938 | manager.save_session(&saved).expect("save session"); |
| 1939 | } |
| 1940 | |
| 1941 | let view = SessionPickerView::new_selecting(&workspace, Locale::En, "session-02"); |
| 1942 | |
| 1943 | assert_eq!( |
| 1944 | view.selected_session().map(|s| s.id.as_str()), |
| 1945 | Some("session-02"), |
| 1946 | "the rail hands off a target row; the picker must land on it" |
| 1947 | ); |
| 1948 | } |
| 1949 | |
| 1950 | #[test] |
| 1951 | fn preselecting_an_archived_row_widens_the_archive_filter_only() { |
| 1952 | let _lock = crate::test_support::lock_test_env(); |
| 1953 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1954 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 1955 | let manager = SessionManager::default_location().expect("session manager"); |
| 1956 | let workspace = tmp.path().join("workspace"); |
| 1957 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 1958 | let mut saved = saved_session_with_messages(vec![text_message("user", "hello")]); |
| 1959 | saved.metadata.id = "session-01".to_string(); |
| 1960 | saved.metadata.title = "Put away".to_string(); |
| 1961 | saved.metadata.workspace.clone_from(&workspace); |
| 1962 | saved.metadata.archived = true; |
| 1963 | manager.save_session(&saved).expect("save session"); |
| 1964 | |
| 1965 | let view = SessionPickerView::new_selecting(&workspace, Locale::En, "session-01"); |
| 1966 | |
| 1967 | assert_eq!( |
| 1968 | view.selected_session().map(|s| s.id.as_str()), |
| 1969 | Some("session-01") |
| 1970 | ); |
| 1971 | assert!(view.show_archived, "archived rows had to be revealed"); |
| 1972 | assert!( |
| 1973 | !view.show_all_workspaces, |
| 1974 | "revealing an archived row must not also broaden the workspace scope" |
| 1975 | ); |
| 1976 | } |
| 1977 | |
| 1978 | #[test] |
| 1979 | fn empty_auto_created_sessions_stay_out_of_the_default_view() { |
| 1980 | let mut empty = test_session(0, crate::session_manager::DEFAULT_SESSION_TITLE); |
| 1981 | empty.message_count = 0; |
| 1982 | let view = SessionPickerView::new_with_sessions( |
| 1983 | std::path::Path::new("/tmp"), |
| 1984 | Locale::En, |
| 1985 | vec![test_session(1, "Real work"), empty], |
| 1986 | ); |
| 1987 | |
| 1988 | assert_eq!( |
| 1989 | view.visible_session_ids(), |
| 1990 | vec!["session-01".to_string()], |
| 1991 | "a session with no messages has nothing to resume (#6014)" |
| 1992 | ); |
| 1993 | } |
| 1994 | |
| 1995 | #[test] |
| 1996 | fn preselecting_an_empty_row_widens_the_empty_filter_only() { |
| 1997 | let _lock = crate::test_support::lock_test_env(); |
| 1998 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1999 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 2000 | let manager = SessionManager::default_location().expect("session manager"); |
| 2001 | let workspace = tmp.path().join("workspace"); |
| 2002 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 2003 | let mut saved = saved_session_with_messages(vec![]); |
| 2004 | saved.metadata.id = "session-01".to_string(); |
| 2005 | saved.metadata.title = crate::session_manager::DEFAULT_SESSION_TITLE.to_string(); |
| 2006 | saved.metadata.message_count = 0; |
| 2007 | saved.metadata.workspace.clone_from(&workspace); |
| 2008 | manager.save_session(&saved).expect("save session"); |
| 2009 | |
| 2010 | let view = SessionPickerView::new_selecting(&workspace, Locale::En, "session-01"); |
| 2011 | |
| 2012 | assert_eq!( |
| 2013 | view.selected_session().map(|s| s.id.as_str()), |
| 2014 | Some("session-01"), |
| 2015 | "an explicit handoff must still land on a hidden empty row (#6014)" |
| 2016 | ); |
| 2017 | assert!( |
| 2018 | !view.hide_empty_sessions, |
| 2019 | "the empty-session filter had to be lifted to reach the row" |
| 2020 | ); |
| 2021 | } |
| 2022 | |
| 2023 | #[test] |
| 2024 | fn current_session_row_carries_a_text_label() { |
| 2025 | let sessions = vec![ |
| 2026 | test_session(1, "first session"), |
| 2027 | test_session(2, "second session"), |
| 2028 | ]; |
| 2029 | let lines = build_list_lines( |
| 2030 | &sessions, |
| 2031 | 1, |
| 2032 | 80, |
| 2033 | 0, |
| 2034 | 5, |
| 2035 | false, |
| 2036 | "", |
| 2037 | "recent", |
| 2038 | false, |
| 2039 | false, |
| 2040 | "", |
| 2041 | None, |
| 2042 | None, |
| 2043 | Some("session-01"), |
| 2044 | Locale::En, |
| 2045 | ); |
| 2046 | let row_for = |needle: &str| { |
| 2047 | lines |
| 2048 | .iter() |
| 2049 | .map(|line| { |
| 2050 | line.spans |
| 2051 | .iter() |
| 2052 | .map(|span| span.content.to_string()) |
| 2053 | .collect::<String>() |
| 2054 | }) |
| 2055 | .find(|text| text.contains(needle)) |
| 2056 | .unwrap_or_default() |
| 2057 | }; |
| 2058 | assert!( |
| 2059 | row_for("first session").contains("current"), |
| 2060 | "the active row needs a monochrome-safe label (#6014): {:?}", |
| 2061 | row_for("first session") |
| 2062 | ); |
| 2063 | assert!(!row_for("second session").contains("current")); |
| 2064 | } |
| 2065 | |
| 2066 | #[test] |
| 2067 | fn preview_lists_the_full_session_id() { |
| 2068 | let mut saved = saved_session_with_messages(vec![text_message("user", "hi")]); |
| 2069 | saved.metadata.id = "session-with-a-long-identifier".to_string(); |
| 2070 | let lines = build_preview_lines(&saved, Locale::En); |
| 2071 | assert!( |
| 2072 | lines |
| 2073 | .iter() |
| 2074 | .any(|line| line.contains("session-with-a-long-identifier")), |
| 2075 | "the row truncates the id; the preview is where the full handle lives (#6014)" |
| 2076 | ); |
| 2077 | } |
| 2078 | |
| 2079 | /// The preview is a read-only view of a saved session, and runtime |
| 2080 | /// control traffic rides `role = "user"` on disk. Rendering it verbatim |
| 2081 | /// opened every Operate-mode preview with `USER:` and the whole internal |
| 2082 | /// `operate_contract` envelope, attributing the runtime's own bookkeeping |
| 2083 | /// to the person. |
| 2084 | #[test] |
| 2085 | fn preview_hides_internal_runtime_traffic() { |
| 2086 | let saved = saved_session_with_messages(vec![ |
| 2087 | crate::runtime_handoff::operate_contract_runtime_message(), |
| 2088 | text_message("user", "ship the release"), |
| 2089 | text_message("assistant", "on it"), |
| 2090 | ]); |
| 2091 | |
| 2092 | let preview = build_preview_lines(&saved, Locale::En).join("\n"); |
| 2093 | |
| 2094 | assert!( |
| 2095 | !preview.contains("codewhale:runtime_event"), |
| 2096 | "internal runtime traffic must not be shown to a person: {preview}" |
| 2097 | ); |
| 2098 | assert!( |
| 2099 | !preview.contains("Input provenance:"), |
| 2100 | "the runtime provenance envelope must not leak either: {preview}" |
| 2101 | ); |
| 2102 | assert!( |
| 2103 | preview.contains("ship the release") && preview.contains("on it"), |
| 2104 | "the person's own conversation still belongs in the preview: {preview}" |
| 2105 | ); |
| 2106 | // The header must count what the body shows. The persisted |
| 2107 | // `metadata.message_count` is 3 here (it counts the runtime event); |
| 2108 | // printing that beside two visible turns is a total the pane cannot |
| 2109 | // account for. |
| 2110 | assert_eq!( |
| 2111 | saved.metadata.message_count, 3, |
| 2112 | "guard the premise: the persisted count still includes runtime traffic" |
| 2113 | ); |
| 2114 | assert!( |
| 2115 | preview.contains("Messages: 2"), |
| 2116 | "the preview counts the conversation it renders, not the persisted \ |
| 2117 | total: {preview}" |
| 2118 | ); |
| 2119 | } |
| 2120 | |
| 2121 | /// The header must count the turns the body actually draws, not the |
| 2122 | /// messages that merely survived the runtime-traffic filter. A |
| 2123 | /// thinking-only assistant turn renders nothing (`message_text_for_history` |
| 2124 | /// yields an empty string for `ContentBlock::Thinking`), so counting the |
| 2125 | /// filtered vector printed a total the pane could not account for -- |
| 2126 | /// the same defect the filter was added to remove. |
| 2127 | #[test] |
| 2128 | fn preview_count_excludes_turns_that_render_nothing() { |
| 2129 | let thinking_only = codewhale_models::Message { |
| 2130 | role: Role::from("assistant"), |
| 2131 | content: vec![codewhale_models::ContentBlock::Thinking { |
| 2132 | thinking: "silent deliberation".to_string(), |
| 2133 | signature: None, |
| 2134 | state: None, |
| 2135 | }], |
| 2136 | }; |
| 2137 | let saved = saved_session_with_messages(vec![ |
| 2138 | crate::runtime_handoff::operate_contract_runtime_message(), |
| 2139 | thinking_only, |
| 2140 | text_message("user", "ship the release"), |
| 2141 | text_message("assistant", "on it"), |
| 2142 | ]); |
| 2143 | |
| 2144 | let preview = build_preview_lines(&saved, Locale::En).join("\n"); |
| 2145 | |
| 2146 | assert!( |
| 2147 | preview.contains("Messages: 2"), |
| 2148 | "two turns are drawn, so the header must say two: {preview}" |
| 2149 | ); |
| 2150 | assert!( |
| 2151 | !preview.contains("silent deliberation"), |
| 2152 | "a thinking block is not a rendered turn: {preview}" |
| 2153 | ); |
| 2154 | // The rendered `ROLE:` headings are the ground truth for the count. |
| 2155 | let drawn = preview |
| 2156 | .lines() |
| 2157 | .filter(|line| *line == "USER:" || *line == "ASSISTANT:") |
| 2158 | .count(); |
| 2159 | assert_eq!(drawn, 2, "header count must equal drawn turns: {preview}"); |
| 2160 | } |
| 2161 | |
| 2162 | #[test] |
| 2163 | fn page_keys_page_the_list_and_clamp_at_the_end() { |
| 2164 | let sessions: Vec<SessionMetadata> = (0..20) |
| 2165 | .map(|i| test_session(i, &format!("work {i}"))) |
| 2166 | .collect(); |
| 2167 | let mut view = picker_with(sessions, None); |
| 2168 | // Seed the preview cache so refresh_preview never reaches the store. |
| 2169 | for session in &view.sessions { |
| 2170 | view.preview_cache |
| 2171 | .insert(session.id.clone(), vec!["preview".to_string()]); |
| 2172 | } |
| 2173 | |
| 2174 | view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); |
| 2175 | assert_eq!(view.selected, 8, "one viewport of eight rows"); |
| 2176 | view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); |
| 2177 | assert_eq!(view.selected, 16); |
| 2178 | view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); |
| 2179 | assert_eq!(view.selected, 19, "paging clamps at the last row"); |
| 2180 | view.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)); |
| 2181 | assert_eq!(view.selected, 11); |
| 2182 | } |
| 2183 | |
| 2184 | /// #6290 step 2: the picker navigates on the shared `list_nav` vocabulary, |
| 2185 | /// so Home/End exist here and the `j`/`k` aliases keep working — the two |
| 2186 | /// behaviors this surface previously hand-rolled (or lacked). |
| 2187 | #[test] |
| 2188 | fn home_end_and_letter_aliases_come_from_the_shared_vocabulary() { |
| 2189 | let sessions: Vec<SessionMetadata> = (0..20) |
| 2190 | .map(|i| test_session(i, &format!("work {i}"))) |
| 2191 | .collect(); |
| 2192 | let mut view = picker_with(sessions, None); |
| 2193 | for session in &view.sessions { |
| 2194 | view.preview_cache |
| 2195 | .insert(session.id.clone(), vec!["preview".to_string()]); |
| 2196 | } |
| 2197 | |
| 2198 | view.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); |
| 2199 | assert_eq!(view.selected, 19, "End lands on the last row"); |
| 2200 | view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); |
| 2201 | assert_eq!(view.selected, 0, "Home lands on the first row"); |
| 2202 | view.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); |
| 2203 | assert_eq!(view.selected, 1, "`j` is still the Down alias"); |
| 2204 | view.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); |
| 2205 | assert_eq!(view.selected, 0, "`k` is still the Up alias"); |
| 2206 | } |
| 2207 | |
| 2208 | #[test] |
| 2209 | fn shift_page_keys_still_scroll_the_history_preview() { |
| 2210 | let mut view = picker_with(vec![test_session(1, "only session")], None); |
| 2211 | view.current_preview = (0..30).map(|i| format!("line {i}")).collect(); |
| 2212 | view.history_visible_rows.set(10); |
| 2213 | |
| 2214 | view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)); |
| 2215 | assert_eq!(view.history_scroll.get(), 10); |
| 2216 | view.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT)); |
| 2217 | assert_eq!(view.history_scroll.get(), 0); |
| 2218 | } |
| 2219 | |
| 2220 | fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String { |
| 2221 | (area.x..area.x.saturating_add(area.width)) |
| 2222 | .map(|x| buf[(x, y)].symbol()) |
| 2223 | .collect() |
| 2224 | } |
| 2225 | |
| 2226 | fn row_containing(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> { |
| 2227 | (area.y..area.y.saturating_add(area.height)) |
| 2228 | .find(|&y| buffer_row_text(buf, area, y).contains(needle)) |
| 2229 | } |
| 2230 | |
| 2231 | fn buffer_text(buf: &Buffer, area: Rect) -> String { |
| 2232 | let mut out = String::new(); |
| 2233 | for y in area.y..area.y.saturating_add(area.height) { |
| 2234 | for x in area.x..area.x.saturating_add(area.width) { |
| 2235 | out.push_str(buf[(x, y)].symbol()); |
| 2236 | } |
| 2237 | out.push('\n'); |
| 2238 | } |
| 2239 | out |
| 2240 | } |
| 2241 | |
| 2242 | #[test] |
| 2243 | fn workspace_scope_filters_sessions_to_current_project() { |
| 2244 | // #1395 reproduction: Ctrl+R in project B must not surface sessions |
| 2245 | // from project A. |
| 2246 | let sessions = vec![ |
| 2247 | test_session_in(1, "project-a chat", "/tmp/project-a"), |
| 2248 | test_session_in(2, "project-b chat", "/tmp/project-b"), |
| 2249 | test_session_in(3, "another project-a chat", "/tmp/project-a"), |
| 2250 | ]; |
| 2251 | let view = picker_with(sessions, Some("/tmp/project-b")); |
| 2252 | assert_eq!(view.filtered.len(), 1, "only project-b session should show"); |
| 2253 | assert_eq!(view.filtered[0].title, "project-b chat"); |
| 2254 | } |
| 2255 | |
| 2256 | #[test] |
| 2257 | fn workspace_scope_toggle_a_expands_to_all_workspaces() { |
| 2258 | let sessions = vec![ |
| 2259 | test_session_in(1, "a", "/tmp/project-a"), |
| 2260 | test_session_in(2, "b", "/tmp/project-b"), |
| 2261 | test_session_in(3, "c", "/tmp/project-c"), |
| 2262 | ]; |
| 2263 | let mut view = picker_with(sessions, Some("/tmp/project-b")); |
| 2264 | assert_eq!(view.filtered.len(), 1); |
| 2265 | |
| 2266 | view.toggle_all_workspaces(); |
| 2267 | assert_eq!(view.filtered.len(), 3, "after toggle, every session shows"); |
| 2268 | assert!(view.show_all_workspaces); |
| 2269 | assert!( |
| 2270 | view.status |
| 2271 | .as_deref() |
| 2272 | .map(|s| s.contains("every workspace")) |
| 2273 | .unwrap_or(false), |
| 2274 | "status should announce the new mode, got {:?}", |
| 2275 | view.status |
| 2276 | ); |
| 2277 | |
| 2278 | view.toggle_all_workspaces(); |
| 2279 | assert_eq!(view.filtered.len(), 1, "toggling back restores the scope"); |
| 2280 | } |
| 2281 | |
| 2282 | #[test] |
| 2283 | fn workspace_scope_none_means_show_all() { |
| 2284 | // An unscoped picker (no workspace) lists everything — matches the |
| 2285 | // pre-#1395 behaviour for any caller that opts out. |
| 2286 | let sessions = vec![ |
| 2287 | test_session_in(1, "a", "/tmp/project-a"), |
| 2288 | test_session_in(2, "b", "/tmp/project-b"), |
| 2289 | ]; |
| 2290 | let view = picker_with(sessions, None); |
| 2291 | assert_eq!(view.filtered.len(), 2); |
| 2292 | } |
| 2293 | |
| 2294 | #[test] |
| 2295 | fn build_list_lines_truncates_to_list_pane_width() { |
| 2296 | let sessions = vec![test_session( |
| 2297 | 1, |
| 2298 | "A very long title that should be truncated by the list pane width", |
| 2299 | )]; |
| 2300 | let width = 24; |
| 2301 | let lines = build_list_lines( |
| 2302 | &sessions, |
| 2303 | 0, |
| 2304 | width, |
| 2305 | 0, |
| 2306 | 5, |
| 2307 | false, |
| 2308 | "", |
| 2309 | "recent", |
| 2310 | false, |
| 2311 | false, |
| 2312 | "", |
| 2313 | None, |
| 2314 | None, |
| 2315 | None, |
| 2316 | Locale::En, |
| 2317 | ); |
| 2318 | |
| 2319 | for line in lines { |
| 2320 | let rendered_width: usize = line.spans.iter().map(|span| span.content.width()).sum(); |
| 2321 | assert!( |
| 2322 | rendered_width <= width as usize, |
| 2323 | "line width {rendered_width} exceeded pane width {width}" |
| 2324 | ); |
| 2325 | } |
| 2326 | } |
| 2327 | |
| 2328 | #[test] |
| 2329 | fn build_list_lines_selected_row_uses_muted_selection_highlight() { |
| 2330 | let sessions = vec![ |
| 2331 | test_session(1, "first session"), |
| 2332 | test_session(2, "second session"), |
| 2333 | ]; |
| 2334 | let lines = build_list_lines( |
| 2335 | &sessions, |
| 2336 | 1, |
| 2337 | 80, |
| 2338 | 0, |
| 2339 | 5, |
| 2340 | false, |
| 2341 | "", |
| 2342 | "recent", |
| 2343 | false, |
| 2344 | false, |
| 2345 | "", |
| 2346 | None, |
| 2347 | None, |
| 2348 | None, |
| 2349 | Locale::En, |
| 2350 | ); |
| 2351 | |
| 2352 | let selected_line = lines |
| 2353 | .iter() |
| 2354 | .find(|line| { |
| 2355 | line.spans |
| 2356 | .iter() |
| 2357 | .any(|span| span.content.contains("second session")) |
| 2358 | }) |
| 2359 | .expect("selected session should render"); |
| 2360 | let span = selected_line |
| 2361 | .spans |
| 2362 | .first() |
| 2363 | .expect("selected row should have a span"); |
| 2364 | |
| 2365 | assert_eq!(span.style.fg, Some(palette::SELECTION_TEXT)); |
| 2366 | assert_eq!(span.style.bg, Some(palette::SELECTION_BG)); |
| 2367 | assert_ne!(span.style.bg, Some(palette::WHALE_ACTION)); |
| 2368 | assert!(span.style.add_modifier.contains(Modifier::BOLD)); |
| 2369 | } |
| 2370 | |
| 2371 | #[test] |
| 2372 | fn session_picker_selected_row_renders_readable_selection_contrast() { |
| 2373 | let mut first = test_session(1, "first contrast fixture"); |
| 2374 | first.id = "alpha-contrast-fixture".to_string(); |
| 2375 | let mut second = test_session(2, "second contrast fixture"); |
| 2376 | second.id = "bravo-contrast-fixture".to_string(); |
| 2377 | let sessions = vec![first, second]; |
| 2378 | let mut view = picker_with(sessions, None); |
| 2379 | view.selected = 1; |
| 2380 | view.ensure_selected_visible(); |
| 2381 | view.current_preview = vec!["preview".to_string()]; |
| 2382 | let selected_id = crate::session_manager::truncate_id(&view.filtered[view.selected].id); |
| 2383 | let area = Rect::new(0, 0, 120, 28); |
| 2384 | let mut buf = Buffer::empty(area); |
| 2385 | |
| 2386 | view.render(area, &mut buf); |
| 2387 | |
| 2388 | let y = |
| 2389 | row_containing(&buf, area, selected_id).expect("selected session row should render"); |
| 2390 | let rendered_row = buffer_row_text(&buf, area, y); |
| 2391 | let highlighted_cells = (area.x..area.x.saturating_add(area.width)) |
| 2392 | .filter(|&x| { |
| 2393 | let cell = &buf[(x, y)]; |
| 2394 | !cell.symbol().trim().is_empty() |
| 2395 | && cell.bg == palette::SELECTION_BG |
| 2396 | && cell.fg == palette::SELECTION_TEXT |
| 2397 | }) |
| 2398 | .count(); |
| 2399 | |
| 2400 | assert!( |
| 2401 | highlighted_cells >= 4, |
| 2402 | "selected /sessions row should use readable selection text; got {highlighted_cells} highlighted cells on {rendered_row:?}" |
| 2403 | ); |
| 2404 | assert!( |
| 2405 | !(area.x..area.x.saturating_add(area.width)) |
| 2406 | .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION), |
| 2407 | "selected /sessions row should not use the bright accent background" |
| 2408 | ); |
| 2409 | } |
| 2410 | |
| 2411 | /// 40x12/60x16 regression: when two bordered panes cannot both show a |
| 2412 | /// content row, the picker keeps a single focused session list — with the |
| 2413 | /// selected session, its resume action, and truthful mouse hitboxes — |
| 2414 | /// instead of two empty headings over a wrapped footer. |
| 2415 | #[test] |
| 2416 | fn session_picker_compact_heights_keep_a_selectable_session() { |
| 2417 | let sessions = (0..6) |
| 2418 | .map(|idx| { |
| 2419 | let mut session = test_session(idx, "compact fixture session"); |
| 2420 | session.id = format!("compact-fixture-{idx:02}"); |
| 2421 | session |
| 2422 | }) |
| 2423 | .collect::<Vec<_>>(); |
| 2424 | let mut view = picker_with(sessions, None); |
| 2425 | view.selected = 4; |
| 2426 | view.ensure_selected_visible(); |
| 2427 | |
| 2428 | for (width, height, label) in [(40u16, 12u16, "40x12"), (60, 16, "60x16")] { |
| 2429 | let area = Rect::new(0, 0, width, height); |
| 2430 | let mut buf = Buffer::empty(area); |
| 2431 | |
| 2432 | view.render(area, &mut buf); |
| 2433 | |
| 2434 | let dump = buffer_text(&buf, area); |
| 2435 | let selected_id = crate::session_manager::truncate_id(&view.filtered[view.selected].id); |
| 2436 | assert!( |
| 2437 | row_containing(&buf, area, selected_id).is_some(), |
| 2438 | "{label} should render the selected session row:\n{dump}" |
| 2439 | ); |
| 2440 | assert!( |
| 2441 | dump.contains("resume"), |
| 2442 | "{label} should keep the resume action visible:\n{dump}" |
| 2443 | ); |
| 2444 | let hitboxes = view.last_row_hitboxes.borrow(); |
| 2445 | assert!( |
| 2446 | !hitboxes.is_empty(), |
| 2447 | "{label} should register session hitboxes:\n{dump}" |
| 2448 | ); |
| 2449 | for (y, idx) in hitboxes.iter() { |
| 2450 | let row = buffer_row_text(&buf, area, *y); |
| 2451 | let id = crate::session_manager::truncate_id(&view.filtered[*idx].id); |
| 2452 | assert!( |
| 2453 | row.contains(id), |
| 2454 | "{label} hitbox at y={y} should map to session {id}; got {row:?}" |
| 2455 | ); |
| 2456 | } |
| 2457 | } |
| 2458 | } |
| 2459 | |
| 2460 | #[test] |
| 2461 | fn session_picker_visual_matrix_covers_narrow_and_medium_rendering() { |
| 2462 | let base_time = DateTime::parse_from_rfc3339("2026-06-25T10:30:00Z") |
| 2463 | .expect("visual matrix timestamp") |
| 2464 | .with_timezone(&Utc); |
| 2465 | let sessions = (0..12) |
| 2466 | .map(|idx| { |
| 2467 | let title = if idx == 6 { |
| 2468 | "selected visual matrix target with 中文内容 and suffix that must truncate" |
| 2469 | } else { |
| 2470 | "A very long terminal visual regression session title with 中文内容 and suffix that must truncate" |
| 2471 | }; |
| 2472 | let mut session = test_session(idx, title); |
| 2473 | session.id = format!("visual-matrix-{idx:02}"); |
| 2474 | session.created_at = base_time - chrono::Duration::seconds(idx as i64); |
| 2475 | session.updated_at = session.created_at; |
| 2476 | session |
| 2477 | }) |
| 2478 | .collect::<Vec<_>>(); |
| 2479 | let mut view = picker_with(sessions, None); |
| 2480 | view.selected = view |
| 2481 | .filtered |
| 2482 | .iter() |
| 2483 | .position(|session| session.id == "visual-matrix-06") |
| 2484 | .expect("visual matrix target session should be filtered"); |
| 2485 | view.ensure_selected_visible(); |
| 2486 | view.current_preview = vec![ |
| 2487 | "Title: terminal visual matrix".to_string(), |
| 2488 | "Updated: 2026-06-25 10:30".to_string(), |
| 2489 | "Messages: 3 | Model: deepseek-v4-pro".to_string(), |
| 2490 | String::new(), |
| 2491 | "USER: narrow panes should keep long CJK text readable 中文中文中文".to_string(), |
| 2492 | "ASSISTANT: overlays should keep borders and truncate rows predictably".to_string(), |
| 2493 | ]; |
| 2494 | |
| 2495 | for (width, height, label) in [(72, 20, "narrow"), (120, 28, "medium")] { |
| 2496 | let area = Rect::new(0, 0, width, height); |
| 2497 | let mut buf = Buffer::empty(area); |
| 2498 | |
| 2499 | view.render(area, &mut buf); |
| 2500 | |
| 2501 | let dump = buffer_text(&buf, area); |
| 2502 | assert!( |
| 2503 | dump.contains("sessions (1-9, PgUp/PgDn)"), |
| 2504 | "{label} sessions pane missing:\n{dump}" |
| 2505 | ); |
| 2506 | assert!( |
| 2507 | dump.contains("history (Shift+PgUp/PgDn)"), |
| 2508 | "{label} history pane missing:\n{dump}" |
| 2509 | ); |
| 2510 | assert!(dump.contains('─'), "{label} hairline missing:\n{dump}"); |
| 2511 | assert!( |
| 2512 | !dump.contains('┌') && !dump.contains('┘'), |
| 2513 | "{label} should use open hairlines, not boxed rooms:\n{dump}" |
| 2514 | ); |
| 2515 | assert!( |
| 2516 | !dump.contains("suffix that must truncate"), |
| 2517 | "{label} long title tail leaked instead of truncating:\n{dump}" |
| 2518 | ); |
| 2519 | assert!( |
| 2520 | dump.contains("..."), |
| 2521 | "{label} should show an explicit ellipsis for truncated rows:\n{dump}" |
| 2522 | ); |
| 2523 | assert!( |
| 2524 | !dump.contains('\u{fffd}'), |
| 2525 | "{label} render emitted replacement characters:\n{dump}" |
| 2526 | ); |
| 2527 | |
| 2528 | assert!( |
| 2529 | row_containing(&buf, area, "selected visual").is_some(), |
| 2530 | "{label} selected session row missing:\n{dump}" |
| 2531 | ); |
| 2532 | } |
| 2533 | } |
| 2534 | |
| 2535 | #[test] |
| 2536 | fn build_list_lines_includes_absolute_updated_timestamp() { |
| 2537 | let mut session = test_session(1, "last friday thread"); |
| 2538 | session.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") |
| 2539 | .expect("timestamp") |
| 2540 | .with_timezone(&Utc); |
| 2541 | let lines = build_list_lines( |
| 2542 | &[session], |
| 2543 | 0, |
| 2544 | 120, |
| 2545 | 0, |
| 2546 | 5, |
| 2547 | false, |
| 2548 | "", |
| 2549 | "recent", |
| 2550 | false, |
| 2551 | false, |
| 2552 | "", |
| 2553 | None, |
| 2554 | None, |
| 2555 | None, |
| 2556 | Locale::En, |
| 2557 | ); |
| 2558 | |
| 2559 | let rendered = lines |
| 2560 | .iter() |
| 2561 | .flat_map(|line| line.spans.iter()) |
| 2562 | .map(|span| span.content.as_ref()) |
| 2563 | .collect::<Vec<_>>() |
| 2564 | .join("\n"); |
| 2565 | assert!( |
| 2566 | rendered.contains("2026-06-01 12:34 UTC"), |
| 2567 | "session picker should include an absolute timestamp, got {rendered:?}" |
| 2568 | ); |
| 2569 | } |
| 2570 | |
| 2571 | #[test] |
| 2572 | fn build_list_lines_marks_fork_lineage() { |
| 2573 | let mut forked = test_session(1, "forked path"); |
| 2574 | forked.parent_session_id = Some("parent-session-abcdef".to_string()); |
| 2575 | forked.forked_from_message_count = Some(3); |
| 2576 | let lines = build_list_lines( |
| 2577 | &[forked], |
| 2578 | 0, |
| 2579 | 120, |
| 2580 | 0, |
| 2581 | 5, |
| 2582 | false, |
| 2583 | "", |
| 2584 | "recent", |
| 2585 | false, |
| 2586 | false, |
| 2587 | "", |
| 2588 | None, |
| 2589 | None, |
| 2590 | None, |
| 2591 | Locale::En, |
| 2592 | ); |
| 2593 | |
| 2594 | let rendered = lines |
| 2595 | .iter() |
| 2596 | .flat_map(|line| line.spans.iter()) |
| 2597 | .map(|span| span.content.as_ref()) |
| 2598 | .collect::<Vec<_>>() |
| 2599 | .join("\n"); |
| 2600 | assert!(rendered.contains("fork")); |
| 2601 | assert!(!rendered.contains("parent-session-abcdef")); |
| 2602 | } |
| 2603 | |
| 2604 | #[test] |
| 2605 | fn build_list_lines_numbers_visible_rows_for_shortcuts() { |
| 2606 | let sessions = vec![ |
| 2607 | test_session(1, "first session"), |
| 2608 | test_session(2, "second session"), |
| 2609 | ]; |
| 2610 | let lines = build_list_lines( |
| 2611 | &sessions, |
| 2612 | 0, |
| 2613 | 80, |
| 2614 | 0, |
| 2615 | 5, |
| 2616 | false, |
| 2617 | "", |
| 2618 | "recent", |
| 2619 | false, |
| 2620 | false, |
| 2621 | "", |
| 2622 | None, |
| 2623 | None, |
| 2624 | None, |
| 2625 | Locale::En, |
| 2626 | ); |
| 2627 | |
| 2628 | let rendered = lines |
| 2629 | .iter() |
| 2630 | .flat_map(|line| line.spans.iter()) |
| 2631 | .map(|span| span.content.as_ref()) |
| 2632 | .collect::<Vec<_>>() |
| 2633 | .join("\n"); |
| 2634 | assert!(rendered.contains("1. session-")); |
| 2635 | assert!(rendered.contains("2. session-")); |
| 2636 | } |
| 2637 | |
| 2638 | #[test] |
| 2639 | fn digit_shortcut_selects_visible_session_for_history() { |
| 2640 | let sessions = vec![ |
| 2641 | test_session(1, "first session"), |
| 2642 | test_session(2, "second session"), |
| 2643 | test_session(3, "third session"), |
| 2644 | ]; |
| 2645 | let mut view = picker_with(sessions, None); |
| 2646 | |
| 2647 | assert!(view.select_visible_shortcut('2')); |
| 2648 | assert_eq!(view.selected, 1); |
| 2649 | assert!( |
| 2650 | view.status |
| 2651 | .as_deref() |
| 2652 | .is_some_and(|status| status.contains("Opened history")) |
| 2653 | ); |
| 2654 | assert!(!view.select_visible_shortcut('9')); |
| 2655 | } |
| 2656 | |
| 2657 | #[test] |
| 2658 | fn history_scroll_pages_and_clamps() { |
| 2659 | let mut view = picker_with(vec![test_session(1, "first")], None); |
| 2660 | view.current_preview = (0..20).map(|idx| format!("line {idx}")).collect(); |
| 2661 | view.history_visible_rows.set(5); |
| 2662 | |
| 2663 | view.scroll_history(6); |
| 2664 | assert_eq!(view.history_scroll.get(), 6); |
| 2665 | view.scroll_history(100); |
| 2666 | assert_eq!(view.history_scroll.get(), 15); |
| 2667 | view.scroll_history(-200); |
| 2668 | assert_eq!(view.history_scroll.get(), 0); |
| 2669 | } |
| 2670 | |
| 2671 | #[test] |
| 2672 | fn history_preview_keeps_header_while_scrolling_transcript() { |
| 2673 | let lines = vec![ |
| 2674 | "Title: version".to_string(), |
| 2675 | "Updated: 2026-05-14 01:02".to_string(), |
| 2676 | "Messages: 100 | Model: auto".to_string(), |
| 2677 | "Mode: agent".to_string(), |
| 2678 | String::new(), |
| 2679 | "USER: oldest prompt".to_string(), |
| 2680 | "ASSISTANT: oldest answer".to_string(), |
| 2681 | "USER: middle prompt".to_string(), |
| 2682 | "ASSISTANT: middle answer".to_string(), |
| 2683 | "USER: newest prompt".to_string(), |
| 2684 | "ASSISTANT: newest answer".to_string(), |
| 2685 | ]; |
| 2686 | |
| 2687 | let max_scroll = max_history_scroll_for(&lines, 8); |
| 2688 | assert_eq!(max_scroll, 3); |
| 2689 | |
| 2690 | let rendered = visible_preview_lines(&lines, max_scroll, 8).join("\n"); |
| 2691 | assert!(rendered.contains("Title: version")); |
| 2692 | assert!(rendered.contains("Updated: 2026-05-14 01:02")); |
| 2693 | assert!(!rendered.contains("oldest prompt")); |
| 2694 | assert!(rendered.contains("newest prompt")); |
| 2695 | assert!(rendered.contains("newest answer")); |
| 2696 | } |
| 2697 | |
| 2698 | #[test] |
| 2699 | fn history_refresh_starts_at_latest_transcript_messages() { |
| 2700 | let mut view = picker_with(vec![test_session(1, "first")], None); |
| 2701 | view.current_preview = vec![ |
| 2702 | "Title: first".to_string(), |
| 2703 | "Updated: 2026-05-14 01:02".to_string(), |
| 2704 | "Messages: 10 | Model: auto".to_string(), |
| 2705 | String::new(), |
| 2706 | "line 0".to_string(), |
| 2707 | "line 1".to_string(), |
| 2708 | "line 2".to_string(), |
| 2709 | "line 3".to_string(), |
| 2710 | "line 4".to_string(), |
| 2711 | "line 5".to_string(), |
| 2712 | ]; |
| 2713 | view.history_visible_rows.set(6); |
| 2714 | |
| 2715 | view.scroll_history_to_latest(); |
| 2716 | |
| 2717 | assert_eq!(view.history_scroll.get(), 4); |
| 2718 | assert!(view.history_pinned_to_latest.get()); |
| 2719 | } |
| 2720 | |
| 2721 | #[test] |
| 2722 | fn build_preview_lines_shows_full_clean_history() { |
| 2723 | let messages = vec![ |
| 2724 | text_message( |
| 2725 | "user", |
| 2726 | "<turn_meta>{\"cache\":\"x\"}</turn_meta>\nFirst visible prompt", |
| 2727 | ), |
| 2728 | text_message( |
| 2729 | "assistant", |
| 2730 | "<thinking>hidden reasoning</thinking>\nFirst visible answer", |
| 2731 | ), |
| 2732 | text_message("user", "Second prompt"), |
| 2733 | text_message("assistant", "Second answer"), |
| 2734 | text_message("user", "Third prompt"), |
| 2735 | text_message("assistant", "Third answer"), |
| 2736 | text_message("user", "Fourth prompt beyond old six-message preview"), |
| 2737 | ]; |
| 2738 | let session = saved_session_with_messages(messages); |
| 2739 | let lines = build_preview_lines(&session, Locale::En).join("\n"); |
| 2740 | |
| 2741 | assert!(lines.contains("Title: Clean session title")); |
| 2742 | assert!(lines.contains("First visible prompt")); |
| 2743 | assert!(lines.contains("First visible answer")); |
| 2744 | assert!(lines.contains("Fourth prompt beyond old six-message preview")); |
| 2745 | assert!(!lines.contains("turn_meta")); |
| 2746 | assert!(!lines.contains("hidden reasoning")); |
| 2747 | } |
| 2748 | |
| 2749 | #[test] |
| 2750 | fn ensure_selected_visible_updates_scroll_window() { |
| 2751 | let sessions = (0..10) |
| 2752 | .map(|idx| test_session(idx, &format!("Session {idx}"))) |
| 2753 | .collect::<Vec<_>>(); |
| 2754 | |
| 2755 | let mut view = SessionPickerView { |
| 2756 | sessions: sessions.clone(), |
| 2757 | pending_list: None, |
| 2758 | load_error: None, |
| 2759 | filtered: sessions, |
| 2760 | selected: 0, |
| 2761 | list_scroll: Cell::new(0), |
| 2762 | list_visible_rows: Cell::new(3), |
| 2763 | history_scroll: Cell::new(0), |
| 2764 | history_pinned_to_latest: Cell::new(true), |
| 2765 | history_visible_rows: Cell::new(12), |
| 2766 | search_input: String::new(), |
| 2767 | search_mode: false, |
| 2768 | sort_mode: SessionSortMode::Recent, |
| 2769 | preview_cache: PreviewCache::default(), |
| 2770 | current_preview: Vec::new(), |
| 2771 | preview_generation: 0, |
| 2772 | pending_preview: None, |
| 2773 | confirm_delete: false, |
| 2774 | rename_mode: false, |
| 2775 | rename_input: String::new(), |
| 2776 | status: None, |
| 2777 | workspace_scope: None, |
| 2778 | show_all_workspaces: true, |
| 2779 | show_archived: false, |
| 2780 | hide_empty_sessions: false, |
| 2781 | current_session_id: None, |
| 2782 | last_row_hitboxes: RefCell::new(Vec::new()), |
| 2783 | locale: Locale::En, |
| 2784 | }; |
| 2785 | |
| 2786 | view.selected = 6; |
| 2787 | view.ensure_selected_visible(); |
| 2788 | assert_eq!(view.list_scroll.get(), 4); |
| 2789 | |
| 2790 | view.selected = 1; |
| 2791 | view.ensure_selected_visible(); |
| 2792 | assert_eq!(view.list_scroll.get(), 1); |
| 2793 | |
| 2794 | view.selected = 9; |
| 2795 | view.ensure_selected_visible(); |
| 2796 | assert_eq!(view.list_scroll.get(), 7); |
| 2797 | } |
| 2798 | |
| 2799 | #[test] |
| 2800 | fn session_picker_is_usable_and_opaque_at_blocker_sizes() { |
| 2801 | use crate::tui::views::ViewStack; |
| 2802 | |
| 2803 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 2804 | for (w, h) in BLOCKER_SIZES { |
| 2805 | let sessions = vec![ |
| 2806 | test_session(1, "first session"), |
| 2807 | test_session(2, "second session"), |
| 2808 | ]; |
| 2809 | let mut view = picker_with(sessions, None); |
| 2810 | view.current_preview = vec![ |
| 2811 | "Title: preview".to_string(), |
| 2812 | "Updated: 2026-06-25 10:30".to_string(), |
| 2813 | String::new(), |
| 2814 | "USER: hello".to_string(), |
| 2815 | ]; |
| 2816 | |
| 2817 | let area = Rect::new(0, 0, w, h); |
| 2818 | let mut buf = Buffer::empty(area); |
| 2819 | for y in 0..h { |
| 2820 | for x in 0..w { |
| 2821 | buf[(x, y)].set_symbol("X"); |
| 2822 | } |
| 2823 | } |
| 2824 | let mut stack = ViewStack::new(); |
| 2825 | stack.push(view); |
| 2826 | stack.render(area, &mut buf); |
| 2827 | |
| 2828 | let rows: Vec<String> = (0..h) |
| 2829 | .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect()) |
| 2830 | .collect(); |
| 2831 | let text = rows.join("\n"); |
| 2832 | |
| 2833 | // Both panes and their key hints survive at every size. The long |
| 2834 | // in-pane action header truncates to the (sometimes narrow) list |
| 2835 | // pane width, so assert the pane titles, which carry the digit-jump |
| 2836 | // and paging shortcuts and always fit. |
| 2837 | assert!(text.contains("sessions"), "{w}x{h}: missing sessions pane"); |
| 2838 | assert!(text.contains("history"), "{w}x{h}: missing history pane"); |
| 2839 | assert!(text.contains("1-9"), "{w}x{h}: missing 1-9 shortcut hint"); |
| 2840 | assert!(text.contains("PgUp/PgDn"), "{w}x{h}: missing paging hint"); |
| 2841 | |
| 2842 | // Composited frame is fully opaque. |
| 2843 | assert!(!text.contains('X'), "{w}x{h}: background bleed-through"); |
| 2844 | assert_eq!( |
| 2845 | buf[(w / 2, h / 2)].bg, |
| 2846 | palette::WHALE_BG, |
| 2847 | "{w}x{h}: modal interior must be opaque" |
| 2848 | ); |
| 2849 | |
| 2850 | // No horizontal overflow. |
| 2851 | for (y, row) in rows.iter().enumerate() { |
| 2852 | assert!( |
| 2853 | UnicodeWidthStr::width(row.trim_end()) <= w as usize, |
| 2854 | "{w}x{h}: row {y} overflows width: {row:?}" |
| 2855 | ); |
| 2856 | } |
| 2857 | } |
| 2858 | } |
| 2859 | |
| 2860 | /// Drive `tick` until the background preview load has been applied. |
| 2861 | fn wait_for_preview(view: &mut SessionPickerView) { |
| 2862 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); |
| 2863 | while view.pending_preview.is_some() { |
| 2864 | view.tick(); |
| 2865 | assert!( |
| 2866 | std::time::Instant::now() < deadline, |
| 2867 | "preview load never landed" |
| 2868 | ); |
| 2869 | std::thread::sleep(std::time::Duration::from_millis(5)); |
| 2870 | } |
| 2871 | } |
| 2872 | |
| 2873 | fn select_id(view: &mut SessionPickerView, id: &str) { |
| 2874 | view.selected = view |
| 2875 | .filtered |
| 2876 | .iter() |
| 2877 | .position(|session| session.id == id) |
| 2878 | .expect("session listed"); |
| 2879 | } |
| 2880 | |
| 2881 | #[test] |
| 2882 | fn preview_cache_is_a_bounded_lru() { |
| 2883 | let mut cache = PreviewCache::default(); |
| 2884 | for idx in 0..PREVIEW_CACHE_CAPACITY { |
| 2885 | cache.insert(format!("s{idx}"), vec![format!("line {idx}")]); |
| 2886 | } |
| 2887 | // Touch the oldest so it survives the next eviction. |
| 2888 | assert!(cache.get("s0").is_some()); |
| 2889 | cache.insert("new".to_string(), vec!["new".to_string()]); |
| 2890 | assert_eq!(cache.len(), PREVIEW_CACHE_CAPACITY); |
| 2891 | assert!(cache.get("s0").is_some(), "recently used entry survives"); |
| 2892 | assert!(cache.get("s1").is_none(), "least recently used is evicted"); |
| 2893 | for idx in 0..40 { |
| 2894 | cache.insert(format!("more{idx}"), Vec::new()); |
| 2895 | } |
| 2896 | assert_eq!(cache.len(), PREVIEW_CACHE_CAPACITY); |
| 2897 | } |
| 2898 | |
| 2899 | /// Selecting a session must not read and parse its transcript on the |
| 2900 | /// event loop: inside a runtime the pane shows a loading placeholder at |
| 2901 | /// once, and the transcript arrives through `tick`. A load that finishes |
| 2902 | /// after the selection moved on is never shown. |
| 2903 | #[tokio::test] |
| 2904 | async fn preview_loads_off_the_event_loop_and_drops_stale_results() { |
| 2905 | let _lock = crate::test_support::lock_test_env(); |
| 2906 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 2907 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 2908 | let manager = SessionManager::default_location().expect("session manager"); |
| 2909 | let mut first = saved_session_with_messages(vec![text_message("user", "alpha body")]); |
| 2910 | first.metadata.id = "session-alpha".to_string(); |
| 2911 | let mut second = saved_session_with_messages(vec![text_message("user", "beta body")]); |
| 2912 | second.metadata.id = "session-beta".to_string(); |
| 2913 | manager.save_session(&first).expect("save first"); |
| 2914 | manager.save_session(&second).expect("save second"); |
| 2915 | let mut view = picker_with(vec![first.metadata.clone(), second.metadata.clone()], None); |
| 2916 | |
| 2917 | select_id(&mut view, "session-alpha"); |
| 2918 | view.refresh_preview(); |
| 2919 | assert!( |
| 2920 | view.pending_preview.is_some(), |
| 2921 | "load must run in the background" |
| 2922 | ); |
| 2923 | assert!( |
| 2924 | view.current_preview.iter().any(|line| line == "\u{2026}") |
| 2925 | && view |
| 2926 | .current_preview |
| 2927 | .iter() |
| 2928 | .any(|line| line.contains("session-alpha")), |
| 2929 | "placeholder names the session and marks the pending body: {:?}", |
| 2930 | view.current_preview |
| 2931 | ); |
| 2932 | let alpha_generation = view.preview_generation; |
| 2933 | |
| 2934 | // Move on before alpha lands: alpha's result must never be shown. |
| 2935 | select_id(&mut view, "session-beta"); |
| 2936 | view.refresh_preview(); |
| 2937 | assert_ne!(view.preview_generation, alpha_generation); |
| 2938 | wait_for_preview(&mut view); |
| 2939 | let shown = view.current_preview.join("\n"); |
| 2940 | assert!(shown.contains("beta body"), "{shown}"); |
| 2941 | assert!(!shown.contains("alpha body"), "{shown}"); |
| 2942 | |
| 2943 | // Returning to alpha loads it again, and a second visit is cached. |
| 2944 | select_id(&mut view, "session-alpha"); |
| 2945 | view.refresh_preview(); |
| 2946 | wait_for_preview(&mut view); |
| 2947 | assert!(view.current_preview.join("\n").contains("alpha body")); |
| 2948 | select_id(&mut view, "session-beta"); |
| 2949 | view.refresh_preview(); |
| 2950 | assert!( |
| 2951 | view.pending_preview.is_none(), |
| 2952 | "a cached preview is shown without another load" |
| 2953 | ); |
| 2954 | assert!(view.current_preview.join("\n").contains("beta body")); |
| 2955 | } |
| 2956 | |
| 2957 | /// A preview that lands in the background must repaint the frame on its |
| 2958 | /// own; otherwise the placeholder stays up until the next key press. |
| 2959 | #[tokio::test] |
| 2960 | async fn landed_preview_requests_a_redraw_through_the_view_stack() { |
| 2961 | let _lock = crate::test_support::lock_test_env(); |
| 2962 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 2963 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 2964 | let manager = SessionManager::default_location().expect("session manager"); |
| 2965 | let mut saved = saved_session_with_messages(vec![text_message("user", "gamma body")]); |
| 2966 | saved.metadata.id = "session-gamma".to_string(); |
| 2967 | manager.save_session(&saved).expect("save session"); |
| 2968 | let mut view = picker_with(vec![saved.metadata.clone()], None); |
| 2969 | select_id(&mut view, "session-gamma"); |
| 2970 | view.refresh_preview(); |
| 2971 | assert!( |
| 2972 | view.pending_preview.is_some(), |
| 2973 | "load runs in the background" |
| 2974 | ); |
| 2975 | |
| 2976 | let mut stack = crate::tui::views::ViewStack::new(); |
| 2977 | stack.push(view); |
| 2978 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); |
| 2979 | let mut redraws = 0; |
| 2980 | loop { |
| 2981 | let tick = stack.tick(); |
| 2982 | assert!(tick.events.is_empty(), "a preview load emits no event"); |
| 2983 | if tick.redraw { |
| 2984 | redraws += 1; |
| 2985 | break; |
| 2986 | } |
| 2987 | assert!( |
| 2988 | std::time::Instant::now() < deadline, |
| 2989 | "preview load never requested a redraw" |
| 2990 | ); |
| 2991 | std::thread::sleep(std::time::Duration::from_millis(5)); |
| 2992 | } |
| 2993 | assert_eq!(redraws, 1); |
| 2994 | assert!( |
| 2995 | !stack.tick().redraw, |
| 2996 | "an idle tick after the preview landed must not keep repainting" |
| 2997 | ); |
| 2998 | } |
| 2999 | |
| 3000 | /// U08-09: a session store that cannot be listed is reported as such, |
| 3001 | /// never painted as "No saved sessions yet". |
| 3002 | #[test] |
| 3003 | fn unreadable_session_store_is_reported_not_shown_empty() { |
| 3004 | let _lock = crate::test_support::lock_test_env(); |
| 3005 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 3006 | // A regular file where the store directory belongs cannot be listed. |
| 3007 | std::fs::write(tmp.path().join("sessions"), b"not a directory").expect("file"); |
| 3008 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path()); |
| 3009 | let view = SessionPickerView::new(tmp.path(), Locale::En); |
| 3010 | assert!(view.load_error.is_some(), "the listing failure was dropped"); |
| 3011 | let lines = build_list_lines( |
| 3012 | &view.filtered, |
| 3013 | 0, |
| 3014 | 120, |
| 3015 | 0, |
| 3016 | 5, |
| 3017 | false, |
| 3018 | "", |
| 3019 | "recent", |
| 3020 | false, |
| 3021 | false, |
| 3022 | "", |
| 3023 | None, |
| 3024 | view.list_notice(), |
| 3025 | None, |
| 3026 | Locale::En, |
| 3027 | ); |
| 3028 | let text = lines |
| 3029 | .iter() |
| 3030 | .map(|line| { |
| 3031 | line.spans |
| 3032 | .iter() |
| 3033 | .map(|span| span.content.as_ref()) |
| 3034 | .collect::<String>() |
| 3035 | }) |
| 3036 | .collect::<Vec<_>>() |
| 3037 | .join("\n"); |
| 3038 | assert!(text.contains("Could not open sessions"), "{text}"); |
| 3039 | assert!(!text.contains("No saved sessions yet"), "{text}"); |
| 3040 | } |
| 3041 | } |
| 3042 |