diff --git a/src/tui/app.rs b/src/tui/app.rs index 68356cc..428aa29 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -25,9 +25,9 @@ use crate::tui::search_state::{ }; use crate::tui::search_worker::{SearchPhase, SearchRequest, SearchResponse, SearchWorker}; use crate::tui::share_state::{ - AppMode, PendingCommandAction, PendingResume, ResumeOrigin, SharePopup, + AppMode, PendingCommandAction, PendingDelete, PendingResume, ResumeOrigin, SharePopup, }; -use crate::tui::text_layout::wrap_visual_rows; +use crate::tui::text_layout::{GUTTER_WIDTH, wrap_visual_rows}; use crate::tui::usage_state::UsageTab; use crate::tui::viewing_state::{SanitizedLine, ViewingSessionSummary, build_viewing_caches}; use crate::types::{BackgroundJobStatus, MatchSource, Message, SearchResult, SemanticProgress}; @@ -164,6 +164,7 @@ pub(crate) struct App { pub(crate) semantic_last_refresh: Instant, pub(crate) settings_selected: usize, pub(crate) pending_resume: Option, + pub(crate) pending_delete: Option, pub(crate) handoff_target_selected: usize, pub(crate) share_popup: Option, pub(crate) share_publish_rx: Option>>, @@ -255,6 +256,7 @@ impl App { semantic_last_refresh: Instant::now(), settings_selected: 0, pending_resume: None, + pending_delete: None, handoff_target_selected: 0, share_popup: None, share_publish_rx: None, @@ -452,6 +454,7 @@ impl App { AppMode::Filters => self.handle_filters_key(key, store), AppMode::HandoffTarget => self.handle_handoff_target_key(key), AppMode::ConfirmResume => self.handle_confirm_resume_key(key), + AppMode::ConfirmDelete => self.handle_confirm_delete_key(key, store), } } @@ -768,7 +771,13 @@ impl App { let lines = self.viewing_sanitized_lines.get(index); let body: usize = lines .map(|lines| { - lines.iter().map(|line| wrap_visual_rows(&line.text, inner_width).len()).sum() + lines + .iter() + .map(|line| { + wrap_visual_rows(&line.text, inner_width.saturating_sub(GUTTER_WIDTH)) + .len() + }) + .sum() }) .unwrap_or(0); rows.push(body + 2); @@ -1101,6 +1110,11 @@ impl App { return; } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('x') { + self.start_delete_confirmation(ResumeOrigin::Search); + return; + } + match key.code { KeyCode::Char('q') if self.query.is_empty() && self.panel_focus == PanelFocus::SessionList => @@ -1219,6 +1233,10 @@ impl App { self.start_app_open_confirmation(ResumeOrigin::Viewing); return; } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('x') { + self.start_delete_confirmation(ResumeOrigin::Viewing); + return; + } match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.mode = AppMode::Search; @@ -1536,6 +1554,70 @@ impl App { } } + fn start_delete_confirmation(&mut self, origin: ResumeOrigin) { + let Some(result) = self.results.get(self.selected_index) else { + return; + }; + let session = &result.session; + self.pending_delete = Some(PendingDelete { + source: session.source.clone(), + source_id: session.source_id.clone(), + session_title: session.title.clone(), + source_label: self.source_label_for(&session.source).to_string(), + origin, + }); + self.mode = AppMode::ConfirmDelete; + } + + fn handle_confirm_delete_key(&mut self, key: KeyEvent, store: &Store) { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let Some(pending) = self.pending_delete.take() else { + self.mode = AppMode::Search; + return; + }; + match store.delete_session_data(&pending.source, &pending.source_id) { + Ok(()) => { + self.viewing_messages.clear(); + self.viewing_selected_msg = 0; + self.viewing_scroll_offset = 0; + self.viewing_session_summary = None; + self.viewing_search_query.clear(); + self.viewing_search_status = None; + self.viewing_sanitized_lines.clear(); + self.viewing_match_cache.clear(); + self.mode = AppMode::Search; + self.refresh_sessions_after_delete(store); + self.status_message = + Some("Removed from index (file kept on disk)".to_string()); + } + Err(err) => { + self.mode = AppMode::Search; + self.status_message = Some(format!("Delete failed: {err}")); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc | KeyCode::Enter => { + let origin = + self.pending_delete.take().map(|p| p.origin).unwrap_or(ResumeOrigin::Search); + self.mode = match origin { + ResumeOrigin::Search => AppMode::Search, + ResumeOrigin::Viewing => AppMode::Viewing, + }; + } + _ => {} + } + } + + fn refresh_sessions_after_delete(&mut self, store: &Store) { + self.update_scope_metrics(store); + if self.query.is_empty() { + self.load_recent(store); + } else { + self.queue_search_now(); + } + } + fn handle_settings_key(&mut self, key: KeyEvent, store: &Store) { match key.code { KeyCode::Esc | KeyCode::Char('q') => { @@ -2426,6 +2508,7 @@ impl App { &msgs, result.session.duration_minutes, &usage_events, + result.session.started_at, )); self.viewing_sanitized_lines = build_viewing_caches(&msgs); self.viewing_messages = msgs; @@ -2449,7 +2532,10 @@ impl App { } fn copy_current_message(&mut self) { - let text = self.viewing_messages.get(self.viewing_selected_msg).map(|m| m.content.clone()); + let text = self + .viewing_messages + .get(self.viewing_selected_msg) + .map(|m| crate::utils::strip_ansi_sequences(&m.content)); if let Some(text) = text { self.copy_to_clipboard(&text); } @@ -2622,12 +2708,7 @@ impl App { } fn short_project_label(path: &str) -> String { - let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); - match parts.len() { - 0 => path.to_string(), - 1 => parts[0].to_string(), - len => format!("{}/{}", parts[len - 2], parts[len - 1]), - } + crate::utils::project_label(path) } fn project_matches_query(path: &str, query: &str) -> bool { @@ -2721,6 +2802,7 @@ mod tests { semantic_last_refresh: Instant::now(), settings_selected: 0, pending_resume: None, + pending_delete: None, handoff_target_selected: 0, share_popup: None, share_publish_rx: None, @@ -3305,7 +3387,8 @@ mod tests { ]; let usage_events = vec![usage_event(10, 5), usage_event(-1, 4)]; - let summary = ViewingSessionSummary::from_session(&messages, None, &usage_events); + let summary = + ViewingSessionSummary::from_session(&messages, None, &usage_events, 1_700_000_000_000); assert_eq!(summary.user_messages, 2); assert_eq!(summary.total_messages, 3); @@ -3317,6 +3400,7 @@ mod tests { assert_eq!(summary.tokens.cache_write_tokens, 4); assert_eq!(summary.tokens.reasoning_tokens, 2); assert_eq!(summary.tokens.total_tokens, 31); + assert!(!summary.started_calendar.is_empty()); } #[test] @@ -3698,4 +3782,91 @@ mod tests { assert!(app.filters_dirty); assert!(!app.search_pending); } + + #[test] + fn test_should_stage_delete_and_enter_confirm_when_ctrl_x_from_search() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let mut app = app_with_sources(); + app.results = vec![codex_search_result()]; + + app.handle_search_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), &store); + + assert!(matches!(app.mode, AppMode::ConfirmDelete)); + let pending = app.pending_delete.as_ref().unwrap(); + assert_eq!(pending.source, "codex"); + assert_eq!(pending.source_id, "019e6d8d-588b-7fd2-a326-c525469ed120"); + assert!(matches!(pending.origin, ResumeOrigin::Search)); + } + + #[test] + fn test_should_delete_session_and_return_to_search_when_confirmed() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let result = codex_search_result(); + store.insert_session(&result.session).unwrap(); + store.insert_messages(&[message(Role::User, None, 0)]).unwrap(); + let mut app = app_with_sources(); + app.results = vec![result]; + + app.handle_search_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), &store); + app.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), &store); + + assert!(matches!(app.mode, AppMode::Search)); + assert!(app.pending_delete.is_none()); + assert!(store.list_sessions_by_ids(&["session1".to_string()]).unwrap().is_empty()); + assert_eq!(app.status_message.as_deref(), Some("Removed from index (file kept on disk)")); + } + + #[test] + fn test_should_restore_viewing_mode_when_delete_cancelled_from_viewing() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let result = codex_search_result(); + store.insert_session(&result.session).unwrap(); + let mut app = app_with_sources(); + app.results = vec![result]; + app.mode = AppMode::Viewing; + + app.handle_viewing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + assert!(matches!(app.mode, AppMode::ConfirmDelete)); + assert!(matches!(app.pending_delete.as_ref().unwrap().origin, ResumeOrigin::Viewing)); + + app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), &store); + + assert!(matches!(app.mode, AppMode::Viewing)); + assert!(app.pending_delete.is_none()); + assert!(!store.list_sessions_by_ids(&["session1".to_string()]).unwrap().is_empty()); + } + + #[test] + fn test_should_restore_search_mode_when_delete_cancelled_with_esc() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let mut app = app_with_sources(); + app.results = vec![codex_search_result()]; + + app.handle_search_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), &store); + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &store); + + assert!(matches!(app.mode, AppMode::Search)); + assert!(app.pending_delete.is_none()); + } + + #[test] + fn test_should_cancel_delete_when_enter_pressed() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let result = codex_search_result(); + store.insert_session(&result.session).unwrap(); + let mut app = app_with_sources(); + app.results = vec![result]; + + app.handle_search_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), &store); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &store); + + assert!(matches!(app.mode, AppMode::Search)); + assert!(app.pending_delete.is_none()); + assert!(!store.list_sessions_by_ids(&["session1".to_string()]).unwrap().is_empty()); + } } diff --git a/src/tui/share_state.rs b/src/tui/share_state.rs index ff9d70b..7573fb7 100644 --- a/src/tui/share_state.rs +++ b/src/tui/share_state.rs @@ -17,6 +17,7 @@ pub(crate) enum AppMode { Filters, HandoffTarget, ConfirmResume, + ConfirmDelete, } #[derive(Clone, Copy)] @@ -34,6 +35,14 @@ pub(crate) struct PendingResume { pub(crate) origin: ResumeOrigin, } +pub(crate) struct PendingDelete { + pub(crate) source: String, + pub(crate) source_id: String, + pub(crate) session_title: String, + pub(crate) source_label: String, + pub(crate) origin: ResumeOrigin, +} + pub(crate) struct SharePopup { pub(crate) url: Option, pub(crate) message: String, diff --git a/src/tui/text_layout.rs b/src/tui/text_layout.rs index 4e9cbda..b9e1d74 100644 --- a/src/tui/text_layout.rs +++ b/src/tui/text_layout.rs @@ -1,6 +1,9 @@ use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthChar; +/// Columns reserved at the left of each viewing-pane row for the selection gutter (`▌ ` / ` `). +pub(crate) const GUTTER_WIDTH: usize = 2; + pub(crate) fn wrap_visual_rows(text: &str, width: usize) -> Vec { wrap_spans_to_lines(vec![Span::raw(text.to_string())], width) .into_iter() diff --git a/src/tui/theme.rs b/src/tui/theme.rs index e452101..5df6236 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -1,5 +1,7 @@ use ratatui::style::Color; +use crate::types::Role; + pub(crate) struct Theme { pub(crate) text: Color, pub(crate) text_muted: Color, @@ -76,3 +78,23 @@ pub(crate) const THEME: Theme = Theme { token_cache_write: Color::Magenta, token_reasoning: Color::Yellow, }; + +/// Role -> Theme token, so the viewing/search/preview panes share one color decision +/// instead of each hardcoding a literal (or duplicating a match arm per call site). +pub(crate) fn role_color(role: &Role) -> Color { + match role { + Role::User => THEME.user, + Role::Assistant => THEME.assistant, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_color_matches_theme_user_and_assistant_tokens() { + assert_eq!(role_color(&Role::User), THEME.user); + assert_eq!(role_color(&Role::Assistant), THEME.assistant); + } +} diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 269dada..de05419 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -5,9 +5,9 @@ mod viewing; use ratatui::Frame; use ratatui::layout::{Margin, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::symbols::scrollbar; -use ratatui::text::{Line, Span}; +use ratatui::text::Span; use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState}; use crate::tui::app::App; @@ -65,19 +65,6 @@ pub(super) fn row_visible(row: usize, viewport_start: usize, viewport_end: usize row >= viewport_start && row < viewport_end } -pub(super) fn line_with_background(mut line: Line<'static>, bg: Color) -> Line<'static> { - if bg == Color::Reset { - return line; - } - - for span in &mut line.spans { - if span.style.bg.is_none() { - span.style.bg = Some(bg); - } - } - line -} - pub(super) fn render_vertical_scrollbar( f: &mut Frame, area: Rect, @@ -176,6 +163,13 @@ pub(crate) fn render(f: &mut Frame, app: &App) { } popups::render_confirm_resume(f, app); } + AppMode::ConfirmDelete => { + match app.pending_delete.as_ref().map(|p| p.origin) { + Some(ResumeOrigin::Viewing) => viewing::render_viewing(f, app), + _ => search::render_search(f, app), + } + popups::render_confirm_delete(f, app); + } } } @@ -189,6 +183,7 @@ mod tests { use crate::db::store::Store; use crate::tui::share_state::AppMode; use crate::tui::share_state::SharePopup; + use crate::tui::share_state::{PendingDelete, ResumeOrigin}; use crate::tui::viewing_state::SanitizedLine; use crate::tui::viewing_state::ViewingSessionSummary; use crate::types::{MatchSource, Message, Role, SearchResult, Session}; @@ -396,6 +391,7 @@ mod tests { reasoning_tokens: 2, total_tokens: 31, }, + started_calendar: "2024-01-01".to_string(), }); let backend = TestBackend::new(100, 8); @@ -409,6 +405,32 @@ mod tests { assert_eq!(terminal.backend().buffer()[(2, 1)].fg, THEME.summary); } + #[test] + fn render_confirm_delete_popup_shows_index_only_warning() { + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let mut app = + App::new(&store, vec![("codex".to_string(), "CDX".to_string())], AppConfig::default()); + app.mode = AppMode::ConfirmDelete; + app.results = vec![numbered_session_result(1)]; + app.pending_delete = Some(PendingDelete { + source: "codex".to_string(), + source_id: "source1".to_string(), + session_title: "Test session".to_string(), + source_label: "CDX".to_string(), + origin: ResumeOrigin::Search, + }); + + let rendered = render_to_text(&app, 100, 18); + + assert!(rendered.contains("Delete session")); + assert!(rendered.contains("search index only")); + assert!(rendered.contains("kept")); + assert!(rendered.contains("[Y]")); + assert!(rendered.contains("delete from index")); + assert!(rendered.contains("[N]")); + } + #[test] fn render_share_result_popup_shows_share_url() { crate::db::schema::register_sqlite_vec(); @@ -534,4 +556,114 @@ mod tests { } row } + + fn viewing_app_two_messages(store: &Store, selected: usize) -> App { + let mut app = + App::new(store, vec![("codex".to_string(), "CDX".to_string())], AppConfig::default()); + app.mode = AppMode::Viewing; + app.results = vec![SearchResult { + session: Session { + id: "session1".to_string(), + source: "codex".to_string(), + source_id: "source1".to_string(), + title: "Test session".to_string(), + directory: Some("/tmp/repo".to_string()), + repo_remote: None, + repo_slug: None, + repo_name: None, + started_at: 0, + updated_at: None, + message_count: 2, + entrypoint: None, + custom_title: None, + summary: None, + duration_minutes: None, + source_file_path: None, + is_import: false, + }, + match_source: MatchSource::Fts, + snippet: None, + }]; + app.viewing_messages = vec![ + Message { + session_id: "session1".to_string(), + role: Role::User, + content: "hello".to_string(), + timestamp: None, + seq: 0, + }, + Message { + session_id: "session1".to_string(), + role: Role::Assistant, + content: "world".to_string(), + timestamp: None, + seq: 1, + }, + ]; + app.viewing_sanitized_lines = vec![ + vec![SanitizedLine { text: "hello".to_string(), lower: "hello".to_string() }], + vec![SanitizedLine { text: "world".to_string(), lower: "world".to_string() }], + ]; + app.viewing_selected_msg = selected; + app + } + + #[test] + fn render_viewing_selected_message_shows_role_colored_gutter() { + use ratatui::layout::Rect; + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let app = viewing_app_two_messages(&store, 0); + + let width = 40u16; + let height = 12u16; + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let frame = terminal.draw(|f| render(f, &app)).unwrap(); + let buffer = frame.buffer; + + let layout = crate::tui::layout::viewing_layout(Rect::new(0, 0, width, height)); + let msgs = layout.messages; + + // Selected (msg 0) header row: gutter cell is the role-colored half block. + assert_eq!(buffer[(msgs.x, msgs.y)].symbol(), "▌"); + assert_eq!(buffer[(msgs.x, msgs.y)].style().fg, Some(THEME.user)); + // Header text is no longer bg-filled (ratatui Cell::style() always reports Some(_); + // an untouched/no-explicit-bg cell reports Some(Color::Reset), never DarkGray). + assert_eq!(buffer[(msgs.x + 2, msgs.y)].style().bg, Some(THEME.background)); + + // Selected body line (msg 0, one wrapped line) also carries the gutter, + // and the body text keeps its own White fg with no DarkGray fill. + assert_eq!(buffer[(msgs.x, msgs.y + 1)].symbol(), "▌"); + assert_eq!(buffer[(msgs.x, msgs.y + 1)].style().fg, Some(THEME.user)); + assert_eq!(buffer[(msgs.x + 2, msgs.y + 1)].symbol(), "h"); + assert_eq!(buffer[(msgs.x + 2, msgs.y + 1)].style().fg, Some(THEME.text)); + assert_ne!(buffer[(msgs.x + 2, msgs.y + 1)].style().bg, Some(THEME.message_highlight)); + } + + #[test] + fn render_viewing_unselected_message_shows_blank_gutter() { + use ratatui::layout::Rect; + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let app = viewing_app_two_messages(&store, 0); + + let width = 40u16; + let height = 12u16; + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let frame = terminal.draw(|f| render(f, &app)).unwrap(); + let buffer = frame.buffer; + + let layout = crate::tui::layout::viewing_layout(Rect::new(0, 0, width, height)); + let msgs = layout.messages; + + // msg 0 occupies rows 0 (header), 1 (body), 2 (blank) -> msg 1 header at row 3. + let hdr1_y = msgs.y + 3; + assert_eq!(buffer[(msgs.x, hdr1_y)].symbol(), " "); + assert_ne!(buffer[(msgs.x, hdr1_y)].symbol(), "▌"); + // Unselected header text keeps role color, no bg fill. + assert_eq!(buffer[(msgs.x + 2, hdr1_y)].style().fg, Some(THEME.assistant)); + assert_eq!(buffer[(msgs.x + 2, hdr1_y)].style().bg, Some(THEME.background)); + } } diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index f2f6415..9ed86f6 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -482,6 +482,56 @@ pub(super) fn render_confirm_resume(f: &mut Frame, app: &App) { f.render_widget(widget, popup); } +pub(super) fn render_confirm_delete(f: &mut Frame, app: &App) { + let Some(pending) = app.pending_delete.as_ref() else { + return; + }; + + let area = f.area(); + let width = area.width.clamp(40, 76); + let height: u16 = 11; + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let popup = Rect::new(x, y, width, height); + + let block = Block::default() + .title(" Delete session ") + .borders(Borders::ALL) + .border_style(Style::default().fg(THEME.error)) + .style(Style::default().bg(THEME.popup_bg)); + + let title: String = pending.session_title.chars().take(width as usize - 10).collect(); + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Source: ", Style::default().fg(THEME.text_muted)), + Span::styled( + pending.source_label.clone(), + Style::default().fg(THEME.source).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(title, Style::default().fg(THEME.text)), + ]), + Line::from(""), + Line::from(Span::styled( + " Removes this session from the search index only. The source file on disk is kept - the next sync re-indexes it unless the file is deleted or its path is excluded.", + Style::default().fg(THEME.text), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" [Y] ", Style::default().fg(THEME.error).add_modifier(Modifier::BOLD)), + Span::styled("delete from index ", Style::default().fg(THEME.text)), + Span::styled("[N] ", Style::default().fg(THEME.accent).add_modifier(Modifier::BOLD)), + Span::styled("cancel", Style::default().fg(THEME.text)), + ]), + ]; + + let widget = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + f.render_widget(Clear, popup); + f.render_widget(widget, popup); +} + pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { let semantic_span = if app.semantic_progress.total_sessions > 0 { let mut text = format!( @@ -529,6 +579,8 @@ pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { Span::styled(" resume ", Style::default().fg(THEME.text_muted)), Span::styled("Ctrl+O", Style::default().fg(THEME.accent)), Span::styled(" app ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+X", Style::default().fg(THEME.accent)), + Span::styled(" delete ", Style::default().fg(THEME.text_muted)), Span::styled("Ctrl+S", Style::default().fg(THEME.accent)), Span::styled(" settings ", Style::default().fg(THEME.text_muted)), Span::styled("Esc", Style::default().fg(THEME.accent)), diff --git a/src/tui/ui/search.rs b/src/tui/ui/search.rs index d164548..b39b75e 100644 --- a/src/tui/ui/search.rs +++ b/src/tui/ui/search.rs @@ -10,7 +10,7 @@ use crate::tui::layout::search_layout; use crate::tui::search_state::{FilterFocus, PanelFocus}; use crate::tui::text_layout::wrap_visual_rows; use crate::tui::theme::THEME; -use crate::types::{MatchSource, Role}; +use crate::types::MatchSource; use super::popups::render_status_bar; use super::{render_vertical_scrollbar, row_visible, truncate_label}; @@ -313,12 +313,12 @@ pub(super) fn render_preview(f: &mut Frame, app: &App, area: Rect) { let viewport_end = viewport_start + inner.height as usize; let mut visual_row = 0usize; let mut lines: Vec = Vec::new(); + let source = + app.results.get(app.selected_index).map(|r| r.session.source.as_str()).unwrap_or(""); for (i, msg) in app.preview_messages.iter().enumerate() { let selected = focused && i == app.preview_selected_msg; - let (prefix, color) = match msg.role { - Role::User => ("User: ", THEME.user), - Role::Assistant => ("Asst: ", THEME.assistant), - }; + let prefix = format!("{}: ", crate::utils::role_label(source, &msg.role)); + let color = crate::tui::theme::role_color(&msg.role); let time_str = crate::utils::format_message_time(msg.timestamp); let header_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { diff --git a/src/tui/ui/viewing.rs b/src/tui/ui/viewing.rs index 1deafae..7420a43 100644 --- a/src/tui/ui/viewing.rs +++ b/src/tui/ui/viewing.rs @@ -1,21 +1,21 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::layout::viewing_layout; -use crate::tui::text_layout::wrap_spans_to_lines; +use crate::tui::text_layout::{GUTTER_WIDTH, wrap_spans_to_lines}; use crate::tui::theme::THEME; use crate::tui::viewing_state::SanitizedLine; -use crate::types::Role; -use super::{ - format_compact, highlight_spans, line_with_background, render_vertical_scrollbar, row_visible, - truncate_label, -}; +use super::{format_compact, highlight_spans, render_vertical_scrollbar, truncate_label}; + +fn gutter_span(selected: bool, color: Color) -> Span<'static> { + if selected { Span::styled("▌ ", Style::default().fg(color)) } else { Span::raw(" ") } +} pub(super) fn render_viewing(f: &mut Frame, app: &App) { let layout = viewing_layout(f.area()); @@ -25,10 +25,15 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { .get(app.selected_index) .map(|r| { let s = &r.session; - let dir = s.directory.as_deref().unwrap_or(""); let count = app.viewing_messages.len(); let pos = app.viewing_selected_msg + 1; - format!(" {} — {dir} [{pos}/{count}] ", s.title) + match s.directory.as_deref().filter(|d| !d.is_empty()) { + Some(dir) => { + let project = crate::utils::project_label(dir); + format!(" {} — {project} [{pos}/{count}] ", s.title) + } + None => format!(" {} [{pos}/{count}] ", s.title), + } }) .unwrap_or_else(|| " Conversation ".to_string()); @@ -45,54 +50,42 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { app.viewing_selected_msg, layout.messages.height as usize, ); - let viewport_end = viewport_start + layout.messages.height as usize; - let mut visual_row = 0usize; let mut lines: Vec = Vec::new(); let needles = app.viewing_search_terms(); + let body_width = inner_width.saturating_sub(GUTTER_WIDTH); + let source = + app.results.get(app.selected_index).map(|r| r.session.source.as_str()).unwrap_or(""); for (i, msg) in app.viewing_messages.iter().enumerate() { let selected = i == app.viewing_selected_msg; - let (prefix, color) = match msg.role { - Role::User => ("User", THEME.user), - Role::Assistant => ("Assistant", THEME.assistant), - }; + let prefix = crate::utils::role_label(source, &msg.role); + let color = crate::tui::theme::role_color(&msg.role); let time_str = crate::utils::format_message_time(msg.timestamp); - let header_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - THEME.message_highlight - } else { - THEME.background - }; - let mut header = vec![Span::styled( - format!("── {prefix} ──"), - Style::default().fg(color).bg(header_bg).add_modifier(Modifier::BOLD), - )]; + let mut header = vec![ + gutter_span(selected, color), + Span::styled( + format!("── {prefix} ──"), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ]; if !time_str.is_empty() { - header.push(Span::styled( - format!(" {time_str}"), - Style::default().fg(THEME.text_muted).bg(header_bg), - )); + header + .push(Span::styled(format!(" {time_str}"), Style::default().fg(THEME.text_muted))); } lines.push(Line::from(header)); - visual_row += 1; let empty: Vec = Vec::new(); let cached_lines = app.viewing_sanitized_lines.get(i).unwrap_or(&empty); for sl in cached_lines { let body_style = Style::default().fg(THEME.text); let spans = highlight_spans(&sl.text, &sl.lower, &needles, body_style); - for line in wrap_spans_to_lines(spans, inner_width) { - let body_bg = if selected && row_visible(visual_row, viewport_start, viewport_end) { - THEME.message_highlight - } else { - THEME.background - }; - lines.push(line_with_background(line, body_bg)); - visual_row += 1; + for mut line in wrap_spans_to_lines(spans, body_width) { + line.spans.insert(0, gutter_span(selected, color)); + lines.push(line); } } lines.push(Line::from("")); - visual_row += 1; } render_viewing_summary(f, app, layout.summary); @@ -125,6 +118,8 @@ pub(super) fn render_viewing(f: &mut Frame, app: &App) { Span::styled(" resume ", Style::default().fg(THEME.text_muted)), Span::styled("Ctrl+O", Style::default().fg(THEME.accent)), Span::styled(" app ", Style::default().fg(THEME.text_muted)), + Span::styled("Ctrl+X", Style::default().fg(THEME.accent)), + Span::styled(" delete ", Style::default().fg(THEME.text_muted)), Span::styled("Esc/q", Style::default().fg(THEME.accent)), Span::styled(" back", Style::default().fg(THEME.text_muted)), ]; @@ -172,17 +167,18 @@ pub(super) fn render_viewing_summary(f: &mut Frame, app: &App, area: Rect) { fn viewing_summary_text(app: &App, width: usize) -> String { let Some(summary) = app.viewing_session_summary.as_ref() else { - return fit_summary_text(vec![" tokens - | time - | user msgs -".to_string()], width); + return fit_summary_text(vec![" - | tokens - | time - | user msgs -".to_string()], width); }; + let date = &summary.started_calendar; let duration = format_duration_minutes(summary.duration_minutes); let user_messages = format!("{}/{}", summary.user_messages, summary.total_messages); if summary.usage_events == 0 { return fit_summary_text( vec![ - format!(" tokens - | time {duration} | user msgs {user_messages}"), - format!(" tok - | {duration} | user {user_messages}"), + format!(" {date} | tokens - | time {duration} | user msgs {user_messages}"), + format!(" {date} | tok - | {duration} | user {user_messages}"), ], width, ); @@ -199,12 +195,12 @@ fn viewing_summary_text(app: &App, width: usize) -> String { fit_summary_text( vec![ format!( - " tokens {total} input {input} output {output} cache r/w {cache_read}/{cache_write} reasoning {reasoning} | time {duration} | user msgs {user_messages}" + " {date} | tokens {total} input {input} output {output} cache r/w {cache_read}/{cache_write} reasoning {reasoning} | time {duration} | user msgs {user_messages}" ), format!( - " tok {total} in {input} out {output} cache {cache_read}/{cache_write} reason {reasoning} | {duration} | user {user_messages}" + " {date} | tok {total} in {input} out {output} cache {cache_read}/{cache_write} reason {reasoning} | {duration} | user {user_messages}" ), - format!(" tok {total} | time {duration} | user {user_messages}"), + format!(" {date} | tok {total} | time {duration} | user {user_messages}"), ], width, ) diff --git a/src/tui/viewing_state.rs b/src/tui/viewing_state.rs index 422a9a9..b382e8d 100644 --- a/src/tui/viewing_state.rs +++ b/src/tui/viewing_state.rs @@ -28,6 +28,7 @@ pub(crate) struct ViewingSessionSummary { pub(crate) duration_minutes: Option, pub(crate) usage_events: usize, pub(crate) tokens: TokenTotals, + pub(crate) started_calendar: String, } impl ViewingSessionSummary { @@ -35,6 +36,7 @@ impl ViewingSessionSummary { messages: &[Message], duration_minutes: Option, usage_events: &[SessionUsageEventRecord], + started_at: i64, ) -> Self { let mut tokens = TokenTotals::default(); for event in usage_events { @@ -56,6 +58,7 @@ impl ViewingSessionSummary { duration_minutes: duration_minutes.or_else(|| message_span_minutes(messages)), usage_events: usage_events.len(), tokens, + started_calendar: crate::utils::format_started_calendar(started_at), } } } diff --git a/src/utils.rs b/src/utils.rs index ea2fcf1..b4cce2c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,7 @@ use std::process::Command; +use crate::types::Role; + pub(crate) fn open_url_in_default_browser(url: &str) -> anyhow::Result<()> { let (program, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") { ("open", vec![url]) @@ -48,9 +50,122 @@ pub(crate) fn parse_since(s: &str) -> Option { Some(now - n * multiplier) } +/// Consumes a CSI parameter/intermediate tail through its final byte +/// (0x40..=0x7e). Returns whether it was terminated before EOF. Shared by +/// the `ESC [` and 8-bit `U+009B` (CSI) introducers. +fn consume_csi_tail(chars: &mut std::iter::Peekable>) -> bool { + for b in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&b) { + return true; + } + } + false +} + +/// Consumes an OSC string tail, terminated by BEL (0x07), ST (`ESC \`), or +/// the 8-bit C1 ST (`U+009C`). Returns whether it was terminated before EOF. +/// Shared by the `ESC ]` and 8-bit `U+009D` (OSC) introducers. +fn consume_osc_tail(chars: &mut std::iter::Peekable>) -> bool { + while let Some(b) = chars.next() { + if b == '\u{07}' || b == '\u{9c}' { + return true; + } + if b == '\u{1b}' { + // possible ST: consume the following '\' + if chars.peek() == Some(&'\\') { + chars.next(); + } + return true; + } + } + false +} + +/// Consumes a DCS/SOS/PM/APC string tail, terminated by ST (`ESC \`) or the +/// 8-bit C1 ST (`U+009C`). Returns whether it was terminated before EOF. +/// Shared by the `ESC P`/`X`/`^`/`_` and 8-bit `U+0090`/`U+0098`/`U+009E`/ +/// `U+009F` introducers. +fn consume_string_tail(chars: &mut std::iter::Peekable>) -> bool { + while let Some(b) = chars.next() { + if b == '\u{9c}' { + return true; + } + if b == '\u{1b}' { + if chars.peek() == Some(&'\\') { + chars.next(); + } + return true; + } + } + false +} + +pub(crate) fn strip_ansi_sequences(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\u{1b}' => { + // Saw ESC. Dispatch on the introducer byte. + let Some(&intro) = chars.peek() else { + break; // lone trailing ESC -> drop (D6) + }; + match intro { + '[' => { + // CSI: ESC [ params/intermediates final(0x40..=0x7e) + chars.next(); + if !consume_csi_tail(&mut chars) { + break; // unterminated CSI at EOF -> drop remainder (D6) + } + } + ']' => { + // OSC: ESC ] ... terminated by BEL (0x07) or ST (ESC \) + chars.next(); + if !consume_osc_tail(&mut chars) { + break; // unterminated OSC -> drop remainder (D6) + } + } + 'P' | 'X' | '^' | '_' => { + // DCS/SOS/PM/APC string: terminated by ST (ESC \) + chars.next(); + if !consume_string_tail(&mut chars) { + break; // unterminated string sequence -> drop remainder (D6) + } + } + _ => { + // 2-byte escape (ESC + single byte), e.g. ESC c, ESC 7, SS2/SS3 (D12) + chars.next(); + } + } + } + '\u{9b}' => { + // 8-bit CSI introducer, equivalent to ESC [ + if !consume_csi_tail(&mut chars) { + break; // unterminated CSI at EOF -> drop remainder (D6) + } + } + '\u{9d}' => { + // 8-bit OSC introducer, equivalent to ESC ] + if !consume_osc_tail(&mut chars) { + break; // unterminated OSC -> drop remainder (D6) + } + } + '\u{90}' | '\u{98}' | '\u{9e}' | '\u{9f}' => { + // 8-bit DCS/SOS/PM/APC introducers, equivalent to ESC P/X/^/_ + if !consume_string_tail(&mut chars) { + break; // unterminated string sequence -> drop remainder (D6) + } + } + _ => out.push(c), + } + } + out +} + pub(crate) fn sanitize_line(line: &str) -> String { - let mut out = String::with_capacity(line.len()); - for c in line.chars() { + let stripped = strip_ansi_sequences(line); + let mut out = String::with_capacity(stripped.len()); + for c in stripped.chars() { if c == '\t' { out.push_str(" "); } else if c.is_control() { @@ -62,6 +177,41 @@ pub(crate) fn sanitize_line(line: &str) -> String { out } +pub(crate) fn role_label(source: &str, role: &Role) -> &'static str { + match role { + Role::User => "You", + Role::Assistant => match source { + "claude-code" => "Claude", + "opencode" => "OpenCode", + "codex" => "Codex", + "pi" => "Pi", + "antigravity-cli" => "Antigravity", + "gemini-cli" => "Gemini", + "grok" => "Grok", + "kiro-cli" => "Kiro", + "copilot-cli" => "Copilot", + "cursor" => "Cursor", + "cline" => "Cline", + _ => "Asst", + }, + } +} + +pub(crate) fn project_label(path: &str) -> String { + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + match parts.len() { + 0 => path.to_string(), + 1 => parts[0].to_string(), + len => format!("{}/{}", parts[len - 2], parts[len - 1]), + } +} + +pub(crate) fn format_started_calendar(started_at: i64) -> String { + chrono::DateTime::from_timestamp_millis(started_at) + .map(|dt| dt.with_timezone(&chrono::Local).format("%Y-%m-%d").to_string()) + .unwrap_or_default() +} + pub(crate) fn format_message_time(ts: Option) -> String { let Some(ts) = ts else { return String::new(); @@ -194,4 +344,125 @@ mod tests { let msgs = [" ship", "real content"]; assert_eq!(title_from_user_messages(&msgs), "real content"); } + + #[test] + fn strip_ansi_removes_csi_and_osc_leaving_text() { + assert_eq!(strip_ansi_sequences("\x1b[31mred\x1b[0m\x1b]0;title\x07"), "red"); + } + + #[test] + fn strip_ansi_preserves_plain_text_tabs_and_newlines() { + assert_eq!(strip_ansi_sequences("a\tb\nc"), "a\tb\nc"); + } + + #[test] + fn strip_ansi_handles_osc_st_terminator() { + // OSC terminated by ST (ESC \) instead of BEL + assert_eq!(strip_ansi_sequences("x\x1b]0;title\x1b\\y"), "xy"); + } + + #[test] + fn strip_ansi_drops_unterminated_csi_remainder() { + assert_eq!(strip_ansi_sequences("ok\x1b[31"), "ok"); + } + + #[test] + fn strip_ansi_drops_unterminated_osc_remainder() { + assert_eq!(strip_ansi_sequences("ok\x1b]0;never"), "ok"); + } + + #[test] + fn strip_ansi_drops_lone_trailing_esc() { + assert_eq!(strip_ansi_sequences("ok\x1b"), "ok"); + } + + #[test] + fn strip_ansi_drops_two_byte_escape() { + // ESC c (RIS reset) is a 2-byte escape; both bytes go, rest stays + assert_eq!(strip_ansi_sequences("a\x1bcb"), "ab"); + } + + #[test] + fn strip_ansi_drops_dcs_string_through_st() { + assert_eq!(strip_ansi_sequences("\x1bPq;data\x1b\\end"), "end"); + } + + #[test] + fn strip_ansi_handles_c1_csi_introducer() { + // U+009B is the 8-bit CSI introducer, equivalent to ESC [ + assert_eq!(strip_ansi_sequences("\u{9b}31mred\u{9b}0m"), "red"); + } + + #[test] + fn strip_ansi_handles_c1_osc_introducer_bel_terminated() { + // U+009D is the 8-bit OSC introducer, equivalent to ESC ] + assert_eq!(strip_ansi_sequences("a\u{9d}0;title\u{07}b"), "ab"); + } + + #[test] + fn strip_ansi_handles_c1_osc_introducer_c1_st_terminated() { + // U+009C is the 8-bit ST, valid terminator alongside ESC \ + assert_eq!(strip_ansi_sequences("a\u{9d}0;title\u{9c}b"), "ab"); + } + + #[test] + fn strip_ansi_handles_c1_dcs_introducer_esc_st_terminated() { + // U+0090 is the 8-bit DCS introducer, equivalent to ESC P + assert_eq!(strip_ansi_sequences("\u{90}payload\u{1b}\\x"), "x"); + } + + #[test] + fn strip_ansi_drops_unterminated_c1_csi_remainder() { + assert_eq!(strip_ansi_sequences("a\u{9b}31"), "a"); + } + + #[test] + fn sanitize_line_strips_escape_residue_and_expands_tabs() { + assert_eq!(sanitize_line("\x1b[31mred\x1b[0m\tX"), "red X"); + } + + #[test] + fn role_label_user_is_you_for_any_source() { + assert_eq!(role_label("claude-code", &Role::User), "You"); + assert_eq!(role_label("totally-unknown", &Role::User), "You"); + } + + #[test] + fn role_label_maps_each_assistant_source() { + let cases = [ + ("claude-code", "Claude"), + ("opencode", "OpenCode"), + ("codex", "Codex"), + ("pi", "Pi"), + ("antigravity-cli", "Antigravity"), + ("gemini-cli", "Gemini"), + ("grok", "Grok"), + ("kiro-cli", "Kiro"), + ("copilot-cli", "Copilot"), + ("cursor", "Cursor"), + ("cline", "Cline"), + ]; + for (src, expected) in cases { + assert_eq!(role_label(src, &Role::Assistant), expected, "source {src}"); + } + } + + #[test] + fn role_label_unknown_assistant_falls_back_to_asst() { + assert_eq!(role_label("mystery-cli", &Role::Assistant), "Asst"); + } + + #[test] + fn project_label_returns_last_two_components() { + assert_eq!(project_label("/home/u/dev/recall"), "dev/recall"); + assert_eq!(project_label("recall"), "recall"); + assert_eq!(project_label(""), ""); + } + + #[test] + fn format_started_calendar_returns_iso_date_shape() { + let out = format_started_calendar(1_700_000_000_000); + assert!(!out.is_empty()); + assert!(out.contains('-')); + } }