diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b9c58a58831..2e8778bdc5e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -132,6 +132,10 @@ client_request_definitions! { params: v2::ThreadArchiveParams, response: v2::ThreadArchiveResponse, }, + ThreadSetName => "thread/name/set" { + params: v2::ThreadSetNameParams, + response: v2::ThreadSetNameResponse, + }, ThreadUnarchive => "thread/unarchive" { params: v2::ThreadUnarchiveParams, response: v2::ThreadUnarchiveResponse, @@ -598,6 +602,7 @@ server_notification_definitions! { /// NEW NOTIFICATIONS Error => "error" (v2::ErrorNotification), ThreadStarted => "thread/started" (v2::ThreadStartedNotification), + ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), TurnCompleted => "turn/completed" (v2::TurnCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1e28b1b086e..8bea0944874 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1295,6 +1295,14 @@ pub struct ThreadArchiveParams { #[ts(export_to = "v2/")] pub struct ThreadArchiveResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1302,6 +1310,11 @@ pub struct ThreadUnarchiveParams { pub thread_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2285,6 +2298,16 @@ pub struct ThreadStartedNotification { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9ebcd96618..3798d76027e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -82,6 +82,7 @@ Example (from OpenAI's official VSCode extension): - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. +- `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1a9fcaa7051..dd2628deb7f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -52,6 +52,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -1097,6 +1098,17 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing.send_response(request_id, response).await; } } + EventMsg::ThreadNameUpdated(thread_name_event) => { + if let ApiVersion::V2 = api_version { + let notification = ThreadNameUpdatedNotification { + thread_id: thread_name_event.thread_id.to_string(), + thread_name: thread_name_event.thread_name, + }; + outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff( conversation_id, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 660b4b87d05..3817bce3a16 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -110,6 +110,8 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -416,6 +418,9 @@ impl CodexMessageProcessor { ClientRequest::ThreadArchive { request_id, params } => { self.thread_archive(request_id, params).await; } + ClientRequest::ThreadSetName { request_id, params } => { + self.thread_set_name(request_id, params).await; + } ClientRequest::ThreadUnarchive { request_id, params } => { self.thread_unarchive(request_id, params).await; } @@ -1780,6 +1785,36 @@ impl CodexMessageProcessor { } } + async fn thread_set_name(&self, request_id: RequestId, params: ThreadSetNameParams) { + let ThreadSetNameParams { thread_id, name } = params; + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + self.send_invalid_request_error( + request_id, + "thread name must not be empty".to_string(), + ) + .await; + return; + }; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if let Err(err) = thread.submit(Op::SetThreadName { name }).await { + self.send_internal_error(request_id, format!("failed to set thread name: {err}")) + .await; + return; + } + + self.outgoing + .send_response(request_id, ThreadSetNameResponse {}) + .await; + } + async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) { // TODO(jif) mostly rewrite this using sqlite after phase 1 let thread_id = match ThreadId::from_string(¶ms.thread_id) { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index dd20f041281..7ab8bc937ab 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -144,7 +144,7 @@ struct CompletionCommand { #[derive(Debug, Parser)] struct ResumeCommand { - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] session_id: Option, @@ -323,6 +323,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec) -> AppExitInfo { + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, total_tokens: 2, @@ -1036,7 +1038,10 @@ mod tests { }; AppExitInfo { token_usage, - thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap), + thread_id: conversation_id + .map(ThreadId::from_string) + .map(Result::unwrap), + thread_name: thread_name.map(str::to_string), update_action: None, exit_reason: ExitReason::UserRequested, } @@ -1047,6 +1052,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }; @@ -1056,7 +1062,7 @@ mod tests { #[test] fn format_exit_messages_includes_resume_hint_without_color() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); let lines = format_exit_messages(exit_info, false); assert_eq!( lines, @@ -1070,12 +1076,28 @@ mod tests { #[test] fn format_exit_messages_applies_color_when_enabled() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); let lines = format_exit_messages(exit_info, true); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } + #[test] + fn format_exit_messages_prefers_thread_name() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + Some("my-thread"), + ); + 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 my-thread".to_string(), + ] + ); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 250ea1ff490..7eefd04ec44 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -26,6 +26,7 @@ use crate::features::maybe_push_unstable_features_warning; use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; @@ -354,6 +355,8 @@ impl Codex { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source, dynamic_tools, @@ -544,6 +547,10 @@ pub(crate) struct SessionConfiguration { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + /// Directory containing all Codex state for this session. + codex_home: PathBuf, + /// Optional user-facing name for the thread, updated during the session. + thread_name: Option, // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, @@ -553,6 +560,10 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + pub(crate) fn codex_home(&self) -> &PathBuf { + &self.codex_home + } + fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), @@ -623,6 +634,11 @@ impl Session { per_turn_config } + pub(crate) async fn codex_home(&self) -> PathBuf { + let state = self.state.lock().await; + state.session_configuration.codex_home().clone() + } + #[allow(clippy::too_many_arguments)] fn make_turn_context( auth_manager: Option>, @@ -683,7 +699,7 @@ impl Session { #[allow(clippy::too_many_arguments)] async fn new( - session_configuration: SessionConfiguration, + mut session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, models_manager: Arc, @@ -863,6 +879,16 @@ impl Session { otel_manager.clone(), ); } + let thread_name = + match session_index::find_thread_name_by_id(&config.codex_home, &conversation_id).await + { + Ok(name) => name, + Err(err) => { + warn!("Failed to read session index for thread name: {err}"); + None + } + }; + session_configuration.thread_name = thread_name.clone(); let state = SessionState::new(session_configuration.clone()); let services = SessionServices { @@ -904,6 +930,7 @@ impl Session { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id, + thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -1451,8 +1478,7 @@ impl Session { .lock() .await .session_configuration - .original_config_do_not_use - .codex_home + .codex_home() .clone(); if !features.enabled(Feature::ExecPolicy) { @@ -2440,6 +2466,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ThreadRollback { num_turns } => { handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; } + Op::SetThreadName { name } => { + handlers::set_thread_name(&sess, sub.id.clone(), name).await; + } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( &sess, @@ -2483,6 +2512,7 @@ mod handlers { use crate::mcp::collect_mcp_snapshot_from_manager; use crate::mcp::effective_mcp_servers; use crate::review_prompts::resolve_review_request; + use crate::rollout::session_index; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; @@ -2499,6 +2529,7 @@ mod handlers { use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SkillsListEntry; + use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -2952,6 +2983,72 @@ mod handlers { .await; } + /// Persists the thread name in the session index, updates in-memory state, and emits + /// a `ThreadNameUpdated` event on success. + /// + /// This appends the name to `CODEX_HOME/sessions_index.jsonl` via `session_index::append_thread_name` for the + /// current `thread_id`, then updates `SessionConfiguration::thread_name`. + /// + /// Returns an error event if the name is empty or session persistence is disabled. + pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { + let Some(name) = crate::util::normalize_thread_name(&name) else { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Thread name cannot be empty.".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(event).await; + return; + }; + + let persistence_enabled = { + let rollout = sess.services.rollout.lock().await; + rollout.is_some() + }; + if !persistence_enabled { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Session persistence is disabled; cannot rename thread.".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + return; + }; + + let codex_home = sess.codex_home().await; + if let Err(e) = + session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await + { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("Failed to set thread name: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + return; + } + + { + let mut state = sess.state.lock().await; + state.session_configuration.thread_name = Some(name.clone()); + } + + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { + thread_id: sess.conversation_id, + thread_name: Some(name), + }), + }) + .await; + } + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services @@ -4440,6 +4537,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4521,6 +4620,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4786,6 +4887,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4900,6 +5003,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5e1de2ef1f8..7a611c05e2d 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -208,6 +208,10 @@ async fn forward_events( id: _, msg: EventMsg::SessionConfigured(_), } => {} + Event { + id: _, + msg: EventMsg::ThreadNameUpdated(_), + } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1b1fb65ecdc..63bba765072 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -100,12 +100,14 @@ pub mod turn_diff_tracker; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; +pub use rollout::RolloutRecorderParams; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_path_by_id_str; +pub use rollout::find_thread_path_by_name_str; pub use rollout::list::Cursor; pub use rollout::list::ThreadItem; pub use rollout::list::ThreadSortKey; diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index cfc2d82d8d9..60775d04b0f 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -12,6 +12,7 @@ pub mod list; pub(crate) mod metadata; pub(crate) mod policy; pub mod recorder; +pub(crate) mod session_index; pub(crate) mod truncation; pub use codex_protocol::protocol::SessionMeta; @@ -23,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; +pub use session_index::find_thread_path_by_name_str; #[cfg(test)] pub mod tests; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2d263c0539c..6de60ae05c0 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -58,6 +58,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) + | EventMsg::ThreadNameUpdated(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::WebSearchBegin(_) diff --git a/codex-rs/core/src/rollout/session_index.rs b/codex-rs/core/src/rollout/session_index.rs new file mode 100644 index 00000000000..98f7e35d848 --- /dev/null +++ b/codex-rs/core/src/rollout/session_index.rs @@ -0,0 +1,325 @@ +use std::fs::File; +use std::io::Read; +use std::io::Seek; +use std::io::SeekFrom; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use serde::Deserialize; +use serde::Serialize; +use tokio::io::AsyncWriteExt; + +const SESSION_INDEX_FILE: &str = "session_index.jsonl"; +const READ_CHUNK_SIZE: usize = 8192; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionIndexEntry { + pub id: ThreadId, + pub thread_name: String, + pub updated_at: String, +} + +/// Append a thread name update to the session index. +/// The index is append-only; the most recent entry wins when resolving names or ids. +pub async fn append_thread_name( + codex_home: &Path, + thread_id: ThreadId, + name: &str, +) -> std::io::Result<()> { + use time::OffsetDateTime; + use time::format_description::well_known::Rfc3339; + + let updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "unknown".to_string()); + let entry = SessionIndexEntry { + id: thread_id, + thread_name: name.to_string(), + updated_at, + }; + append_session_index_entry(codex_home, &entry).await +} + +/// Append a raw session index entry to `session_index.jsonl`. +/// The file is append-only; consumers scan from the end to find the newest match. +pub async fn append_session_index_entry( + codex_home: &Path, + entry: &SessionIndexEntry, +) -> std::io::Result<()> { + let path = session_index_path(codex_home); + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await?; + let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?; + line.push('\n'); + file.write_all(line.as_bytes()).await?; + file.flush().await?; + Ok(()) +} + +/// Find the latest thread name for a thread id, if any. +pub async fn find_thread_name_by_id( + codex_home: &Path, + thread_id: &ThreadId, +) -> std::io::Result> { + let path = session_index_path(codex_home); + if !path.exists() { + return Ok(None); + } + let id = *thread_id; + let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id)) + .await + .map_err(std::io::Error::other)??; + Ok(entry.map(|entry| entry.thread_name)) +} + +/// Find the most recently updated thread id for a thread name, if any. +pub async fn find_thread_id_by_name( + codex_home: &Path, + name: &str, +) -> std::io::Result> { + if name.trim().is_empty() { + return Ok(None); + } + let path = session_index_path(codex_home); + if !path.exists() { + return Ok(None); + } + let name = name.to_string(); + let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_name(&path, &name)) + .await + .map_err(std::io::Error::other)??; + Ok(entry.map(|entry| entry.id)) +} + +/// Locate a recorded thread rollout file by thread name using newest-first ordering. +/// Returns `Ok(Some(path))` if found, `Ok(None)` if not present. +pub async fn find_thread_path_by_name_str( + codex_home: &Path, + name: &str, +) -> std::io::Result> { + let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else { + return Ok(None); + }; + super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await +} + +fn session_index_path(codex_home: &Path) -> PathBuf { + codex_home.join(SESSION_INDEX_FILE) +} + +fn scan_index_from_end_by_id( + path: &Path, + thread_id: &ThreadId, +) -> std::io::Result> { + scan_index_from_end(path, |entry| entry.id == *thread_id) +} + +fn scan_index_from_end_by_name( + path: &Path, + name: &str, +) -> std::io::Result> { + scan_index_from_end(path, |entry| entry.thread_name == name) +} + +fn scan_index_from_end( + path: &Path, + mut predicate: F, +) -> std::io::Result> +where + F: FnMut(&SessionIndexEntry) -> bool, +{ + let mut file = File::open(path)?; + let mut remaining = file.metadata()?.len(); + let mut line_rev: Vec = Vec::new(); + let mut buf = vec![0u8; READ_CHUNK_SIZE]; + + while remaining > 0 { + let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64)) + .map_err(std::io::Error::other)?; + remaining -= read_size as u64; + file.seek(SeekFrom::Start(remaining))?; + file.read_exact(&mut buf[..read_size])?; + + for &byte in buf[..read_size].iter().rev() { + if byte == b'\n' { + if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? { + return Ok(Some(entry)); + } + continue; + } + line_rev.push(byte); + } + } + + if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? { + return Ok(Some(entry)); + } + + Ok(None) +} + +fn parse_line_from_rev( + line_rev: &mut Vec, + predicate: &mut F, +) -> std::io::Result> +where + F: FnMut(&SessionIndexEntry) -> bool, +{ + if line_rev.is_empty() { + return Ok(None); + } + line_rev.reverse(); + let line = std::mem::take(line_rev); + let Ok(mut line) = String::from_utf8(line) else { + return Ok(None); + }; + if line.ends_with('\r') { + line.pop(); + } + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let Ok(entry) = serde_json::from_str::(trimmed) else { + return Ok(None); + }; + if predicate(&entry) { + return Ok(Some(entry)); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> { + let mut out = String::new(); + for entry in lines { + out.push_str(&serde_json::to_string(entry).unwrap()); + out.push('\n'); + } + std::fs::write(path, out) + } + + #[test] + fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id1 = ThreadId::new(); + let id2 = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id: id1, + thread_name: "same".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id: id2, + thread_name: "same".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_name(&path, "same")?; + assert_eq!(found.map(|entry| entry.id), Some(id2)); + Ok(()) + } + + #[test] + fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id, + thread_name: "first".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id, + thread_name: "second".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_id(&path, &id)?; + assert_eq!( + found.map(|entry| entry.thread_name), + Some("second".to_string()) + ); + Ok(()) + } + + #[test] + fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![SessionIndexEntry { + id, + thread_name: "present".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }]; + write_index(&path, &lines)?; + + let missing_name = scan_index_from_end_by_name(&path, "missing")?; + assert_eq!(missing_name, None); + + let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?; + assert_eq!(missing_id, None); + Ok(()) + } + + #[test] + fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id_target = ThreadId::new(); + let id_other = ThreadId::new(); + let expected = SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-03T00:00:00Z".to_string(), + }; + let expected_other = SessionIndexEntry { + id: id_other, + thread_name: "target".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }; + // Resolution is based on append order (scan from end), not updated_at. + let lines = vec![ + SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + expected_other.clone(), + expected.clone(), + SessionIndexEntry { + id: ThreadId::new(), + thread_name: "another".to_string(), + updated_at: "2024-01-04T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found_by_name = scan_index_from_end_by_name(&path, "target")?; + assert_eq!(found_by_name, Some(expected.clone())); + + let found_by_id = scan_index_from_end_by_id(&path, &id_target)?; + assert_eq!(found_by_id, Some(expected)); + + let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?; + assert_eq!(found_other_by_id, Some(expected_other)); + Ok(()) + } +} diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index a100f284437..1a538da558e 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -2,10 +2,13 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use codex_protocol::ThreadId; use rand::Rng; use tracing::debug; use tracing::error; +use crate::parse_command::shlex_join; + const INITIAL_DELAY_MS: u64 = 200; const BACKOFF_FACTOR: f64 = 2.0; @@ -72,6 +75,32 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { } } +/// Trim a thread name and return `None` if it is empty after trimming. +pub fn normalize_thread_name(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { + let resume_target = thread_name + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); + resume_target.map(|target| { + let needs_double_dash = target.starts_with('-'); + let escaped = shlex_join(&[target]); + if needs_double_dash { + format!("codex resume -- {escaped}") + } else { + format!("codex resume {escaped}") + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +136,51 @@ mod tests { feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); } + + #[test] + fn normalize_thread_name_trims_and_rejects_empty() { + assert_eq!(normalize_thread_name(" "), None); + assert_eq!( + normalize_thread_name(" my thread "), + Some("my thread".to_string()) + ); + } + + #[test] + fn resume_command_prefers_name_over_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(Some("my-thread"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-thread".to_string())); + } + + #[test] + fn resume_command_with_only_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(None, Some(thread_id)); + assert_eq!( + command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn resume_command_with_no_name_or_id() { + let command = resume_command(None, None); + assert_eq!(command, None); + } + + #[test] + fn resume_command_quotes_thread_name_when_needed() { + let command = resume_command(Some("-starts-with-dash"), None); + assert_eq!( + command, + Some("codex resume -- -starts-with-dash".to_string()) + ); + + let command = resume_command(Some("two words"), None); + assert_eq!(command, Some("codex resume 'two words'".to_string())); + + let command = resume_command(Some("quote'case"), None); + assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); + } } diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 2af6192dbe9..fc3ab80fc31 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -3,8 +3,16 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_core::RolloutRecorder; +use codex_core::RolloutRecorderParams; +use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; +use codex_core::protocol::SessionSource; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use pretty_assertions::assert_eq; use tempfile::TempDir; use uuid::Uuid; @@ -87,6 +95,54 @@ async fn find_ignores_granular_gitignore_rules() { assert_eq!(found, Some(expected)); } +#[tokio::test] +async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> { + // Ensures the name-based finder locates a rollout produced by the real recorder. + let home = TempDir::new().unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let thread_id = ThreadId::new(); + let thread_name = "named thread"; + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + ), + None, + None, + ) + .await?; + recorder.flush().await?; + + let index_path = home.path().join("session_index.jsonl"); + std::fs::write( + &index_path, + format!( + "{}\n", + serde_json::json!({ + "id": thread_id, + "thread_name": thread_name, + "updated_at": "2024-01-01T00:00:00Z" + }) + ), + )?; + + let found = find_thread_path_by_name_str(home.path(), thread_name).await?; + + let path = found.expect("expected rollout path to be found"); + assert!(path.exists()); + let contents = std::fs::read_to_string(&path)?; + assert!(contents.contains(&thread_id.to_string())); + recorder.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn find_archived_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 9156c22ea3c..d4a64772e10 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -47,6 +47,7 @@ ts-rs = { workspace = true, features = [ "serde-json-impl", "no-serde-warnings", ] } +uuid = { workspace = true } [dev-dependencies] diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 4c10bb0d728..2980370de79 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -114,7 +114,7 @@ pub enum Command { struct ResumeArgsRaw { // Note: This is the direct clap shape. We reinterpret the positional when --last is set // so "codex resume --last " treats the positional as a prompt, not a session id. - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] session_id: Option, @@ -144,7 +144,7 @@ struct ResumeArgsRaw { #[derive(Debug)] pub struct ResumeArgs { - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. pub session_id: Option, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index fb7aa54ab46..e11e1fcadba 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -750,7 +750,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ExecApprovalRequest(_) + EventMsg::ThreadNameUpdated(_) + | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index d43ce4387c6..687df574d60 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -117,6 +117,7 @@ impl EventProcessorWithJsonOutput { pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec { match &event.msg { protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), + protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f2c212d8958..f7176c13b0b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -59,12 +59,14 @@ use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; +use uuid::Uuid; 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_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; enum InitialOperation { UserTurn { @@ -619,8 +621,13 @@ async fn resolve_resume_path( } } } else if let Some(id_str) = args.session_id.as_deref() { - let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) + if Uuid::parse_str(id_str).is_ok() { + let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; + Ok(path) + } else { + let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?; + Ok(path) + } } else { Ok(None) } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 105824c60db..dc5f220fd37 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -88,6 +88,7 @@ fn session_configured_produces_thread_started_event() { EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, forked_from_id: None, + thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index dcaf8a89e6c..c96bc607065 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -308,6 +308,9 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } + EventMsg::ThreadNameUpdated(_) => { + // Ignore session metadata updates in MCP tool runner. + } EventMsg::AgentMessageDelta(_) => { // TODO: think how we want to support this in the MCP } diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 2069db69d07..954e50d9def 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -258,6 +258,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -298,6 +299,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -362,6 +364,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7ba01fa4bd9..fbaf4f44577 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -273,6 +273,11 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, + /// Set a user-facing thread name in the persisted rollout metadata. + /// This is a local-only operation handled by codex-core; it does not + /// involve the model. + SetThreadName { name: String }, + /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). Undo, @@ -735,6 +740,9 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), + /// Updated session metadata (e.g., thread name changes). + ThreadNameUpdated(ThreadNameUpdatedEvent), + /// Incremental MCP startup progress updates. McpStartupUpdate(McpStartupUpdateEvent), @@ -2186,11 +2194,15 @@ pub struct SkillsListEntry { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { - /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + /// Optional user-facing thread name (may be unset). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, + /// Tell the client what model is being queried. pub model: String, @@ -2226,6 +2238,14 @@ pub struct SessionConfiguredEvent { pub rollout_path: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadNameUpdatedEvent { + pub thread_id: ThreadId, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + /// User's decision in response to an ExecApprovalRequest. #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -2569,6 +2589,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6664e7f9c55..7f7fe51c5d7 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -94,6 +94,7 @@ tree-sitter-highlight = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } +uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe33df83780..107e9f6ed60 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -108,6 +108,7 @@ const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, + pub thread_name: Option, pub update_action: Option, pub exit_reason: ExitReason, } @@ -117,6 +118,7 @@ impl AppExitInfo { Self { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::Fatal(message.into()), } @@ -135,13 +137,17 @@ pub enum ExitReason { Fatal(String), } -fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Option { +fn session_summary( + token_usage: TokenUsage, + thread_id: Option, + thread_name: Option, +) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = thread_id.map(|thread_id| format!("codex resume {thread_id}")); + let resume_command = codex_core::util::resume_command(thread_name.as_deref(), thread_id); Some(SessionSummary { usage_line, resume_command, @@ -496,6 +502,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -1208,6 +1215,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, exit_reason, }) @@ -1269,8 +1277,11 @@ impl App { match event { AppEvent::NewSession => { let model = self.chat_widget.current_model().to_string(); - let summary = - session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); self.shutdown_current_thread().await; if let Err(err) = self.server.remove_and_close_all_threads().await { tracing::warn!(error = %err, "failed to close all threads"); @@ -1343,6 +1354,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), + self.chat_widget.thread_name(), ); match self .server @@ -1401,8 +1413,11 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ForkCurrentSession => { - let summary = - session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); if let Some(path) = self.chat_widget.rollout_path() { match self .server @@ -2241,6 +2256,7 @@ impl App { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: config_snapshot.model, model_provider_id: config_snapshot.model_provider_id, approval_policy: config_snapshot.approval_policy, @@ -2908,6 +2924,7 @@ mod tests { let event = SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2960,6 +2977,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -3005,6 +3023,7 @@ mod tests { let event = SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -3036,7 +3055,7 @@ mod tests { #[tokio::test] async fn session_summary_skip_zero_usage() { - assert!(session_summary(TokenUsage::default(), None).is_none()); + assert!(session_summary(TokenUsage::default(), None, None).is_none()); } #[tokio::test] @@ -3049,7 +3068,7 @@ mod tests { }; let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let summary = session_summary(usage, Some(conversation)).expect("summary"); + let summary = session_summary(usage, Some(conversation), None).expect("summary"); assert_eq!( summary.usage_line, "Token usage: total=12 input=10 output=2" @@ -3059,4 +3078,22 @@ mod tests { Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } + + #[tokio::test] + async fn session_summary_prefers_name_over_id() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + let summary = session_summary(usage, Some(conversation), Some("my-session".to_string())) + .expect("summary"); + assert_eq!( + summary.resume_command, + Some("codex resume my-session".to_string()) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 90cae4b4a0c..7c8b7585acb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2054,7 +2054,7 @@ impl ChatComposer { self.personality_command_enabled, self.windows_degraded_sandbox_active, ) - && cmd == SlashCommand::Review + && matches!(cmd, SlashCommand::Review | SlashCommand::Rename) { self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9857956ed92..8223c40ce05 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,6 +23,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -197,7 +198,6 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use crate::streaming::controller::StreamController; -use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; @@ -517,6 +517,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + thread_name: Option, forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -788,6 +789,7 @@ impl ChatWidget { self.set_skills(None); self.bottom_pane.set_connectors_snapshot(None); self.thread_id = Some(event.session_id); + self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); let initial_messages = event.initial_messages.clone(); @@ -828,6 +830,13 @@ impl ChatWidget { } } + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { + if self.thread_id == Some(event.thread_id) { + self.thread_name = event.thread_name; + self.request_redraw(); + } + } + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } @@ -2167,6 +2176,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, @@ -2307,6 +2317,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, saw_plan_update_this_turn: false, queued_user_messages: VecDeque::new(), @@ -2436,6 +2447,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, @@ -2706,6 +2718,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -2907,6 +2922,17 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Rename if !trimmed.is_empty() => { + let Some(name) = codex_core::util::normalize_thread_name(trimmed) else { + self.add_error_message("Thread name cannot be empty.".to_string()); + return; + }; + let cell = Self::rename_confirmation_cell(&name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetThreadName { name })); + } SlashCommand::Collab | SlashCommand::Plan => { let _ = trimmed; self.dispatch_command(cmd); @@ -2925,6 +2951,38 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let has_name = self + .thread_name + .as_ref() + .is_some_and(|name| !name.is_empty()); + let title = if has_name { + "Rename thread" + } else { + "Name thread" + }; + let thread_id = self.thread_id; + let view = CustomPromptView::new( + title.to_string(), + "Type a name and press Enter".to_string(), + None, + Box::new(move |name: String| { + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event("Thread name cannot be empty.".to_string()), + ))); + return; + }; + let cell = Self::rename_confirmation_cell(&name, thread_id); + tx.send(AppEvent::InsertHistoryCell(Box::new(cell))); + tx.send(AppEvent::CodexOp(Op::SetThreadName { name })); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -3125,7 +3183,10 @@ impl ChatWidget { /// distinguish replayed events from live ones. fn replay_initial_messages(&mut self, events: Vec) { for msg in events { - if matches!(msg, EventMsg::SessionConfigured(_)) { + if matches!( + msg, + EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) + ) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. @@ -3169,6 +3230,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) @@ -3468,6 +3530,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.thread_name.clone(), self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, @@ -5275,6 +5338,20 @@ impl ChatWidget { self.request_redraw(); } + fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { + let resume_cmd = codex_core::util::resume_command(Some(name), thread_id) + .unwrap_or_else(|| format!("codex resume {name}")); + let name = name.to_string(); + let line = vec![ + "• ".into(), + "Thread renamed to ".into(), + name.cyan(), + ", to resume this thread run ".into(), + resume_cmd.cyan(), + ]; + PlainHistoryCell::new(vec![line.into()]) + } + pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); @@ -5796,6 +5873,9 @@ impl ChatWidget { self.thread_id } + pub(crate) fn thread_name(&self) -> Option { + self.thread_name.clone() + } pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 144fc199c56..331afdf90aa 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -134,6 +134,7 @@ async fn resumed_initial_messages_render_history() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -200,6 +201,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -252,6 +254,7 @@ async fn submission_preserves_text_elements_and_local_images() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -823,6 +826,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 010f8c83e5e..c9f307dbe4c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -26,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; @@ -47,6 +48,7 @@ use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; +use uuid::Uuid; mod additional_dirs; mod app; @@ -411,6 +413,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: Some(action), exit_reason: ExitReason::UserRequested, }); @@ -451,6 +454,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -484,6 +488,7 @@ async fn run_ratatui_app( Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::Fatal(format!( "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." @@ -494,7 +499,13 @@ async fn run_ratatui_app( let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); let session_selection = if use_fork { if let Some(id_str) = cli.fork_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let path = if is_uuid { + find_thread_path_by_id_str(&config.codex_home, id_str).await? + } else { + find_thread_path_by_name_str(&config.codex_home, id_str).await? + }; + match path { Some(path) => resume_picker::SessionSelection::Fork(path), None => return missing_session_exit(id_str, "fork"), } @@ -533,6 +544,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -543,7 +555,13 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh } } else if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let path = if is_uuid { + find_thread_path_by_id_str(&config.codex_home, id_str).await? + } else { + find_thread_path_by_name_str(&config.codex_home, id_str).await? + }; + match path { Some(path) => resume_picker::SessionSelection::Resume(path), None => return missing_session_exit(id_str, "resume"), } @@ -584,6 +602,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9488577c1b2..4b68645aef6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -20,6 +20,7 @@ pub enum SlashCommand { Experimental, Skills, Review, + Rename, New, Resume, Fork, @@ -53,6 +54,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", @@ -103,6 +105,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff + | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 5e2fe57b8ac..23005274db0 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -68,6 +68,7 @@ struct StatusHistoryCell { collaboration_mode: Option, model_provider: Option, account: Option, + thread_name: Option, session_id: Option, forked_from: Option, token_usage: StatusTokenUsageData, @@ -81,6 +82,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + thread_name, forked_from, rate_limits, plan_type, @@ -116,6 +119,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, @@ -197,6 +201,7 @@ impl StatusHistoryCell { collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, + thread_name, session_id, forked_from, token_usage, @@ -377,6 +382,7 @@ impl HistoryCell for StatusHistoryCell { .map(str::to_string) .collect(); let mut seen: BTreeSet = labels.iter().cloned().collect(); + let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); if self.model_provider.is_some() { push_label(&mut labels, &mut seen, "Model provider"); @@ -384,6 +390,9 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if thread_name.is_some() { + push_label(&mut labels, &mut seen, "Thread name"); + } if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -442,10 +451,12 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(thread_name) = thread_name { + lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); + } if let Some(collab_mode) = self.collaboration_mode.as_ref() { lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); } - if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 92ed3408178..c8844904abe 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -148,6 +148,7 @@ async fn status_snapshot_includes_reasoning_details() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -200,6 +201,7 @@ async fn status_snapshot_includes_forked_from() { Some(&token_info), &usage, &Some(session_id), + None, Some(forked_from), None, None, @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -307,6 +310,7 @@ async fn status_snapshot_shows_unlimited_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -443,6 +449,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -489,6 +496,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, None, None, + None, now, &model_slug, None, @@ -546,6 +554,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -596,6 +605,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, None, None, + None, now, &model_slug, None, @@ -660,6 +670,7 @@ async fn status_snapshot_includes_credits_and_limits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -714,6 +725,7 @@ async fn status_snapshot_shows_empty_limits_message() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -777,6 +789,7 @@ async fn status_snapshot_shows_stale_limits_message() { &usage, &None, None, + None, Some(&rate_display), None, now, @@ -844,6 +857,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { &usage, &None, None, + None, Some(&rate_display), None, now, @@ -903,6 +917,7 @@ async fn status_context_window_uses_last_usage() { None, None, None, + None, now, &model_slug, None, diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 84a96f54d0c..a8521f359f5 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. +Use /rename to rename your threads for easier thread resuming. Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`. Join the OpenAI community Discord: http://discord.gg/openai Visit the Codex community forum: https://community.openai.com/c/codex/37