update: tui -rの際のリストの時系列ソート

This commit is contained in:
Keisuke Hirata 2026-05-23 08:02:05 +09:00
parent fbe8846393
commit 48625f5077
4 changed files with 196 additions and 37 deletions

1
Cargo.lock generated
View File

@ -3653,6 +3653,7 @@ dependencies = [
"serde",
"serde_json",
"session-store",
"tempfile",
"tokio",
"toml",
"tools",

View File

@ -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 }

View File

@ -293,11 +293,11 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// 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<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {

View File

@ -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<session_store::StoreError> 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 <UUID>` 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 <UUID>` which will fail with `SegmentConflict` — the
/// badge warns the user up-front.
live_pod: Option<String>,
}
@ -92,25 +96,9 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
if sessions.is_empty() {
return Err(PickerError::NoSessions);
}
let mut rows: Vec<Row> = 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<PickerOutcome, PickerError> {
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<FsStore, PickerError> {
Ok(FsStore::new(&dir)?)
}
fn build_preview(store: &FsStore, session_id: SessionId, segment_id: SegmentId) -> String {
fn build_rows(store: &FsStore, sessions: Vec<SessionId>) -> Result<Vec<Row>, 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<u64> {
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();
}
}