diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 9fd520b1..48d5d0b4 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -50,6 +50,7 @@ pub struct SessionPickerView { list_scroll: Cell, list_visible_rows: Cell, history_scroll: Cell, + history_pinned_to_latest: Cell, history_visible_rows: Cell, search_input: String, search_mode: bool, @@ -84,6 +85,7 @@ impl SessionPickerView { list_scroll: Cell::new(0), list_visible_rows: Cell::new(8), history_scroll: Cell::new(0), + history_pinned_to_latest: Cell::new(true), history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false, @@ -205,26 +207,35 @@ impl SessionPickerView { } fn scroll_history(&self, delta: isize) { - let max_scroll = self - .current_preview - .len() - .saturating_sub(self.history_visible_rows.get().max(1)); + let max_scroll = + max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); let current = self.history_scroll.get(); let next = if delta.is_negative() { current.saturating_sub(delta.unsigned_abs()) } else { current.saturating_add(delta as usize) }; - self.history_scroll.set(next.min(max_scroll)); + let next = next.min(max_scroll); + self.history_scroll.set(next); + self.history_pinned_to_latest.set(next == max_scroll); } fn ensure_history_scroll_in_bounds(&self) { - let max_scroll = self - .current_preview - .len() - .saturating_sub(self.history_visible_rows.get().max(1)); - self.history_scroll - .set(self.history_scroll.get().min(max_scroll)); + let max_scroll = + max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); + if self.history_pinned_to_latest.get() { + self.history_scroll.set(max_scroll); + } else { + self.history_scroll + .set(self.history_scroll.get().min(max_scroll)); + } + } + + fn scroll_history_to_latest(&self) { + let max_scroll = + max_history_scroll_for(&self.current_preview, self.history_visible_rows.get()); + self.history_scroll.set(max_scroll); + self.history_pinned_to_latest.set(true); } fn ensure_selected_visible(&self) { @@ -303,13 +314,13 @@ impl SessionPickerView { fn refresh_preview(&mut self) { let Some(session) = self.selected_session() else { self.current_preview = vec!["No sessions found.".to_string()]; - self.history_scroll.set(0); + self.scroll_history_to_latest(); return; }; if let Some(lines) = self.preview_cache.get(&session.id) { self.current_preview = lines.clone(); - self.history_scroll.set(0); + self.scroll_history_to_latest(); return; } @@ -317,7 +328,7 @@ impl SessionPickerView { Ok(manager) => manager, Err(_) => { self.current_preview = vec!["Failed to open sessions directory.".to_string()]; - self.history_scroll.set(0); + self.scroll_history_to_latest(); return; } }; @@ -326,7 +337,7 @@ impl SessionPickerView { Ok(saved) => saved, Err(_) => { self.current_preview = vec!["Failed to load session preview.".to_string()]; - self.history_scroll.set(0); + self.scroll_history_to_latest(); return; } }; @@ -335,7 +346,7 @@ impl SessionPickerView { self.preview_cache .insert(session.id.clone(), preview.clone()); self.current_preview = preview; - self.history_scroll.set(0); + self.scroll_history_to_latest(); } } @@ -503,11 +514,15 @@ impl ModalView for SessionPickerView { let history_inner = modal_block(" History (PgUp/PgDn) ").inner(history_area); self.update_history_viewport(history_inner.height as usize); - let preview_lines = format_preview(&self.current_preview); + let visible_preview = visible_preview_lines( + &self.current_preview, + self.history_scroll.get(), + history_inner.height as usize, + ); + let preview_lines = format_preview(&visible_preview); let preview = Paragraph::new(preview_lines) .block(modal_block(" History (PgUp/PgDn) ")) - .scroll((self.history_scroll.get().min(u16::MAX as usize) as u16, 0)) .wrap(Wrap { trim: false }); preview.render(history_area, buf); } @@ -711,6 +726,53 @@ fn format_preview(lines: &[String]) -> Vec> { out } +fn preview_body_start(lines: &[String], visible_rows: usize) -> Option { + let visible_rows = visible_rows.max(1); + let body_start = lines + .iter() + .position(|line| line.is_empty()) + .map(|idx| idx + 1)?; + (body_start < visible_rows).then_some(body_start) +} + +fn max_history_scroll_for(lines: &[String], visible_rows: usize) -> usize { + let visible_rows = visible_rows.max(1); + let Some(body_start) = preview_body_start(lines, visible_rows) else { + return lines.len().saturating_sub(visible_rows); + }; + let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); + lines + .len() + .saturating_sub(body_start) + .saturating_sub(body_visible_rows) +} + +fn visible_preview_lines(lines: &[String], scroll: usize, visible_rows: usize) -> Vec { + let visible_rows = visible_rows.max(1); + let max_scroll = max_history_scroll_for(lines, visible_rows); + let scroll = scroll.min(max_scroll); + let Some(body_start) = preview_body_start(lines, visible_rows) else { + return lines + .iter() + .skip(scroll) + .take(visible_rows) + .cloned() + .collect(); + }; + + let body_visible_rows = visible_rows.saturating_sub(body_start).max(1); + let mut out = Vec::with_capacity(visible_rows); + out.extend(lines.iter().take(body_start).cloned()); + out.extend( + lines + .iter() + .skip(body_start + scroll) + .take(body_visible_rows) + .cloned(), + ); + out +} + fn format_relative_time(dt: &DateTime) -> String { let now = chrono::Utc::now(); let duration = now.signed_duration_since(*dt); @@ -842,6 +904,7 @@ mod tests { list_scroll: Cell::new(0), list_visible_rows: Cell::new(8), history_scroll: Cell::new(0), + history_pinned_to_latest: Cell::new(true), history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false, @@ -1006,6 +1069,56 @@ mod tests { assert_eq!(view.history_scroll.get(), 0); } + #[test] + fn history_preview_keeps_header_while_scrolling_transcript() { + let lines = vec![ + "Title: version".to_string(), + "Updated: 2026-05-14 01:02".to_string(), + "Messages: 100 | Model: auto".to_string(), + "Mode: agent".to_string(), + String::new(), + "USER: oldest prompt".to_string(), + "ASSISTANT: oldest answer".to_string(), + "USER: middle prompt".to_string(), + "ASSISTANT: middle answer".to_string(), + "USER: newest prompt".to_string(), + "ASSISTANT: newest answer".to_string(), + ]; + + let max_scroll = max_history_scroll_for(&lines, 8); + assert_eq!(max_scroll, 3); + + let rendered = visible_preview_lines(&lines, max_scroll, 8).join("\n"); + assert!(rendered.contains("Title: version")); + assert!(rendered.contains("Updated: 2026-05-14 01:02")); + assert!(!rendered.contains("oldest prompt")); + assert!(rendered.contains("newest prompt")); + assert!(rendered.contains("newest answer")); + } + + #[test] + fn history_refresh_starts_at_latest_transcript_messages() { + let mut view = picker_with(vec![test_session(1, "first")], None); + view.current_preview = vec![ + "Title: first".to_string(), + "Updated: 2026-05-14 01:02".to_string(), + "Messages: 10 | Model: auto".to_string(), + String::new(), + "line 0".to_string(), + "line 1".to_string(), + "line 2".to_string(), + "line 3".to_string(), + "line 4".to_string(), + "line 5".to_string(), + ]; + view.history_visible_rows.set(6); + + view.scroll_history_to_latest(); + + assert_eq!(view.history_scroll.get(), 4); + assert!(view.history_pinned_to_latest.get()); + } + #[test] fn build_preview_lines_shows_full_clean_history() { let messages = vec![ @@ -1047,6 +1160,7 @@ mod tests { list_scroll: Cell::new(0), list_visible_rows: Cell::new(3), history_scroll: Cell::new(0), + history_pinned_to_latest: Cell::new(true), history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false,