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