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