Compare commits

...

4 Commits

Author SHA1 Message Date
ychhabria
6945c400fb tui: fix argument-comment lint
Adjust the new /resume recovery callsites and tests to satisfy the argument comment lint after merging main into the branch.

Validation:
- cargo test -p codex-tui

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 17:23:04 -07:00
ychhabria
8ac526ab3e Merge branch 'main' into codex/undo-clear-resume 2026-04-06 17:10:37 -07:00
ychhabria
385827b1f9 Merge branch 'main' into codex/undo-clear-resume 2026-04-06 14:55:15 -07:00
ychhabria
d441b8a0d7 tui: recover cleared sessions via resume
Add a temporary displaced-session marker for /clear and /new so /resume can immediately recover the prior thread. The resume picker now pins that displaced session, shows a hint in the fresh session history, and clears the marker after a successful resume or fork.

Validation:
- cargo test -p codex-tui

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 14:54:06 -07:00
5 changed files with 434 additions and 83 deletions

View File

@@ -45,13 +45,16 @@ use crate::pager_overlay::Overlay;
use crate::read_session_model;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::PreferredSession;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
#[cfg(test)]
use crate::test_support::PathBufExt;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use chrono::Utc;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
@@ -327,6 +330,29 @@ fn session_summary(
})
}
fn session_transition_history_lines(
summary: Option<SessionSummary>,
include_resume_picker_hint: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if let Some(summary) = summary {
lines.push(summary.usage_line.into());
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
}
if include_resume_picker_hint {
let spans = vec![
"Previous session available via ".into(),
"/resume".magenta(),
".".into(),
];
lines.push(spans.into());
}
lines
}
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
response
.skills
@@ -1003,6 +1029,7 @@ pub(crate) struct App {
primary_session_configured: Option<ThreadSessionState>,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
last_displaced_session: Option<PreferredSession>,
}
#[derive(Default)]
@@ -3266,6 +3293,24 @@ impl App {
self.sync_active_agent_label();
}
fn displaced_session_for_resume(&self) -> Option<PreferredSession> {
let thread_id = self.chat_widget.thread_id()?;
let thread_name = self.chat_widget.thread_name();
let path = self
.chat_widget
.rollout_path()
.filter(|path| !path.as_os_str().is_empty());
Some(PreferredSession {
target: SessionTarget { path, thread_id },
preview: thread_name
.clone()
.unwrap_or_else(|| String::from("Previous session")),
thread_name,
cwd: Some(self.config.cwd.to_path_buf()),
updated_at: Utc::now(),
})
}
async fn start_fresh_session_with_summary_hint(
&mut self,
tui: &mut tui::Tui,
@@ -3300,12 +3345,11 @@ impl App {
self.chat_widget.add_error_message(format!(
"Failed to attach to fresh app-server thread: {err}"
));
} else if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
} else if summary.is_some() || self.last_displaced_session.is_some() {
let lines = session_transition_history_lines(
summary,
self.last_displaced_session.is_some(),
);
self.chat_widget.add_plain_history_lines(lines);
}
}
@@ -3794,6 +3838,7 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
last_displaced_session: None,
};
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -4015,10 +4060,12 @@ impl App {
) -> Result<AppRunControl> {
match event {
AppEvent::NewSession => {
self.last_displaced_session = self.displaced_session_for_resume();
self.start_fresh_session_with_summary_hint(tui, app_server)
.await;
}
AppEvent::ClearUi => {
self.last_displaced_session = self.displaced_session_for_resume();
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
@@ -4052,6 +4099,7 @@ impl App {
/*show_all*/ false,
/*include_non_interactive*/ false,
picker_app_server,
self.last_displaced_session.clone(),
)
.await?
{
@@ -4113,16 +4161,10 @@ impl App {
.await
{
Ok(()) => {
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.last_displaced_session = None;
let lines =
session_transition_history_lines(summary, false);
if !lines.is_empty() {
self.chat_widget.add_plain_history_lines(lines);
}
}
@@ -4173,16 +4215,9 @@ impl App {
.await
{
Ok(()) => {
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.last_displaced_session = None;
let lines = session_transition_history_lines(summary, false);
if !lines.is_empty() {
self.chat_widget.add_plain_history_lines(lines);
}
}
@@ -9105,6 +9140,7 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
last_displaced_session: None,
}
}
@@ -9159,6 +9195,7 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
last_displaced_session: None,
},
rx,
op_rx,
@@ -10947,4 +10984,72 @@ guardian_approval = true
Some("codex resume my-session".to_string())
);
}
#[test]
fn session_transition_history_lines_include_resume_picker_hint() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let thread_id =
ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id");
let summary =
session_summary(usage, Some(thread_id), Some("my-session".to_string())).expect("line");
let rendered = session_transition_history_lines(
Some(summary),
/*include_resume_picker_hint*/ true,
)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(
"session_transition_history_lines_with_resume_picker_hint",
rendered
);
}
#[tokio::test]
async fn displaced_session_for_resume_captures_current_thread_metadata() {
let mut app = make_test_app().await;
let thread_id =
ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id");
let rollout_path =
PathBuf::from("/tmp/sessions/123e4567-e89b-12d3-a456-426614174000.jsonl");
app.config.cwd = PathBuf::from("/tmp/project").abs();
app.chat_widget.handle_thread_session(ThreadSessionState {
thread_id,
forked_from_id: None,
thread_name: Some(String::from("saved-session")),
model: String::from("gpt-test"),
model_provider_id: String::from("test-provider"),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/tmp/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
network_proxy: None,
rollout_path: Some(rollout_path.clone()),
});
let displaced = app
.displaced_session_for_resume()
.expect("current thread should be captured");
assert_eq!(displaced.target.thread_id, thread_id);
assert_eq!(displaced.target.path, Some(rollout_path));
assert_eq!(displaced.thread_name, Some(String::from("saved-session")));
assert_eq!(displaced.preview, String::from("saved-session"));
assert_eq!(displaced.cwd, Some(PathBuf::from("/tmp/project")));
}
}

View File

@@ -1258,6 +1258,7 @@ async fn run_ratatui_app(
cli.resume_show_all,
cli.resume_include_non_interactive,
app_server,
/*preferred_session*/ None,
)
.await?
{

View File

@@ -69,6 +69,15 @@ pub enum SessionSelection {
Exit,
}
#[derive(Clone, Debug)]
pub(crate) struct PreferredSession {
pub(crate) target: SessionTarget,
pub(crate) preview: String,
pub(crate) thread_name: Option<String>,
pub(crate) cwd: Option<PathBuf>,
pub(crate) updated_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug)]
pub enum SessionPickerAction {
Resume,
@@ -116,6 +125,13 @@ enum ProviderFilter {
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
struct SessionPickerOptions {
show_all: bool,
action: SessionPickerAction,
is_remote: bool,
preferred_session: Option<PreferredSession>,
}
enum BackgroundEvent {
PageLoaded {
request_token: usize,
@@ -138,6 +154,17 @@ struct PickerPage {
reached_scan_cap: bool,
}
struct PickerStateInit {
codex_home: PathBuf,
requester: FrameRequester,
page_loader: PageLoader,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
preferred_session: Option<PreferredSession>,
}
/// Interactive session picker that lists recorded rollout files with simple
/// search and pagination.
///
@@ -169,6 +196,7 @@ pub async fn run_resume_picker_with_app_server(
show_all: bool,
include_non_interactive: bool,
app_server: AppServerSession,
preferred_session: Option<PreferredSession>,
) -> Result<SessionSelection> {
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let is_remote = app_server.is_remote();
@@ -180,11 +208,14 @@ pub async fn run_resume_picker_with_app_server(
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,
SessionPickerOptions {
show_all,
action: SessionPickerAction::Resume,
is_remote,
preferred_session,
},
)
.await
}
@@ -205,13 +236,16 @@ pub async fn run_fork_picker_with_app_server(
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,
SessionPickerOptions {
show_all,
action: SessionPickerAction::Fork,
is_remote,
preferred_session: None,
},
)
.await
}
@@ -227,11 +261,14 @@ async fn run_session_picker(
run_session_picker_with_loader(
tui,
config,
show_all,
action,
/*is_remote*/ false,
spawn_rollout_page_loader(config, bg_tx),
bg_rx,
SessionPickerOptions {
show_all,
action,
is_remote: false,
preferred_session: None,
},
)
.await
}
@@ -239,20 +276,18 @@ async fn run_session_picker(
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>,
options: SessionPickerOptions,
) -> Result<SessionSelection> {
let alt = AltScreenGuard::enter(tui);
let provider_filter = if is_remote {
let provider_filter = if options.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 {
let filter_cwd = if options.show_all || options.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.
@@ -261,15 +296,16 @@ async fn run_session_picker_with_loader(
std::env::current_dir().ok()
};
let mut state = PickerState::new(
codex_home.to_path_buf(),
alt.tui.frame_requester(),
let mut state = PickerState::new(PickerStateInit {
codex_home: codex_home.to_path_buf(),
requester: alt.tui.frame_requester(),
page_loader,
provider_filter,
show_all,
show_all: options.show_all,
filter_cwd,
action,
);
action: options.action,
preferred_session: options.preferred_session,
});
state.start_initial_load();
state.request_frame();
@@ -444,6 +480,7 @@ struct PickerState {
sort_key: ThreadSortKey,
thread_name_cache: HashMap<ThreadId, Option<String>>,
inline_error: Option<String>,
preferred_row: Option<Row>,
}
struct PaginationState {
@@ -537,6 +574,7 @@ struct Row {
updated_at: Option<DateTime<Utc>>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
is_preferred: bool,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -571,15 +609,17 @@ impl Row {
}
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 {
fn new(init: PickerStateInit) -> Self {
let PickerStateInit {
codex_home,
requester,
page_loader,
provider_filter,
show_all,
filter_cwd,
action,
preferred_session,
} = init;
Self {
codex_home,
requester,
@@ -608,6 +648,17 @@ impl PickerState {
sort_key: ThreadSortKey::UpdatedAt,
thread_name_cache: HashMap::new(),
inline_error: None,
preferred_row: preferred_session.map(|session| Row {
path: session.target.path,
preview: session.preview,
thread_id: Some(session.target.thread_id),
thread_name: session.thread_name,
created_at: None,
updated_at: Some(session.updated_at),
cwd: session.cwd,
git_branch: None,
is_preferred: true,
}),
}
}
@@ -722,6 +773,7 @@ impl PickerState {
self.filtered_rows.clear();
self.seen_rows.clear();
self.selected = 0;
self.seed_preferred_row();
let search_token = if self.query.is_empty() {
self.search_state = SearchState::Idle;
@@ -737,7 +789,7 @@ impl PickerState {
request_token,
search_token,
});
self.request_frame();
self.apply_filter();
(self.page_loader)(PageLoadRequest {
cursor: None,
@@ -780,6 +832,16 @@ impl PickerState {
self.pagination.loading = LoadingState::Idle;
}
fn seed_preferred_row(&mut self) {
let Some(row) = self.preferred_row.clone() else {
return;
};
if let Some(seen_key) = row.seen_key() {
self.seen_rows.insert(seen_key);
}
self.all_rows.push(row);
}
fn ingest_page(&mut self, page: PickerPage) {
if let Some(cursor) = page.next_cursor.clone() {
self.pagination.next_cursor = Some(cursor);
@@ -874,6 +936,9 @@ impl PickerState {
}
fn row_matches_filter(&self, row: &Row) -> bool {
if row.is_preferred {
return true;
}
if self.show_all {
return true;
}
@@ -1091,6 +1156,7 @@ fn head_to_row(item: &ThreadItem) -> Row {
updated_at,
cwd: item.cwd.clone(),
git_branch: item.git_branch.clone(),
is_preferred: false,
}
}
@@ -1118,6 +1184,7 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
.map(|dt| dt.with_timezone(&Utc)),
cwd: Some(thread.cwd),
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
is_preferred: false,
})
}
@@ -1689,6 +1756,27 @@ mod tests {
})
}
fn test_picker_state(
codex_home: PathBuf,
loader: PageLoader,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
preferred_session: Option<PreferredSession>,
) -> PickerState {
PickerState::new(PickerStateInit {
codex_home,
requester: FrameRequester::test_dummy(),
page_loader: loader,
provider_filter,
show_all,
filter_cwd,
action,
preferred_session,
})
}
#[allow(dead_code)]
fn set_rollout_mtime(path: &Path, updated_at: DateTime<Utc>) {
let times = FileTimes::new().set_modified(updated_at.into());
@@ -1857,6 +1945,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
is_preferred: false,
};
assert_eq!(row.display_preview(), "My session");
@@ -1899,14 +1988,14 @@ mod tests {
#[test]
fn remote_picker_does_not_filter_rows_by_local_cwd() {
let loader: PageLoader = Arc::new(|_| {});
let state = PickerState::new(
let state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::Any,
/*show_all*/ false,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let row = Row {
path: None,
@@ -1917,6 +2006,7 @@ mod tests {
updated_at: None,
cwd: Some(PathBuf::from("/srv/remote-project")),
git_branch: None,
is_preferred: false,
};
assert!(state.row_matches_filter(&row));
@@ -1930,14 +2020,14 @@ mod tests {
use ratatui::layout::Layout;
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let now = Utc::now();
@@ -1951,6 +2041,7 @@ mod tests {
updated_at: Some(now - Duration::seconds(42)),
cwd: None,
git_branch: None,
is_preferred: false,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
@@ -1961,6 +2052,7 @@ mod tests {
updated_at: Some(now - Duration::minutes(35)),
cwd: None,
git_branch: None,
is_preferred: false,
},
Row {
path: Some(PathBuf::from("/tmp/c.jsonl")),
@@ -1971,6 +2063,7 @@ mod tests {
updated_at: Some(now - Duration::hours(2)),
cwd: None,
git_branch: None,
is_preferred: false,
},
];
state.all_rows = rows.clone();
@@ -2009,14 +2102,14 @@ mod tests {
use crate::test_backend::VT100Backend;
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.inline_error = Some(String::from(
"Failed to read session metadata from /tmp/missing.jsonl",
@@ -2245,14 +2338,14 @@ mod tests {
std::fs::write(&session_index_path, out).expect("write session index");
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
tempdir.path().to_path_buf(),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let now = Utc::now();
@@ -2266,6 +2359,7 @@ mod tests {
updated_at: Some(now - Duration::days(2)),
cwd: None,
git_branch: None,
is_preferred: false,
},
Row {
path: Some(PathBuf::from("/tmp/b.jsonl")),
@@ -2276,6 +2370,7 @@ mod tests {
updated_at: Some(now - Duration::days(3)),
cwd: None,
git_branch: None,
is_preferred: false,
},
];
state.all_rows = rows.clone();
@@ -2327,14 +2422,14 @@ mod tests {
.expect("write session index");
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
tempdir.path().to_path_buf(),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.all_rows = vec![Row {
@@ -2346,6 +2441,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
is_preferred: false,
}];
state.filtered_rows = state.all_rows.clone();
@@ -2362,16 +2458,149 @@ mod tests {
}
#[test]
fn pageless_scrolling_deduplicates_and_keeps_order() {
fn preferred_session_is_pinned_and_deduped() {
let thread_id =
ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id");
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let now = Utc::now();
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
Some(PreferredSession {
target: SessionTarget {
path: Some(PathBuf::from("/tmp/recent.jsonl")),
thread_id,
},
preview: String::from("Previous session"),
thread_name: Some(String::from("Recovered thread")),
cwd: Some(PathBuf::from("/tmp/project")),
updated_at: now,
}),
);
state.start_initial_load();
state.ingest_page(PickerPage {
rows: vec![
Row {
path: Some(PathBuf::from("/tmp/recent.jsonl")),
preview: String::from("duplicate"),
thread_id: Some(thread_id),
thread_name: Some(String::from("Duplicate row")),
created_at: None,
updated_at: Some(now - Duration::minutes(1)),
cwd: None,
git_branch: None,
is_preferred: false,
},
Row {
path: Some(PathBuf::from("/tmp/older.jsonl")),
preview: String::from("Older session"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: Some(now - Duration::hours(1)),
cwd: None,
git_branch: None,
is_preferred: false,
},
],
next_cursor: None,
num_scanned_files: 2,
reached_scan_cap: false,
});
assert_eq!(state.filtered_rows.len(), 2);
assert_eq!(state.selected, 0);
assert_eq!(state.filtered_rows[0].display_preview(), "Recovered thread");
assert!(state.filtered_rows[0].is_preferred);
assert_eq!(state.filtered_rows[1].display_preview(), "Older session");
}
#[test]
fn preferred_session_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 now = Utc::now();
let mut state = test_picker_state(
PathBuf::from("/tmp"),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
Some(PreferredSession {
target: SessionTarget {
path: Some(PathBuf::from("/tmp/recent.jsonl")),
thread_id: ThreadId::new(),
},
preview: String::from("Previous session"),
thread_name: Some(String::from("Recovered thread")),
cwd: Some(PathBuf::from("/tmp/project")),
updated_at: now,
}),
);
state.start_initial_load();
state.ingest_page(PickerPage {
rows: vec![Row {
path: Some(PathBuf::from("/tmp/older.jsonl")),
preview: String::from("Older session"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: Some(now - Duration::hours(1)),
cwd: None,
git_branch: None,
is_preferred: false,
}],
next_cursor: None,
num_scanned_files: 1,
reached_scan_cap: false,
});
state.pagination.loading = LoadingState::Idle;
state.relative_time_reference = Some(now);
state.view_rows = Some(2);
state.update_view_rows(/*rows*/ 2);
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now);
let width: u16 = 80;
let height: u16 = 4;
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_preferred_session", snapshot);
}
#[test]
fn pageless_scrolling_deduplicates_and_keeps_order() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = test_picker_state(
PathBuf::from("/tmp"),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.reset_pagination();
@@ -2433,14 +2662,14 @@ mod tests {
request_sink.lock().unwrap().push(req);
});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.reset_pagination();
state.ingest_page(page(
@@ -2514,14 +2743,14 @@ mod tests {
request_sink.lock().unwrap().push(req);
});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.start_initial_load();
@@ -2544,14 +2773,14 @@ mod tests {
#[tokio::test]
async fn page_navigation_uses_view_rows() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let mut items = Vec::new();
@@ -2592,14 +2821,14 @@ mod tests {
#[tokio::test]
async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let row = Row {
@@ -2611,6 +2840,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
is_preferred: false,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -2632,14 +2862,14 @@ mod tests {
#[tokio::test]
async fn enter_on_pathless_thread_uses_thread_id() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let thread_id = ThreadId::new();
let row = Row {
@@ -2651,6 +2881,7 @@ mod tests {
updated_at: None,
cwd: None,
git_branch: None,
is_preferred: false,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
@@ -2702,14 +2933,14 @@ mod tests {
#[tokio::test]
async fn up_at_bottom_does_not_scroll_when_visible() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
let mut items = Vec::new();
@@ -2750,14 +2981,14 @@ mod tests {
request_sink.lock().unwrap().push(req);
});
let mut state = PickerState::new(
let mut state = test_picker_state(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
/*preferred_session*/ None,
);
state.reset_pagination();
state.ingest_page(page(

View File

@@ -0,0 +1,7 @@
---
source: tui/src/app.rs
expression: rendered
---
Token usage: total=12 input=10 output=2
To continue this session, run codex resume my-session
Previous session available via /resume.

View File

@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: snapshot
---
Created Updated Branch CWD Conversation
> - 0 seconds ago - tmp/project Recovered thread
- 1 hour ago - - Older session