diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dd54eb25da7..f1498c6c689 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -117,6 +117,10 @@ client_request_definitions! { params: v2::ThreadArchiveParams, response: v2::ThreadArchiveResponse, }, + ThreadSetName => "thread/name/set" { + params: v2::ThreadSetNameParams, + response: v2::ThreadSetNameResponse, + }, ThreadRollback => "thread/rollback" { params: v2::ThreadRollbackParams, response: v2::ThreadRollbackResponse, @@ -545,6 +549,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 8655d89c412..24171109ed9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1150,6 +1150,19 @@ 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/")] +pub struct ThreadSetNameResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1964,6 +1977,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 63f4dea965d..9d269358260 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -80,6 +80,7 @@ Example (from OpenAI's official VSCode extension): - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `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/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. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0b2520098d7..26d677b149c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -51,6 +51,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; @@ -991,6 +992,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 fc7115447db..c63e105c702 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -99,6 +99,8 @@ use codex_app_server_protocol::ThreadLoadedListResponse; 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::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -385,6 +387,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::ThreadRollback { request_id, params } => { self.thread_rollback(request_id, params).await; } @@ -1549,6 +1554,42 @@ impl CodexMessageProcessor { } } + async fn thread_set_name(&self, request_id: RequestId, params: ThreadSetNameParams) { + let ThreadSetNameParams { thread_id, name } = params; + let trimmed = name.trim(); + if trimmed.is_empty() { + 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: trimmed.to_string(), + }) + .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_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) { let ThreadRollbackParams { thread_id, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 337be81e5dd..8a486e8b9ba 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -146,7 +146,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, @@ -325,6 +325,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, @@ -945,7 +947,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, } @@ -956,6 +961,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }; @@ -965,7 +971,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, @@ -979,12 +985,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 61fb48f6974..f5ec46ebac7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -23,6 +23,7 @@ use crate::features::Features; 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; @@ -695,9 +696,19 @@ impl Session { .await .map(Arc::new); } - let state = SessionState::new(session_configuration.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 + } + }; + let state = SessionState::new(session_configuration.clone(), thread_name.clone()); let services = SessionServices { + codex_home: config.codex_home.clone(), mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), @@ -733,6 +744,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + thread_name, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -1886,6 +1898,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, @@ -1928,6 +1943,7 @@ mod handlers { use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; + use crate::rollout::session_index; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; @@ -1944,6 +1960,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; @@ -2319,6 +2336,73 @@ 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 `SessionState::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 name = name.trim().to_string(); + if name.is_empty() { + 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 = sess.services.rollout.lock().await.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; + }; + + if let Err(e) = session_index::append_thread_name( + &sess.services.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.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 @@ -3414,7 +3498,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, None); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 10.0, @@ -3480,7 +3564,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, None); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 15.0, @@ -3741,10 +3825,11 @@ mod tests { session_configuration.session_source.clone(), ); - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), None); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { + codex_home: config.codex_home.clone(), mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), @@ -3836,10 +3921,11 @@ mod tests { session_configuration.session_source.clone(), ); - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), None); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { + codex_home: config.codex_home.clone(), mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 49409f8e84b..86ca3c5589f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -200,6 +200,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 498c45748fd..6318547bb75 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -98,6 +98,7 @@ pub use rollout::SessionMeta; #[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::ThreadsPage; diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 5b65bada7c4..5cd26c7ff55 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod error; pub mod list; pub(crate) mod policy; pub mod recorder; +pub(crate) mod session_index; pub(crate) mod truncation; pub use codex_protocol::protocol::SessionMeta; @@ -20,6 +21,7 @@ pub use list::find_thread_path_by_id_str; pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; 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 fafdc83102a..827dcfc844d 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..d811c07de7c --- /dev/null +++ b/codex-rs/core/src/rollout/session_index.rs @@ -0,0 +1,255 @@ +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, +} + +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 +} + +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 line = serde_json::to_string(entry).map_err(std::io::Error::other)?; + file.write_all(line.as_bytes()).await?; + file.write_all(b"\n").await?; + file.flush().await?; + Ok(()) +} + +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)) +} + +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(()) + } +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index cd1f1c04984..b423b740b43 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use crate::AuthManager; @@ -16,6 +17,7 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; pub(crate) struct SessionServices { + pub(crate) codex_home: PathBuf, pub(crate) mcp_connection_manager: Arc>, pub(crate) mcp_startup_cancellation_token: Mutex, pub(crate) unified_exec_manager: UnifiedExecProcessManager, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d1883735..4012e653acf 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -14,16 +14,21 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, + pub(crate) thread_name: Option, } impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { + pub(crate) fn new( + session_configuration: SessionConfiguration, + thread_name: Option, + ) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, + thread_name, } } diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index a100f284437..1b6f467ea5f 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -2,6 +2,7 @@ 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; @@ -72,6 +73,43 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { } } +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| { + if needs_shell_escaping(&target) { + format!("codex resume -- {}", shell_escape(&target)) + } else { + format!("codex resume {target}") + } + }) +} + +fn needs_shell_escaping(value: &str) -> bool { + value.starts_with('-') || value.chars().any(char::is_whitespace) || value.contains('\'') +} + +fn shell_escape(value: &str) -> String { + // Single-quote escape for POSIX shells so resume hints are copy/paste safe. + if value.is_empty() { + return "''".to_string(); + } + + let mut out = String::with_capacity(value.len() + 2); + out.push('\''); + for ch in value.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +145,45 @@ mod tests { feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); } + + #[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 518f26c5625..e29141e6688 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_thread_path_by_id_str; +use pretty_assertions::assert_eq; use tempfile::TempDir; use uuid::Uuid; diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0e4e1ddde32..6bf965e9dac 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,6 +133,10 @@ sequenceDiagram task->>-user: Event::TurnComplete ``` +### Session Metadata + +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::ThreadNameUpdated` when the thread name changes. When unset, `thread_name` is omitted (not sent as `null`). Thread names accept any non-empty string and are persisted in `CODEX_HOME/session_index.jsonl` as an append-only index where the newest entry wins. + ### Task Interrupt Interrupting a task and continuing with additional user input. 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 3d2001ae615..251f6889f80 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -110,7 +110,7 @@ pub enum Command { #[derive(Parser, 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. #[arg(value_name = "SESSION_ID")] 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 42a83e44d19..bfd4e9cd471 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::WebSearchBegin(_) + | EventMsg::ThreadNameUpdated(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TerminalInteraction(_) 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 3679b573806..a9eb2f88f45 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -92,6 +92,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 cdd8bf7daff..e504a03e48a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -53,12 +53,14 @@ use tracing::error; use tracing::info; 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 { @@ -528,8 +530,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 e60fcba8e80..21caa1ad593 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -75,6 +75,7 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, + 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 531bf90d23f..84b4f8e835e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -307,6 +307,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 0b05bc36092..c0ea9fba8c7 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,6 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -296,6 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -359,6 +361,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: thread_id, + 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 7ab9099ae8a..fa4140a2053 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -218,6 +218,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, @@ -680,6 +685,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), @@ -2004,9 +2012,13 @@ 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, + /// 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, @@ -2040,6 +2052,14 @@ pub struct SessionConfiguredEvent { pub rollout_path: PathBuf, } +#[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")] @@ -2379,6 +2399,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + 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 9b7782edf29..fd1b1249443 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -91,6 +91,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 87d76909de1..3499fb42a24 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -80,6 +80,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, + pub thread_name: Option, pub update_action: Option, pub exit_reason: ExitReason, } @@ -96,13 +97,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, @@ -304,6 +309,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, }); @@ -596,6 +602,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, }) @@ -657,8 +664,11 @@ impl App { .await; match event { AppEvent::NewSession => { - 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; let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), @@ -698,6 +708,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 @@ -771,6 +782,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 @@ -2004,6 +2016,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2045,6 +2058,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2087,6 +2101,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2118,7 +2133,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] @@ -2131,7 +2146,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" @@ -2141,4 +2156,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/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d566356e20..d9fac67b863 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; @@ -168,7 +169,6 @@ use self::agent::spawn_agent_from_existing; mod session_header; use self::session_header::SessionHeader; use crate::streaming::controller::StreamController; -use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; @@ -440,6 +440,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, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -600,6 +601,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.thread_id = Some(event.session_id); + self.thread_name = event.thread_name.clone(); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -630,6 +632,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); } @@ -1699,6 +1708,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1797,6 +1807,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2015,6 +2026,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -2174,6 +2188,12 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Rename if !trimmed.is_empty() => { + let name = trimmed.to_string(); + self.add_info_message(format!("Thread renamed to \"{name}\""), None); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetThreadName { name })); + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -2188,6 +2208,23 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename thread".to_string(), + "Type a new name and press Enter".to_string(), + None, + Box::new(move |name: String| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Thread renamed to \"{name}\""), None), + ))); + 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); } @@ -2331,7 +2368,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. @@ -2367,6 +2407,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) @@ -2635,6 +2676,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.thread_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), @@ -4331,6 +4373,10 @@ impl ChatWidget { self.thread_id } + pub(crate) fn thread_name(&self) -> Option { + self.thread_name.clone() + } + fn is_session_configured(&self) -> bool { self.thread_id.is_some() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 40d30c89560..90a88e00b5b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -131,6 +131,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -428,6 +429,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 29bf2b1ce0c..f83925f4c31 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -22,6 +22,7 @@ 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_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_core::terminal::Multiplexer; @@ -34,6 +35,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; @@ -385,6 +387,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, }); @@ -425,6 +428,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, }); @@ -458,6 +462,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." @@ -468,7 +473,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"), } @@ -506,6 +517,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, }); @@ -516,7 +528,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"), } @@ -554,6 +572,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 0d274f8eb55..db5a165df28 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -19,6 +19,7 @@ pub enum SlashCommand { Experimental, Skills, Review, + Rename, New, Resume, Fork, @@ -47,6 +48,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 a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", @@ -89,6 +91,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 7174cf41c94..aa9f665af1f 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -65,6 +65,7 @@ struct StatusHistoryCell { agents_summary: String, model_provider: Option, account: Option, + thread_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + thread_name, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -162,6 +166,7 @@ impl StatusHistoryCell { agents_summary, model_provider, account, + thread_name, session_id, token_usage, rate_limits, @@ -348,6 +353,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + push_label(&mut labels, &mut seen, "Thread name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -400,6 +406,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + let thread_name = self.thread_name.as_deref().unwrap_or(""); + lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); 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/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap index dbb634bab1c..627032f7a28 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.05K total (700 input + 350 output) │ │ Context window: 100% left (1.45K used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap index 1707a4c5fbc..2a34abc899e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 2K total (1.4K input + 600 output) │ │ Context window: 100% left (2.2K used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index 3ecc4fa8ed2..83f59b5d2d0 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.2K total (800 input + 400 output) │ │ Context window: 100% left (1.2K used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index c22577407ee..45a3700c4d6 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap index f0e6b734454..44032b0c878 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index f0e6b734454..44032b0c878 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index a12be950bcc..08b1a239f54 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 02ba1adec91..faf006a92ec 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bb16f13328e..65985b4e247 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -146,6 +146,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -202,6 +203,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -246,6 +248,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -289,6 +292,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -332,6 +336,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -373,6 +378,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -416,6 +422,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -470,6 +477,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -517,6 +525,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -578,6 +587,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -629,6 +639,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -689,6 +700,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -753,6 +765,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -809,6 +822,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, ); diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index 391d2169996..c35a8521535 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/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/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index cd174fbbbd6..b055b527857 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -98,6 +98,7 @@ use crate::history_cell::UpdateAvailableHistoryCell; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub thread_name: Option, pub update_action: Option, pub exit_reason: ExitReason, /// ANSI-styled transcript lines to print after the TUI exits. @@ -129,6 +130,7 @@ impl From for codex_tui::AppExitInfo { codex_tui::AppExitInfo { token_usage: info.token_usage, thread_id: info.conversation_id, + thread_name: info.thread_name, update_action: info.update_action.map(Into::into), exit_reason, } @@ -138,14 +140,14 @@ impl From for codex_tui::AppExitInfo { fn session_summary( token_usage: TokenUsage, conversation_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 = - conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); + let resume_command = codex_core::util::resume_command(thread_name.as_deref(), conversation_id); Some(SessionSummary { usage_line, resume_command, @@ -343,6 +345,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), @@ -677,6 +680,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, exit_reason, session_lines, @@ -1433,6 +1437,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.thread_name(), ); self.shutdown_current_conversation().await; let init = crate::chatwidget::ChatWidgetInit { @@ -1472,6 +1477,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.thread_name(), ); match self .server @@ -1544,6 +1550,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.thread_name(), ); match self .server @@ -2637,6 +2644,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2678,6 +2686,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2960,6 +2969,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2991,7 +3001,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] @@ -3025,7 +3035,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" @@ -3035,4 +3045,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/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5facd756f74..b70b530e8e8 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/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; @@ -162,7 +163,6 @@ mod session_header; use self::session_header::SessionHeader; use crate::streaming::controller::StreamController; use crate::version::CODEX_CLI_VERSION; -use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; @@ -383,6 +383,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, + thread_name: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -522,6 +523,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.conversation_id = Some(event.session_id); + self.thread_name = event.thread_name.clone(); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -552,6 +554,13 @@ impl ChatWidget { } } + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { + if self.conversation_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); } @@ -1502,6 +1511,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1598,6 +1608,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1790,6 +1801,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -1943,6 +1957,12 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Rename if !trimmed.is_empty() => { + let name = trimmed.to_string(); + self.add_info_message(format!("Thread renamed to \"{name}\""), None); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetThreadName { name })); + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -1957,6 +1977,23 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename thread".to_string(), + "Type a new name and press Enter".to_string(), + None, + Box::new(move |name: String| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Thread renamed to \"{name}\""), None), + ))); + 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); } @@ -2097,7 +2134,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. @@ -2133,6 +2173,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) @@ -2400,6 +2441,7 @@ impl ChatWidget { token_info, total_usage, &self.conversation_id, + self.thread_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), @@ -4048,6 +4090,10 @@ impl ChatWidget { self.conversation_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/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 22871c39d76..c7c380562dc 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -120,6 +120,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -414,6 +415,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + thread_name: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 55e640c58fb..2fb4da8b912 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -22,6 +22,7 @@ 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_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_core::terminal::Multiplexer; @@ -34,6 +35,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; @@ -404,6 +406,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + thread_name: None, update_action: Some(action), exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), @@ -445,6 +448,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), @@ -479,6 +483,7 @@ async fn run_ratatui_app( Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_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." @@ -490,7 +495,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"), } @@ -528,6 +539,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), @@ -539,7 +551,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"), } @@ -577,6 +595,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index e2d77612210..6fdf8076cdb 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -18,6 +18,7 @@ pub enum SlashCommand { ElevateSandbox, Skills, Review, + Rename, New, Resume, Fork, @@ -45,6 +46,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 a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", @@ -84,6 +86,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff + | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 55896365e25..68a801ef8b2 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -65,6 +65,7 @@ struct StatusHistoryCell { agents_summary: String, model_provider: Option, account: Option, + thread_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + thread_name, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -162,6 +166,7 @@ impl StatusHistoryCell { agents_summary, model_provider, account, + thread_name, session_id, token_usage, rate_limits, @@ -348,6 +353,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + push_label(&mut labels, &mut seen, "Thread name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -399,6 +405,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + let thread_name = self.thread_name.as_deref().unwrap_or(""); + lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap index 5c805561461..d829a768733 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.05K total (700 input + 350 output) │ │ Context window: 100% left (1.45K used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap index 7a914837399..f6d6384f82f 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 2K total (1.4K input + 600 output) │ │ Context window: 100% left (2.2K used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap index 61701111155..8a85bcbfb8a 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.2K total (800 input + 400 output) │ │ Context window: 100% left (1.2K used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap index 1e88139cc43..44cdefe8739 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap index ac824827e3a..68bbef8f616 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap index ac824827e3a..68bbef8f616 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap index ffdb825bac6..3c9f830f34e 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 1762b1b715f..c34b70af49d 100644 --- a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -15,6 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index b16940a4def..57e9044bfe1 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -146,6 +146,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -201,6 +202,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -244,6 +246,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -287,6 +290,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -330,6 +334,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -371,6 +376,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -414,6 +420,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -468,6 +475,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -515,6 +523,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -575,6 +584,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -626,6 +636,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -686,6 +697,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -750,6 +762,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -806,6 +819,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, );