From ad6aebda908aa6b737f30b7bc45c9e0c76873f65 Mon Sep 17 00:00:00 2001 From: Florian Schneider Date: Mon, 14 Sep 2026 12:09:31 +0200 Subject: [PATCH] Sort/display by last activity, show latest messages, poll for updates Sessions were sorted and previewed using the timestamp and text of the first message in the JSONL file, so a session that started hours ago but is still being actively written never bubbled to the top, and the list showed the opening prompt instead of what's actually happening now. - Sort and "time ago" now use the file's mtime (last write), not the first message's timestamp. - The list's message preview is now the most recent user message (read backwards from the end of the file, doubling the read window until found, so multi-hundred-MB session files stay cheap). - Each session row gains a second, indented line showing the last line of the most recent assistant reply -- the part of the response most likely to carry a question or next step. - The TUI now rescans ~/.claude/projects/ every 3s while browsing (a full rescan is ~23ms for ~100 sessions), so sessions written to by another process show up live without relaunching. Selection and scroll position are preserved across a refresh by session id. List rendering, scrolling and mouse click handling were reworked to support the resulting variable row heights (1 or 2 lines per session). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AHAipZS7kk9Az8b7cykMQW --- src/discovery.rs | 149 ++++++++++++++++++++++++++++++++--------- src/search.rs | 7 ++ src/session.rs | 3 + src/tui/mod.rs | 140 ++++++++++++++++++++++++++++++++++++-- src/tui/view.rs | 22 +++++- tests/grouping_test.rs | 1 + 6 files changed, 281 insertions(+), 41 deletions(-) diff --git a/src/discovery.rs b/src/discovery.rs index 2905072..76fbfec 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,7 +1,7 @@ // Session discovery: scanning ~/.claude/projects/ for session JSONL files use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; @@ -60,19 +60,17 @@ pub fn discover_sessions(claude_home: &Path) -> Vec { sessions } -/// Parse a single JSONL session file and extract the first user message. +/// Parse a single JSONL session file and extract the most recent user message. fn parse_session_file(path: &Path) -> Option { let session_id = path.file_stem()?.to_str()?.to_string(); let file = fs::File::open(path).ok()?; let reader = BufReader::new(file); - // Track metadata from the first user entry (for cwd, branch, timestamp) - // but keep scanning for a non-meta message to display + // cwd/branch never change within a session, so the first user entry is + // enough for metadata -- stop as soon as it's found. let mut cwd = String::new(); let mut git_branch: Option = None; - let mut timestamp: DateTime = Utc::now(); - let mut first_message = String::new(); let mut found_metadata = false; for line in reader.lines().take(50) { @@ -92,32 +90,9 @@ fn parse_session_file(path: &Path) -> Option { continue; } - // Grab metadata from the first user entry - if !found_metadata { - cwd = entry.cwd.clone().unwrap_or_default(); - git_branch = entry.git_branch.clone(); - timestamp = entry - .timestamp - .as_deref() - .and_then(|t| t.parse().ok()) - .unwrap_or_else(Utc::now); - found_metadata = true; - } - - // Extract and clean message text - let raw_text = entry.message.map(|m| m.content.text()).unwrap_or_default(); - if is_meta_message(&raw_text) { - continue; - } - - let cleaned = clean_message(&raw_text); - first_message = cleaned - .lines() - .next() - .unwrap_or("") - .chars() - .take(200) - .collect(); + cwd = entry.cwd.clone().unwrap_or_default(); + git_branch = entry.git_branch.clone(); + found_metadata = true; break; } @@ -125,6 +100,14 @@ fn parse_session_file(path: &Path) -> Option { return None; } + // The messages shown in the list are the most recent ones, not the + // first -- read backwards from the end of the file instead of scanning + // it whole, since a session's JSONL can run into the hundreds of MB. + let (last_user, last_assistant) = read_tail_previews(path); + let first_message = last_user.unwrap_or_default(); + let last_assistant_line = last_assistant.unwrap_or_default(); + let timestamp: DateTime = Utc::now(); + let project_name = Path::new(&cwd) .file_name() .and_then(|n| n.to_str()) @@ -133,6 +116,14 @@ fn parse_session_file(path: &Path) -> Option { let project_exists = Path::new(&cwd).exists(); + // Sort/display by last write to the file, not the first message's + // timestamp -- otherwise a session that started hours ago but is still + // being actively written never bubbles to the top of the list. + let timestamp = fs::metadata(path) + .and_then(|m| m.modified()) + .map(DateTime::::from) + .unwrap_or(timestamp); + Some(Session { id: session_id, project_path: cwd.clone(), @@ -140,11 +131,105 @@ fn parse_session_file(path: &Path) -> Option { git_branch, timestamp, first_message, + last_assistant_line, cwd, project_exists, }) } +/// Find the most recent non-meta user message and the most recent assistant +/// text reply's last line, reading backwards from the end of the file +/// instead of scanning it whole (doubling the read window until both turn +/// up, capped, so a pathological file can't stall discovery). +fn read_tail_previews(path: &Path) -> (Option, Option) { + let mut file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return (None, None), + }; + let file_len = match file.metadata() { + Ok(m) => m.len(), + Err(_) => return (None, None), + }; + + let mut window: u64 = 64 * 1024; + let max_window: u64 = 8 * 1024 * 1024; + let mut last_user: Option = None; + let mut last_assistant: Option = None; + + loop { + let start = file_len.saturating_sub(window); + if file.seek(SeekFrom::Start(start)).is_err() { + return (last_user, last_assistant); + } + let mut buf = Vec::with_capacity((file_len - start) as usize); + if file + .by_ref() + .take(file_len - start) + .read_to_end(&mut buf) + .is_err() + { + return (last_user, last_assistant); + } + let text = String::from_utf8_lossy(&buf); + + // The window boundary likely lands mid-line; drop that partial first + // line unless we're already reading from the true start of the file. + let lines: Vec<&str> = if start == 0 { + text.lines().collect() + } else { + text.lines().skip(1).collect() + }; + + for line in lines.iter().rev() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let entry: SessionFileEntry = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => continue, + }; + let raw_text = entry.message.map(|m| m.content.text()).unwrap_or_default(); + if is_meta_message(&raw_text) { + continue; + } + + match entry.entry_type.as_str() { + "user" if last_user.is_none() => { + let msg: String = clean_message(&raw_text).chars().take(200).collect(); + if !msg.is_empty() { + last_user = Some(msg); + } + } + "assistant" if last_assistant.is_none() => { + let line: String = clean_message_multiline(&raw_text) + .lines() + .rev() + .map(|l| l.trim()) + .find(|l| !l.is_empty()) + .unwrap_or("") + .chars() + .take(200) + .collect(); + if !line.is_empty() { + last_assistant = Some(line); + } + } + _ => {} + } + + if last_user.is_some() && last_assistant.is_some() { + return (last_user, last_assistant); + } + } + + if start == 0 || window >= max_window { + return (last_user, last_assistant); + } + window = (window * 2).min(max_window); + } +} + /// Load all conversation messages (user + assistant) from a session JSONL file. /// /// Returns messages in chronological order. Skips file-history-snapshot entries, diff --git a/src/search.rs b/src/search.rs index bd81fa0..e46563a 100644 --- a/src/search.rs +++ b/src/search.rs @@ -572,6 +572,12 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option { let project_exists = Path::new(&cwd).exists(); + // Same rationale as discovery.rs: last write beats first-message time. + let timestamp = fs::metadata(path) + .and_then(|m| m.modified()) + .map(DateTime::::from) + .unwrap_or(timestamp); + Some(Session { id: session_id, project_path: cwd.clone(), @@ -579,6 +585,7 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option { git_branch: entry.git_branch, timestamp, first_message, + last_assistant_line: String::new(), cwd, project_exists, }) diff --git a/src/session.rs b/src/session.rs index fef4fa6..1e15820 100644 --- a/src/session.rs +++ b/src/session.rs @@ -13,6 +13,9 @@ pub struct Session { pub git_branch: Option, pub timestamp: DateTime, pub first_message: String, + /// Last line of the most recent assistant text reply, for the list's + /// indented second row. Empty if the session has no assistant reply yet. + pub last_assistant_line: String, pub cwd: String, pub project_exists: bool, } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 65babd4..d23b0f6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -19,7 +19,7 @@ use crossterm::terminal::{ use ratatui::prelude::*; use crate::clipboard; -use crate::discovery::{get_claude_home, load_conversation}; +use crate::discovery::{discover_sessions, get_claude_home, load_conversation}; use crate::filter::filter_sessions; use crate::search::{self, SearchResult}; use crate::session::{ConversationMessage, Session}; @@ -193,6 +193,41 @@ impl App { self.scroll_offset = 0; } + /// Replace the session list with a freshly rescanned one (periodic + /// background poll), keeping the current filter, selection and scroll + /// position stable. Skipped while a background content search is in + /// flight, so a rescan can't race its cancel flag / receiver. + pub fn refresh_sessions(&mut self, new_sessions: Vec, claude_home: &std::path::Path) { + if self.content_search_state == ContentSearchState::Searching { + return; + } + + let selected_id = self + .display_items + .get(self.selected) + .and_then(|item| match item { + DisplayItem::Session(e) => Some(self.display_session(e).id.clone()), + DisplayItem::Header(_) => None, + }); + + self.sessions = new_sessions; + self.session_index = Arc::new(search::build_session_index(claude_home, &self.sessions)); + self.filtered_indices = filter_sessions(&self.sessions, &self.filter_query); + self.rebuild_display_items(); + + if let Some(id) = selected_id { + if let Some(pos) = self.display_items.iter().position(|item| match item { + DisplayItem::Session(e) => self.display_session(e).id == id, + DisplayItem::Header(_) => false, + }) { + self.selected = pos; + } else { + self.selected = self.selected.min(self.display_items.len().saturating_sub(1)); + } + } + self.scroll_offset = self.scroll_offset.min(self.selected); + } + /// Build merged display items from metadata matches and content results. /// /// In flat mode: wraps each DisplayEntry in DisplayItem::Session (existing behavior). @@ -349,19 +384,94 @@ impl App { self.selected = self.selected.saturating_sub(1); } + /// Line height of a display item: session rows with a last-assistant-line + /// preview take 2 lines (message + indented reply), everything else 1. + fn item_height(&self, idx: usize) -> usize { + match self.display_items.get(idx) { + Some(DisplayItem::Session(entry)) => { + let session = self.display_session(entry); + if session.last_assistant_line.trim().is_empty() { + 1 + } else { + 2 + } + } + _ => 1, + } + } + + /// The display item rendered at `row` lines below `scroll_offset` + /// (0-based), accounting for variable item heights. + pub fn item_at_line(&self, scroll_offset: usize, row: usize) -> Option { + let mut acc = 0usize; + let mut idx = scroll_offset; + while idx < self.display_items.len() { + let h = self.item_height(idx); + if row < acc + h { + return Some(idx); + } + acc += h; + idx += 1; + } + None + } + + /// The half-open range `[start, end)` of display items that fit within + /// `visible_lines`, rendered starting at `scroll_offset`. Items have + /// variable line height, so this is not simply `scroll_offset + count`. + pub fn visible_range(&self, scroll_offset: usize, visible_lines: usize) -> (usize, usize) { + let mut acc = 0usize; + let mut end = scroll_offset; + while end < self.display_items.len() { + let h = self.item_height(end); + if acc > 0 && acc + h > visible_lines { + break; + } + acc += h; + end += 1; + if acc >= visible_lines { + break; + } + } + (scroll_offset, end) + } + /// Ensure the selected item is visible by adjusting scroll_offset. - pub fn ensure_visible(&mut self, visible_items: usize) { - if visible_items == 0 { + pub fn ensure_visible(&mut self, visible_lines: usize) { + if visible_lines == 0 || self.display_items.is_empty() { return; } if self.mode == Mode::Conversation || self.mode == Mode::ConversationSearch { return; } + if self.selected < self.scroll_offset { self.scroll_offset = self.selected; - } else if self.selected >= self.scroll_offset + visible_items { - self.scroll_offset = self.selected - visible_items + 1; + return; } + + let (_, end) = self.visible_range(self.scroll_offset, visible_lines); + if self.selected < end { + return; // already visible + } + + // Bottom-align: walk backward from `selected`, accumulating heights, + // to find the topmost item that still fits above it. + let mut acc = 0usize; + let mut offset = self.selected; + loop { + let h = self.item_height(offset); + if acc > 0 && acc + h > visible_lines { + offset += 1; + break; + } + acc += h; + if offset == 0 { + break; + } + offset -= 1; + } + self.scroll_offset = offset; } /// Enter conversation viewer for a display item. @@ -534,6 +644,14 @@ pub fn run( let mut deferred_command: Option = None; let mut deferred_cwd: Option = None; + // Rescan ~/.claude/projects/ periodically so sessions being actively + // written elsewhere (timestamps, last-message previews) stay current + // without needing to relaunch. A full rescan is cheap (tail-reads only, + // no matter the JSONL file size -- ~23ms for 94 sessions), so a short + // interval costs well under 1% CPU. + const REFRESH_INTERVAL: Duration = Duration::from_secs(3); + let mut last_refresh = Instant::now(); + loop { app.tick_status(); @@ -545,6 +663,13 @@ pub fn run( app.check_debounce(); } + if last_refresh.elapsed() >= REFRESH_INTERVAL { + last_refresh = Instant::now(); + if app.mode == Mode::Browsing { + app.refresh_sessions(discover_sessions(&claude_home), &claude_home); + } + } + terminal.draw(|frame| { let height = frame.area().height.saturating_sub(2) as usize; app.ensure_visible(height); @@ -611,8 +736,9 @@ pub fn run( MouseEventKind::Down(crossterm::event::MouseButton::Left) if app.mode == Mode::Browsing => { let clicked_row = mouse.row as usize; if clicked_row >= 1 { - let item_idx = app.scroll_offset + clicked_row - 1; - if item_idx < app.display_items.len() { + let item_idx = + app.item_at_line(app.scroll_offset, clicked_row - 1); + if let Some(item_idx) = item_idx { if item_idx == app.selected { match &app.display_items[item_idx] { DisplayItem::Header(group) => { diff --git a/src/tui/view.rs b/src/tui/view.rs index ef196bc..a9d2e9d 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -31,14 +31,14 @@ pub fn render(frame: &mut Frame, app: &mut App) { fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { let width = area.width.saturating_sub(2) as usize; // account for left/right borders let height = area.height as usize; - let visible_items = height.saturating_sub(2); // account for top/bottom borders + let visible_lines = height.saturating_sub(2); // account for top/bottom borders let mut lines: Vec = Vec::new(); let terms = search_terms(app); let term_refs: Vec<&str> = terms.iter().map(|s| s.as_str()).collect(); let start = app.scroll_offset; - let end = (start + visible_items).min(app.display_items.len()); + let (_, end) = app.visible_range(start, visible_lines); for i in start..end { let item = &app.display_items[i]; @@ -127,6 +127,24 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { } else { lines.push(line); } + + if !session.last_assistant_line.is_empty() { + let indent = " ".repeat(cursor_len + 2); + let reply_prefix = "\u{21B3} "; + let reply_max = width + .saturating_sub(indent.chars().count() + reply_prefix.chars().count()); + let reply_text = truncate_str(&session.last_assistant_line, reply_max); + let reply_line = Line::from(vec![ + Span::raw(indent), + Span::styled(reply_prefix, dim), + Span::styled(reply_text, dim), + ]); + if is_selected { + lines.push(reply_line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(reply_line); + } + } } DisplayItem::Header(group) => { let is_expanded = if !app.filter_query.is_empty() { diff --git a/tests/grouping_test.rs b/tests/grouping_test.rs index 8cc0c3e..e0bd700 100644 --- a/tests/grouping_test.rs +++ b/tests/grouping_test.rs @@ -17,6 +17,7 @@ fn make_session(id: &str, project: &str, hours_ago: i64, message: &str) -> Sessi git_branch: None, timestamp: ts, first_message: message.to_string(), + last_assistant_line: String::new(), cwd: format!("/test/{}", project), project_exists: true, }