Files
codex/codex-rs/tui/src/resume_picker.rs
Eric Traut 0ab8eda375 Add remote --cd forwarding for app-server sessions (#16700)
Addresses #16124

Problem: `codex --remote --cd <path>` canonicalized the path locally and
then omitted it from remote thread lifecycle requests, so remote-only
working directories failed or were ignored.

Solution: Keep remote startup on the local cwd, forward explicit `--cd`
values verbatim to `thread/start`, `thread/resume`, and `thread/fork`,
and cover the behavior with `codex-tui` tests.

Testing: I manually tested `--remote --cd` with both absolute and
relative paths and validated correct behavior.


---

Update based on code review feedback:

Problem: Remote `--cd` was forwarded to `thread/resume` and
`thread/fork`, but not to `thread/list` lookups, so `--resume --last`
and picker flows could select a session from the wrong cwd; relative cwd
filters also failed against stored absolute paths.

Solution: Apply explicit remote `--cd` to `thread/list` lookups for
`--last` and picker flows, normalize relative cwd filters on the
app-server before exact matching, and document/test the behavior.
2026-04-03 11:26:45 -07:00

2858 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::diff_render::display_path_for;
use crate::key_hint;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_core::Cursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
use codex_core::ThreadItem;
use codex_core::ThreadSortKey;
use codex_core::ThreadsPage;
use codex_core::config::Config;
use codex_core::find_thread_names_by_ids;
use codex_core::path_utils;
use codex_protocol::ThreadId;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::text::Span;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::warn;
use unicode_width::UnicodeWidthStr;
const PAGE_SIZE: usize = 25;
const LOAD_NEAR_THRESHOLD: usize = 5;
#[derive(Debug, Clone)]
pub struct SessionTarget {
pub path: Option<PathBuf>,
pub thread_id: ThreadId,
}
impl SessionTarget {
pub fn display_label(&self) -> String {
self.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("thread {}", self.thread_id))
}
}
#[derive(Debug, Clone)]
pub enum SessionSelection {
StartFresh,
Resume(SessionTarget),
Fork(SessionTarget),
Exit,
}
#[derive(Clone, Copy, Debug)]
pub enum SessionPickerAction {
Resume,
Fork,
}
impl SessionPickerAction {
fn title(self) -> &'static str {
match self {
SessionPickerAction::Resume => "Resume a previous session",
SessionPickerAction::Fork => "Fork a previous session",
}
}
fn action_label(self) -> &'static str {
match self {
SessionPickerAction::Resume => "resume",
SessionPickerAction::Fork => "fork",
}
}
fn selection(self, path: Option<PathBuf>, thread_id: ThreadId) -> SessionSelection {
let target_session = SessionTarget { path, thread_id };
match self {
SessionPickerAction::Resume => SessionSelection::Resume(target_session),
SessionPickerAction::Fork => SessionSelection::Fork(target_session),
}
}
}
#[derive(Clone)]
struct PageLoadRequest {
cursor: Option<PageCursor>,
request_token: usize,
search_token: Option<usize>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
}
#[derive(Clone)]
enum ProviderFilter {
Any,
MatchDefault(String),
}
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
enum BackgroundEvent {
PageLoaded {
request_token: usize,
search_token: Option<usize>,
page: std::io::Result<PickerPage>,
},
}
#[derive(Clone)]
enum PageCursor {
#[allow(dead_code)]
Rollout(Cursor),
AppServer(String),
}
struct PickerPage {
rows: Vec<Row>,
next_cursor: Option<PageCursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
}
/// Interactive session picker that lists recorded rollout files with simple
/// 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).
#[allow(dead_code)]
pub async fn run_resume_picker(
tui: &mut Tui,
config: &Config,
show_all: bool,
) -> Result<SessionSelection> {
run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await
}
pub async fn run_resume_picker_with_app_server(
tui: &mut Tui,
config: &Config,
show_all: bool,
include_non_interactive: bool,
app_server: AppServerSession,
) -> Result<SessionSelection> {
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let is_remote = app_server.is_remote();
let cwd_filter = if show_all {
None
} else {
app_server.remote_cwd_override().map(Path::to_path_buf)
};
run_session_picker_with_loader(
tui,
config,
show_all,
SessionPickerAction::Resume,
is_remote,
spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx),
bg_rx,
)
.await
}
pub async fn run_fork_picker_with_app_server(
tui: &mut Tui,
config: &Config,
show_all: bool,
app_server: AppServerSession,
) -> Result<SessionSelection> {
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let is_remote = app_server.is_remote();
let cwd_filter = if show_all {
None
} else {
app_server.remote_cwd_override().map(Path::to_path_buf)
};
run_session_picker_with_loader(
tui,
config,
show_all,
SessionPickerAction::Fork,
is_remote,
spawn_app_server_page_loader(
app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx,
),
bg_rx,
)
.await
}
#[allow(dead_code)]
async fn run_session_picker(
tui: &mut Tui,
config: &Config,
show_all: bool,
action: SessionPickerAction,
) -> Result<SessionSelection> {
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
run_session_picker_with_loader(
tui,
config,
show_all,
action,
/*is_remote*/ false,
spawn_rollout_page_loader(config, bg_tx),
bg_rx,
)
.await
}
async fn run_session_picker_with_loader(
tui: &mut Tui,
config: &Config,
show_all: bool,
action: SessionPickerAction,
is_remote: bool,
page_loader: PageLoader,
bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
) -> Result<SessionSelection> {
let alt = AltScreenGuard::enter(tui);
let provider_filter = if is_remote {
ProviderFilter::Any
} else {
ProviderFilter::MatchDefault(config.model_provider_id.to_string())
};
let codex_home = config.codex_home.as_path();
let filter_cwd = if show_all || is_remote {
// Remote sessions live in the server's filesystem namespace, so the client
// process cwd is not a meaningful row filter. If the user provided an
// explicit remote --cd, filtering is handled server-side in thread/list.
None
} else {
std::env::current_dir().ok()
};
let mut state = PickerState::new(
codex_home.to_path_buf(),
alt.tui.frame_requester(),
page_loader,
provider_filter,
show_all,
filter_cwd,
action,
);
state.start_initial_load();
state.request_frame();
let mut tui_events = alt.tui.event_stream().fuse();
let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse();
loop {
tokio::select! {
Some(ev) = tui_events.next() => {
match ev {
TuiEvent::Key(key) => {
if matches!(key.kind, KeyEventKind::Release) {
continue;
}
if let Some(sel) = state.handle_key(key).await? {
return Ok(sel);
}
}
TuiEvent::Draw => {
if let Ok(size) = alt.tui.terminal.size() {
let list_height = size.height.saturating_sub(4) as usize;
state.update_view_rows(list_height);
state.ensure_minimum_rows_for_view(list_height);
}
draw_picker(alt.tui, &state)?;
}
_ => {}
}
}
Some(event) = background_events.next() => {
state.handle_background_event(event).await?;
}
else => break,
}
}
// Fallback treat as cancel/new
Ok(SessionSelection::StartFresh)
}
#[allow(dead_code)]
fn spawn_rollout_page_loader(
config: &Config,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
) -> PageLoader {
let config = config.clone();
let loader_tx = bg_tx;
Arc::new(move |request: PageLoadRequest| {
let tx = loader_tx.clone();
let config = config.clone();
tokio::spawn(async move {
let default_provider = match request.provider_filter {
ProviderFilter::Any => None,
ProviderFilter::MatchDefault(default_provider) => Some(default_provider),
};
let cursor = match request.cursor.as_ref() {
Some(PageCursor::Rollout(cursor)) => Some(cursor),
Some(PageCursor::AppServer(_)) => None,
None => None,
};
let page = RolloutRecorder::list_threads(
&config,
PAGE_SIZE,
cursor,
request.sort_key,
INTERACTIVE_SESSION_SOURCES.as_slice(),
default_provider.as_ref().map(std::slice::from_ref),
default_provider.as_deref().unwrap_or_default(),
/*search_term*/ None,
)
.await
.map(picker_page_from_rollout_page);
let _ = tx.send(BackgroundEvent::PageLoaded {
request_token: request.request_token,
search_token: request.search_token,
page,
});
});
})
}
fn spawn_app_server_page_loader(
app_server: AppServerSession,
cwd_filter: Option<PathBuf>,
include_non_interactive: bool,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
) -> PageLoader {
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PageLoadRequest>();
tokio::spawn(async move {
let mut app_server = app_server;
while let Some(request) = request_rx.recv().await {
let cursor = match request.cursor {
Some(PageCursor::AppServer(cursor)) => Some(cursor),
Some(PageCursor::Rollout(_)) => None,
None => None,
};
let page = load_app_server_page(
&mut app_server,
cursor,
cwd_filter.as_deref(),
request.provider_filter,
request.sort_key,
include_non_interactive,
)
.await;
let _ = bg_tx.send(BackgroundEvent::PageLoaded {
request_token: request.request_token,
search_token: request.search_token,
page,
});
}
if let Err(err) = app_server.shutdown().await {
warn!(%err, "Failed to shut down app-server picker session");
}
});
Arc::new(move |request: PageLoadRequest| {
let _ = request_tx.send(request);
})
}
/// 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,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
struct PickerState {
codex_home: PathBuf,
requester: FrameRequester,
pagination: PaginationState,
all_rows: Vec<Row>,
filtered_rows: Vec<Row>,
seen_rows: HashSet<SeenRowKey>,
selected: usize,
scroll_top: usize,
query: String,
search_state: SearchState,
next_request_token: usize,
next_search_token: usize,
page_loader: PageLoader,
view_rows: Option<usize>,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
sort_key: ThreadSortKey,
thread_name_cache: HashMap<ThreadId, Option<String>>,
inline_error: Option<String>,
}
struct PaginationState {
next_cursor: Option<PageCursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
loading: LoadingState,
}
#[derive(Clone, Copy, Debug)]
enum LoadingState {
Idle,
Pending(PendingLoad),
}
#[derive(Clone, Copy, Debug)]
struct PendingLoad {
request_token: usize,
search_token: Option<usize>,
}
#[derive(Clone, Copy, Debug)]
enum SearchState {
Idle,
Active { token: usize },
}
enum LoadTrigger {
Scroll,
Search { token: usize },
}
impl LoadingState {
fn is_pending(&self) -> bool {
matches!(self, LoadingState::Pending(_))
}
}
async fn load_app_server_page(
app_server: &mut AppServerSession,
cursor: Option<String>,
cwd_filter: Option<&Path>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
) -> std::io::Result<PickerPage> {
let response = app_server
.thread_list(thread_list_params(
cursor,
cwd_filter,
provider_filter,
sort_key,
include_non_interactive,
))
.await
.map_err(std::io::Error::other)?;
let num_scanned_files = response.data.len();
Ok(PickerPage {
rows: response
.data
.into_iter()
.filter_map(row_from_app_server_thread)
.collect(),
next_cursor: response.next_cursor.map(PageCursor::AppServer),
num_scanned_files,
reached_scan_cap: false,
})
}
impl SearchState {
fn active_token(&self) -> Option<usize> {
match self {
SearchState::Idle => None,
SearchState::Active { token } => Some(*token),
}
}
fn is_active(&self) -> bool {
self.active_token().is_some()
}
}
#[derive(Clone)]
struct Row {
path: Option<PathBuf>,
preview: String,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum SeenRowKey {
Path(PathBuf),
Thread(ThreadId),
}
impl Row {
fn seen_key(&self) -> Option<SeenRowKey> {
if let Some(path) = self.path.clone() {
return Some(SeenRowKey::Path(path));
}
self.thread_id.map(SeenRowKey::Thread)
}
fn display_preview(&self) -> &str {
self.thread_name.as_deref().unwrap_or(&self.preview)
}
fn matches_query(&self, query: &str) -> bool {
if self.preview.to_lowercase().contains(query) {
return true;
}
if let Some(thread_name) = self.thread_name.as_ref()
&& thread_name.to_lowercase().contains(query)
{
return true;
}
false
}
}
impl PickerState {
fn new(
codex_home: PathBuf,
requester: FrameRequester,
page_loader: PageLoader,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
) -> Self {
Self {
codex_home,
requester,
pagination: PaginationState {
next_cursor: None,
num_scanned_files: 0,
reached_scan_cap: false,
loading: LoadingState::Idle,
},
all_rows: Vec::new(),
filtered_rows: Vec::new(),
seen_rows: HashSet::new(),
selected: 0,
scroll_top: 0,
query: String::new(),
search_state: SearchState::Idle,
next_request_token: 0,
next_search_token: 0,
page_loader,
view_rows: None,
provider_filter,
show_all,
filter_cwd,
action,
sort_key: ThreadSortKey::UpdatedAt,
thread_name_cache: HashMap::new(),
inline_error: None,
}
}
fn request_frame(&self) {
self.requester.schedule_frame();
}
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<SessionSelection>> {
self.inline_error = None;
match key.code {
KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)),
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return Ok(Some(SessionSelection::Exit));
}
KeyCode::Enter => {
if let Some(row) = self.filtered_rows.get(self.selected) {
let path = row.path.clone();
let thread_id = match row.thread_id {
Some(thread_id) => Some(thread_id),
None => match path.as_ref() {
Some(path) => {
crate::resolve_session_thread_id(
path.as_path(),
/*id_str_if_uuid*/ None,
)
.await
}
None => None,
},
};
if let Some(thread_id) = thread_id {
return Ok(Some(self.action.selection(path, thread_id)));
}
self.inline_error = Some(match path {
Some(path) => {
format!("Failed to read session metadata from {}", path.display())
}
None => {
String::from("Failed to read session metadata from selected session")
}
});
self.request_frame();
}
}
KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
self.ensure_selected_visible();
}
self.request_frame();
}
KeyCode::Down => {
if self.selected + 1 < self.filtered_rows.len() {
self.selected += 1;
self.ensure_selected_visible();
}
self.maybe_load_more_for_scroll();
self.request_frame();
}
KeyCode::PageUp => {
let step = self.view_rows.unwrap_or(10).max(1);
if self.selected > 0 {
self.selected = self.selected.saturating_sub(step);
self.ensure_selected_visible();
self.request_frame();
}
}
KeyCode::PageDown => {
if !self.filtered_rows.is_empty() {
let step = self.view_rows.unwrap_or(10).max(1);
let max_index = self.filtered_rows.len().saturating_sub(1);
self.selected = (self.selected + step).min(max_index);
self.ensure_selected_visible();
self.maybe_load_more_for_scroll();
self.request_frame();
}
}
KeyCode::Tab => {
self.toggle_sort_key();
self.request_frame();
}
KeyCode::Backspace => {
let mut new_query = self.query.clone();
new_query.pop();
self.set_query(new_query);
}
KeyCode::Char(c) => {
// basic text input for search
if !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
let mut new_query = self.query.clone();
new_query.push(c);
self.set_query(new_query);
}
}
_ => {}
}
Ok(None)
}
fn start_initial_load(&mut self) {
self.reset_pagination();
self.all_rows.clear();
self.filtered_rows.clear();
self.seen_rows.clear();
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,
});
self.request_frame();
(self.page_loader)(PageLoadRequest {
cursor: None,
request_token,
search_token,
provider_filter: self.provider_filter.clone(),
sort_key: self.sort_key,
});
}
async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
match event {
BackgroundEvent::PageLoaded {
request_token,
search_token,
page,
} => {
let pending = match self.pagination.loading {
LoadingState::Pending(pending) => pending,
LoadingState::Idle => return Ok(()),
};
if pending.request_token != request_token {
return Ok(());
}
self.pagination.loading = LoadingState::Idle;
let page = page.map_err(color_eyre::Report::from)?;
self.ingest_page(page);
self.update_thread_names().await;
let completed_token = pending.search_token.or(search_token);
self.continue_search_if_token_matches(completed_token);
}
}
Ok(())
}
fn reset_pagination(&mut self) {
self.pagination.next_cursor = None;
self.pagination.num_scanned_files = 0;
self.pagination.reached_scan_cap = false;
self.pagination.loading = LoadingState::Idle;
}
fn ingest_page(&mut self, page: PickerPage) {
if let Some(cursor) = page.next_cursor.clone() {
self.pagination.next_cursor = Some(cursor);
} else {
self.pagination.next_cursor = None;
}
self.pagination.num_scanned_files = self
.pagination
.num_scanned_files
.saturating_add(page.num_scanned_files);
if page.reached_scan_cap {
self.pagination.reached_scan_cap = true;
}
for row in page.rows {
if let Some(seen_key) = row.seen_key() {
if self.seen_rows.insert(seen_key) {
self.all_rows.push(row);
}
} else {
self.all_rows.push(row);
}
}
self.apply_filter();
}
async fn update_thread_names(&mut self) {
let mut missing_ids = HashSet::new();
for row in &self.all_rows {
let Some(thread_id) = row.thread_id else {
continue;
};
if self.thread_name_cache.contains_key(&thread_id) {
continue;
}
missing_ids.insert(thread_id);
}
if missing_ids.is_empty() {
return;
}
let names = find_thread_names_by_ids(&self.codex_home, &missing_ids)
.await
.unwrap_or_default();
for thread_id in missing_ids {
let thread_name = names.get(&thread_id).cloned();
self.thread_name_cache.insert(thread_id, thread_name);
}
let mut updated = false;
for row in self.all_rows.iter_mut() {
let Some(thread_id) = row.thread_id else {
continue;
};
let Some(thread_name) = self.thread_name_cache.get(&thread_id).cloned().flatten()
else {
continue;
};
if row.thread_name.as_ref() == Some(&thread_name) {
continue;
}
row.thread_name = Some(thread_name);
updated = true;
}
if updated {
self.apply_filter();
}
}
fn apply_filter(&mut self) {
let base_iter = self
.all_rows
.iter()
.filter(|row| self.row_matches_filter(row));
if self.query.is_empty() {
self.filtered_rows = base_iter.cloned().collect();
} else {
let q = self.query.to_lowercase();
self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect();
}
if self.selected >= self.filtered_rows.len() {
self.selected = self.filtered_rows.len().saturating_sub(1);
}
if self.filtered_rows.is_empty() {
self.scroll_top = 0;
}
self.ensure_selected_visible();
self.request_frame();
}
fn row_matches_filter(&self, row: &Row) -> bool {
if self.show_all {
return true;
}
let Some(filter_cwd) = self.filter_cwd.as_ref() else {
return true;
};
let Some(row_cwd) = row.cwd.as_ref() else {
return false;
};
paths_match(row_cwd, filter_cwd)
}
fn set_query(&mut self, new_query: String) {
if self.query == new_query {
return;
}
self.query = new_query;
self.selected = 0;
self.apply_filter();
if self.query.is_empty() {
self.search_state = SearchState::Idle;
return;
}
if !self.filtered_rows.is_empty() {
self.search_state = SearchState::Idle;
return;
}
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
self.search_state = SearchState::Idle;
return;
}
let token = self.allocate_search_token();
self.search_state = SearchState::Active { token };
self.load_more_if_needed(LoadTrigger::Search { token });
}
fn continue_search_if_needed(&mut self) {
let Some(token) = self.search_state.active_token() else {
return;
};
if !self.filtered_rows.is_empty() {
self.search_state = SearchState::Idle;
return;
}
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
self.search_state = SearchState::Idle;
return;
}
self.load_more_if_needed(LoadTrigger::Search { token });
}
fn continue_search_if_token_matches(&mut self, completed_token: Option<usize>) {
let Some(active) = self.search_state.active_token() else {
return;
};
if let Some(token) = completed_token
&& token != active
{
return;
}
self.continue_search_if_needed();
}
fn ensure_selected_visible(&mut self) {
if self.filtered_rows.is_empty() {
self.scroll_top = 0;
return;
}
let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1);
if self.selected < self.scroll_top {
self.scroll_top = self.selected;
} else {
let last_visible = self.scroll_top.saturating_add(capacity - 1);
if self.selected > last_visible {
self.scroll_top = self.selected.saturating_sub(capacity - 1);
}
}
let max_start = self.filtered_rows.len().saturating_sub(capacity);
if self.scroll_top > max_start {
self.scroll_top = max_start;
}
}
fn ensure_minimum_rows_for_view(&mut self, minimum_rows: usize) {
if minimum_rows == 0 {
return;
}
if self.filtered_rows.len() >= minimum_rows {
return;
}
if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() {
return;
}
if let Some(token) = self.search_state.active_token() {
self.load_more_if_needed(LoadTrigger::Search { token });
} else {
self.load_more_if_needed(LoadTrigger::Scroll);
}
}
fn update_view_rows(&mut self, rows: usize) {
self.view_rows = if rows == 0 { None } else { Some(rows) };
self.ensure_selected_visible();
}
fn maybe_load_more_for_scroll(&mut self) {
if self.pagination.loading.is_pending() {
return;
}
if self.pagination.next_cursor.is_none() {
return;
}
if self.filtered_rows.is_empty() {
return;
}
let remaining = self.filtered_rows.len().saturating_sub(self.selected + 1);
if remaining <= LOAD_NEAR_THRESHOLD {
self.load_more_if_needed(LoadTrigger::Scroll);
}
}
fn load_more_if_needed(&mut self, trigger: LoadTrigger) {
if self.pagination.loading.is_pending() {
return;
}
let Some(cursor) = self.pagination.next_cursor.clone() else {
return;
};
let request_token = self.allocate_request_token();
let search_token = match trigger {
LoadTrigger::Scroll => None,
LoadTrigger::Search { token } => Some(token),
};
self.pagination.loading = LoadingState::Pending(PendingLoad {
request_token,
search_token,
});
self.request_frame();
(self.page_loader)(PageLoadRequest {
cursor: Some(cursor),
request_token,
search_token,
provider_filter: self.provider_filter.clone(),
sort_key: self.sort_key,
});
}
fn allocate_request_token(&mut self) -> usize {
let token = self.next_request_token;
self.next_request_token = self.next_request_token.wrapping_add(1);
token
}
fn allocate_search_token(&mut self) -> usize {
let token = self.next_search_token;
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();
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn picker_page_from_rollout_page(page: ThreadsPage) -> PickerPage {
PickerPage {
rows: rows_from_items(page.items),
next_cursor: page.next_cursor.map(PageCursor::Rollout),
num_scanned_files: page.num_scanned_files,
reached_scan_cap: page.reached_scan_cap,
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn rows_from_items(items: Vec<ThreadItem>) -> Vec<Row> {
items.into_iter().map(|item| head_to_row(&item)).collect()
}
#[cfg_attr(not(test), allow(dead_code))]
fn head_to_row(item: &ThreadItem) -> Row {
let created_at = item.created_at.as_deref().and_then(parse_timestamp_str);
let updated_at = item
.updated_at
.as_deref()
.and_then(parse_timestamp_str)
.or(created_at);
let preview = item
.first_user_message
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| String::from("(no message yet)"));
Row {
path: Some(item.path.clone()),
preview,
thread_id: item.thread_id,
thread_name: None,
created_at,
updated_at,
cwd: item.cwd.clone(),
git_branch: item.git_branch.clone(),
}
}
fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
let thread_id = match ThreadId::from_string(&thread.id) {
Ok(thread_id) => thread_id,
Err(err) => {
warn!(thread_id = thread.id, %err, "Skipping app-server picker row with invalid id");
return None;
}
};
let preview = thread.preview.trim();
Some(Row {
path: thread.path,
preview: if preview.is_empty() {
String::from("(no message yet)")
} else {
preview.to_string()
},
thread_id: Some(thread_id),
thread_name: thread.name,
created_at: chrono::DateTime::from_timestamp(thread.created_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
cwd: Some(thread.cwd),
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
})
}
fn thread_list_params(
cursor: Option<String>,
cwd_filter: Option<&Path>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
) -> ThreadListParams {
ThreadListParams {
cursor,
limit: Some(PAGE_SIZE as u32),
sort_key: Some(match sort_key {
ThreadSortKey::CreatedAt => AppServerThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt,
}),
model_providers: match provider_filter {
ProviderFilter::Any => None,
ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]),
},
source_kinds: (!include_non_interactive)
.then_some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]),
archived: Some(false),
cwd: cwd_filter.map(|cwd| cwd.to_string_lossy().to_string()),
search_term: None,
}
}
fn paths_match(a: &Path, b: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(a),
path_utils::normalize_for_path_comparison(b),
) {
return ca == cb;
}
a == b
}
#[cfg_attr(not(test), allow(dead_code))]
fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|dt| dt.with_timezone(&Utc))
.ok()
}
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
// Render full-screen overlay
let height = tui.terminal.size()?.height;
tui.draw(height, |frame| {
let area = frame.area();
let [header, search, columns, list, hint] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(area.height.saturating_sub(4)),
Constraint::Length(1),
])
.areas(area);
// 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
frame.render_widget_ref(search_line(state), search);
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
// Column headers and list
render_column_headers(frame, columns, &metrics, state.sort_key);
render_list(frame, list, state, &metrics);
// Hint line
let action_label = state.action.action_label();
let hint_line: Line = vec![
key_hint::plain(KeyCode::Enter).into(),
format!(" to {action_label} ").dim(),
" ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to start new ".dim(),
" ".dim(),
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(),
" to browse".dim(),
]
.into();
frame.render_widget_ref(hint_line, hint);
})
}
fn search_line(state: &PickerState) -> Line<'_> {
if let Some(error) = state.inline_error.as_deref() {
return Line::from(error.red());
}
if state.query.is_empty() {
return Line::from("Type to search".dim());
}
Line::from(format!("Search: {}", state.query))
}
fn render_list(
frame: &mut crate::custom_terminal::Frame,
area: Rect,
state: &PickerState,
metrics: &ColumnMetrics,
) {
if area.height == 0 {
return;
}
let rows = &state.filtered_rows;
if rows.is_empty() {
let message = render_empty_state_line(state);
frame.render_widget_ref(message, area);
return;
}
let capacity = area.height as usize;
let start = state.scroll_top.min(rows.len().saturating_sub(1));
let end = rows.len().min(start + capacity);
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, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end]
.iter()
.zip(labels[start..end].iter())
.enumerate()
{
let is_sel = start + idx == state.selected;
let marker = if is_sel { "> ".bold() } else { " ".into() };
let marker_width = 2usize;
let created_span = if visibility.show_created {
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
} else {
None
};
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(
Span::from(format!(
"{empty:<width$}",
empty = "-",
width = max_branch_width
))
.dim(),
)
} else {
Some(Span::from(format!("{branch_label:<max_branch_width$}")).cyan())
};
let cwd_span = if !visibility.show_cwd {
None
} else if cwd_label.is_empty() {
Some(
Span::from(format!(
"{empty:<width$}",
empty = "-",
width = max_cwd_width
))
.dim(),
)
} else {
Some(Span::from(format!("{cwd_label:<max_cwd_width$}")).dim())
};
let mut preview_width = area.width as usize;
preview_width = preview_width.saturating_sub(marker_width);
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 visibility.show_branch {
preview_width = preview_width.saturating_sub(max_branch_width + 2);
}
if visibility.show_cwd {
preview_width = preview_width.saturating_sub(max_cwd_width + 2);
}
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());
}
if let Some(branch) = branch_span {
spans.push(branch);
spans.push(" ".into());
}
if let Some(cwd) = cwd_span {
spans.push(cwd);
spans.push(" ".into());
}
if add_leading_gap {
spans.push(" ".into());
}
spans.push(preview.into());
let line: Line = spans.into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(line, rect);
y = y.saturating_add(1);
}
if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) {
let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(loading_line, rect);
}
}
fn render_empty_state_line(state: &PickerState) -> Line<'static> {
if !state.query.is_empty() {
if state.search_state.is_active()
|| (state.pagination.loading.is_pending() && state.pagination.next_cursor.is_some())
{
return vec!["Searching…".italic().dim()].into();
}
if state.pagination.reached_scan_cap {
let msg = format!(
"Search scanned first {} sessions; more may exist",
state.pagination.num_scanned_files
);
return vec![Span::from(msg).italic().dim()].into();
}
return vec!["No results for your search".italic().dim()].into();
}
if state.pagination.loading.is_pending() {
if state.all_rows.is_empty() && state.pagination.num_scanned_files == 0 {
return vec!["Loading sessions…".italic().dim()].into();
}
return vec!["Loading older sessions…".italic().dim()].into();
}
vec!["No sessions yet".italic().dim()].into()
}
fn human_time_ago(ts: DateTime<Utc>) -> String {
let now = Utc::now();
let delta = now - ts;
let secs = delta.num_seconds();
if secs < 60 {
let n = secs.max(0);
if n == 1 {
format!("{n} second ago")
} else {
format!("{n} seconds ago")
}
} else if secs < 60 * 60 {
let m = secs / 60;
if m == 1 {
format!("{m} minute ago")
} else {
format!("{m} minutes ago")
}
} else if secs < 60 * 60 * 24 {
let h = secs / 3600;
if h == 1 {
format!("{h} hour ago")
} else {
format!("{h} hours ago")
}
} else {
let d = secs / (60 * 60 * 24);
if d == 1 {
format!("{d} day ago")
} else {
format!("{d} days ago")
}
}
}
fn format_updated_label(row: &Row) -> String {
match (row.updated_at, row.created_at) {
(Some(updated), _) => human_time_ago(updated),
(None, Some(created)) => human_time_ago(created),
(None, None) => "-".to_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()];
let visibility = column_visibility(area.width, metrics, sort_key);
if visibility.show_created {
let label = format!(
"{text:<width$}",
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 visibility.show_branch {
let label = format!(
"{text:<width$}",
text = "Branch",
width = metrics.max_branch_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
if visibility.show_cwd {
let label = format!(
"{text:<width$}",
text = "CWD",
width = metrics.max_cwd_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
spans.push("Conversation".bold());
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,
/// (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 {
fn right_elide(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
if max <= 1 {
return "".to_string();
}
let tail_len = max - 1;
let tail: String = s
.chars()
.rev()
.take(tail_len)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{tail}")
}
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")
} else {
0
};
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, /*max*/ 24);
let cwd = if include_cwd {
let cwd_raw = row
.cwd
.as_ref()
.map(|p| display_path_for(p, std::path::Path::new("/")))
.unwrap_or_default();
right_elide(&cwd_raw, /*max*/ 24)
} 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((created, updated, branch, cwd));
}
ColumnMetrics {
max_created_width,
max_updated_width,
max_branch_width,
max_cwd_width,
labels,
}
}
/// 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 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;
fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem {
ThreadItem {
path: PathBuf::from(path),
first_user_message: Some(preview.to_string()),
created_at: Some(ts.to_string()),
updated_at: Some(ts.to_string()),
..Default::default()
}
}
fn cursor_from_str(repr: &str) -> Cursor {
serde_json::from_str::<Cursor>(&format!("\"{repr}\""))
.expect("cursor format should deserialize")
}
fn page(
items: Vec<ThreadItem>,
next_cursor: Option<Cursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
) -> PickerPage {
picker_page_from_rollout_page(ThreadsPage {
items,
next_cursor,
num_scanned_files,
reached_scan_cap,
})
}
#[allow(dead_code)]
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");
}
// TODO(jif) fix
// #[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::EventMsg(EventMsg::UserMessage(UserMessageEvent {
// message: preview.to_string(),
// images: None,
// text_elements: Vec::new(),
// local_images: Vec::new(),
// })),
// };
// 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 {
path: PathBuf::from("/tmp/a.jsonl"),
first_user_message: Some("real question".to_string()),
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T00:00:00Z".into()),
..Default::default()
};
let row = head_to_row(&item);
assert_eq!(row.preview, "real question");
}
#[test]
fn rows_from_items_preserves_backend_order() {
// Construct two items with different timestamps and real user text.
let a = ThreadItem {
path: PathBuf::from("/tmp/a.jsonl"),
first_user_message: Some("A".to_string()),
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T00:00:00Z".into()),
..Default::default()
};
let b = ThreadItem {
path: PathBuf::from("/tmp/b.jsonl"),
first_user_message: Some("B".to_string()),
created_at: Some("2025-01-02T00:00:00Z".into()),
updated_at: Some("2025-01-02T00:00:00Z".into()),
..Default::default()
};
let rows = rows_from_items(vec![a, b]);
assert_eq!(rows.len(), 2);
// Preserve the given order even if timestamps differ; backend already provides newest-first.
assert!(rows[0].preview.contains('A'));
assert!(rows[1].preview.contains('B'));
}
#[test]
fn row_uses_tail_timestamp_for_updated_at() {
let item = ThreadItem {
path: PathBuf::from("/tmp/a.jsonl"),
first_user_message: Some("Hello".to_string()),
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T01:00:00Z".into()),
..Default::default()
};
let row = head_to_row(&item);
let expected_created = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let expected_updated = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(row.created_at, Some(expected_created));
assert_eq!(row.updated_at, Some(expected_updated));
}
#[test]
fn row_display_preview_prefers_thread_name() {
let row = Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: None,
thread_name: Some(String::from("My session")),
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
};
assert_eq!(row.display_preview(), "My session");
}
#[test]
fn remote_thread_list_params_omit_provider_filter() {
let params = thread_list_params(
Some(String::from("cursor-1")),
Some(Path::new("repo/on/server")),
ProviderFilter::Any,
ThreadSortKey::UpdatedAt,
/*include_non_interactive*/ false,
);
assert_eq!(params.cursor, Some(String::from("cursor-1")));
assert_eq!(params.model_providers, None);
assert_eq!(
params.source_kinds,
Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode])
);
assert_eq!(params.cwd.as_deref(), Some("repo/on/server"));
}
#[test]
fn remote_thread_list_params_can_include_non_interactive_sources() {
let params = thread_list_params(
Some(String::from("cursor-1")),
/*cwd_filter*/ None,
ProviderFilter::Any,
ThreadSortKey::UpdatedAt,
/*include_non_interactive*/ true,
);
assert_eq!(params.cursor, Some(String::from("cursor-1")));
assert_eq!(params.model_providers, None);
assert_eq!(params.source_kinds, None);
}
#[test]
fn remote_picker_does_not_filter_rows_by_local_cwd() {
let loader: PageLoader = Arc::new(|_| {});
let state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::Any,
/*show_all*/ false,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let row = Row {
path: None,
preview: String::from("remote session"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: None,
cwd: Some(PathBuf::from("/srv/remote-project")),
git_branch: None,
};
assert!(state.row_matches_filter(&row));
}
#[test]
fn resume_table_snapshot() {
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let now = Utc::now();
let rows = vec![
Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("Fix resume picker timestamps"),
thread_id: None,
thread_name: None,
created_at: Some(now - Duration::minutes(16)),
updated_at: Some(now - Duration::seconds(42)),
cwd: None,
git_branch: None,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
preview: String::from("Investigate lazy pagination cap"),
thread_id: None,
thread_name: None,
created_at: Some(now - Duration::hours(1)),
updated_at: Some(now - Duration::minutes(35)),
cwd: None,
git_branch: None,
},
Row {
path: Some(PathBuf::from("/tmp/c.jsonl")),
preview: String::from("Explain the codebase"),
thread_id: None,
thread_name: None,
created_at: Some(now - Duration::hours(2)),
updated_at: Some(now - Duration::hours(2)),
cwd: None,
git_branch: None,
},
];
state.all_rows = rows.clone();
state.filtered_rows = rows;
state.view_rows = Some(3);
state.selected = 1;
state.scroll_top = 0;
state.update_view_rows(/*rows*/ 3);
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
let width: u16 = 80;
let height: u16 = 6;
let backend = VT100Backend::new(width, height);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
{
let mut frame = terminal.get_frame();
let area = frame.area();
let segments =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
render_list(&mut frame, segments[1], &state, &metrics);
}
terminal.flush().expect("flush");
let snapshot = terminal.backend().to_string();
assert_snapshot!("resume_picker_table", snapshot);
}
#[test]
fn resume_search_error_snapshot() {
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.inline_error = Some(String::from(
"Failed to read session metadata from /tmp/missing.jsonl",
));
let width: u16 = 80;
let height: u16 = 1;
let backend = VT100Backend::new(width, height);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
{
let mut frame = terminal.get_frame();
let line = search_line(&state);
frame.render_widget_ref(line, frame.area());
}
terminal.flush().expect("flush");
let snapshot = terminal.backend().to_string();
assert_snapshot!("resume_picker_search_error", snapshot);
}
// TODO(jif) fix
// #[tokio::test]
// async fn resume_picker_screen_snapshot() {
// use crate::custom_terminal::Terminal;
// use crate::test_backend::VT100Backend;
// use uuid::Uuid;
//
// // Create real rollout files so the snapshot uses the actual listing pipeline.
// 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();
//
// // Helper to write a rollout file with minimal meta + one user message.
// let write_rollout = |ts: DateTime<Utc>, cwd: &str, branch: &str, preview: &str| {
// 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 = serde_json::json!({
// "timestamp": ts.to_rfc3339(),
// "item": {
// "SessionMeta": {
// "meta": {
// "id": Uuid::new_v4(),
// "timestamp": ts.to_rfc3339(),
// "cwd": cwd,
// "originator": "user",
// "cli_version": "0.0.0",
// "source": "Cli",
// "model_provider": "openai",
// }
// }
// }
// });
// let user = serde_json::json!({
// "timestamp": ts.to_rfc3339(),
// "item": {
// "EventMsg": {
// "UserMessage": {
// "message": preview,
// "images": null
// }
// }
// }
// });
// let branch_meta = serde_json::json!({
// "timestamp": ts.to_rfc3339(),
// "item": {
// "EventMsg": {
// "SessionMeta": {
// "meta": {
// "git_branch": branch
// }
// }
// }
// }
// });
// std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n"))
// .expect("write rollout");
// };
//
// write_rollout(
// now - Duration::seconds(42),
// "/tmp/project",
// "feature/resume",
// "Fix resume picker timestamps",
// );
// write_rollout(
// now - Duration::minutes(35),
// "/tmp/other",
// "main",
// "Investigate lazy pagination cap",
// );
//
// let loader: PageLoader = Arc::new(|_| {});
// let mut state = PickerState::new(
// PathBuf::from("/tmp"),
// FrameRequester::test_dummy(),
// loader,
// String::from("openai"),
// true,
// None,
// SessionPickerAction::Resume,
// );
//
// let page = RolloutRecorder::list_threads(
// &state.codex_home,
// PAGE_SIZE,
// None,
// ThreadSortKey::CreatedAt,
// INTERACTIVE_SESSION_SOURCES,
// Some(&[String::from("openai")]),
// "openai",
// )
// .await
// .expect("list conversations");
//
// let rows = rows_from_items(page.items);
// state.all_rows = rows.clone();
// state.filtered_rows = rows;
// state.view_rows = Some(4);
// state.selected = 0;
// state.scroll_top = 0;
// state.update_view_rows(4);
//
// let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
//
// let width: u16 = 80;
// let height: u16 = 9;
// let backend = VT100Backend::new(width, height);
// let mut terminal = Terminal::with_options(backend).expect("terminal");
// terminal.set_viewport_area(Rect::new(0, 0, width, height));
//
// {
// let mut frame = terminal.get_frame();
// let area = frame.area();
// let [header, search, columns, list, hint] = Layout::vertical([
// Constraint::Length(1),
// Constraint::Length(1),
// Constraint::Length(1),
// Constraint::Min(area.height.saturating_sub(4)),
// Constraint::Length(1),
// ])
// .areas(area);
//
// frame.render_widget_ref(
// 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, state.sort_key);
// render_list(&mut frame, list, &state, &metrics);
//
// let hint_line: Line = vec![
// key_hint::plain(KeyCode::Enter).into(),
// " to resume ".dim(),
// " ".dim(),
// key_hint::plain(KeyCode::Esc).into(),
// " to start new ".dim(),
// " ".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);
// }
// terminal.flush().expect("flush");
//
// let snapshot = terminal.backend().to_string();
// assert_snapshot!("resume_picker_screen", snapshot);
// }
#[tokio::test]
async fn resume_picker_thread_names_snapshot() {
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
let tempdir = tempfile::tempdir().expect("tempdir");
let session_index_path = tempdir.path().join("session_index.jsonl");
let id1 =
ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id 1");
let id2 =
ThreadId::from_string("22222222-2222-2222-2222-222222222222").expect("thread id 2");
let entries = vec![
json!({
"id": id1,
"thread_name": "Keep this for now",
"updated_at": "2025-01-01T00:00:00Z",
}),
json!({
"id": id2,
"thread_name": "Named thread",
"updated_at": "2025-01-01T00:00:00Z",
}),
];
let mut out = String::new();
for entry in entries {
out.push_str(&serde_json::to_string(&entry).expect("session index entry"));
out.push('\n');
}
std::fs::write(&session_index_path, out).expect("write session index");
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
tempdir.path().to_path_buf(),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let now = Utc::now();
let rows = vec![
Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("First message preview"),
thread_id: Some(id1),
thread_name: None,
created_at: None,
updated_at: Some(now - Duration::days(2)),
cwd: None,
git_branch: None,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
preview: String::from("Second message preview"),
thread_id: Some(id2),
thread_name: None,
created_at: None,
updated_at: Some(now - Duration::days(3)),
cwd: None,
git_branch: None,
},
];
state.all_rows = rows.clone();
state.filtered_rows = rows;
state.view_rows = Some(2);
state.selected = 0;
state.scroll_top = 0;
state.update_view_rows(/*rows*/ 2);
state.update_thread_names().await;
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
let width: u16 = 80;
let height: u16 = 5;
let backend = VT100Backend::new(width, height);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
{
let mut frame = terminal.get_frame();
let area = frame.area();
let segments =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
render_list(&mut frame, segments[1], &state, &metrics);
}
terminal.flush().expect("flush");
let snapshot = terminal.backend().to_string();
assert_snapshot!("resume_picker_thread_names", snapshot);
}
#[tokio::test]
async fn update_thread_names_prefers_local_session_index_names() {
let tempdir = tempfile::tempdir().expect("tempdir");
let thread_id =
ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id");
let session_index_entry = json!({
"id": thread_id,
"thread_name": "Saved session name",
"updated_at": "2025-01-01T00:00:00Z",
});
std::fs::write(
tempdir.path().join("session_index.jsonl"),
format!("{session_index_entry}\n"),
)
.expect("write session index");
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
tempdir.path().to_path_buf(),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.all_rows = vec![Row {
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("First prompt"),
thread_id: Some(thread_id),
thread_name: Some(String::from("stale backend title")),
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
}];
state.filtered_rows = state.all_rows.clone();
state.update_thread_names().await;
assert_eq!(
state.all_rows[0].thread_name,
Some(String::from("Saved session name"))
);
assert_eq!(
state.filtered_rows[0].display_preview(),
"Saved session name"
);
}
#[test]
fn pageless_scrolling_deduplicates_and_keeps_order() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
vec![
make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "third"),
make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"),
],
Some(cursor_from_str(
"2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000",
)),
/*num_scanned_files*/ 2,
/*reached_scan_cap*/ false,
));
state.ingest_page(page(
vec![
make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"),
make_item("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"),
],
Some(cursor_from_str(
"2025-01-01T00-00-00|00000000-0000-0000-0000-000000000001",
)),
/*num_scanned_files*/ 2,
/*reached_scan_cap*/ false,
));
state.ingest_page(page(
vec![make_item(
"/tmp/d.jsonl",
"2024-12-31T23:00:00Z",
"very old",
)],
/*next_cursor*/ None,
/*num_scanned_files*/ 1,
/*reached_scan_cap*/ false,
));
let previews: Vec<_> = state
.filtered_rows
.iter()
.map(|row| row.preview.as_str())
.collect();
assert_eq!(previews, vec!["third", "second", "first", "very old"]);
let unique_paths = state
.filtered_rows
.iter()
.map(|row| row.path.clone())
.collect::<std::collections::HashSet<_>>();
assert_eq!(unique_paths.len(), 4);
}
#[test]
fn ensure_minimum_rows_prefetches_when_underfilled() {
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,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
vec![
make_item("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"),
make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"),
],
Some(cursor_from_str(
"2025-01-03T00-00-00|00000000-0000-0000-0000-000000000000",
)),
/*num_scanned_files*/ 2,
/*reached_scan_cap*/ false,
));
assert!(recorded_requests.lock().unwrap().is_empty());
state.ensure_minimum_rows_for_view(/*minimum_rows*/ 10);
let guard = recorded_requests.lock().unwrap();
assert_eq!(guard.len(), 1);
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(/*area_width*/ 30, &metrics, ThreadSortKey::CreatedAt);
assert_eq!(
created,
ColumnVisibility {
show_created: true,
show_updated: false,
show_branch: false,
show_cwd: false,
}
);
let updated = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::UpdatedAt);
assert_eq!(
updated,
ColumnVisibility {
show_created: false,
show_updated: true,
show_branch: false,
show_cwd: false,
}
);
let wide = column_visibility(/*area_width*/ 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,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ 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::UpdatedAt);
}
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::CreatedAt);
}
#[tokio::test]
async fn page_navigation_uses_view_rows() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let mut items = Vec::new();
for idx in 0..20 {
let ts = format!("2025-01-{:02}T00:00:00Z", idx + 1);
let preview = format!("item-{idx}");
let path = format!("/tmp/item-{idx}.jsonl");
items.push(make_item(&path, &ts, &preview));
}
state.reset_pagination();
state.ingest_page(page(
items, /*next_cursor*/ None, /*num_scanned_files*/ 20,
/*reached_scan_cap*/ false,
));
state.update_view_rows(/*rows*/ 5);
assert_eq!(state.selected, 0);
state
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.selected, 5);
state
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.selected, 10);
state
.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.selected, 5);
}
#[tokio::test]
async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let row = Row {
path: Some(PathBuf::from("/tmp/missing.jsonl")),
preview: String::from("missing metadata"),
thread_id: None,
thread_name: None,
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
let selection = state
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("enter should not abort the picker");
assert!(selection.is_none());
assert_eq!(
state.inline_error,
Some(String::from(
"Failed to read session metadata from /tmp/missing.jsonl"
))
);
}
#[tokio::test]
async fn enter_on_pathless_thread_uses_thread_id() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let thread_id = ThreadId::new();
let row = Row {
path: None,
preview: String::from("pathless thread"),
thread_id: Some(thread_id),
thread_name: None,
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
let selection = state
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("enter should not abort the picker");
match selection {
Some(SessionSelection::Resume(SessionTarget {
path: None,
thread_id: selected_thread_id,
})) => assert_eq!(selected_thread_id, thread_id),
other => panic!("unexpected selection: {other:?}"),
}
}
#[test]
fn app_server_row_keeps_pathless_threads() {
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
forked_from_id: None,
preview: String::from("remote thread"),
ephemeral: false,
model_provider: String::from("openai"),
created_at: 1,
updated_at: 2,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp"),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some(String::from("Named thread")),
turns: Vec::new(),
};
let row = row_from_app_server_thread(thread).expect("row should be preserved");
assert_eq!(row.path, None);
assert_eq!(row.thread_id, Some(thread_id));
assert_eq!(row.thread_name, Some(String::from("Named thread")));
}
#[tokio::test]
async fn up_at_bottom_does_not_scroll_when_visible() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let mut items = Vec::new();
for idx in 0..10 {
let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1);
let preview = format!("item-{idx}");
let path = format!("/tmp/item-{idx}.jsonl");
items.push(make_item(&path, &ts, &preview));
}
state.reset_pagination();
state.ingest_page(page(
items, /*next_cursor*/ None, /*num_scanned_files*/ 10,
/*reached_scan_cap*/ false,
));
state.update_view_rows(/*rows*/ 5);
state.selected = state.filtered_rows.len().saturating_sub(1);
state.ensure_selected_visible();
let initial_top = state.scroll_top;
assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(5));
state
.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.scroll_top, initial_top);
assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2));
}
#[tokio::test]
async fn set_query_loads_until_match_and_respects_scan_cap() {
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,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
vec![make_item(
"/tmp/start.jsonl",
"2025-01-01T00:00:00Z",
"alpha",
)],
Some(cursor_from_str(
"2025-01-02T00-00-00|00000000-0000-0000-0000-000000000000",
)),
/*num_scanned_files*/ 1,
/*reached_scan_cap*/ false,
));
recorded_requests.lock().unwrap().clear();
state.set_query("target".to_string());
let first_request = {
let guard = recorded_requests.lock().unwrap();
assert_eq!(guard.len(), 1);
guard[0].clone()
};
state
.handle_background_event(BackgroundEvent::PageLoaded {
request_token: first_request.request_token,
search_token: first_request.search_token,
page: Ok(page(
vec![make_item("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")],
Some(cursor_from_str(
"2025-01-03T00-00-00|00000000-0000-0000-0000-000000000001",
)),
/*num_scanned_files*/ 5,
/*reached_scan_cap*/ false,
)),
})
.await
.unwrap();
let second_request = {
let guard = recorded_requests.lock().unwrap();
assert_eq!(guard.len(), 2);
guard[1].clone()
};
assert!(state.search_state.is_active());
assert!(state.filtered_rows.is_empty());
state
.handle_background_event(BackgroundEvent::PageLoaded {
request_token: second_request.request_token,
search_token: second_request.search_token,
page: Ok(page(
vec![make_item(
"/tmp/match.jsonl",
"2025-01-03T00:00:00Z",
"target log",
)],
Some(cursor_from_str(
"2025-01-04T00-00-00|00000000-0000-0000-0000-000000000002",
)),
/*num_scanned_files*/ 7,
/*reached_scan_cap*/ false,
)),
})
.await
.unwrap();
assert!(!state.filtered_rows.is_empty());
assert!(!state.search_state.is_active());
recorded_requests.lock().unwrap().clear();
state.set_query("missing".to_string());
let active_request = {
let guard = recorded_requests.lock().unwrap();
assert_eq!(guard.len(), 1);
guard[0].clone()
};
state
.handle_background_event(BackgroundEvent::PageLoaded {
request_token: second_request.request_token,
search_token: second_request.search_token,
page: Ok(page(
Vec::new(),
/*next_cursor*/ None,
/*num_scanned_files*/ 0,
/*reached_scan_cap*/ false,
)),
})
.await
.unwrap();
assert_eq!(recorded_requests.lock().unwrap().len(), 1);
state
.handle_background_event(BackgroundEvent::PageLoaded {
request_token: active_request.request_token,
search_token: active_request.search_token,
page: Ok(page(
Vec::new(),
/*next_cursor*/ None,
/*num_scanned_files*/ 3,
/*reached_scan_cap*/ true,
)),
})
.await
.unwrap();
assert!(state.filtered_rows.is_empty());
assert!(!state.search_state.is_active());
assert!(state.pagination.reached_scan_cap);
}
}