返回 DeepSeek-TUI-2026
session_picker.rs
根目录 / crates / tui / src / tui / session_picker.rs
1 //! Session resume picker view for the TUI.
2
3 use std::cell::Cell;
4 use std::collections::HashMap;
5
6 use chrono::{DateTime, Local};
7 use crossterm::event::{KeyCode, KeyEvent};
8 use ratatui::{
9 buffer::Buffer,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
14 };
15 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16
17 use crate::palette;
18 use crate::session_manager::{SavedSession, SessionManager, SessionMetadata};
19 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
20
21 fn modal_block(title: &str) -> Block<'static> {
22 Block::default()
23 .title(Line::from(vec![Span::styled(
24 title.to_string(),
25 Style::default()
26 .fg(palette::DEEPSEEK_BLUE)
27 .add_modifier(Modifier::BOLD),
28 )]))
29 .borders(Borders::ALL)
30 .border_style(Style::default().fg(palette::BORDER_COLOR))
31 .padding(Padding::uniform(1))
32 }
33
34 #[derive(Debug, Clone, Copy)]
35 enum SortMode {
36 Recent,
37 Name,
38 Size,
39 }
40
41 pub struct SessionPickerView {
42 sessions: Vec<SessionMetadata>,
43 filtered: Vec<SessionMetadata>,
44 selected: usize,
45 list_scroll: Cell<usize>,
46 list_visible_rows: Cell<usize>,
47 search_input: String,
48 search_mode: bool,
49 sort_mode: SortMode,
50 preview_cache: HashMap<String, Vec<String>>,
51 current_preview: Vec<String>,
52 confirm_delete: bool,
53 status: Option<String>,
54 }
55
56 impl SessionPickerView {
57 pub fn new() -> Self {
58 let sessions = SessionManager::default_location()
59 .and_then(|manager| manager.list_sessions())
60 .unwrap_or_default();
61
62 let mut view = Self {
63 sessions,
64 filtered: Vec::new(),
65 selected: 0,
66 list_scroll: Cell::new(0),
67 list_visible_rows: Cell::new(8),
68 search_input: String::new(),
69 search_mode: false,
70 sort_mode: SortMode::Recent,
71 preview_cache: HashMap::new(),
72 current_preview: Vec::new(),
73 confirm_delete: false,
74 status: None,
75 };
76 view.apply_sort_and_filter();
77 view.refresh_preview();
78 view
79 }
80
81 fn apply_sort_and_filter(&mut self) {
82 match self.sort_mode {
83 SortMode::Recent => {
84 self.sessions
85 .sort_by_key(|s| std::cmp::Reverse(s.updated_at));
86 }
87 SortMode::Name => {
88 self.sessions.sort_by(|a, b| a.title.cmp(&b.title));
89 }
90 SortMode::Size => {
91 self.sessions
92 .sort_by_key(|s| std::cmp::Reverse(s.message_count));
93 }
94 }
95
96 let query = self.search_input.trim().to_ascii_lowercase();
97 if query.is_empty() {
98 self.filtered = self.sessions.clone();
99 } else {
100 self.filtered = self
101 .sessions
102 .iter()
103 .filter(|session| fuzzy_match(&query, session))
104 .cloned()
105 .collect();
106 }
107
108 if self.selected >= self.filtered.len() {
109 self.selected = 0;
110 }
111 self.ensure_selected_visible();
112
113 self.refresh_preview();
114 }
115
116 fn move_selection(&mut self, delta: isize) {
117 if self.filtered.is_empty() {
118 self.selected = 0;
119 return;
120 }
121 let len = self.filtered.len() as isize;
122 let next = (self.selected as isize + delta).clamp(0, len - 1) as usize;
123 self.selected = next;
124 self.ensure_selected_visible();
125 self.refresh_preview();
126 }
127
128 fn update_list_viewport(&self, visible_rows: usize) {
129 self.list_visible_rows.set(visible_rows.max(1));
130 self.ensure_selected_visible();
131 }
132
133 fn ensure_selected_visible(&self) {
134 if self.filtered.is_empty() {
135 self.list_scroll.set(0);
136 return;
137 }
138
139 let visible_rows = self.list_visible_rows.get().max(1);
140 let max_scroll = self.filtered.len().saturating_sub(visible_rows);
141 let mut scroll = self.list_scroll.get().min(max_scroll);
142
143 if self.selected < scroll {
144 scroll = self.selected;
145 } else if self.selected >= scroll.saturating_add(visible_rows) {
146 scroll = self.selected.saturating_add(1).saturating_sub(visible_rows);
147 }
148
149 self.list_scroll.set(scroll.min(max_scroll));
150 }
151
152 fn selected_session(&self) -> Option<&SessionMetadata> {
153 self.filtered.get(self.selected)
154 }
155
156 fn cycle_sort(&mut self) {
157 self.sort_mode = match self.sort_mode {
158 SortMode::Recent => SortMode::Name,
159 SortMode::Name => SortMode::Size,
160 SortMode::Size => SortMode::Recent,
161 };
162 self.apply_sort_and_filter();
163 self.status = Some(format!("Sort: {}", self.sort_label()));
164 }
165
166 fn sort_label(&self) -> &'static str {
167 match self.sort_mode {
168 SortMode::Recent => "recent",
169 SortMode::Name => "name",
170 SortMode::Size => "size",
171 }
172 }
173
174 fn enter_search(&mut self) {
175 self.search_mode = true;
176 self.search_input.clear();
177 self.status = Some("Search: type to filter, Enter to apply".to_string());
178 }
179
180 fn exit_search(&mut self) {
181 self.search_mode = false;
182 self.apply_sort_and_filter();
183 self.status = None;
184 }
185
186 fn delete_selected(&mut self) -> Option<ViewEvent> {
187 let session = self.selected_session().cloned()?;
188 let manager = SessionManager::default_location().ok()?;
189 if let Err(err) = manager.delete_session(&session.id) {
190 self.status = Some(format!("Delete failed: {err}"));
191 return None;
192 }
193 self.sessions.retain(|s| s.id != session.id);
194 self.apply_sort_and_filter();
195 self.refresh_preview();
196 self.status = Some(format!(
197 "Deleted session {}",
198 crate::session_manager::truncate_id(&session.id)
199 ));
200 Some(ViewEvent::SessionDeleted {
201 session_id: session.id,
202 title: session.title,
203 })
204 }
205
206 fn refresh_preview(&mut self) {
207 let Some(session) = self.selected_session() else {
208 self.current_preview = vec!["No sessions found.".to_string()];
209 return;
210 };
211
212 if let Some(lines) = self.preview_cache.get(&session.id) {
213 self.current_preview = lines.clone();
214 return;
215 }
216
217 let manager = match SessionManager::default_location() {
218 Ok(manager) => manager,
219 Err(_) => {
220 self.current_preview = vec!["Failed to open sessions directory.".to_string()];
221 return;
222 }
223 };
224
225 let saved = match manager.load_session(&session.id) {
226 Ok(saved) => saved,
227 Err(_) => {
228 self.current_preview = vec!["Failed to load session preview.".to_string()];
229 return;
230 }
231 };
232
233 let preview = build_preview_lines(&saved);
234 self.preview_cache
235 .insert(session.id.clone(), preview.clone());
236 self.current_preview = preview;
237 }
238 }
239
240 impl ModalView for SessionPickerView {
241 fn kind(&self) -> ModalKind {
242 ModalKind::SessionPicker
243 }
244
245 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
246 self
247 }
248
249 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
250 if self.search_mode {
251 match key.code {
252 KeyCode::Enter => {
253 self.exit_search();
254 }
255 KeyCode::Esc => {
256 self.exit_search();
257 return ViewAction::None;
258 }
259 KeyCode::Backspace => {
260 self.search_input.pop();
261 self.apply_sort_and_filter();
262 return ViewAction::None;
263 }
264 KeyCode::Char(c) => {
265 self.search_input.push(c);
266 self.apply_sort_and_filter();
267 return ViewAction::None;
268 }
269 _ => {}
270 }
271 }
272
273 if self.confirm_delete {
274 match key.code {
275 KeyCode::Char('y') | KeyCode::Char('Y') => {
276 self.confirm_delete = false;
277 if let Some(event) = self.delete_selected() {
278 return ViewAction::Emit(event);
279 }
280 return ViewAction::None;
281 }
282 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
283 self.confirm_delete = false;
284 self.status = Some("Delete cancelled".to_string());
285 return ViewAction::None;
286 }
287 _ => return ViewAction::None,
288 }
289 }
290
291 match key.code {
292 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
293 KeyCode::Up | KeyCode::Char('k') => {
294 self.move_selection(-1);
295 ViewAction::None
296 }
297 KeyCode::Down | KeyCode::Char('j') => {
298 self.move_selection(1);
299 ViewAction::None
300 }
301 KeyCode::PageUp => {
302 self.move_selection(-5);
303 ViewAction::None
304 }
305 KeyCode::PageDown => {
306 self.move_selection(5);
307 ViewAction::None
308 }
309 KeyCode::Char('/') => {
310 self.enter_search();
311 ViewAction::None
312 }
313 KeyCode::Char('s') | KeyCode::Char('S') => {
314 self.cycle_sort();
315 ViewAction::None
316 }
317 KeyCode::Char('d') | KeyCode::Char('D') => {
318 self.confirm_delete = true;
319 self.status = Some("Delete session? (y/n)".to_string());
320 ViewAction::None
321 }
322 KeyCode::Enter => {
323 if let Some(session) = self.selected_session() {
324 ViewAction::EmitAndClose(ViewEvent::SessionSelected {
325 session_id: session.id.clone(),
326 })
327 } else {
328 ViewAction::None
329 }
330 }
331 _ => ViewAction::None,
332 }
333 }
334
335 fn render(&self, area: Rect, buf: &mut Buffer) {
336 let popup_area = Rect {
337 x: area.x.saturating_add(1),
338 y: area.y.saturating_add(1),
339 width: area.width.saturating_sub(2),
340 height: area.height.saturating_sub(2),
341 };
342
343 Clear.render(popup_area, buf);
344
345 let chunks = Layout::default()
346 .direction(if popup_area.width < 95 {
347 Direction::Vertical
348 } else {
349 Direction::Horizontal
350 })
351 .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
352 .split(popup_area);
353
354 let list_inner = modal_block(" Sessions ").inner(chunks[0]);
355 let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some());
356 let footer_rows = usize::from(!self.filtered.is_empty());
357 let visible_rows = usize::from(list_inner.height)
358 .saturating_sub(header_rows + footer_rows)
359 .max(1);
360 self.update_list_viewport(visible_rows);
361 let list_scroll = self.list_scroll.get();
362
363 let list_lines = build_list_lines(
364 &self.filtered,
365 self.selected,
366 list_inner.width,
367 list_scroll,
368 visible_rows,
369 self.search_mode,
370 &self.search_input,
371 self.sort_label(),
372 self.confirm_delete,
373 self.status.as_deref(),
374 );
375 let list = Paragraph::new(list_lines)
376 .block(modal_block(" Sessions "))
377 .wrap(Wrap { trim: false });
378 list.render(chunks[0], buf);
379
380 let preview_inner = modal_block(" Preview ").inner(chunks[1]);
381 let preview_lines = format_preview(
382 &self.current_preview,
383 preview_inner.width,
384 preview_inner.height as usize,
385 );
386
387 let preview = Paragraph::new(preview_lines)
388 .block(modal_block(" Preview "))
389 .wrap(Wrap { trim: false });
390 preview.render(chunks[1], buf);
391 }
392 }
393
394 #[allow(clippy::too_many_arguments)]
395 fn build_list_lines(
396 sessions: &[SessionMetadata],
397 selected: usize,
398 width: u16,
399 scroll: usize,
400 visible_rows: usize,
401 search_mode: bool,
402 search_input: &str,
403 sort_label: &str,
404 confirm_delete: bool,
405 status: Option<&str>,
406 ) -> Vec<Line<'static>> {
407 let mut lines = Vec::new();
408 let header = if search_mode {
409 format!("/{}", search_input)
410 } else {
411 format!("Sort: {sort_label} | / search | s sort | d delete")
412 };
413 lines.push(Line::from(Span::styled(
414 truncate(&header, width),
415 Style::default().fg(palette::TEXT_MUTED),
416 )));
417
418 if confirm_delete {
419 lines.push(Line::from(Span::styled(
420 "Confirm delete (y/n)",
421 Style::default()
422 .fg(palette::STATUS_WARNING)
423 .add_modifier(Modifier::BOLD),
424 )));
425 } else if let Some(status) = status {
426 lines.push(Line::from(Span::styled(
427 truncate(status, width),
428 Style::default().fg(palette::DEEPSEEK_SKY),
429 )));
430 }
431
432 if sessions.is_empty() {
433 lines.push(Line::from(Span::styled(
434 "No sessions available.",
435 Style::default().fg(palette::TEXT_MUTED),
436 )));
437 return lines;
438 }
439
440 for (idx, session) in sessions.iter().enumerate().skip(scroll).take(visible_rows) {
441 let mut line = format_session_line(session);
442 line = truncate(&line, width);
443 let style = if idx == selected {
444 Style::default()
445 .fg(palette::SELECTION_TEXT)
446 .bg(palette::SELECTION_BG)
447 } else {
448 Style::default().fg(palette::TEXT_PRIMARY)
449 };
450 lines.push(Line::from(Span::styled(line, style)));
451 }
452
453 if sessions.len() > visible_rows {
454 let start = scroll.saturating_add(1);
455 let end = (scroll + visible_rows).min(sessions.len());
456 lines.push(Line::from(Span::styled(
457 truncate(
458 &format!("Showing {start}-{end} / {}", sessions.len()),
459 width,
460 ),
461 Style::default().fg(palette::TEXT_DIM),
462 )));
463 }
464
465 lines
466 }
467
468 fn format_session_line(session: &SessionMetadata) -> String {
469 let updated = format_relative_time(&session.updated_at);
470 let title = truncate(&session.title, 32);
471 let mode = session
472 .mode
473 .as_deref()
474 .unwrap_or("unknown")
475 .to_ascii_lowercase();
476 format!(
477 "{} | {} | {} msgs | {} | {}",
478 crate::session_manager::truncate_id(&session.id),
479 title,
480 session.message_count,
481 mode,
482 updated
483 )
484 }
485
486 fn build_preview_lines(session: &SavedSession) -> Vec<String> {
487 let mut out = Vec::new();
488 out.push(format!("Title: {}", session.metadata.title));
489 out.push(format!(
490 "Updated: {}",
491 session
492 .metadata
493 .updated_at
494 .with_timezone(&Local)
495 .format("%Y-%m-%d %H:%M")
496 ));
497 out.push(format!(
498 "Messages: {} | Model: {}",
499 session.metadata.message_count, session.metadata.model
500 ));
501 if let Some(mode) = session.metadata.mode.as_deref() {
502 out.push(format!("Mode: {}", mode));
503 }
504 out.push("".to_string());
505
506 for message in session.messages.iter().take(6) {
507 let role = message.role.to_ascii_uppercase();
508 let mut text = String::new();
509 for block in &message.content {
510 if let crate::models::ContentBlock::Text { text: body, .. } = block {
511 text.push_str(body);
512 }
513 }
514 let preview = truncate(&text.replace('\n', " "), 120);
515 out.push(format!("{role}: {preview}"));
516 }
517 out
518 }
519
520 fn format_preview(lines: &[String], width: u16, height: usize) -> Vec<Line<'static>> {
521 let mut out = Vec::new();
522 let available = height.saturating_sub(2).max(1);
523 for line in lines.iter().take(available) {
524 out.push(Line::from(Span::styled(
525 truncate(line, width),
526 Style::default().fg(palette::TEXT_PRIMARY),
527 )));
528 }
529 out
530 }
531
532 fn format_relative_time(dt: &DateTime<chrono::Utc>) -> String {
533 let now = chrono::Utc::now();
534 let duration = now.signed_duration_since(*dt);
535 if duration.num_minutes() < 1 {
536 "just now".to_string()
537 } else if duration.num_hours() < 1 {
538 format!("{}m ago", duration.num_minutes())
539 } else if duration.num_days() < 1 {
540 format!("{}h ago", duration.num_hours())
541 } else {
542 format!("{}d ago", duration.num_days())
543 }
544 }
545
546 fn truncate(text: &str, width: u16) -> String {
547 let max = width.max(1) as usize;
548 if text.width() <= max {
549 return text.to_string();
550 }
551 let mut out = String::new();
552 let mut current = 0;
553 for ch in text.chars() {
554 let w = ch.width().unwrap_or(0);
555 if current + w >= max.saturating_sub(3) {
556 break;
557 }
558 out.push(ch);
559 current += w;
560 }
561 out.push_str("...");
562 out
563 }
564
565 fn fuzzy_match(query: &str, session: &SessionMetadata) -> bool {
566 let haystack = format!(
567 "{} {} {}",
568 session.title,
569 session.id,
570 session.workspace.display()
571 )
572 .to_ascii_lowercase();
573 if haystack.contains(query) {
574 return true;
575 }
576 is_subsequence(query, &haystack)
577 }
578
579 fn is_subsequence(needle: &str, haystack: &str) -> bool {
580 let mut chars = needle.chars();
581 let mut current = match chars.next() {
582 Some(c) => c,
583 None => return true,
584 };
585 for ch in haystack.chars() {
586 if ch == current {
587 if let Some(next) = chars.next() {
588 current = next;
589 } else {
590 return true;
591 }
592 }
593 }
594 false
595 }
596
597 #[cfg(test)]
598 mod tests {
599 use super::*;
600 use chrono::Utc;
601 use unicode_width::UnicodeWidthStr;
602
603 fn test_session(idx: usize, title: &str) -> SessionMetadata {
604 SessionMetadata {
605 id: format!("session-{idx:02}"),
606 title: title.to_string(),
607 created_at: Utc::now(),
608 updated_at: Utc::now(),
609 message_count: idx + 1,
610 total_tokens: 100,
611 model: "deepseek-v4-pro".to_string(),
612 workspace: std::path::PathBuf::from("/tmp"),
613 mode: Some("agent".to_string()),
614 }
615 }
616
617 #[test]
618 fn build_list_lines_truncates_to_list_pane_width() {
619 let sessions = vec![test_session(
620 1,
621 "A very long title that should be truncated by the list pane width",
622 )];
623 let width = 24;
624 let lines = build_list_lines(&sessions, 0, width, 0, 5, false, "", "recent", false, None);
625
626 for line in lines {
627 let rendered_width: usize = line.spans.iter().map(|span| span.content.width()).sum();
628 assert!(
629 rendered_width <= width as usize,
630 "line width {} exceeded pane width {}",
631 rendered_width,
632 width
633 );
634 }
635 }
636
637 #[test]
638 fn ensure_selected_visible_updates_scroll_window() {
639 let sessions = (0..10)
640 .map(|idx| test_session(idx, &format!("Session {idx}")))
641 .collect::<Vec<_>>();
642
643 let mut view = SessionPickerView {
644 sessions: sessions.clone(),
645 filtered: sessions,
646 selected: 0,
647 list_scroll: Cell::new(0),
648 list_visible_rows: Cell::new(3),
649 search_input: String::new(),
650 search_mode: false,
651 sort_mode: SortMode::Recent,
652 preview_cache: HashMap::new(),
653 current_preview: Vec::new(),
654 confirm_delete: false,
655 status: None,
656 };
657
658 view.selected = 6;
659 view.ensure_selected_visible();
660 assert_eq!(view.list_scroll.get(), 4);
661
662 view.selected = 1;
663 view.ensure_selected_visible();
664 assert_eq!(view.list_scroll.get(), 1);
665
666 view.selected = 9;
667 view.ensure_selected_visible();
668 assert_eq!(view.list_scroll.get(), 7);
669 }
670 }
671
671 lines RUST