返回 CodeWhale
session_picker.rs
根目录 / crates / tui / src / tui / session_picker.rs
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
3042 lines RUST