diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ae6dabe672..fa08594beb 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -284,6 +284,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec Vec String { + if !selector.contains(char::is_whitespace) && !selector.contains('"') { + return selector.to_string(); + } + + let escaped = selector.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Handle the app exit and print the results. Optionally run the update action. fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { let update_action = exit_info.update_action; @@ -823,11 +835,13 @@ mod tests { total_tokens: 2, ..Default::default() }; + let resume_selector = conversation.map(ToString::to_string); AppExitInfo { token_usage, conversation_id: conversation .map(ConversationId::from_string) .map(Result::unwrap), + resume_selector, update_action: None, } } @@ -837,6 +851,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, }; let lines = format_exit_messages(exit_info, false); @@ -865,6 +880,32 @@ mod tests { assert!(lines[1].contains("\u{1b}[36m")); } + #[test] + fn format_exit_messages_prefers_resume_selector_when_present() { + let token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + let exit_info = AppExitInfo { + token_usage, + conversation_id: Some( + ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(), + ), + resume_selector: Some("pap".to_string()), + update_action: None, + }; + + let lines = format_exit_messages(exit_info, false); + assert_eq!( + lines, + vec![ + "Token usage: total=2 input=0 output=2".to_string(), + "To continue this session, run codex resume pap".to_string(), + ] + ); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 8794484083..0b4b7f0d5a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -87,11 +87,13 @@ pub use rollout::RolloutRecorder; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; pub use rollout::find_conversation_path_by_id_str; +pub use rollout::find_conversation_path_by_selector; pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; +pub use rollout::read_rollout_session_title; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index e2ef0e883c..e5eec9e517 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -158,88 +158,65 @@ async fn traverse_directories_for_paths( }; let mut more_matches_available = false; - let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; - - 'outer: for (_year, year_path) in year_dirs.iter() { + let day_dirs = collect_day_dirs_desc(&root).await?; + 'outer: for day_path in day_dirs { if scanned_files >= MAX_SCAN_FILES { break; } - let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; - for (_month, month_path) in month_dirs.iter() { + let day_files = collect_rollout_files_desc(&day_path).await?; + for (ts, sid, path) in day_files { + scanned_files += 1; + if items.len() == page_size { + more_matches_available = true; + break 'outer; + } if scanned_files >= MAX_SCAN_FILES { break 'outer; } - let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; - for (_day, day_path) in day_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; + if !anchor_passed { + if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { + anchor_passed = true; + } else { + continue; } - let mut day_files = collect_files(day_path, |name_str, path| { - if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { - return None; - } + } - parse_timestamp_uuid_from_filename(name_str) - .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) - }) - .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); - for (ts, sid, _name_str, path) in day_files.into_iter() { - scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { - more_matches_available = true; - break 'outer; - } - if !anchor_passed { - if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { - anchor_passed = true; - } else { - continue; - } - } - if items.len() == page_size { - more_matches_available = true; - break 'outer; - } - // Read head and detect message events; stop once meta + user are found. - let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + // Read head and detect message events; stop once meta + user are found. + let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) + { + continue; + } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + continue; + } + // Apply filters: must have session meta and at least one user message event + if summary.saw_session_meta && summary.saw_user_event { + let HeadTailSummary { + head, + created_at, + mut updated_at, + .. + } = summary; + if updated_at.is_none() { + updated_at = file_modified_rfc3339(&path) .await - .unwrap_or_default(); - if !allowed_sources.is_empty() - && !summary - .source - .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) - { - continue; - } - if let Some(matcher) = provider_matcher - && !matcher.matches(summary.model_provider.as_deref()) - { - continue; - } - // Apply filters: must have session meta and at least one user message event - if summary.saw_session_meta && summary.saw_user_event { - let HeadTailSummary { - head, - created_at, - mut updated_at, - .. - } = summary; - if updated_at.is_none() { - updated_at = file_modified_rfc3339(&path) - .await - .unwrap_or(None) - .or_else(|| created_at.clone()); - } - items.push(ConversationItem { - path, - head, - created_at, - updated_at, - }); - } + .unwrap_or(None) + .or_else(|| created_at.clone()); } + items.push(ConversationItem { + path, + head, + created_at, + updated_at, + }); } } } @@ -350,6 +327,35 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui Some((ts, uuid)) } +async fn collect_day_dirs_desc(sessions_root: &Path) -> io::Result> { + let mut day_dirs: Vec = Vec::new(); + let year_dirs = collect_dirs_desc(sessions_root, |s| s.parse::().ok()).await?; + for (_year, year_path) in year_dirs { + let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs { + let days = collect_dirs_desc(&month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in days { + day_dirs.push(day_path); + } + } + } + Ok(day_dirs) +} + +async fn collect_rollout_files_desc( + day_path: &Path, +) -> io::Result> { + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf())) + }) + .await?; + day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid))); + Ok(day_files) +} + struct ProviderMatcher<'a> { filters: &'a [String], matches_default_provider: bool, @@ -500,3 +506,81 @@ pub async fn find_conversation_path_by_id_str( .next() .map(|m| root.join(m.path))) } + +/// Locate a recorded conversation rollout file by either UUID (fast path) or by matching +/// `SessionMeta.title` in the first JSONL line of each rollout file. +/// +/// Assumes the invariant that the first rollout line is always the `session_meta` record. +/// If multiple sessions share the same title, returns the newest matching rollout. +pub async fn find_conversation_path_by_selector( + codex_home: &Path, + selector: &str, +) -> io::Result> { + if let Some(path) = find_conversation_path_by_id_str(codex_home, selector).await? { + return Ok(Some(path)); + } + + let normalized = selector.trim().to_lowercase(); + if normalized.is_empty() { + return Ok(None); + } + + let mut root = codex_home.to_path_buf(); + root.push(SESSIONS_SUBDIR); + if !root.exists() { + return Ok(None); + } + + use tokio::io::AsyncBufReadExt; + + let day_dirs = collect_day_dirs_desc(&root).await?; + for day_path in day_dirs { + let day_files = collect_rollout_files_desc(&day_path).await?; + for (_ts, _sid, path) in day_files { + let file = tokio::fs::File::open(&path).await?; + let mut lines = tokio::io::BufReader::new(file).lines(); + let first_line = lines.next_line().await?; + let Some(first_line) = first_line else { + continue; + }; + + let rollout_line: RolloutLine = match serde_json::from_str(first_line.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { + continue; + }; + let Some(title) = meta_line.meta.title.as_deref() else { + continue; + }; + if title.trim().to_lowercase() == normalized { + return Ok(Some(path)); + } + } + } + + Ok(None) +} + +/// Read the `SessionMeta.title` from a rollout file. +/// +/// Rollout files are expected to begin with a `session_meta` line. +pub async fn read_rollout_session_title(path: &Path) -> io::Result> { + use tokio::io::AsyncBufReadExt; + + let file = tokio::fs::File::open(path).await?; + let mut lines = tokio::io::BufReader::new(file).lines(); + let first_line = lines.next_line().await?; + let Some(first_line) = first_line else { + return Ok(None); + }; + + let rollout_line: RolloutLine = + serde_json::from_str(first_line.trim()).map_err(|e| io::Error::other(e))?; + let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { + return Ok(None); + }; + + Ok(meta_line.meta.title) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 540d204be3..6a1c8e0322 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -15,6 +15,8 @@ pub mod recorder; pub use codex_protocol::protocol::SessionMeta; pub(crate) use error::map_session_init_error; pub use list::find_conversation_path_by_id_str; +pub use list::find_conversation_path_by_selector; +pub use list::read_rollout_session_title; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 1d40718d4b..10005a19e0 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::path::PathBuf; use codex_core::find_conversation_path_by_id_str; +use codex_core::find_conversation_path_by_selector; use tempfile::TempDir; use uuid::Uuid; @@ -38,6 +39,42 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { file } +fn write_minimal_rollout_with_id_and_title( + codex_home: &Path, + id: Uuid, + timestamp_fragment: &str, + title: &str, +) -> PathBuf { + let sessions = codex_home.join("sessions/2024/01/01"); + std::fs::create_dir_all(&sessions).unwrap(); + + let file = sessions.join(format!( + "rollout-2024-01-01T{timestamp_fragment}-{id}.jsonl" + )); + let mut f = std::fs::File::create(&file).unwrap(); + writeln!( + f, + "{}", + serde_json::json!({ + "timestamp": "2024-01-01T00:00:00.000Z", + "type": "session_meta", + "payload": { + "id": id, + "timestamp": "2024-01-01T00:00:00Z", + "cwd": ".", + "title": title, + "originator": "test", + "cli_version": "test", + "instructions": null, + "model_provider": "test-provider" + } + }) + ) + .unwrap(); + + file +} + #[tokio::test] async fn find_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); @@ -67,6 +104,34 @@ async fn find_handles_gitignore_covering_codex_home_directory() { assert_eq!(found, Some(expected)); } +#[tokio::test] +async fn resolve_selector_finds_rollout_file_by_title() { + let home = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + let expected = write_minimal_rollout_with_id_and_title(home.path(), id, "00-00-00", "My Title"); + + let resolved = find_conversation_path_by_selector(home.path(), "my title") + .await + .unwrap(); + + assert_eq!(resolved, Some(expected)); +} + +#[tokio::test] +async fn resolve_selector_picks_newest_when_titles_duplicate() { + let home = TempDir::new().unwrap(); + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + let _older = write_minimal_rollout_with_id_and_title(home.path(), id1, "00-00-00", "Dup"); + let newer = write_minimal_rollout_with_id_and_title(home.path(), id2, "00-00-01", "Dup"); + + let resolved = find_conversation_path_by_selector(home.path(), "dup") + .await + .unwrap(); + + assert_eq!(resolved, Some(newer)); +} + #[tokio::test] async fn find_ignores_granular_gitignore_rules() { let home = TempDir::new().unwrap(); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 93a481b630..fc84413e48 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -55,7 +55,7 @@ use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use codex_core::default_client::set_default_originator; -use codex_core::find_conversation_path_by_id_str; +use codex_core::find_conversation_path_by_selector; enum InitialOperation { UserTurn { @@ -501,8 +501,7 @@ async fn resolve_resume_path( } } } else if let Some(id_str) = args.session_id.as_deref() { - let path = find_conversation_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) + Ok(find_conversation_path_by_selector(&config.codex_home, id_str).await?) } else { Ok(None) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e0af51e7eb..e3cd5e597f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -71,20 +71,21 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + /// Preferred selector for `codex resume`: title when set, otherwise UUID. + pub resume_selector: Option, pub update_action: Option, } fn session_summary( token_usage: TokenUsage, - conversation_id: Option, + resume_selector: Option<&str>, ) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = - conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); + let resume_command = resume_selector.map(|selector| format!("codex resume {selector}")); Some(SessionSummary { usage_line, resume_command, @@ -276,6 +277,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, }); } @@ -499,9 +501,23 @@ impl App { } } {} tui.terminal.clear()?; + let conversation_id = app.chat_widget.conversation_id(); + let mut resume_selector = conversation_id.as_ref().map(ToString::to_string); + if let Some(conversation_id) = &conversation_id + && let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str( + &app.config.codex_home, + &conversation_id.to_string(), + ) + .await + && let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await + && !title.trim().is_empty() + { + resume_selector = Some(title); + } Ok(AppExitInfo { token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), + conversation_id, + resume_selector, update_action: app.pending_update_action, }) } @@ -562,10 +578,13 @@ impl App { .await; match event { AppEvent::NewSession => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); + let resume_selector = self + .chat_widget + .conversation_id() + .as_ref() + .map(ToString::to_string); + let summary = + session_summary(self.chat_widget.token_usage(), resume_selector.as_deref()); self.shutdown_current_conversation().await; let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), @@ -602,9 +621,14 @@ impl App { .await? { ResumeSelection::Resume(path) => { + let resume_selector = self + .chat_widget + .conversation_id() + .as_ref() + .map(ToString::to_string); let summary = session_summary( self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), + resume_selector.as_deref(), ); match self .server @@ -1643,10 +1667,8 @@ mod tests { total_tokens: 12, ..Default::default() }; - let conversation = - ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - - let summary = session_summary(usage, Some(conversation)).expect("summary"); + let summary = + session_summary(usage, Some("123e4567-e89b-12d3-a456-426614174000")).expect("summary"); assert_eq!( summary.usage_line, "Token usage: total=12 input=10 output=2" diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6b784affce..d47bc4d779 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; -use codex_core::find_conversation_path_by_id_str; +use codex_core::find_conversation_path_by_selector; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::SandboxMode; @@ -371,6 +371,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: Some(action), }); } @@ -410,6 +411,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, }); } @@ -429,7 +431,7 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_conversation_path_by_id_str(&config.codex_home, id_str).await? { + match find_conversation_path_by_selector(&config.codex_home, id_str).await? { Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); @@ -438,13 +440,14 @@ async fn run_ratatui_app( let _ = tui.terminal.clear(); if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, }); } @@ -483,6 +486,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, }); } diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 84d16415d7..4a25c54331 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -87,6 +87,8 @@ use crate::history_cell::UpdateAvailableHistoryCell; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + /// Preferred selector for `codex resume`: title when set, otherwise UUID. + pub resume_selector: Option, pub update_action: Option, /// ANSI-styled transcript lines to print after the TUI exits. /// @@ -101,6 +103,7 @@ impl From for codex_tui::AppExitInfo { codex_tui::AppExitInfo { token_usage: info.token_usage, conversation_id: info.conversation_id, + resume_selector: info.resume_selector, update_action: info.update_action.map(Into::into), } } @@ -304,6 +307,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, session_lines: Vec::new(), }); @@ -581,9 +585,23 @@ impl App { }; tui.terminal.clear()?; + let conversation_id = app.chat_widget.conversation_id(); + let mut resume_selector = conversation_id.as_ref().map(ToString::to_string); + if let Some(conversation_id) = &conversation_id + && let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str( + &app.config.codex_home, + &conversation_id.to_string(), + ) + .await + && let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await + && !title.trim().is_empty() + { + resume_selector = Some(title); + } Ok(AppExitInfo { token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), + conversation_id, + resume_selector, update_action: app.pending_update_action, session_lines, }) diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 3c5ac92f6c..9a86fdbf21 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; -use codex_core::find_conversation_path_by_id_str; +use codex_core::find_conversation_path_by_selector; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::SandboxMode; @@ -386,6 +386,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: Some(action), session_lines: Vec::new(), }); @@ -426,6 +427,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, session_lines: Vec::new(), }); @@ -446,7 +448,7 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_conversation_path_by_id_str(&config.codex_home, id_str).await? { + match find_conversation_path_by_selector(&config.codex_home, id_str).await? { Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); @@ -455,13 +457,14 @@ async fn run_ratatui_app( let _ = tui.terminal.clear(); if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, session_lines: Vec::new(), }); @@ -501,6 +504,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + resume_selector: None, update_action: None, session_lines: Vec::new(), });