From ccc6efc0e630d5d5a9dcc6c700970f5d7b9284eb Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 23 May 2026 08:02:05 +0900 Subject: [PATCH] =?UTF-8?q?update:=20tui=20-r=E3=81=AE=E9=9A=9B=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AE=E6=99=82=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/main.rs | 4 +- crates/tui/src/picker.rs | 227 +++++++++++++++++++++++++++++++++------ 4 files changed, 196 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e334614..08a37e13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3653,6 +3653,7 @@ dependencies = [ "serde", "serde_json", "session-store", + "tempfile", "tokio", "toml", "tools", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 70c05b83..560174ae 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -22,4 +22,5 @@ pulldown-cmark = { version = "0.13.3", default-features = false } llm-worker.workspace = true [dev-dependencies] +tempfile = { workspace = true } tools = { workspace = true } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 85195aa1..58614c82 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -293,11 +293,11 @@ async fn run_resume() -> Result<(), Box> { // Phase 1: pick a session in its own inline viewport, dropping the // viewport before the name dialog opens so each phase gets fresh // vertical room. - let leaf_segment_id = match picker::run().await? { + let segment_id = match picker::run().await? { PickerOutcome::Picked { segment_id } => segment_id, PickerOutcome::Cancelled => return Ok(()), }; - run_spawn(Some(leaf_segment_id)).await + run_spawn(Some(segment_id)).await } async fn run_spawn(resume_from: Option) -> Result<(), Box> { diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 0c52eb25..dc3f0a91 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -1,7 +1,7 @@ //! Inline-viewport "pick a session to restore" UX. //! -//! Reads the most recent sessions from the configured store, lets the -//! user pick one with the arrow keys, and returns the chosen +//! Reads the most recently updated sessions from the configured store, +//! lets the user pick one with the arrow keys, and returns the chosen //! `SegmentId`. Closes its inline viewport before returning so the //! caller can open a fresh viewport for the name dialog. //! @@ -62,27 +62,31 @@ impl From for PickerError { } pub enum PickerOutcome { - /// User picked a session; resume at its leaf segment. The pod-cli - /// rehydrates `session_id` via `Store::lookup_session_of` so we only - /// need to surface the segment here. + /// User picked a session; resume at the segment represented by the + /// selected row. The pod-cli rehydrates `session_id` via + /// `Store::lookup_session_of` so we only need to surface the segment + /// here. Picked { segment_id: SegmentId, }, Cancelled, } -/// One row in the picker view. Rendered from the leaf segment of a -/// Session so the user can recognise their conversation at a glance -/// without parsing UUIDs. +/// One row in the picker view. Rendered from the most recently updated +/// segment of a Session so the user can recognise their conversation at a +/// glance without parsing UUIDs. struct Row { session_id: SessionId, - leaf_segment_id: SegmentId, + segment_id: SegmentId, + /// Latest log-entry timestamp in the row's selected segment. Used only + /// to order the picker newest-update first. + updated_at: u64, /// Last user / assistant snippet, or a `[corrupt]` placeholder. preview: String, /// `Some(pod_name)` when a live Pod currently holds an allocation - /// for this session's leaf segment in `pods.json`. Picking such a - /// row launches `pod --session ` which will fail with - /// `SegmentConflict` — the badge warns the user up-front. + /// for this row's segment in `pods.json`. Picking such a row launches + /// `pod --session ` which will fail with `SegmentConflict` — the + /// badge warns the user up-front. live_pod: Option, } @@ -92,25 +96,9 @@ pub async fn run() -> Result { if sessions.is_empty() { return Err(PickerError::NoSessions); } - let mut rows: Vec = Vec::with_capacity(MAX_ROWS); - for session_id in sessions.into_iter().take(MAX_ROWS) { - let Some(leaf_segment_id) = store.list_segments(session_id)?.into_iter().next() else { - continue; - }; - let preview = build_preview(&store, session_id, leaf_segment_id); - // Best-effort live check. A pods.json I/O hiccup downgrades - // the row to "no badge" rather than killing the picker — the - // user still gets to see the listing. - let live_pod = lookup_segment(leaf_segment_id) - .ok() - .flatten() - .map(|info| info.pod_name); - rows.push(Row { - session_id, - leaf_segment_id, - preview, - live_pod, - }); + let rows = build_rows(&store, sessions)?; + if rows.is_empty() { + return Err(PickerError::NoSessions); } let mut selected = 0usize; @@ -132,7 +120,7 @@ pub async fn run() -> Result { Some(Action::Submit) => { close_viewport(&mut terminal)?; return Ok(PickerOutcome::Picked { - segment_id: rows[selected].leaf_segment_id, + segment_id: rows[selected].segment_id, }); } Some(Action::Cancel) => { @@ -176,10 +164,86 @@ fn open_default_store() -> Result { Ok(FsStore::new(&dir)?) } -fn build_preview(store: &FsStore, session_id: SessionId, segment_id: SegmentId) -> String { +fn build_rows(store: &FsStore, sessions: Vec) -> Result, PickerError> { + let mut rows = Vec::new(); + for session_id in sessions { + let mut selected_segment: Option<(SegmentId, u64, String)> = None; + for segment_id in store.list_segments(session_id)? { + let (updated_at, preview) = summarize_segment(store, session_id, segment_id); + if selected_segment + .as_ref() + .is_none_or(|(best_segment_id, best_updated_at, _)| { + updated_at > *best_updated_at + || (updated_at == *best_updated_at && segment_id > *best_segment_id) + }) + { + selected_segment = Some((segment_id, updated_at, preview)); + } + } + + let Some((segment_id, updated_at, preview)) = selected_segment else { + continue; + }; + rows.push(Row { + session_id, + segment_id, + updated_at, + preview, + live_pod: None, + }); + } + + rows.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| b.segment_id.cmp(&a.segment_id)) + .then_with(|| b.session_id.cmp(&a.session_id)) + }); + rows.truncate(MAX_ROWS); + for row in &mut rows { + // Best-effort live check. A pods.json I/O hiccup downgrades + // the row to "no badge" rather than killing the picker — the + // user still gets to see the listing. + row.live_pod = lookup_segment(row.segment_id) + .ok() + .flatten() + .map(|info| info.pod_name); + } + Ok(rows) +} + +fn summarize_segment( + store: &FsStore, + session_id: SessionId, + segment_id: SegmentId, +) -> (u64, String) { match store.read_all(session_id, segment_id) { - Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()), - Err(_) => "[corrupt]".to_string(), + Ok(entries) => ( + last_entry_ts(&entries).unwrap_or(0), + last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()), + ), + Err(_) => (0, "[corrupt]".to_string()), + } +} + +fn last_entry_ts(entries: &[LogEntry]) -> Option { + entries.iter().map(log_entry_ts).max() +} + +fn log_entry_ts(entry: &LogEntry) -> u64 { + match entry { + LogEntry::SegmentStart { ts, .. } + | LogEntry::Invoke { ts, .. } + | LogEntry::UserInput { ts, .. } + | LogEntry::AssistantItem { ts, .. } + | LogEntry::ToolResult { ts, .. } + | LogEntry::SystemItem { ts, .. } + | LogEntry::TurnEnd { ts, .. } + | LogEntry::RunCompleted { ts, .. } + | LogEntry::RunErrored { ts, .. } + | LogEntry::ConfigChanged { ts, .. } + | LogEntry::LlmUsage { ts, .. } + | LogEntry::Extension { ts, .. } => *ts, } } @@ -335,3 +399,96 @@ fn short_segment(id: SessionId) -> String { let s = id.to_string(); s.chars().take(8).collect() } + +#[cfg(test)] +mod tests { + use super::*; + use llm_worker::llm_client::types::RequestConfig; + use session_store::{new_segment_id, new_session_id}; + use tempfile::tempdir; + + #[test] + fn rows_are_sorted_by_latest_log_entry_timestamp() { + let dir = tempdir().unwrap(); + let store = FsStore::new(dir.path()).unwrap(); + let earlier_session = new_session_id(); + let later_session = new_session_id(); + let earlier_segment = new_segment_id(); + let later_segment = new_segment_id(); + + append_start(&store, earlier_session, earlier_segment, 10); + append_start(&store, later_session, later_segment, 20); + append_user( + &store, + earlier_session, + earlier_segment, + 100, + "latest update", + ); + + let rows = build_rows(&store, store.list_sessions().unwrap()).unwrap(); + + assert_eq!(rows[0].session_id, earlier_session); + assert_eq!(rows[0].segment_id, earlier_segment); + assert_eq!(rows[0].updated_at, 100); + assert_eq!(rows[0].preview, "user: latest update"); + assert_eq!(rows[1].session_id, later_session); + } + + #[test] + fn row_uses_the_most_recently_updated_segment_in_a_session() { + let dir = tempdir().unwrap(); + let store = FsStore::new(dir.path()).unwrap(); + let session_id = new_session_id(); + let old_segment = new_segment_id(); + let new_segment = new_segment_id(); + + append_start(&store, session_id, old_segment, 10); + append_start(&store, session_id, new_segment, 20); + append_user(&store, session_id, old_segment, 200, "continued old branch"); + + let rows = build_rows(&store, vec![session_id]).unwrap(); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].segment_id, old_segment); + assert_eq!(rows[0].updated_at, 200); + assert_eq!(rows[0].preview, "user: continued old branch"); + } + + fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) { + store + .append( + session_id, + segment_id, + &LogEntry::SegmentStart { + ts, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }, + ) + .unwrap(); + } + + fn append_user( + store: &FsStore, + session_id: SessionId, + segment_id: SegmentId, + ts: u64, + text: &str, + ) { + store + .append( + session_id, + segment_id, + &LogEntry::UserInput { + ts, + segments: vec![protocol::Segment::text(text)], + }, + ) + .unwrap(); + } +}