feat(tui): add sortable resume picker with created/updated timestamp toggle (#10752)
## Summary
- Add sorting support to the resume session picker with Tab key toggle
- Sessions can now be sorted by either creation time or last updated
time
- Display the current sort mode in the picker header
- Default to sorting by creation time (most recent first)
## Changes
- Add `sort_key` field to `PickerState` to track current sort order
- Pass sort key to `RolloutRecorder::list_threads()` for proper backend
sorting
- Add Tab key handler to toggle between `CreatedAt` and `UpdatedAt`
sorting
- Show current sort mode ("Created at" / "Updated at") in header
- Add "Tab to toggle sort" keyboard hint
- Intelligently hide secondary date column when terminal is narrow
- Reload session list when sort order changes
## Test plan
- [x] Unit tests for sort key toggle functionality
- [x] Snapshot tests updated for new header format
- [x] Test that Tab key triggers reload with new sort key
- [x] Test column visibility adapts to narrow terminals
This commit is contained in:
parent
b0e5a6305b
commit
22545bf206
4 changed files with 411 additions and 43 deletions
|
|
@ -83,6 +83,7 @@ struct PageLoadRequest {
|
|||
request_token: usize,
|
||||
search_token: Option<usize>,
|
||||
default_provider: String,
|
||||
sort_key: ThreadSortKey,
|
||||
}
|
||||
|
||||
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
|
||||
|
|
@ -96,9 +97,21 @@ enum BackgroundEvent {
|
|||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the session name when available, otherwise the
|
||||
/// first user input as the preview, relative time (e.g., "5 seconds ago"), and
|
||||
/// the absolute path.
|
||||
/// search and pagination.
|
||||
///
|
||||
/// The picker displays sessions in a table with timestamp columns (created/updated),
|
||||
/// git branch, working directory, and conversation preview. Users can toggle
|
||||
/// between sorting by creation time and last-updated time using the Tab key.
|
||||
///
|
||||
/// Sessions are loaded on-demand via cursor-based pagination. The backend
|
||||
/// `RolloutRecorder::list_threads` returns pages ordered by the selected sort key,
|
||||
/// and the picker deduplicates across pages to handle overlapping windows when
|
||||
/// new sessions appear during pagination.
|
||||
///
|
||||
/// Filtering happens in two layers:
|
||||
/// 1. Provider and source filtering at the backend (only interactive CLI sessions
|
||||
/// for the current model provider).
|
||||
/// 2. Working-directory filtering at the picker (unless `--all` is passed).
|
||||
pub async fn run_resume_picker(
|
||||
tui: &mut Tui,
|
||||
codex_home: &Path,
|
||||
|
|
@ -157,7 +170,7 @@ async fn run_session_picker(
|
|||
&request.codex_home,
|
||||
PAGE_SIZE,
|
||||
request.cursor.as_ref(),
|
||||
ThreadSortKey::CreatedAt,
|
||||
request.sort_key,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
request.default_provider.as_str(),
|
||||
|
|
@ -220,6 +233,14 @@ async fn run_session_picker(
|
|||
Ok(SessionSelection::StartFresh)
|
||||
}
|
||||
|
||||
/// Returns the human-readable column header for the given sort key.
|
||||
fn sort_key_label(sort_key: ThreadSortKey) -> &'static str {
|
||||
match sort_key {
|
||||
ThreadSortKey::CreatedAt => "Created at",
|
||||
ThreadSortKey::UpdatedAt => "Updated at",
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that ensures we leave the alt-screen on scope exit.
|
||||
struct AltScreenGuard<'a> {
|
||||
tui: &'a mut Tui,
|
||||
|
|
@ -257,6 +278,7 @@ struct PickerState {
|
|||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
sort_key: ThreadSortKey,
|
||||
thread_name_cache: HashMap<ThreadId, Option<String>>,
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +395,7 @@ impl PickerState {
|
|||
show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
sort_key: ThreadSortKey::CreatedAt,
|
||||
thread_name_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -429,6 +452,10 @@ impl PickerState {
|
|||
self.request_frame();
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.toggle_sort_key();
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut new_query = self.query.clone();
|
||||
new_query.pop();
|
||||
|
|
@ -456,13 +483,21 @@ impl PickerState {
|
|||
self.all_rows.clear();
|
||||
self.filtered_rows.clear();
|
||||
self.seen_paths.clear();
|
||||
self.search_state = SearchState::Idle;
|
||||
self.selected = 0;
|
||||
|
||||
let search_token = if self.query.is_empty() {
|
||||
self.search_state = SearchState::Idle;
|
||||
None
|
||||
} else {
|
||||
let token = self.allocate_search_token();
|
||||
self.search_state = SearchState::Active { token };
|
||||
Some(token)
|
||||
};
|
||||
|
||||
let request_token = self.allocate_request_token();
|
||||
self.pagination.loading = LoadingState::Pending(PendingLoad {
|
||||
request_token,
|
||||
search_token: None,
|
||||
search_token,
|
||||
});
|
||||
self.request_frame();
|
||||
|
||||
|
|
@ -470,8 +505,9 @@ impl PickerState {
|
|||
codex_home: self.codex_home.clone(),
|
||||
cursor: None,
|
||||
request_token,
|
||||
search_token: None,
|
||||
search_token,
|
||||
default_provider: self.default_provider.clone(),
|
||||
sort_key: self.sort_key,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -742,6 +778,7 @@ impl PickerState {
|
|||
request_token,
|
||||
search_token,
|
||||
default_provider: self.default_provider.clone(),
|
||||
sort_key: self.sort_key,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -756,6 +793,19 @@ impl PickerState {
|
|||
self.next_search_token = self.next_search_token.wrapping_add(1);
|
||||
token
|
||||
}
|
||||
|
||||
/// Cycles the sort order between creation time and last-updated time.
|
||||
///
|
||||
/// Triggers a full reload because the backend must re-sort all sessions.
|
||||
/// The existing `all_rows` are cleared and pagination restarts from the
|
||||
/// beginning with the new sort key.
|
||||
fn toggle_sort_key(&mut self) {
|
||||
self.sort_key = match self.sort_key {
|
||||
ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt,
|
||||
ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt,
|
||||
};
|
||||
self.start_initial_load();
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_from_items(items: Vec<ThreadItem>) -> Vec<Row> {
|
||||
|
|
@ -821,7 +871,15 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
|||
.areas(area);
|
||||
|
||||
// Header
|
||||
frame.render_widget_ref(Line::from(vec![state.action.title().bold().cyan()]), header);
|
||||
let header_line: Line = vec![
|
||||
state.action.title().bold().cyan(),
|
||||
" ".into(),
|
||||
"Sort:".dim(),
|
||||
" ".into(),
|
||||
sort_key_label(state.sort_key).magenta(),
|
||||
]
|
||||
.into();
|
||||
frame.render_widget_ref(header_line, header);
|
||||
|
||||
// Search line
|
||||
let q = if state.query.is_empty() {
|
||||
|
|
@ -834,7 +892,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
|||
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
|
||||
|
||||
// Column headers and list
|
||||
render_column_headers(frame, columns, &metrics);
|
||||
render_column_headers(frame, columns, &metrics, state.sort_key);
|
||||
render_list(frame, list, state, &metrics);
|
||||
|
||||
// Hint line
|
||||
|
|
@ -849,6 +907,9 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
|||
key_hint::ctrl(KeyCode::Char('c')).into(),
|
||||
" to quit ".dim(),
|
||||
" ".dim(),
|
||||
key_hint::plain(KeyCode::Tab).into(),
|
||||
" to toggle sort ".dim(),
|
||||
" ".dim(),
|
||||
key_hint::plain(KeyCode::Up).into(),
|
||||
"/".dim(),
|
||||
key_hint::plain(KeyCode::Down).into(),
|
||||
|
|
@ -882,11 +943,13 @@ fn render_list(
|
|||
let labels = &metrics.labels;
|
||||
let mut y = area.y;
|
||||
|
||||
let visibility = column_visibility(area.width, metrics, state.sort_key);
|
||||
let max_created_width = metrics.max_created_width;
|
||||
let max_updated_width = metrics.max_updated_width;
|
||||
let max_branch_width = metrics.max_branch_width;
|
||||
let max_cwd_width = metrics.max_cwd_width;
|
||||
|
||||
for (idx, (row, (updated_label, branch_label, cwd_label))) in rows[start..end]
|
||||
for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end]
|
||||
.iter()
|
||||
.zip(labels[start..end].iter())
|
||||
.enumerate()
|
||||
|
|
@ -894,12 +957,17 @@ fn render_list(
|
|||
let is_sel = start + idx == state.selected;
|
||||
let marker = if is_sel { "> ".bold() } else { " ".into() };
|
||||
let marker_width = 2usize;
|
||||
let updated_span = if max_updated_width == 0 {
|
||||
None
|
||||
let created_span = if visibility.show_created {
|
||||
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
|
||||
} else {
|
||||
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
|
||||
None
|
||||
};
|
||||
let branch_span = if max_branch_width == 0 {
|
||||
let updated_span = if visibility.show_updated {
|
||||
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let branch_span = if !visibility.show_branch {
|
||||
None
|
||||
} else if branch_label.is_empty() {
|
||||
Some(
|
||||
|
|
@ -913,7 +981,7 @@ fn render_list(
|
|||
} else {
|
||||
Some(Span::from(format!("{branch_label:<max_branch_width$}")).cyan())
|
||||
};
|
||||
let cwd_span = if max_cwd_width == 0 {
|
||||
let cwd_span = if !visibility.show_cwd {
|
||||
None
|
||||
} else if cwd_label.is_empty() {
|
||||
Some(
|
||||
|
|
@ -930,21 +998,31 @@ fn render_list(
|
|||
|
||||
let mut preview_width = area.width as usize;
|
||||
preview_width = preview_width.saturating_sub(marker_width);
|
||||
if max_updated_width > 0 {
|
||||
if visibility.show_created {
|
||||
preview_width = preview_width.saturating_sub(max_created_width + 2);
|
||||
}
|
||||
if visibility.show_updated {
|
||||
preview_width = preview_width.saturating_sub(max_updated_width + 2);
|
||||
}
|
||||
if max_branch_width > 0 {
|
||||
if visibility.show_branch {
|
||||
preview_width = preview_width.saturating_sub(max_branch_width + 2);
|
||||
}
|
||||
if max_cwd_width > 0 {
|
||||
if visibility.show_cwd {
|
||||
preview_width = preview_width.saturating_sub(max_cwd_width + 2);
|
||||
}
|
||||
let add_leading_gap = max_updated_width == 0 && max_branch_width == 0 && max_cwd_width == 0;
|
||||
let add_leading_gap = !visibility.show_created
|
||||
&& !visibility.show_updated
|
||||
&& !visibility.show_branch
|
||||
&& !visibility.show_cwd;
|
||||
if add_leading_gap {
|
||||
preview_width = preview_width.saturating_sub(2);
|
||||
}
|
||||
let preview = truncate_text(row.display_preview(), preview_width);
|
||||
let mut spans: Vec<Span> = vec![marker];
|
||||
if let Some(created) = created_span {
|
||||
spans.push(created);
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if let Some(updated) = updated_span {
|
||||
spans.push(updated);
|
||||
spans.push(" ".into());
|
||||
|
|
@ -1046,26 +1124,44 @@ fn format_updated_label(row: &Row) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn format_created_label(row: &Row) -> String {
|
||||
match row.created_at {
|
||||
Some(created) => human_time_ago(created),
|
||||
None => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_column_headers(
|
||||
frame: &mut crate::custom_terminal::Frame,
|
||||
area: Rect,
|
||||
metrics: &ColumnMetrics,
|
||||
sort_key: ThreadSortKey,
|
||||
) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut spans: Vec<Span> = vec![" ".into()];
|
||||
if metrics.max_updated_width > 0 {
|
||||
let visibility = column_visibility(area.width, metrics, sort_key);
|
||||
if visibility.show_created {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "Updated",
|
||||
text = "Created at",
|
||||
width = metrics.max_created_width
|
||||
);
|
||||
spans.push(Span::from(label).bold());
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if visibility.show_updated {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "Updated at",
|
||||
width = metrics.max_updated_width
|
||||
);
|
||||
spans.push(Span::from(label).bold());
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if metrics.max_branch_width > 0 {
|
||||
if visibility.show_branch {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "Branch",
|
||||
|
|
@ -1074,7 +1170,7 @@ fn render_column_headers(
|
|||
spans.push(Span::from(label).bold());
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if metrics.max_cwd_width > 0 {
|
||||
if visibility.show_cwd {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "CWD",
|
||||
|
|
@ -1087,11 +1183,30 @@ fn render_column_headers(
|
|||
frame.render_widget_ref(Line::from(spans), area);
|
||||
}
|
||||
|
||||
/// Pre-computed column widths and formatted labels for all visible rows.
|
||||
///
|
||||
/// Widths are measured in Unicode display width (not byte length) so columns
|
||||
/// align correctly when labels contain non-ASCII characters.
|
||||
struct ColumnMetrics {
|
||||
max_created_width: usize,
|
||||
max_updated_width: usize,
|
||||
max_branch_width: usize,
|
||||
max_cwd_width: usize,
|
||||
labels: Vec<(String, String, String)>,
|
||||
/// (created_label, updated_label, branch_label, cwd_label) per row.
|
||||
labels: Vec<(String, String, String, String)>,
|
||||
}
|
||||
|
||||
/// Determines which columns to render given available terminal width.
|
||||
///
|
||||
/// When the terminal is narrow, only one timestamp column is shown (whichever
|
||||
/// matches the current sort key). Branch and CWD are hidden if their max
|
||||
/// widths are zero (no data to show).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ColumnVisibility {
|
||||
show_created: bool,
|
||||
show_updated: bool,
|
||||
show_branch: bool,
|
||||
show_cwd: bool,
|
||||
}
|
||||
|
||||
fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
|
||||
|
|
@ -1114,8 +1229,9 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
|
|||
format!("…{tail}")
|
||||
}
|
||||
|
||||
let mut labels: Vec<(String, String, String)> = Vec::with_capacity(rows.len());
|
||||
let mut max_updated_width = UnicodeWidthStr::width("Updated");
|
||||
let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len());
|
||||
let mut max_created_width = UnicodeWidthStr::width("Created at");
|
||||
let mut max_updated_width = UnicodeWidthStr::width("Updated at");
|
||||
let mut max_branch_width = UnicodeWidthStr::width("Branch");
|
||||
let mut max_cwd_width = if include_cwd {
|
||||
UnicodeWidthStr::width("CWD")
|
||||
|
|
@ -1124,6 +1240,7 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
|
|||
};
|
||||
|
||||
for row in rows {
|
||||
let created = format_created_label(row);
|
||||
let updated = format_updated_label(row);
|
||||
let branch_raw = row.git_branch.clone().unwrap_or_default();
|
||||
let branch = right_elide(&branch_raw, 24);
|
||||
|
|
@ -1137,13 +1254,15 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
|
|||
} else {
|
||||
String::new()
|
||||
};
|
||||
max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str()));
|
||||
max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str()));
|
||||
max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str()));
|
||||
max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str()));
|
||||
labels.push((updated, branch, cwd));
|
||||
labels.push((created, updated, branch, cwd));
|
||||
}
|
||||
|
||||
ColumnMetrics {
|
||||
max_created_width,
|
||||
max_updated_width,
|
||||
max_branch_width,
|
||||
max_cwd_width,
|
||||
|
|
@ -1151,15 +1270,79 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
/// Computes which columns fit in the available width.
|
||||
///
|
||||
/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the
|
||||
/// conversation preview. If both timestamp columns don't fit, only the one
|
||||
/// matching the current sort key is shown.
|
||||
fn column_visibility(
|
||||
area_width: u16,
|
||||
metrics: &ColumnMetrics,
|
||||
sort_key: ThreadSortKey,
|
||||
) -> ColumnVisibility {
|
||||
const MIN_PREVIEW_WIDTH: usize = 10;
|
||||
|
||||
let show_branch = metrics.max_branch_width > 0;
|
||||
let show_cwd = metrics.max_cwd_width > 0;
|
||||
|
||||
// Calculate remaining width after all optional columns.
|
||||
let mut preview_width = area_width as usize;
|
||||
preview_width = preview_width.saturating_sub(2); // marker
|
||||
if metrics.max_created_width > 0 {
|
||||
preview_width = preview_width.saturating_sub(metrics.max_created_width + 2);
|
||||
}
|
||||
if metrics.max_updated_width > 0 {
|
||||
preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2);
|
||||
}
|
||||
if show_branch {
|
||||
preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2);
|
||||
}
|
||||
if show_cwd {
|
||||
preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2);
|
||||
}
|
||||
|
||||
// If preview would be too narrow, hide the non-active timestamp column.
|
||||
let show_both = preview_width >= MIN_PREVIEW_WIDTH;
|
||||
let show_created = if show_both {
|
||||
metrics.max_created_width > 0
|
||||
} else {
|
||||
sort_key == ThreadSortKey::CreatedAt
|
||||
};
|
||||
let show_updated = if show_both {
|
||||
metrics.max_updated_width > 0
|
||||
} else {
|
||||
sort_key == ThreadSortKey::UpdatedAt
|
||||
};
|
||||
|
||||
ColumnVisibility {
|
||||
show_created,
|
||||
show_updated,
|
||||
show_branch,
|
||||
show_cwd,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
|
@ -1200,6 +1383,105 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn set_rollout_mtime(path: &Path, updated_at: DateTime<Utc>) {
|
||||
let times = FileTimes::new().set_modified(updated_at.into());
|
||||
OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.expect("open rollout")
|
||||
.set_times(times)
|
||||
.expect("set times");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_picker_orders_by_updated_at() {
|
||||
use uuid::Uuid;
|
||||
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let sessions_root = tempdir.path().join("sessions");
|
||||
std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root");
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let write_rollout = |ts: DateTime<Utc>, preview: &str| -> PathBuf {
|
||||
let dir = sessions_root
|
||||
.join(ts.format("%Y").to_string())
|
||||
.join(ts.format("%m").to_string())
|
||||
.join(ts.format("%d").to_string());
|
||||
std::fs::create_dir_all(&dir).expect("mkdir date dirs");
|
||||
let filename = format!(
|
||||
"rollout-{}-{}.jsonl",
|
||||
ts.format("%Y-%m-%dT%H-%M-%S"),
|
||||
Uuid::new_v4()
|
||||
);
|
||||
let path = dir.join(filename);
|
||||
let meta = SessionMeta {
|
||||
id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
timestamp: ts.to_rfc3339(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
originator: String::from("user"),
|
||||
cli_version: String::from("0.0.0"),
|
||||
source: SessionSource::Cli,
|
||||
model_provider: Some(String::from("openai")),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
};
|
||||
let meta_line = RolloutLine {
|
||||
timestamp: ts.to_rfc3339(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine { meta, git: None }),
|
||||
};
|
||||
let user_line = RolloutLine {
|
||||
timestamp: ts.to_rfc3339(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: String::from("user"),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: preview.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}),
|
||||
};
|
||||
let meta_json = serde_json::to_string(&meta_line).expect("serialize meta");
|
||||
let user_json = serde_json::to_string(&user_line).expect("serialize user");
|
||||
std::fs::write(&path, format!("{meta_json}\n{user_json}\n")).expect("write rollout");
|
||||
path
|
||||
};
|
||||
|
||||
let created_a = now - Duration::minutes(1);
|
||||
let created_b = now - Duration::minutes(2);
|
||||
|
||||
let path_a = write_rollout(created_a, "A (created newer)");
|
||||
let path_b = write_rollout(created_b, "B (created older)");
|
||||
|
||||
set_rollout_mtime(&path_a, now - Duration::minutes(10));
|
||||
set_rollout_mtime(&path_b, now - Duration::seconds(10));
|
||||
|
||||
let page = RolloutRecorder::list_threads(
|
||||
tempdir.path(),
|
||||
PAGE_SIZE,
|
||||
None,
|
||||
ThreadSortKey::UpdatedAt,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(&[String::from("openai")]),
|
||||
"openai",
|
||||
)
|
||||
.await
|
||||
.expect("list threads");
|
||||
|
||||
let rows = rows_from_items(page.items);
|
||||
let previews: Vec<String> = rows.iter().map(|row| row.preview.clone()).collect();
|
||||
|
||||
assert_eq!(
|
||||
previews,
|
||||
vec![
|
||||
"B (created older)".to_string(),
|
||||
"A (created newer)".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_to_row_uses_first_user_message() {
|
||||
let item = ThreadItem {
|
||||
|
|
@ -1374,7 +1656,7 @@ mod tests {
|
|||
let area = frame.area();
|
||||
let segments =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
|
||||
render_column_headers(&mut frame, segments[0], &metrics);
|
||||
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
|
||||
render_list(&mut frame, segments[1], &state, &metrics);
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
|
@ -1517,13 +1799,19 @@ mod tests {
|
|||
.areas(area);
|
||||
|
||||
frame.render_widget_ref(
|
||||
Line::from(vec!["Resume a previous session".bold().cyan()]),
|
||||
Line::from(vec![
|
||||
"Resume a previous session".bold().cyan(),
|
||||
" ".into(),
|
||||
"Sort:".dim(),
|
||||
" ".into(),
|
||||
"Created at".magenta(),
|
||||
]),
|
||||
header,
|
||||
);
|
||||
|
||||
frame.render_widget_ref(Line::from("Type to search".dim()), search);
|
||||
|
||||
render_column_headers(&mut frame, columns, &metrics);
|
||||
render_column_headers(&mut frame, columns, &metrics, state.sort_key);
|
||||
render_list(&mut frame, list, &state, &metrics);
|
||||
|
||||
let hint_line: Line = vec![
|
||||
|
|
@ -1535,6 +1823,9 @@ mod tests {
|
|||
" ".dim(),
|
||||
key_hint::ctrl(KeyCode::Char('c')).into(),
|
||||
" to quit ".dim(),
|
||||
" ".dim(),
|
||||
key_hint::plain(KeyCode::Tab).into(),
|
||||
" to toggle sort ".dim(),
|
||||
]
|
||||
.into();
|
||||
frame.render_widget_ref(hint_line, hint);
|
||||
|
|
@ -1634,7 +1925,7 @@ mod tests {
|
|||
let area = frame.area();
|
||||
let segments =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
|
||||
render_column_headers(&mut frame, segments[0], &metrics);
|
||||
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
|
||||
render_list(&mut frame, segments[1], &state, &metrics);
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
|
@ -1744,6 +2035,85 @@ mod tests {
|
|||
assert!(guard[0].search_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_visibility_hides_extra_date_column_when_narrow() {
|
||||
let metrics = ColumnMetrics {
|
||||
max_created_width: 8,
|
||||
max_updated_width: 12,
|
||||
max_branch_width: 0,
|
||||
max_cwd_width: 0,
|
||||
labels: Vec::new(),
|
||||
};
|
||||
|
||||
let created = column_visibility(30, &metrics, ThreadSortKey::CreatedAt);
|
||||
assert_eq!(
|
||||
created,
|
||||
ColumnVisibility {
|
||||
show_created: true,
|
||||
show_updated: false,
|
||||
show_branch: false,
|
||||
show_cwd: false,
|
||||
}
|
||||
);
|
||||
|
||||
let updated = column_visibility(30, &metrics, ThreadSortKey::UpdatedAt);
|
||||
assert_eq!(
|
||||
updated,
|
||||
ColumnVisibility {
|
||||
show_created: false,
|
||||
show_updated: true,
|
||||
show_branch: false,
|
||||
show_cwd: false,
|
||||
}
|
||||
);
|
||||
|
||||
let wide = column_visibility(40, &metrics, ThreadSortKey::CreatedAt);
|
||||
assert_eq!(
|
||||
wide,
|
||||
ColumnVisibility {
|
||||
show_created: true,
|
||||
show_updated: true,
|
||||
show_branch: false,
|
||||
show_cwd: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggle_sort_key_reloads_with_new_sort() {
|
||||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_sink = recorded_requests.clone();
|
||||
let loader: PageLoader = Arc::new(move |req: PageLoadRequest| {
|
||||
request_sink.lock().unwrap().push(req);
|
||||
});
|
||||
|
||||
let mut state = PickerState::new(
|
||||
PathBuf::from("/tmp"),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
String::from("openai"),
|
||||
true,
|
||||
None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
|
||||
state.start_initial_load();
|
||||
{
|
||||
let guard = recorded_requests.lock().unwrap();
|
||||
assert_eq!(guard.len(), 1);
|
||||
assert_eq!(guard[0].sort_key, ThreadSortKey::CreatedAt);
|
||||
}
|
||||
|
||||
state
|
||||
.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let guard = recorded_requests.lock().unwrap();
|
||||
assert_eq!(guard.len(), 2);
|
||||
assert_eq!(guard[1].sort_key, ThreadSortKey::UpdatedAt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_navigation_uses_view_rows() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
assertion_line: 1438
|
||||
expression: snapshot
|
||||
---
|
||||
Resume a previous session
|
||||
Resume a previous session Sort: Created at
|
||||
Type to search
|
||||
Updated Branch CWD Conversation
|
||||
Created at Updated at Branch CWD Conversation
|
||||
No sessions yet
|
||||
|
||||
|
||||
|
||||
|
||||
enter to resume esc to start new ctrl + c to quit
|
||||
enter to resume esc to start new ctrl + c to quit tab to toggle sort
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: tui/src/resume_picker.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Updated Branch CWD Conversation
|
||||
42 seconds ago - - Fix resume picker timestamps
|
||||
> 35 minutes ago - - Investigate lazy pagination cap
|
||||
2 hours ago - - Explain the codebase
|
||||
Created at Updated at Branch CWD Conversation
|
||||
16 minutes ago 42 seconds ago - - Fix resume picker timestamps
|
||||
> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap
|
||||
2 hours ago 2 hours ago - - Explain the codebase
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
assertion_line: 1683
|
||||
expression: snapshot
|
||||
---
|
||||
Updated Branch CWD Conversation
|
||||
> 2 days ago - - Keep this for now
|
||||
3 days ago - - Named thread
|
||||
Created at Updated at Branch CWD Conversation
|
||||
> - 2 days ago - - Keep this for now
|
||||
- 3 days ago - - Named thread
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue