From 0a6d2d5d823acf6903a22f2459e7a2671af05b9b Mon Sep 17 00:00:00 2001 From: pap Date: Mon, 5 Jan 2026 21:23:07 +0000 Subject: [PATCH 01/23] adding session naming with /rename and metadata storage in SessionMeta --- codex-rs/core/src/codex.rs | 45 +++++++++++++++++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 17 +++++++++++++ codex-rs/tui2/src/chatwidget.rs | 17 +++++++++++++ 3 files changed, 79 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 96e90c5cfa6..1af41e9252e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1709,6 +1709,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::SetSessionTitle { title } => { + handlers::set_session_title(&sess, sub.id.clone(), title).await; + } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( &sess, @@ -2121,6 +2124,48 @@ mod handlers { .await; } + pub async fn set_session_title(sess: &Arc, sub_id: String, title: String) { + let title = title.trim().to_string(); + if title.is_empty() { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Session title cannot be empty.".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(event).await; + return; + } + + let recorder = { + let guard = sess.services.rollout.lock().await; + guard.clone() + }; + let Some(recorder) = recorder else { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Session persistence is disabled; cannot rename session.".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + return; + }; + + if let Err(e) = recorder.set_session_title(title).await { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("Failed to set session title: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + } + } + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd0ba788b2b..5754cb40610 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1907,6 +1907,23 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename session".to_string(), + "Type a new name and press Enter".to_string(), + None, + Box::new(move |title: String| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Session renamed to \"{title}\""), None), + ))); + tx.send(AppEvent::CodexOp(Op::SetSessionTitle { title })); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index df5acb442a0..22470f95407 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1735,6 +1735,23 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename session".to_string(), + "Type a new name and press Enter".to_string(), + None, + Box::new(move |title: String| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(format!("Session renamed to \"{title}\""), None), + ))); + tx.send(AppEvent::CodexOp(Op::SetSessionTitle { title })); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } From bfc7a3e53b8a401000bf534a80c0fdc475e0195b Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 6 Jan 2026 08:31:27 +0000 Subject: [PATCH 02/23] fix Restore writer file handle on rename failure --- codex-rs/core/src/rollout/recorder.rs | 174 ++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index d571ad191e6..58201c8c350 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -392,6 +392,118 @@ async fn rollout_writer( Ok(()) } +async fn rewrite_session_title( + writer: &mut JsonlWriter, + rollout_path: &Path, + title: &str, +) -> std::io::Result<()> { + // Flush and close the writer's file handle before swapping the on-disk file, + // otherwise subsequent appends would keep writing to the old inode/handle. + writer.file.flush().await?; + + // Compute the rewritten contents first so any read/parse/legacy-format errors + // don't disturb the active writer handle. + let rewritten_contents = rewrite_first_session_meta_line_title(rollout_path, title).await?; + + // Close the active handle using a portable placeholder. + let placeholder = tokio::fs::File::from_std(tempfile::tempfile()?); + let old_file = std::mem::replace(&mut writer.file, placeholder); + drop(old_file); + + if let Err(e) = replace_rollout_file(rollout_path, rewritten_contents).await { + // Best-effort: ensure the writer keeps pointing at the rollout file, not the placeholder. + let reopened = tokio::fs::OpenOptions::new() + .append(true) + .create(true) + .open(rollout_path) + .await; + if let Ok(reopened) = reopened { + let placeholder = std::mem::replace(&mut writer.file, reopened); + drop(placeholder); + } + return Err(e); + } + + // Re-open the rollout for appends and drop the placeholder handle. + let reopened = tokio::fs::OpenOptions::new() + .append(true) + .open(rollout_path) + .await?; + let placeholder = std::mem::replace(&mut writer.file, reopened); + drop(placeholder); + + Ok(()) +} + +async fn rewrite_first_session_meta_line_title( + rollout_path: &Path, + title: &str, +) -> std::io::Result { + let text = tokio::fs::read_to_string(rollout_path).await?; + let mut rewritten = false; + + // Rewrite the first non-empty line only. Since 43809a454 ("Introduce rollout items", + // 2025-09-09), rollouts we write always start with a RolloutLine wrapping + // RolloutItem::SessionMeta(_). + let mut out = String::with_capacity(text.len() + 32); + for line in text.lines() { + if !rewritten && !line.trim().is_empty() { + out.push_str(&rewrite_session_meta_line_title(line, title)?); + rewritten = true; + } else { + out.push_str(line); + } + out.push('\n'); + } + + if !rewritten { + return Err(IoError::other( + "failed to set session title: rollout has no SessionMeta line", + )); + } + + Ok(out) +} + +fn rewrite_session_meta_line_title(line: &str, title: &str) -> std::io::Result { + let mut rollout_line = serde_json::from_str::(line).map_err(IoError::other)?; + let RolloutItem::SessionMeta(meta_line) = &mut rollout_line.item else { + return Err(IoError::other( + "failed to set session title: rollout has no SessionMeta line", + )); + }; + + meta_line.meta.title = Some(title.to_string()); + serde_json::to_string(&rollout_line).map_err(IoError::other) +} + +async fn replace_rollout_file(path: &Path, contents: String) -> std::io::Result<()> { + let Some(dir) = path.parent() else { + return Err(IoError::other("rollout path has no parent directory")); + }; + + let mut tmp = tempfile::NamedTempFile::new_in(dir)?; + use std::io::Write as _; + tmp.write_all(contents.as_bytes())?; + tmp.flush()?; + + let (_file, tmp_path) = tmp.keep()?; + drop(_file); + + #[cfg(windows)] + { + let _ = std::fs::remove_file(path); + std::fs::rename(&tmp_path, path)?; + } + + #[cfg(not(windows))] + { + std::fs::rename(&tmp_path, path)?; + } + + Ok(()) +} + struct JsonlWriter { file: tokio::fs::File, } @@ -419,3 +531,65 @@ impl JsonlWriter { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tokio::io::AsyncWriteExt; + use uuid::Uuid; + + #[tokio::test] + async fn set_session_title_rewrites_first_session_meta_line() -> std::io::Result<()> { + let config = crate::config::test_config(); + + let conversation_id = + ConversationId::from_string(&Uuid::new_v4().to_string()).expect("uuid should parse"); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new(conversation_id, None, SessionSource::Cli), + ) + .await?; + + recorder + .set_session_title("My Session Title".to_string()) + .await?; + + let text = tokio::fs::read_to_string(&recorder.rollout_path).await?; + let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let rollout_line: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { + panic!("expected SessionMeta as first rollout line"); + }; + assert_eq!(meta_line.meta.title.as_deref(), Some("My Session Title")); + Ok(()) + } + + #[tokio::test] + async fn set_session_title_failure_does_not_redirect_future_writes() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let rollout_path = dir.path().join("rollout.jsonl"); + + // Invalid JSON as the first non-empty line triggers a parse error in the rewrite step. + tokio::fs::write(&rollout_path, "{\n").await?; + + let file = tokio::fs::OpenOptions::new() + .append(true) + .open(&rollout_path) + .await?; + let mut writer = JsonlWriter { file }; + + assert!( + rewrite_session_title(&mut writer, &rollout_path, "title") + .await + .is_err() + ); + + writer.file.write_all(b"AFTER\n").await?; + writer.file.flush().await?; + + let text = tokio::fs::read_to_string(&rollout_path).await?; + assert!(text.trim_end().ends_with("AFTER")); + Ok(()) + } +} From 27bbc02f859a895b0cca550f7eeccc31db316737 Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 11:28:51 +0000 Subject: [PATCH 03/23] changing attribute to name + support command with arg --- codex-rs/app-server/tests/common/rollout.rs | 1 + codex-rs/core/src/codex.rs | 16 +++--- codex-rs/core/src/rollout/recorder.rs | 55 +++++++++++++------ codex-rs/core/src/rollout/tests.rs | 1 + codex-rs/protocol/src/protocol.rs | 14 +++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 12 +++- .../tui2/src/bottom_pane/chat_composer.rs | 2 +- codex-rs/tui2/src/chatwidget.rs | 12 +++- 9 files changed, 81 insertions(+), 34 deletions(-) diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index b5829716af6..40aab240bd6 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -44,6 +44,7 @@ pub fn create_fake_rollout( id: conversation_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), + name: None, originator: "codex".to_string(), cli_version: "0.0.0".to_string(), instructions: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1af41e9252e..86d1ed5d6ef 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1709,8 +1709,8 @@ 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::SetSessionTitle { title } => { - handlers::set_session_title(&sess, sub.id.clone(), title).await; + Op::SetSessionName { name } => { + handlers::set_session_name(&sess, sub.id.clone(), name).await; } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( @@ -2124,13 +2124,13 @@ mod handlers { .await; } - pub async fn set_session_title(sess: &Arc, sub_id: String, title: String) { - let title = title.trim().to_string(); - if title.is_empty() { + pub async fn set_session_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: "Session title cannot be empty.".to_string(), + message: "Session name cannot be empty.".to_string(), codex_error_info: Some(CodexErrorInfo::BadRequest), }), }; @@ -2154,11 +2154,11 @@ mod handlers { return; }; - if let Err(e) = recorder.set_session_title(title).await { + if let Err(e) = recorder.set_session_name(name).await { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { - message: format!("Failed to set session title: {e}"), + message: format!("Failed to set session name: {e}"), codex_error_info: Some(CodexErrorInfo::Other), }), }; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 58201c8c350..aa78b9e2ba2 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -67,6 +67,11 @@ enum RolloutCmd { Flush { ack: oneshot::Sender<()>, }, + /// Rewrite the first SessionMeta line in the rollout file to include a name. + SetSessionName { + name: String, + ack: oneshot::Sender>, + }, Shutdown { ack: oneshot::Sender<()>, }, @@ -143,6 +148,7 @@ impl RolloutRecorder { id: session_id, timestamp, cwd: config.cwd.clone(), + name: None, originator: originator().value.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), instructions, @@ -207,6 +213,16 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) } + pub async fn set_session_name(&self, name: String) -> std::io::Result<()> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(RolloutCmd::SetSessionName { name, ack: tx }) + .await + .map_err(|e| IoError::other(format!("failed to queue session name update: {e}")))?; + rx.await + .map_err(|e| IoError::other(format!("failed waiting for session name update: {e}")))? + } + pub async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; @@ -383,6 +399,10 @@ async fn rollout_writer( } let _ = ack.send(()); } + RolloutCmd::SetSessionName { name, ack } => { + let result = rewrite_session_name(&mut writer, &rollout_path, &name).await; + let _ = ack.send(result); + } RolloutCmd::Shutdown { ack } => { let _ = ack.send(()); } @@ -392,10 +412,10 @@ async fn rollout_writer( Ok(()) } -async fn rewrite_session_title( +async fn rewrite_session_name( writer: &mut JsonlWriter, rollout_path: &Path, - title: &str, + name: &str, ) -> std::io::Result<()> { // Flush and close the writer's file handle before swapping the on-disk file, // otherwise subsequent appends would keep writing to the old inode/handle. @@ -403,7 +423,7 @@ async fn rewrite_session_title( // Compute the rewritten contents first so any read/parse/legacy-format errors // don't disturb the active writer handle. - let rewritten_contents = rewrite_first_session_meta_line_title(rollout_path, title).await?; + let rewritten_contents = rewrite_first_session_meta_line_name(rollout_path, name).await?; // Close the active handle using a portable placeholder. let placeholder = tokio::fs::File::from_std(tempfile::tempfile()?); @@ -435,9 +455,9 @@ async fn rewrite_session_title( Ok(()) } -async fn rewrite_first_session_meta_line_title( +async fn rewrite_first_session_meta_line_name( rollout_path: &Path, - title: &str, + name: &str, ) -> std::io::Result { let text = tokio::fs::read_to_string(rollout_path).await?; let mut rewritten = false; @@ -448,7 +468,7 @@ async fn rewrite_first_session_meta_line_title( let mut out = String::with_capacity(text.len() + 32); for line in text.lines() { if !rewritten && !line.trim().is_empty() { - out.push_str(&rewrite_session_meta_line_title(line, title)?); + out.push_str(&rewrite_session_meta_line_name(line, name)?); rewritten = true; } else { out.push_str(line); @@ -458,22 +478,22 @@ async fn rewrite_first_session_meta_line_title( if !rewritten { return Err(IoError::other( - "failed to set session title: rollout has no SessionMeta line", + "failed to set session name: rollout has no SessionMeta line", )); } Ok(out) } -fn rewrite_session_meta_line_title(line: &str, title: &str) -> std::io::Result { +fn rewrite_session_meta_line_name(line: &str, name: &str) -> std::io::Result { let mut rollout_line = serde_json::from_str::(line).map_err(IoError::other)?; let RolloutItem::SessionMeta(meta_line) = &mut rollout_line.item else { return Err(IoError::other( - "failed to set session title: rollout has no SessionMeta line", + "failed to set session name: rollout has no SessionMeta line", )); }; - meta_line.meta.title = Some(title.to_string()); + meta_line.meta.name = Some(name.to_string()); serde_json::to_string(&rollout_line).map_err(IoError::other) } @@ -535,16 +555,15 @@ impl JsonlWriter { #[cfg(test)] mod tests { use super::*; + use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use tokio::io::AsyncWriteExt; - use uuid::Uuid; #[tokio::test] - async fn set_session_title_rewrites_first_session_meta_line() -> std::io::Result<()> { + async fn set_session_name_rewrites_first_session_meta_line() -> std::io::Result<()> { let config = crate::config::test_config(); - let conversation_id = - ConversationId::from_string(&Uuid::new_v4().to_string()).expect("uuid should parse"); + let conversation_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, RolloutRecorderParams::new(conversation_id, None, SessionSource::Cli), @@ -552,7 +571,7 @@ mod tests { .await?; recorder - .set_session_title("My Session Title".to_string()) + .set_session_name("My Session Name".to_string()) .await?; let text = tokio::fs::read_to_string(&recorder.rollout_path).await?; @@ -561,12 +580,12 @@ mod tests { let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { panic!("expected SessionMeta as first rollout line"); }; - assert_eq!(meta_line.meta.title.as_deref(), Some("My Session Title")); + assert_eq!(meta_line.meta.name.as_deref(), Some("My Session Name")); Ok(()) } #[tokio::test] - async fn set_session_title_failure_does_not_redirect_future_writes() -> std::io::Result<()> { + async fn set_session_name_failure_does_not_redirect_future_writes() -> std::io::Result<()> { let dir = tempfile::tempdir()?; let rollout_path = dir.path().join("rollout.jsonl"); @@ -580,7 +599,7 @@ mod tests { let mut writer = JsonlWriter { file }; assert!( - rewrite_session_title(&mut writer, &rollout_path, "title") + rewrite_session_name(&mut writer, &rollout_path, "name") .await .is_err() ); diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index f7c13c70f8a..51f842eecef 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -588,6 +588,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { timestamp: ts.to_string(), instructions: None, cwd: ".".into(), + name: None, originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e3748bafc6b..62a11962fe8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -207,6 +207,11 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, + /// Set a user-facing session name in the persisted rollout metadata. + /// This is a local-only operation handled by codex-core; it does not + /// involve the model. + SetSessionName { name: String }, + /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). Undo, @@ -1291,6 +1296,11 @@ pub struct SessionMeta { pub id: ThreadId, pub timestamp: String, pub cwd: PathBuf, +<<<<<<< HEAD +======= + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +>>>>>>> 9f1ca0435 (changing attribute to name + support command with arg) pub originator: String, pub cli_version: String, pub instructions: Option, @@ -1305,6 +1315,10 @@ impl Default for SessionMeta { id: ThreadId::default(), timestamp: String::new(), cwd: PathBuf::new(), +<<<<<<< HEAD +======= + name: None, +>>>>>>> 9f1ca0435 (changing attribute to name + support command with arg) originator: String::new(), cli_version: String::new(), instructions: None, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 620b52d9f9e..caa0544ece4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1342,7 +1342,7 @@ impl ChatComposer { && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review + && matches!(cmd, SlashCommand::Review | SlashCommand::Rename) { return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5754cb40610..49ddccfcd71 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1893,6 +1893,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!("Session renamed to \"{name}\""), None); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetSessionName { name })); + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -1913,11 +1919,11 @@ impl ChatWidget { "Rename session".to_string(), "Type a new name and press Enter".to_string(), None, - Box::new(move |title: String| { + Box::new(move |name: String| { tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(format!("Session renamed to \"{title}\""), None), + history_cell::new_info_event(format!("Session renamed to \"{name}\""), None), ))); - tx.send(AppEvent::CodexOp(Op::SetSessionTitle { title })); + tx.send(AppEvent::CodexOp(Op::SetSessionName { name })); }), ); diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 22e62bb4fba..b17e16107c5 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1259,7 +1259,7 @@ impl ChatComposer { && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review + && matches!(cmd, SlashCommand::Review | SlashCommand::Rename) { return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 22470f95407..fe6429bc731 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1721,6 +1721,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!("Session renamed to \"{name}\""), None); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetSessionName { name })); + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -1741,11 +1747,11 @@ impl ChatWidget { "Rename session".to_string(), "Type a new name and press Enter".to_string(), None, - Box::new(move |title: String| { + Box::new(move |name: String| { tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(format!("Session renamed to \"{title}\""), None), + history_cell::new_info_event(format!("Session renamed to \"{name}\""), None), ))); - tx.send(AppEvent::CodexOp(Op::SetSessionTitle { title })); + tx.send(AppEvent::CodexOp(Op::SetSessionName { name })); }), ); From 41e7e026ad5db93a06cc823ad9b7440e20bf46f6 Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 12:53:22 +0000 Subject: [PATCH 04/23] fix --- codex-rs/core/src/rollout/recorder.rs | 3 ++- codex-rs/protocol/src/protocol.rs | 6 ------ codex-rs/tui/src/chatwidget.rs | 3 +++ codex-rs/tui/src/slash_command.rs | 3 +++ codex-rs/tui2/src/chatwidget.rs | 3 +++ codex-rs/tui2/src/slash_command.rs | 3 +++ 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index aa78b9e2ba2..8b63bae467f 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -178,7 +178,7 @@ impl RolloutRecorder { // Spawn a Tokio task that owns the file handle and performs async // writes. Using `tokio::fs::File` keeps everything on the async I/O // driver instead of blocking the runtime. - tokio::task::spawn(rollout_writer(file, rx, meta, cwd)); + tokio::task::spawn(rollout_writer(file, rx, meta, cwd, rollout_path.clone())); Ok(Self { tx, rollout_path }) } @@ -364,6 +364,7 @@ async fn rollout_writer( mut rx: mpsc::Receiver, mut meta: Option, cwd: std::path::PathBuf, + rollout_path: std::path::PathBuf, ) -> std::io::Result<()> { let mut writer = JsonlWriter { file }; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 62a11962fe8..a2ac3501602 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1296,11 +1296,8 @@ pub struct SessionMeta { pub id: ThreadId, pub timestamp: String, pub cwd: PathBuf, -<<<<<<< HEAD -======= #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, ->>>>>>> 9f1ca0435 (changing attribute to name + support command with arg) pub originator: String, pub cli_version: String, pub instructions: Option, @@ -1315,10 +1312,7 @@ impl Default for SessionMeta { id: ThreadId::default(), timestamp: String::new(), cwd: PathBuf::new(), -<<<<<<< HEAD -======= name: None, ->>>>>>> 9f1ca0435 (changing attribute to name + support command with arg) originator: String::new(), cli_version: String::new(), instructions: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 49ddccfcd71..1cb2809d160 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1734,6 +1734,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a5bab57d9a2..49c4d412d79 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, Init, @@ -46,6 +47,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 session", SlashCommand::Resume => "resume a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", @@ -86,6 +88,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/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index fe6429bc731..3a67690e4cd 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1568,6 +1568,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index bbebcd40944..218d960b4bd 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, Init, @@ -44,6 +45,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 session", SlashCommand::Resume => "resume a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", @@ -81,6 +83,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff + | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status From 9f6aac54db59ded5096e616fd4f75b9b1027a37b Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 16:02:02 +0000 Subject: [PATCH 05/23] Adding session_configured_event & /status output of session name --- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/codex.rs | 38 ++++++++++++++++--- codex-rs/core/src/codex_delegate.rs | 4 ++ codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/state/session.rs | 7 +++- codex-rs/docs/protocol_v1.md | 6 +++ .../src/event_processor_with_human_output.rs | 1 + .../src/event_processor_with_jsonl_output.rs | 1 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 ++ codex-rs/mcp-server/src/outgoing_message.rs | 2 + codex-rs/protocol/src/protocol.rs | 16 ++++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/chatwidget.rs | 20 +++++++++- codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/status/card.rs | 11 ++++++ codex-rs/tui/src/status/tests.rs | 14 +++++++ codex-rs/tui2/src/app.rs | 2 + codex-rs/tui2/src/chatwidget.rs | 20 +++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 2 + codex-rs/tui2/src/status/card.rs | 11 ++++++ codex-rs/tui2/src/status/tests.rs | 14 +++++++ 22 files changed, 168 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d18a7878f47..5435f72d5c1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -115,6 +115,7 @@ pub(crate) async fn apply_bespoke_event_handling( ) .await; } + EventMsg::SessionMetaUpdated(_) => {} EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 86d1ed5d6ef..33a5ba0db2f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -681,7 +681,8 @@ impl Session { .await .map(Arc::new); } - let state = SessionState::new(session_configuration.clone()); + let session_name = Self::session_name_from_rollout(&initial_history.get_rollout_items()); + let state = SessionState::new(session_configuration.clone(), session_name.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -718,6 +719,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + session_name, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -865,6 +867,13 @@ impl Session { }) } + fn session_name_from_rollout(rollout_items: &[RolloutItem]) -> Option { + rollout_items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.name.clone(), + _ => None, + }) + } + pub(crate) async fn update_settings( &self, updates: SessionSettingsUpdate, @@ -1768,6 +1777,7 @@ mod handlers { use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; + use codex_protocol::protocol::SessionMetaUpdatedEvent; use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; @@ -2154,7 +2164,8 @@ mod handlers { return; }; - if let Err(e) = recorder.set_session_name(name).await { + let name_for_recorder = name.clone(); + if let Err(e) = recorder.set_session_name(name_for_recorder).await { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { @@ -2163,7 +2174,22 @@ mod handlers { }), }; sess.send_event_raw(event).await; + return; } + + { + let mut state = sess.state.lock().await; + state.session_name = Some(name.clone()); + } + + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::SessionMetaUpdated(SessionMetaUpdatedEvent { + session_id: sess.conversation_id, + session_name: Some(name), + }), + }) + .await; } pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { @@ -3223,7 +3249,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, @@ -3289,7 +3315,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, @@ -3550,7 +3576,7 @@ 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 { @@ -3644,7 +3670,7 @@ 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 { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 72c2911bfe8..73bbf0e1152 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::SessionMetaUpdated(_), + } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 6c02ad09425..750a63e1920 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::SessionMetaUpdated(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::WebSearchBegin(_) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d1883735..eff0f7f6665 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) session_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, + session_name: Option, + ) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, + session_name, } } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 805abb0ea8c..df005081cb7 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -131,6 +131,12 @@ sequenceDiagram task->>-user: Event::TaskComplete ``` +### Session Metadata + +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include +`session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the +session name changes. + ### Task Interrupt Interrupting a task and continuing with additional user input. 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 ba6d99c5a82..bee47d8eccc 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -573,6 +573,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::WebSearchBegin(_) + | EventMsg::SessionMetaUpdated(_) | 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 0b2df544559..51994bc8c94 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -107,6 +107,7 @@ impl EventProcessorWithJsonOutput { pub fn collect_thread_events(&mut self, event: &Event) -> Vec { match &event.msg { EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), + EventMsg::SessionMetaUpdated(_) => Vec::new(), EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), 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 a3e23181655..5d87b059d86 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, + session_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 73b75dcbf83..4ad896b9e2d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -253,6 +253,9 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } + EventMsg::SessionMetaUpdated(_) => { + // 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 fef5c8bac7f..7f9c356c851 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: conversation_id, + session_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, + session_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 a2ac3501602..f0c88b12491 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -591,6 +591,9 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), + /// Updated session metadata (e.g., session name changes). + SessionMetaUpdated(SessionMetaUpdatedEvent), + /// Incremental MCP startup progress updates. McpStartupUpdate(McpStartupUpdateEvent), @@ -1825,6 +1828,10 @@ pub struct SessionConfiguredEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, + /// Optional user-facing session name (may be unset). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_name: Option, + /// Tell the client what model is being queried. pub model: String, @@ -1858,6 +1865,14 @@ pub struct SessionConfiguredEvent { pub rollout_path: PathBuf, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SessionMetaUpdatedEvent { + /// Name left as session_id instead of thread_id for backwards compatibility. + pub session_id: ThreadId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_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")] @@ -2044,6 +2059,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + session_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9e5ac2d95e4..8178108a7ea 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1690,6 +1690,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + session_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -1745,6 +1746,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, + session_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1cb2809d160..01c96453a6e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,6 +1,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; @@ -132,7 +133,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; @@ -349,6 +349,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + session_name: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -440,6 +441,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.session_name = event.session_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(); @@ -467,6 +469,13 @@ impl ChatWidget { } } + fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { + if self.thread_id == Some(event.session_id) { + self.session_name = event.session_name; + } + self.request_redraw(); + } + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } @@ -1468,6 +1477,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + session_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1554,6 +1564,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + session_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2082,7 +2093,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::SessionMetaUpdated(_) + ) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. @@ -2118,6 +2132,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::SessionMetaUpdated(e) => self.on_session_meta_updated(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) @@ -2353,6 +2368,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.session_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1b9723ec54e..484f1a85076 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -123,6 +123,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + session_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, thread_id: None, + session_name: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7bf066eebfe..5a1a35a961c 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { sandbox: String, agents_summary: String, account: Option, + session_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -75,6 +76,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + session_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -87,6 +89,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + session_name, rate_limits, plan_type, now, @@ -104,6 +107,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + session_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -158,6 +162,7 @@ impl StatusHistoryCell { sandbox, agents_summary, account, + session_name, session_id, token_usage, rate_limits, @@ -341,6 +346,9 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if self.session_name.is_some() { + push_label(&mut labels, &mut seen, "Session name"); + } if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -390,6 +398,9 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(session_name) = self.session_name.as_ref() { + lines.push(formatter.line("Session name", vec![Span::from(session_name.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 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/src/app.rs b/codex-rs/tui2/src/app.rs index 292ccb5ac6f..36b80a19ab6 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -2481,6 +2481,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + session_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2776,6 +2777,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + session_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 3a67690e4cd..9a7b4e1f4c1 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1,6 +1,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; @@ -128,7 +129,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; @@ -316,6 +316,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, + session_name: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -406,6 +407,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.session_name = event.session_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(); @@ -433,6 +435,13 @@ impl ChatWidget { } } + fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { + if self.conversation_id == Some(event.session_id) { + self.session_name = event.session_name; + } + self.request_redraw(); + } + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } @@ -1330,6 +1339,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + session_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1414,6 +1424,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + session_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1888,7 +1899,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::SessionMetaUpdated(_) + ) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. @@ -1924,6 +1938,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::SessionMetaUpdated(e) => self.on_session_meta_updated(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) @@ -2159,6 +2174,7 @@ impl ChatWidget { token_info, total_usage, &self.conversation_id, + self.session_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 9978911186e..900a3fe035a 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -112,6 +112,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + session_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -402,6 +403,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + session_name: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 1cffb7efeb1..1ddc392edc9 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -63,6 +63,7 @@ struct StatusHistoryCell { sandbox: String, agents_summary: String, account: Option, + session_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -75,6 +76,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + session_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -87,6 +89,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + session_name, rate_limits, plan_type, now, @@ -104,6 +107,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + session_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -158,6 +162,7 @@ impl StatusHistoryCell { sandbox, agents_summary, account, + session_name, session_id, token_usage, rate_limits, @@ -341,6 +346,9 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if self.session_name.is_some() { + push_label(&mut labels, &mut seen, "Session name"); + } if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -389,6 +397,9 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(session_name) = self.session_name.as_ref() { + lines.push(formatter.line("Session name", vec![Span::from(session_name.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/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, ); From 4d6e184249926be09cd6d4a02d3816933d602960 Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 16:02:14 +0000 Subject: [PATCH 06/23] +docs --- codex-rs/docs/protocol_v1.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index df005081cb7..c509ac4b084 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,9 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include -`session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the -session name changes. +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the session name changes. ### Task Interrupt From 3fd2ccc50212920307185fa3d56dd473bc9a2938 Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 16:55:40 +0000 Subject: [PATCH 07/23] resume work wip --- codex-rs/cli/src/main.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 231 ++++++++++++------ codex-rs/core/src/rollout/mod.rs | 1 + .../core/tests/suite/rollout_list_find.rs | 90 ++++++- codex-rs/exec/Cargo.toml | 1 + codex-rs/exec/src/cli.rs | 2 +- codex-rs/exec/src/lib.rs | 11 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 11 +- codex-rs/tui2/Cargo.toml | 1 + codex-rs/tui2/src/lib.rs | 11 +- 12 files changed, 264 insertions(+), 99 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 11ba7cfa277..985c939293f 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 session 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, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1fb25ebc138..ac265dc1bf6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -97,6 +97,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/list.rs b/codex-rs/core/src/rollout/list.rs index 487304ddc80..3ae767be9d9 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -157,7 +157,6 @@ async fn traverse_directories_for_paths( provider_matcher: Option<&ProviderMatcher<'_>>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); - let mut scanned_files = 0usize; let mut anchor_passed = anchor.is_none(); let (anchor_ts, anchor_id) = match anchor { Some(c) => (c.ts, c.id), @@ -165,93 +164,65 @@ async fn traverse_directories_for_paths( }; let mut more_matches_available = false; - let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; + let (files, reached_scan_cap) = newest_rollout_files(&root).await?; + let mut scanned_files = 0usize; - 'outer: for (_year, year_path) in year_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { + for (ts, sid, path) in files.into_iter() { + scanned_files += 1; + if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + more_matches_available = true; break; } - let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; - for (_month, month_path) in month_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; + if !anchor_passed { + if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { + anchor_passed = true; + } else { + continue; } - let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; - for (_day, day_path) in day_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; - } - let mut day_files = collect_files(day_path, |name_str, path| { - if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { - return None; - } - - parse_timestamp_uuid_from_filename(name_str) - .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) - }) - .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); - for (ts, sid, _name_str, path) in day_files.into_iter() { - scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { - more_matches_available = true; - break 'outer; - } - if !anchor_passed { - if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { - anchor_passed = true; - } else { - continue; - } - } - if items.len() == page_size { - more_matches_available = true; - break 'outer; - } - // Read head and detect message events; stop once meta + user are found. - let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) - .await - .unwrap_or_default(); - if !allowed_sources.is_empty() - && !summary - .source - .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) - { - continue; - } - if let Some(matcher) = provider_matcher - && !matcher.matches(summary.model_provider.as_deref()) - { - continue; - } - // Apply filters: must have session meta and at least one user message event - if summary.saw_session_meta && summary.saw_user_event { - let HeadTailSummary { - head, - created_at, - mut updated_at, - .. - } = summary; - if updated_at.is_none() { - updated_at = file_modified_rfc3339(&path) - .await - .unwrap_or(None) - .or_else(|| created_at.clone()); - } - items.push(ThreadItem { - path, - head, - created_at, - updated_at, - }); - } - } + } + if items.len() == page_size { + more_matches_available = true; + break; + } + // Read head and detect message events; stop once meta + user are found. + let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) + { + continue; + } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + continue; + } + // Apply filters: must have session meta and at least one user message event + if summary.saw_session_meta && summary.saw_user_event { + let HeadTailSummary { + head, + created_at, + mut updated_at, + .. + } = summary; + if updated_at.is_none() { + updated_at = file_modified_rfc3339(&path) + .await + .unwrap_or(None) + .or_else(|| created_at.clone()); } + items.push(ThreadItem { + path, + head, + created_at, + updated_at, + }); } } - let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } @@ -340,6 +311,51 @@ where Ok(collected) } +async fn newest_rollout_files( + root: &Path, +) -> io::Result<(Vec<(OffsetDateTime, Uuid, PathBuf)>, bool)> { + let mut files = Vec::new(); + let mut reached_scan_cap = false; + let year_dirs = collect_dirs_desc(root, |s| s.parse::().ok()).await?; + + 'outer: for (_year, year_path) in year_dirs.iter() { + if files.len() >= MAX_SCAN_FILES { + break; + } + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if files.len() >= MAX_SCAN_FILES { + break 'outer; + } + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if files.len() >= MAX_SCAN_FILES { + break 'outer; + } + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str) + .map(|(ts, id)| (ts, id, path.to_path_buf())) + }) + .await?; + day_files.sort_by_key(|(ts, id, _path)| (Reverse(*ts), Reverse(*id))); + for (ts, sid, path) in day_files.into_iter() { + files.push((ts, sid, path)); + if files.len() >= MAX_SCAN_FILES { + reached_scan_cap = true; + break 'outer; + } + } + } + } + } + + Ok((files, reached_scan_cap)) +} + fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; @@ -462,6 +478,61 @@ async fn file_modified_rfc3339(path: &Path) -> io::Result> { Ok(dt.format(&Rfc3339).ok()) } +async fn read_first_session_meta_name(path: &Path) -> io::Result> { + use tokio::io::AsyncBufReadExt; + + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let parsed: Result = serde_json::from_str(trimmed); + let Ok(rollout_line) = parsed else { + return Ok(None); + }; + + return Ok(match rollout_line.item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.name, + _ => None, + }); + } + + Ok(None) +} + +/// Locate a recorded thread rollout file by session 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, +) -> io::Result> { + if name.trim().is_empty() { + return Ok(None); + } + + let mut root = codex_home.to_path_buf(); + root.push(SESSIONS_SUBDIR); + if !root.exists() { + return Ok(None); + } + + let (files, _reached_scan_cap) = newest_rollout_files(&root).await?; + for (_ts, _sid, path) in files.into_iter() { + if let Some(candidate) = read_first_session_meta_name(&path).await? + && candidate == name + { + return Ok(Some(path)); + } + } + + Ok(None) +} + /// Locate a recorded thread rollout file by its UUID string using the existing /// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present /// or the id is invalid. diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 5b65bada7c4..cb70d10fcdd 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -18,6 +18,7 @@ pub(crate) use error::map_session_init_error; pub use list::find_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; +pub use list::find_thread_path_by_name_str; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 518f26c5625..82989432458 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -4,33 +4,54 @@ use std::path::Path; use std::path::PathBuf; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; +use pretty_assertions::assert_eq; use tempfile::TempDir; use uuid::Uuid; /// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the /// provided conversation id in the SessionMeta line. Returns the absolute path. +/// +/// This test file covers the low-level "find rollout by X" helpers, so the +/// minimal rollout writer lives here to keep the lookup tests concise. fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { - let sessions = codex_home.join("sessions/2024/01/01"); + write_minimal_rollout(codex_home, "2024/01/01", "2024-01-01T00-00-00", id, None) +} + +// Helper for name lookup tests: lets us create older/newer rollouts without +// duplicating JSONL construction logic. +fn write_minimal_rollout( + codex_home: &Path, + subdir: &str, + filename_ts: &str, + id: Uuid, + name: Option<&str>, +) -> PathBuf { + let sessions = codex_home.join(format!("sessions/{subdir}")); std::fs::create_dir_all(&sessions).unwrap(); - let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); + let file = sessions.join(format!("rollout-{filename_ts}-{id}.jsonl")); let mut f = std::fs::File::create(&file).unwrap(); // Minimal first line: session_meta with the id so content search can find it + let mut payload = serde_json::json!({ + "id": id, + "timestamp": "2024-01-01T00:00:00Z", + "instructions": null, + "cwd": ".", + "originator": "test", + "cli_version": "test", + "model_provider": "test-provider" + }); + if let Some(name) = name { + payload["name"] = serde_json::Value::String(name.to_string()); + } writeln!( f, "{}", serde_json::json!({ "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", - "payload": { - "id": id, - "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, - "cwd": ".", - "originator": "test", - "cli_version": "test", - "model_provider": "test-provider" - } + "payload": payload }) ) .unwrap(); @@ -80,3 +101,50 @@ async fn find_ignores_granular_gitignore_rules() { assert_eq!(found, Some(expected)); } + +#[tokio::test] +async fn find_locates_rollout_file_by_name_latest_first() { + // This test lives here because it verifies the core "find rollout by name" + // helper, including newest-first ordering and filename timestamp parsing. + let home = TempDir::new().unwrap(); + let name = "release-notes"; + let older = write_minimal_rollout( + home.path(), + "2024/01/01", + "2024-01-01T00-00-00", + Uuid::new_v4(), + Some(name), + ); + let newer = write_minimal_rollout( + home.path(), + "2024/01/02", + "2024-01-02T00-00-00", + Uuid::new_v4(), + Some(name), + ); + + let found = find_thread_path_by_name_str(home.path(), name) + .await + .unwrap(); + + assert_eq!(found, Some(newer)); + assert_ne!(found, Some(older)); +} + +#[tokio::test] +async fn find_returns_none_for_unknown_name() { + let home = TempDir::new().unwrap(); + write_minimal_rollout( + home.path(), + "2024/01/01", + "2024-01-01T00-00-00", + Uuid::new_v4(), + Some("known"), + ); + + let found = find_thread_path_by_name_str(home.path(), "missing") + .await + .unwrap(); + + assert_eq!(found, None); +} 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 8cff14f929a..0638347e0e5 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 session 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/lib.rs b/codex-rs/exec/src/lib.rs index 44ce1d6e2bd..7c038f98fa2 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -50,12 +50,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 { @@ -502,8 +504,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/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 3248c7377c5..8ae4f90aa96 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/lib.rs b/codex-rs/tui/src/lib.rs index f4e5e771fcc..2d73d2cc621 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -20,6 +20,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_protocol::config_types::SandboxMode; @@ -30,6 +31,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; @@ -430,7 +432,12 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let resume_path = if Uuid::parse_str(id_str).is_ok() { + 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 resume_path { Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); @@ -439,7 +446,7 @@ async fn run_ratatui_app( let _ = tui.terminal.clear(); if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with ID or name {id_str}. Run `codex resume` without an ID to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index 3108e5561e5..2660d2269e2 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/lib.rs b/codex-rs/tui2/src/lib.rs index ac062cf667c..9db513f4a6d 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -20,6 +20,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_protocol::config_types::SandboxMode; @@ -30,6 +31,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; @@ -450,7 +452,12 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let resume_path = if Uuid::parse_str(id_str).is_ok() { + 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 resume_path { Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); @@ -459,7 +466,7 @@ async fn run_ratatui_app( let _ = tui.terminal.clear(); if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with ID or name {id_str}. Run `codex resume` without an ID to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } From a0f9f6846a0691d8aafd99a4ad4eeb439d8d1196 Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 17:39:49 +0000 Subject: [PATCH 08/23] safe session_name + always showing session name in /status --- codex-rs/core/src/codex.rs | 46 +++++++++++++++++++++++++++ codex-rs/core/src/rollout/recorder.rs | 6 ++-- codex-rs/docs/protocol_v1.md | 2 +- codex-rs/protocol/src/protocol.rs | 2 ++ codex-rs/tui/src/status/card.rs | 9 ++---- codex-rs/tui2/src/status/card.rs | 9 ++---- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 33a5ba0db2f..848ea5963f6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2148,6 +2148,19 @@ mod handlers { return; } + if !is_valid_session_name(&name) { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Session name must use only letters, numbers, '.', '_' or '-', and cannot start with '-'." + .to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(event).await; + return; + } + let recorder = { let guard = sess.services.rollout.lock().await; guard.clone() @@ -2192,6 +2205,39 @@ mod handlers { .await; } + fn is_valid_session_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if first == '-' || !is_valid_session_name_char(first) { + return false; + } + + chars.all(is_valid_session_name_char) + } + + fn is_valid_session_name_char(ch: char) -> bool { + matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-') + } + + #[cfg(test)] + mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn validates_session_name_tokens() { + assert_eq!(is_valid_session_name("my-session"), true); + assert_eq!(is_valid_session_name("Alpha_1.2"), true); + assert_eq!(is_valid_session_name("-starts-with-dash"), false); + assert_eq!(is_valid_session_name("has space"), false); + assert_eq!(is_valid_session_name("slash/name"), false); + assert_eq!(is_valid_session_name(""), false); + } + } + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 8b63bae467f..d6e979a0f1b 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -571,9 +571,7 @@ mod tests { ) .await?; - recorder - .set_session_name("My Session Name".to_string()) - .await?; + recorder.set_session_name("my-session".to_string()).await?; let text = tokio::fs::read_to_string(&recorder.rollout_path).await?; let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); @@ -581,7 +579,7 @@ mod tests { let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { panic!("expected SessionMeta as first rollout line"); }; - assert_eq!(meta_line.meta.name.as_deref(), Some("My Session Name")); + assert_eq!(meta_line.meta.name.as_deref(), Some("my-session")); Ok(()) } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index c509ac4b084..1257c3c0026 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,7 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the session name changes. +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the session name changes. When unset, `session_name` is omitted (not sent as `null`). Session names are shell-safe tokens: they may include letters, numbers, `.`, `_`, or `-`, and cannot start with `-`. ### Task Interrupt diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f0c88b12491..d18f8834d0e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1830,6 +1830,7 @@ pub struct SessionConfiguredEvent { /// Optional user-facing session name (may be unset). #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub session_name: Option, /// Tell the client what model is being queried. @@ -1870,6 +1871,7 @@ pub struct SessionMetaUpdatedEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub session_name: Option, } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 5a1a35a961c..9869c762931 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -346,9 +346,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } - if self.session_name.is_some() { - push_label(&mut labels, &mut seen, "Session name"); - } + push_label(&mut labels, &mut seen, "Session name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -398,9 +396,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } - if let Some(session_name) = self.session_name.as_ref() { - lines.push(formatter.line("Session name", vec![Span::from(session_name.clone())])); - } + let session_name = self.session_name.as_deref().unwrap_or(""); + lines.push(formatter.line("Session name", vec![Span::from(session_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/card.rs b/codex-rs/tui2/src/status/card.rs index 1ddc392edc9..e60fbd17f13 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -346,9 +346,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } - if self.session_name.is_some() { - push_label(&mut labels, &mut seen, "Session name"); - } + push_label(&mut labels, &mut seen, "Session name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -397,9 +395,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } - if let Some(session_name) = self.session_name.as_ref() { - lines.push(formatter.line("Session name", vec![Span::from(session_name.clone())])); - } + let session_name = self.session_name.as_deref().unwrap_or(""); + lines.push(formatter.line("Session name", vec![Span::from(session_name.to_string())])); if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } From eebd0a270eaacbd4ee7ab9ded49921b371bfc62c Mon Sep 17 00:00:00 2001 From: pap Date: Fri, 9 Jan 2026 18:35:43 +0000 Subject: [PATCH 09/23] adding appexitinfo hint codex resume if set --- codex-rs/cli/src/main.rs | 30 +++++++++++--- codex-rs/core/src/util.rs | 16 ++++++++ codex-rs/tui/src/app.rs | 41 ++++++++++++++++--- codex-rs/tui/src/chatwidget.rs | 4 ++ codex-rs/tui/src/lib.rs | 4 ++ ...ched_limits_hide_credits_without_flag.snap | 1 + ..._snapshot_includes_credits_and_limits.snap | 1 + ...tatus_snapshot_includes_monthly_limit.snap | 1 + ...s_snapshot_includes_reasoning_details.snap | 1 + ...s_snapshot_shows_empty_limits_message.snap | 1 + ...snapshot_shows_missing_limits_message.snap | 1 + ...s_snapshot_shows_stale_limits_message.snap | 1 + ...snapshot_truncates_in_narrow_terminal.snap | 1 + codex-rs/tui2/src/app.rs | 32 +++++++++++++-- codex-rs/tui2/src/chatwidget.rs | 4 ++ codex-rs/tui2/src/lib.rs | 4 ++ ...ched_limits_hide_credits_without_flag.snap | 1 + ..._snapshot_includes_credits_and_limits.snap | 1 + ...tatus_snapshot_includes_monthly_limit.snap | 1 + ...s_snapshot_includes_reasoning_details.snap | 1 + ...s_snapshot_shows_empty_limits_message.snap | 1 + ...snapshot_shows_missing_limits_message.snap | 1 + ...s_snapshot_shows_stale_limits_message.snap | 1 + ...snapshot_truncates_in_narrow_terminal.snap | 1 + 24 files changed, 136 insertions(+), 15 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 985c939293f..a23dfce4b3f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -286,6 +286,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec) -> AppExitInfo { + fn sample_exit_info(conversation: Option<&str>, session_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, total_tokens: 2, @@ -832,6 +834,7 @@ mod tests { AppExitInfo { token_usage, thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap), + session_name: session_name.map(str::to_string), update_action: None, } } @@ -841,6 +844,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + session_name: None, update_action: None, }; let lines = format_exit_messages(exit_info, false); @@ -849,7 +853,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, @@ -863,12 +867,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_session_name() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + Some("my-session"), + ); + 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-session".to_string(), + ] + ); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index a100f284437..849f78ac7c1 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,14 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { } } +pub fn resume_command(session_name: Option<&str>, thread_id: Option) -> Option { + let resume_target = session_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| format!("codex resume {target}")) +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +116,11 @@ 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-session"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-session".to_string())); + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8178108a7ea..3fd6e73961d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,16 +75,21 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, + pub session_name: Option, pub update_action: Option, } -fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Option { +fn session_summary( + token_usage: TokenUsage, + thread_id: Option, + session_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(session_name.as_deref(), thread_id); Some(SessionSummary { usage_line, resume_command, @@ -276,6 +281,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + session_name: None, update_action: None, }); } @@ -495,6 +501,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), + session_name: app.chat_widget.session_name(), update_action: app.pending_update_action, }) } @@ -555,8 +562,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.session_name(), + ); self.shutdown_current_thread().await; let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), @@ -596,6 +606,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), + self.chat_widget.session_name(), ); match self .server @@ -1778,7 +1789,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] @@ -1791,7 +1802,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" @@ -1801,4 +1812,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 01c96453a6e..c7a450bd6b0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3913,6 +3913,10 @@ impl ChatWidget { self.thread_id } + pub(crate) fn session_name(&self) -> Option { + self.session_name.clone() + } + pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2d73d2cc621..c452bf1cac7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -374,6 +374,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + session_name: None, update_action: Some(action), }); } @@ -413,6 +414,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + session_name: None, update_action: None, }); } @@ -453,6 +455,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + session_name: None, update_action: None, }); } @@ -491,6 +494,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + session_name: None, update_action: None, }); } 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..ec1f33d4896 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: │ +│ Session 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..b2e5155c836 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: │ +│ Session 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..d560a8c6868 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: │ +│ Session 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..a583d4cb754 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: │ +│ Session 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..f2318177619 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: │ +│ Session 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..f2318177619 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: │ +│ Session 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..0c2f706262d 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: │ +│ Session 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..3f75db20ce1 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: │ +│ Session 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/app.rs b/codex-rs/tui2/src/app.rs index 36b80a19ab6..e68813e5829 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -96,6 +96,7 @@ use crate::history_cell::UpdateAvailableHistoryCell; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub session_name: Option, pub update_action: Option, /// ANSI-styled transcript lines to print after the TUI exits. /// @@ -110,6 +111,7 @@ impl From for codex_tui::AppExitInfo { codex_tui::AppExitInfo { token_usage: info.token_usage, thread_id: info.conversation_id, + session_name: info.session_name, update_action: info.update_action.map(Into::into), } } @@ -118,14 +120,14 @@ impl From for codex_tui::AppExitInfo { fn session_summary( token_usage: TokenUsage, conversation_id: Option, + session_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(session_name.as_deref(), conversation_id); Some(SessionSummary { usage_line, resume_command, @@ -313,6 +315,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + session_name: None, update_action: None, session_lines: Vec::new(), }); @@ -600,6 +603,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + session_name: app.chat_widget.session_name(), update_action: app.pending_update_action, session_lines, }) @@ -1355,6 +1359,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.session_name(), ); self.shutdown_current_conversation().await; let init = crate::chatwidget::ChatWidgetInit { @@ -1394,6 +1399,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.session_name(), ); match self .server @@ -2809,7 +2815,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] @@ -2843,7 +2849,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" @@ -2853,4 +2859,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 9a7b4e1f4c1..25ebf1ae7d2 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3675,6 +3675,10 @@ impl ChatWidget { self.conversation_id } + pub(crate) fn session_name(&self) -> Option { + self.session_name.clone() + } + pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 9db513f4a6d..dfcf0c20713 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -392,6 +392,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + session_name: None, update_action: Some(action), session_lines: Vec::new(), }); @@ -432,6 +433,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + session_name: None, update_action: None, session_lines: Vec::new(), }); @@ -473,6 +475,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + session_name: None, update_action: None, session_lines: Vec::new(), }); @@ -512,6 +515,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + session_name: None, update_action: None, session_lines: Vec::new(), }); 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..302bdd627b6 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: │ +│ Session 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..0a682a3f6c7 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: │ +│ Session 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..c08989f412e 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: │ +│ Session 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..ae1ec97ed32 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: │ +│ Session 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..890c1cce564 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: │ +│ Session 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..890c1cce564 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: │ +│ Session 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..7b6e3dc6bdf 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: │ +│ Session 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..b307306019d 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: │ +│ Session name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ From 32d4278893f94c7d3723fd7055d029154b4ef3d7 Mon Sep 17 00:00:00 2001 From: pap Date: Mon, 12 Jan 2026 22:43:31 +0000 Subject: [PATCH 10/23] uses thread_name instead of session_name --- codex-rs/cli/src/main.rs | 22 ++++---- codex-rs/core/src/codex.rs | 50 +++++++++---------- codex-rs/core/src/rollout/list.rs | 2 +- codex-rs/core/src/rollout/recorder.rs | 30 +++++------ codex-rs/core/src/state/session.rs | 6 +-- codex-rs/core/src/util.rs | 8 +-- codex-rs/docs/protocol_v1.md | 2 +- codex-rs/exec/src/cli.rs | 2 +- .../tests/event_processor_with_json_output.rs | 2 +- codex-rs/mcp-server/src/outgoing_message.rs | 4 +- codex-rs/protocol/src/protocol.rs | 14 +++--- codex-rs/tui/src/app.rs | 18 +++---- codex-rs/tui/src/chatwidget.rs | 26 +++++----- codex-rs/tui/src/chatwidget/tests.rs | 4 +- codex-rs/tui/src/lib.rs | 8 +-- codex-rs/tui/src/slash_command.rs | 2 +- codex-rs/tui/src/status/card.rs | 16 +++--- ...ched_limits_hide_credits_without_flag.snap | 2 +- ..._snapshot_includes_credits_and_limits.snap | 2 +- ...tatus_snapshot_includes_monthly_limit.snap | 2 +- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...s_snapshot_shows_empty_limits_message.snap | 2 +- ...snapshot_shows_missing_limits_message.snap | 2 +- ...s_snapshot_shows_stale_limits_message.snap | 2 +- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- codex-rs/tui2/src/app.rs | 20 ++++---- codex-rs/tui2/src/chatwidget.rs | 26 +++++----- codex-rs/tui2/src/chatwidget/tests.rs | 4 +- codex-rs/tui2/src/lib.rs | 8 +-- codex-rs/tui2/src/slash_command.rs | 2 +- codex-rs/tui2/src/status/card.rs | 16 +++--- ...ched_limits_hide_credits_without_flag.snap | 2 +- ..._snapshot_includes_credits_and_limits.snap | 2 +- ...tatus_snapshot_includes_monthly_limit.snap | 2 +- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...s_snapshot_shows_empty_limits_message.snap | 2 +- ...snapshot_shows_missing_limits_message.snap | 2 +- ...s_snapshot_shows_stale_limits_message.snap | 2 +- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- 39 files changed, 162 insertions(+), 162 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index a23dfce4b3f..02fb4c8a187 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) or session name. UUIDs take precedence if it parses. + /// 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, @@ -286,7 +286,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec, session_name: Option<&str>) -> AppExitInfo { + fn sample_exit_info(conversation: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, total_tokens: 2, @@ -834,7 +834,7 @@ mod tests { AppExitInfo { token_usage, thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap), - session_name: session_name.map(str::to_string), + thread_name: thread_name.map(str::to_string), update_action: None, } } @@ -844,7 +844,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: None, }; let lines = format_exit_messages(exit_info, false); @@ -859,7 +859,7 @@ mod tests { lines, vec![ "Token usage: total=2 input=0 output=2".to_string(), - "To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" + "To continue this thread, run codex resume 123e4567-e89b-12d3-a456-426614174000" .to_string(), ] ); @@ -874,17 +874,17 @@ mod tests { } #[test] - fn format_exit_messages_prefers_session_name() { + fn format_exit_messages_prefers_thread_name() { let exit_info = sample_exit_info( Some("123e4567-e89b-12d3-a456-426614174000"), - Some("my-session"), + 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-session".to_string(), + "To continue this thread, run codex resume my-thread".to_string(), ] ); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 848ea5963f6..6c57ae4b859 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -681,8 +681,8 @@ impl Session { .await .map(Arc::new); } - let session_name = Self::session_name_from_rollout(&initial_history.get_rollout_items()); - let state = SessionState::new(session_configuration.clone(), session_name.clone()); + let thread_name = Self::thread_name_from_rollout(&initial_history.get_rollout_items()); + let state = SessionState::new(session_configuration.clone(), thread_name.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -719,7 +719,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - session_name, + thread_name, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -867,7 +867,7 @@ impl Session { }) } - fn session_name_from_rollout(rollout_items: &[RolloutItem]) -> Option { + fn thread_name_from_rollout(rollout_items: &[RolloutItem]) -> Option { rollout_items.iter().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.name.clone(), _ => None, @@ -1718,8 +1718,8 @@ 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::SetSessionName { name } => { - handlers::set_session_name(&sess, sub.id.clone(), name).await; + Op::SetThreadName { name } => { + handlers::set_thread_name(&sess, sub.id.clone(), name).await; } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( @@ -2134,13 +2134,13 @@ mod handlers { .await; } - pub async fn set_session_name(sess: &Arc, sub_id: String, name: String) { + 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: "Session name cannot be empty.".to_string(), + message: "Thread name cannot be empty.".to_string(), codex_error_info: Some(CodexErrorInfo::BadRequest), }), }; @@ -2148,11 +2148,11 @@ mod handlers { return; } - if !is_valid_session_name(&name) { + if !is_valid_thread_name(&name) { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { - message: "Session name must use only letters, numbers, '.', '_' or '-', and cannot start with '-'." + message: "Thread name must use only letters, numbers, '.', '_' or '-', and cannot start with '-'." .to_string(), codex_error_info: Some(CodexErrorInfo::BadRequest), }), @@ -2178,11 +2178,11 @@ mod handlers { }; let name_for_recorder = name.clone(); - if let Err(e) = recorder.set_session_name(name_for_recorder).await { + if let Err(e) = recorder.set_thread_name(name_for_recorder).await { let event = Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { - message: format!("Failed to set session name: {e}"), + message: format!("Failed to set thread name: {e}"), codex_error_info: Some(CodexErrorInfo::Other), }), }; @@ -2192,33 +2192,33 @@ mod handlers { { let mut state = sess.state.lock().await; - state.session_name = Some(name.clone()); + state.thread_name = Some(name.clone()); } sess.send_event_raw(Event { id: sub_id, msg: EventMsg::SessionMetaUpdated(SessionMetaUpdatedEvent { session_id: sess.conversation_id, - session_name: Some(name), + thread_name: Some(name), }), }) .await; } - fn is_valid_session_name(name: &str) -> bool { + fn is_valid_thread_name(name: &str) -> bool { let mut chars = name.chars(); let Some(first) = chars.next() else { return false; }; - if first == '-' || !is_valid_session_name_char(first) { + if first == '-' || !is_valid_thread_name_char(first) { return false; } - chars.all(is_valid_session_name_char) + chars.all(is_valid_thread_name_char) } - fn is_valid_session_name_char(ch: char) -> bool { + fn is_valid_thread_name_char(ch: char) -> bool { matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-') } @@ -2228,13 +2228,13 @@ mod handlers { use pretty_assertions::assert_eq; #[test] - fn validates_session_name_tokens() { - assert_eq!(is_valid_session_name("my-session"), true); - assert_eq!(is_valid_session_name("Alpha_1.2"), true); - assert_eq!(is_valid_session_name("-starts-with-dash"), false); - assert_eq!(is_valid_session_name("has space"), false); - assert_eq!(is_valid_session_name("slash/name"), false); - assert_eq!(is_valid_session_name(""), false); + fn validates_thread_name_tokens() { + assert_eq!(is_valid_thread_name("my-thread"), true); + assert_eq!(is_valid_thread_name("Alpha_1.2"), true); + assert_eq!(is_valid_thread_name("-starts-with-dash"), false); + assert_eq!(is_valid_thread_name("has space"), false); + assert_eq!(is_valid_thread_name("slash/name"), false); + assert_eq!(is_valid_thread_name(""), false); } } diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3ae767be9d9..2f9313e352f 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -505,7 +505,7 @@ async fn read_first_session_meta_name(path: &Path) -> io::Result> Ok(None) } -/// Locate a recorded thread rollout file by session name using newest-first ordering. +/// 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, diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index d6e979a0f1b..4004e105539 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -68,7 +68,7 @@ enum RolloutCmd { ack: oneshot::Sender<()>, }, /// Rewrite the first SessionMeta line in the rollout file to include a name. - SetSessionName { + SetThreadName { name: String, ack: oneshot::Sender>, }, @@ -213,14 +213,14 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) } - pub async fn set_session_name(&self, name: String) -> std::io::Result<()> { + pub async fn set_thread_name(&self, name: String) -> std::io::Result<()> { let (tx, rx) = oneshot::channel(); self.tx - .send(RolloutCmd::SetSessionName { name, ack: tx }) + .send(RolloutCmd::SetThreadName { name, ack: tx }) .await - .map_err(|e| IoError::other(format!("failed to queue session name update: {e}")))?; + .map_err(|e| IoError::other(format!("failed to queue thread name update: {e}")))?; rx.await - .map_err(|e| IoError::other(format!("failed waiting for session name update: {e}")))? + .map_err(|e| IoError::other(format!("failed waiting for thread name update: {e}")))? } pub async fn get_rollout_history(path: &Path) -> std::io::Result { @@ -400,8 +400,8 @@ async fn rollout_writer( } let _ = ack.send(()); } - RolloutCmd::SetSessionName { name, ack } => { - let result = rewrite_session_name(&mut writer, &rollout_path, &name).await; + RolloutCmd::SetThreadName { name, ack } => { + let result = rewrite_thread_name(&mut writer, &rollout_path, &name).await; let _ = ack.send(result); } RolloutCmd::Shutdown { ack } => { @@ -413,7 +413,7 @@ async fn rollout_writer( Ok(()) } -async fn rewrite_session_name( +async fn rewrite_thread_name( writer: &mut JsonlWriter, rollout_path: &Path, name: &str, @@ -479,7 +479,7 @@ async fn rewrite_first_session_meta_line_name( if !rewritten { return Err(IoError::other( - "failed to set session name: rollout has no SessionMeta line", + "failed to set thread name: rollout has no SessionMeta line", )); } @@ -490,7 +490,7 @@ fn rewrite_session_meta_line_name(line: &str, name: &str) -> std::io::Result(line).map_err(IoError::other)?; let RolloutItem::SessionMeta(meta_line) = &mut rollout_line.item else { return Err(IoError::other( - "failed to set session name: rollout has no SessionMeta line", + "failed to set thread name: rollout has no SessionMeta line", )); }; @@ -561,7 +561,7 @@ mod tests { use tokio::io::AsyncWriteExt; #[tokio::test] - async fn set_session_name_rewrites_first_session_meta_line() -> std::io::Result<()> { + async fn set_thread_name_rewrites_first_session_meta_line() -> std::io::Result<()> { let config = crate::config::test_config(); let conversation_id = ThreadId::new(); @@ -571,7 +571,7 @@ mod tests { ) .await?; - recorder.set_session_name("my-session".to_string()).await?; + recorder.set_thread_name("my-thread".to_string()).await?; let text = tokio::fs::read_to_string(&recorder.rollout_path).await?; let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); @@ -579,12 +579,12 @@ mod tests { let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { panic!("expected SessionMeta as first rollout line"); }; - assert_eq!(meta_line.meta.name.as_deref(), Some("my-session")); + assert_eq!(meta_line.meta.name.as_deref(), Some("my-thread")); Ok(()) } #[tokio::test] - async fn set_session_name_failure_does_not_redirect_future_writes() -> std::io::Result<()> { + async fn set_thread_name_failure_does_not_redirect_future_writes() -> std::io::Result<()> { let dir = tempfile::tempdir()?; let rollout_path = dir.path().join("rollout.jsonl"); @@ -598,7 +598,7 @@ mod tests { let mut writer = JsonlWriter { file }; assert!( - rewrite_session_name(&mut writer, &rollout_path, "name") + rewrite_thread_name(&mut writer, &rollout_path, "name") .await .is_err() ); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index eff0f7f6665..4012e653acf 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -14,21 +14,21 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, - pub(crate) session_name: 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, - session_name: Option, + thread_name: Option, ) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, - session_name, + thread_name, } } diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 849f78ac7c1..60efa4691b5 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -73,8 +73,8 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { } } -pub fn resume_command(session_name: Option<&str>, thread_id: Option) -> Option { - let resume_target = session_name +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())); @@ -120,7 +120,7 @@ mod tests { #[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-session"), Some(thread_id)); - assert_eq!(command, Some("codex resume my-session".to_string())); + let command = resume_command(Some("my-thread"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-thread".to_string())); } } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 1257c3c0026..57c712334c9 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,7 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `session_name` when available, and clients may receive `Event::SessionMetaUpdated` when the session name changes. When unset, `session_name` is omitted (not sent as `null`). Session names are shell-safe tokens: they may include letters, numbers, `.`, `_`, or `-`, and cannot start with `-`. +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` when the thread name changes. When unset, `thread_name` is omitted (not sent as `null`). Thread names are shell-safe tokens: they may include letters, numbers, `.`, `_`, or `-`, and cannot start with `-`. ### Task Interrupt diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0638347e0e5..48efe224d9d 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) or session name. UUIDs take precedence if it parses. + /// 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/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5d87b059d86..2c8ff10df79 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -75,7 +75,7 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, - session_name: 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/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 7f9c356c851..abadc7d0366 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,7 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - session_name: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -297,7 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, - session_name: 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 d18f8834d0e..65a7ff908db 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -207,10 +207,10 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, - /// Set a user-facing session name in the persisted rollout metadata. + /// 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. - SetSessionName { name: String }, + SetThreadName { name: String }, /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). Undo, @@ -591,7 +591,7 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), - /// Updated session metadata (e.g., session name changes). + /// Updated session metadata (e.g., thread name changes). SessionMetaUpdated(SessionMetaUpdatedEvent), /// Incremental MCP startup progress updates. @@ -1828,10 +1828,10 @@ pub struct SessionConfiguredEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, - /// Optional user-facing session name (may be unset). + /// Optional user-facing thread name (may be unset). #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub session_name: Option, + pub thread_name: Option, /// Tell the client what model is being queried. pub model: String, @@ -1872,7 +1872,7 @@ pub struct SessionMetaUpdatedEvent { pub session_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - pub session_name: Option, + pub thread_name: Option, } /// User's decision in response to an ExecApprovalRequest. @@ -2061,7 +2061,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - session_name: 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/src/app.rs b/codex-rs/tui/src/app.rs index 3fd6e73961d..7afe666e82e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,21 +75,21 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, - pub session_name: Option, + pub thread_name: Option, pub update_action: Option, } fn session_summary( token_usage: TokenUsage, thread_id: Option, - session_name: Option, + thread_name: Option, ) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = codex_core::util::resume_command(session_name.as_deref(), thread_id); + let resume_command = codex_core::util::resume_command(thread_name.as_deref(), thread_id); Some(SessionSummary { usage_line, resume_command, @@ -281,7 +281,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: None, }); } @@ -501,7 +501,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), - session_name: app.chat_widget.session_name(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, }) } @@ -565,7 +565,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), - self.chat_widget.session_name(), + self.chat_widget.thread_name(), ); self.shutdown_current_thread().await; let init = crate::chatwidget::ChatWidgetInit { @@ -606,7 +606,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), - self.chat_widget.session_name(), + self.chat_widget.thread_name(), ); match self .server @@ -1701,7 +1701,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), - session_name: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -1757,7 +1757,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, - session_name: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c7a450bd6b0..1d1753d909c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -349,7 +349,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, - session_name: Option, + thread_name: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -441,7 +441,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.session_name = event.session_name.clone(); + 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(); @@ -471,7 +471,7 @@ impl ChatWidget { fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { if self.thread_id == Some(event.session_id) { - self.session_name = event.session_name; + self.thread_name = event.thread_name; } self.request_redraw(); } @@ -1477,7 +1477,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, - session_name: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1564,7 +1564,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, - session_name: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1909,9 +1909,9 @@ impl ChatWidget { match cmd { SlashCommand::Rename if !trimmed.is_empty() => { let name = trimmed.to_string(); - self.add_info_message(format!("Session renamed to \"{name}\""), None); + self.add_info_message(format!("Thread renamed to \"{name}\""), None); self.app_event_tx - .send(AppEvent::CodexOp(Op::SetSessionName { name })); + .send(AppEvent::CodexOp(Op::SetThreadName { name })); } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { @@ -1930,14 +1930,14 @@ impl ChatWidget { fn show_rename_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( - "Rename session".to_string(), + "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!("Session renamed to \"{name}\""), None), + history_cell::new_info_event(format!("Thread renamed to \"{name}\""), None), ))); - tx.send(AppEvent::CodexOp(Op::SetSessionName { name })); + tx.send(AppEvent::CodexOp(Op::SetThreadName { name })); }), ); @@ -2368,7 +2368,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, - self.session_name.clone(), + self.thread_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), @@ -3913,8 +3913,8 @@ impl ChatWidget { self.thread_id } - pub(crate) fn session_name(&self) -> Option { - self.session_name.clone() + pub(crate) fn thread_name(&self) -> Option { + self.thread_name.clone() } pub(crate) fn rollout_path(&self) -> Option { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 484f1a85076..096ceae94a2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -123,7 +123,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - session_name: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -415,7 +415,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, - session_name: 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 c452bf1cac7..1362a1e5e29 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -374,7 +374,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: Some(action), }); } @@ -414,7 +414,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: None, }); } @@ -455,7 +455,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: None, }); } @@ -494,7 +494,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, - session_name: None, + thread_name: None, update_action: None, }); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 49c4d412d79..2a6a1829bc4 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -47,7 +47,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 session", + SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 9869c762931..e7dc833f0bd 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -63,7 +63,7 @@ struct StatusHistoryCell { sandbox: String, agents_summary: String, account: Option, - session_name: Option, + thread_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -76,7 +76,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, - session_name: Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,7 +89,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, - session_name, + thread_name, rate_limits, plan_type, now, @@ -107,7 +107,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, - session_name: Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -162,7 +162,7 @@ impl StatusHistoryCell { sandbox, agents_summary, account, - session_name, + thread_name, session_id, token_usage, rate_limits, @@ -346,7 +346,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } - push_label(&mut labels, &mut seen, "Session name"); + push_label(&mut labels, &mut seen, "Thread name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -396,8 +396,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } - let session_name = self.session_name.as_deref().unwrap_or(""); - lines.push(formatter.line("Session name", vec![Span::from(session_name.to_string())])); + 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 ec1f33d4896..9cfd49871d3 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 b2e5155c836..37987220777 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 d560a8c6868..e81452168b7 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 a583d4cb754..b3cecdcafc9 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ -│ Session name: │ +│ 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 f2318177619..39e047ff194 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 f2318177619..39e047ff194 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 0c2f706262d..e0655046f3a 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 3f75db20ce1..7cce9f9074e 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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/app.rs b/codex-rs/tui2/src/app.rs index e68813e5829..e6a217bee92 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -96,7 +96,7 @@ use crate::history_cell::UpdateAvailableHistoryCell; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, - pub session_name: Option, + pub thread_name: Option, pub update_action: Option, /// ANSI-styled transcript lines to print after the TUI exits. /// @@ -111,7 +111,7 @@ impl From for codex_tui::AppExitInfo { codex_tui::AppExitInfo { token_usage: info.token_usage, thread_id: info.conversation_id, - session_name: info.session_name, + thread_name: info.thread_name, update_action: info.update_action.map(Into::into), } } @@ -120,14 +120,14 @@ impl From for codex_tui::AppExitInfo { fn session_summary( token_usage: TokenUsage, conversation_id: Option, - session_name: Option, + thread_name: Option, ) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = codex_core::util::resume_command(session_name.as_deref(), conversation_id); + let resume_command = codex_core::util::resume_command(thread_name.as_deref(), conversation_id); Some(SessionSummary { usage_line, resume_command, @@ -315,7 +315,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, - session_name: None, + thread_name: None, update_action: None, session_lines: Vec::new(), }); @@ -603,7 +603,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), - session_name: app.chat_widget.session_name(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, session_lines, }) @@ -1359,7 +1359,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), - self.chat_widget.session_name(), + self.chat_widget.thread_name(), ); self.shutdown_current_conversation().await; let init = crate::chatwidget::ChatWidgetInit { @@ -1399,7 +1399,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), - self.chat_widget.session_name(), + self.chat_widget.thread_name(), ); match self .server @@ -2487,7 +2487,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), - session_name: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2783,7 +2783,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, - session_name: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 25ebf1ae7d2..6e213fe8c84 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -316,7 +316,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, - session_name: Option, + thread_name: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -407,7 +407,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.session_name = event.session_name.clone(); + 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(); @@ -437,7 +437,7 @@ impl ChatWidget { fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { if self.conversation_id == Some(event.session_id) { - self.session_name = event.session_name; + self.thread_name = event.thread_name; } self.request_redraw(); } @@ -1339,7 +1339,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, - session_name: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1424,7 +1424,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, - session_name: None, + thread_name: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1737,9 +1737,9 @@ impl ChatWidget { match cmd { SlashCommand::Rename if !trimmed.is_empty() => { let name = trimmed.to_string(); - self.add_info_message(format!("Session renamed to \"{name}\""), None); + self.add_info_message(format!("Thread renamed to \"{name}\""), None); self.app_event_tx - .send(AppEvent::CodexOp(Op::SetSessionName { name })); + .send(AppEvent::CodexOp(Op::SetThreadName { name })); } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { @@ -1758,14 +1758,14 @@ impl ChatWidget { fn show_rename_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( - "Rename session".to_string(), + "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!("Session renamed to \"{name}\""), None), + history_cell::new_info_event(format!("Thread renamed to \"{name}\""), None), ))); - tx.send(AppEvent::CodexOp(Op::SetSessionName { name })); + tx.send(AppEvent::CodexOp(Op::SetThreadName { name })); }), ); @@ -2174,7 +2174,7 @@ impl ChatWidget { token_info, total_usage, &self.conversation_id, - self.session_name.clone(), + self.thread_name.clone(), self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), @@ -3675,8 +3675,8 @@ impl ChatWidget { self.conversation_id } - pub(crate) fn session_name(&self) -> Option { - self.session_name.clone() + pub(crate) fn thread_name(&self) -> Option { + self.thread_name.clone() } pub(crate) fn rollout_path(&self) -> Option { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 900a3fe035a..6c1e9912148 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -112,7 +112,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - session_name: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -403,7 +403,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, - session_name: 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 dfcf0c20713..28de2ab4b22 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -392,7 +392,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, - session_name: None, + thread_name: None, update_action: Some(action), session_lines: Vec::new(), }); @@ -433,7 +433,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, - session_name: None, + thread_name: None, update_action: None, session_lines: Vec::new(), }); @@ -475,7 +475,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, - session_name: None, + thread_name: None, update_action: None, session_lines: Vec::new(), }); @@ -515,7 +515,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, - session_name: None, + thread_name: None, update_action: None, session_lines: Vec::new(), }); diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index 218d960b4bd..ab9c257448a 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -45,7 +45,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 session", + SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index e60fbd17f13..21f48ea4d01 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -63,7 +63,7 @@ struct StatusHistoryCell { sandbox: String, agents_summary: String, account: Option, - session_name: Option, + thread_name: Option, session_id: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, @@ -76,7 +76,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, - session_name: Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,7 +89,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, - session_name, + thread_name, rate_limits, plan_type, now, @@ -107,7 +107,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, - session_name: Option, + thread_name: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -162,7 +162,7 @@ impl StatusHistoryCell { sandbox, agents_summary, account, - session_name, + thread_name, session_id, token_usage, rate_limits, @@ -346,7 +346,7 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } - push_label(&mut labels, &mut seen, "Session name"); + push_label(&mut labels, &mut seen, "Thread name"); if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -395,8 +395,8 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } - let session_name = self.session_name.as_deref().unwrap_or(""); - lines.push(formatter.line("Session name", vec![Span::from(session_name.to_string())])); + 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 302bdd627b6..7bda415607d 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 0a682a3f6c7..75221ab7edf 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 c08989f412e..f82b913874a 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 ae1ec97ed32..1799ae093ae 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ -│ Session name: │ +│ 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 890c1cce564..935acc48baf 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 890c1cce564..935acc48baf 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 7b6e3dc6bdf..c0bbcd193ca 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ 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 b307306019d..08394ab92aa 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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Session name: │ +│ Thread name: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ From aa4d0621ca5b168742c37e2d4361da2d1ccc0161 Mon Sep 17 00:00:00 2001 From: pap Date: Mon, 12 Jan 2026 22:56:50 +0000 Subject: [PATCH 11/23] adds shell escaping for exit hint info instead of restrictive session name --- codex-rs/core/src/codex.rs | 46 --------------------------------- codex-rs/core/src/util.rs | 49 +++++++++++++++++++++++++++++++++++- codex-rs/docs/protocol_v1.md | 2 +- 3 files changed, 49 insertions(+), 48 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6c57ae4b859..a8234af477d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2148,19 +2148,6 @@ mod handlers { return; } - if !is_valid_thread_name(&name) { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: "Thread name must use only letters, numbers, '.', '_' or '-', and cannot start with '-'." - .to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }; - sess.send_event_raw(event).await; - return; - } - let recorder = { let guard = sess.services.rollout.lock().await; guard.clone() @@ -2205,39 +2192,6 @@ mod handlers { .await; } - fn is_valid_thread_name(name: &str) -> bool { - let mut chars = name.chars(); - let Some(first) = chars.next() else { - return false; - }; - - if first == '-' || !is_valid_thread_name_char(first) { - return false; - } - - chars.all(is_valid_thread_name_char) - } - - fn is_valid_thread_name_char(ch: char) -> bool { - matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-') - } - - #[cfg(test)] - mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn validates_thread_name_tokens() { - assert_eq!(is_valid_thread_name("my-thread"), true); - assert_eq!(is_valid_thread_name("Alpha_1.2"), true); - assert_eq!(is_valid_thread_name("-starts-with-dash"), false); - assert_eq!(is_valid_thread_name("has space"), false); - assert_eq!(is_valid_thread_name("slash/name"), false); - assert_eq!(is_valid_thread_name(""), false); - } - } - pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 60efa4691b5..f8302a08568 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -78,7 +78,36 @@ pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> .filter(|name| !name.is_empty()) .map(str::to_string) .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); - resume_target.map(|target| format!("codex resume {target}")) + 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(|ch| ch.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)] @@ -123,4 +152,22 @@ mod tests { let command = resume_command(Some("my-thread"), Some(thread_id)); assert_eq!(command, Some("codex resume my-thread".to_string())); } + + #[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/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 57c712334c9..6dbbe627efe 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,7 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` when the thread name changes. When unset, `thread_name` is omitted (not sent as `null`). Thread names are shell-safe tokens: they may include letters, numbers, `.`, `_`, or `-`, and cannot start with `-`. +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` when the thread name changes. When unset, `thread_name` is omitted (not sent as `null`). Thread names accept any non-empty string. ### Task Interrupt From 3186b1735199ed9c2bbe8f96336970c90ab132b8 Mon Sep 17 00:00:00 2001 From: pap Date: Mon, 12 Jan 2026 23:51:56 +0000 Subject: [PATCH 12/23] moving to index file instead of sessionmeta --- codex-rs/core/src/codex.rs | 40 +-- codex-rs/core/src/rollout/list.rs | 50 +--- codex-rs/core/src/rollout/mod.rs | 1 + codex-rs/core/src/rollout/recorder.rs | 192 +------------- codex-rs/core/src/rollout/session_index.rs | 243 ++++++++++++++++++ codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/util.rs | 2 +- .../core/tests/suite/rollout_list_find.rs | 63 ++--- codex-rs/docs/protocol_v1.md | 2 +- 9 files changed, 305 insertions(+), 290 deletions(-) create mode 100644 codex-rs/core/src/rollout/session_index.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a8234af477d..dab3832f9d6 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; @@ -681,10 +682,19 @@ impl Session { .await .map(Arc::new); } - let thread_name = Self::thread_name_from_rollout(&initial_history.get_rollout_items()); + 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: CancellationToken::new(), unified_exec_manager: UnifiedExecProcessManager::default(), @@ -867,13 +877,6 @@ impl Session { }) } - fn thread_name_from_rollout(rollout_items: &[RolloutItem]) -> Option { - rollout_items.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => meta_line.meta.name.clone(), - _ => None, - }) - } - pub(crate) async fn update_settings( &self, updates: SessionSettingsUpdate, @@ -2135,6 +2138,7 @@ mod handlers { } pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { + use crate::rollout::session_index; let name = name.trim().to_string(); if name.is_empty() { let event = Event { @@ -2148,15 +2152,12 @@ mod handlers { return; } - let recorder = { - let guard = sess.services.rollout.lock().await; - guard.clone() - }; - let Some(recorder) = recorder else { + 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 session.".to_string(), + message: "Session persistence is disabled; cannot rename thread.".to_string(), codex_error_info: Some(CodexErrorInfo::Other), }), }; @@ -2164,8 +2165,13 @@ mod handlers { return; }; - let name_for_recorder = name.clone(); - if let Err(e) = recorder.set_thread_name(name_for_recorder).await { + 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 { @@ -3580,6 +3586,7 @@ mod tests { 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: CancellationToken::new(), unified_exec_manager: UnifiedExecProcessManager::default(), @@ -3674,6 +3681,7 @@ mod tests { 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: CancellationToken::new(), unified_exec_manager: UnifiedExecProcessManager::default(), diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 2f9313e352f..9e6aa2820b1 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -14,6 +14,7 @@ use time::macros::format_description; use uuid::Uuid; use super::SESSIONS_SUBDIR; +use super::session_index::find_thread_id_by_name; use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; @@ -478,59 +479,16 @@ async fn file_modified_rfc3339(path: &Path) -> io::Result> { Ok(dt.format(&Rfc3339).ok()) } -async fn read_first_session_meta_name(path: &Path) -> io::Result> { - use tokio::io::AsyncBufReadExt; - - let file = tokio::fs::File::open(path).await?; - let reader = tokio::io::BufReader::new(file); - let mut lines = reader.lines(); - - while let Some(line) = lines.next_line().await? { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let parsed: Result = serde_json::from_str(trimmed); - let Ok(rollout_line) = parsed else { - return Ok(None); - }; - - return Ok(match rollout_line.item { - RolloutItem::SessionMeta(meta_line) => meta_line.meta.name, - _ => None, - }); - } - - Ok(None) -} - /// 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, ) -> io::Result> { - if name.trim().is_empty() { - return Ok(None); - } - - let mut root = codex_home.to_path_buf(); - root.push(SESSIONS_SUBDIR); - if !root.exists() { + let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else { return Ok(None); - } - - let (files, _reached_scan_cap) = newest_rollout_files(&root).await?; - for (_ts, _sid, path) in files.into_iter() { - if let Some(candidate) = read_first_session_meta_name(&path).await? - && candidate == name - { - return Ok(Some(path)); - } - } - - Ok(None) + }; + find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await } /// Locate a recorded thread rollout file by its UUID string using the existing diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index cb70d10fcdd..57a4a3f0b8c 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; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 4004e105539..9a32d7f357c 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -67,11 +67,6 @@ enum RolloutCmd { Flush { ack: oneshot::Sender<()>, }, - /// Rewrite the first SessionMeta line in the rollout file to include a name. - SetThreadName { - name: String, - ack: oneshot::Sender>, - }, Shutdown { ack: oneshot::Sender<()>, }, @@ -213,16 +208,6 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) } - pub async fn set_thread_name(&self, name: String) -> std::io::Result<()> { - let (tx, rx) = oneshot::channel(); - self.tx - .send(RolloutCmd::SetThreadName { name, ack: tx }) - .await - .map_err(|e| IoError::other(format!("failed to queue thread name update: {e}")))?; - rx.await - .map_err(|e| IoError::other(format!("failed waiting for thread name update: {e}")))? - } - pub async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; @@ -364,7 +349,7 @@ async fn rollout_writer( mut rx: mpsc::Receiver, mut meta: Option, cwd: std::path::PathBuf, - rollout_path: std::path::PathBuf, + _rollout_path: std::path::PathBuf, ) -> std::io::Result<()> { let mut writer = JsonlWriter { file }; @@ -400,10 +385,6 @@ async fn rollout_writer( } let _ = ack.send(()); } - RolloutCmd::SetThreadName { name, ack } => { - let result = rewrite_thread_name(&mut writer, &rollout_path, &name).await; - let _ = ack.send(result); - } RolloutCmd::Shutdown { ack } => { let _ = ack.send(()); } @@ -413,118 +394,6 @@ async fn rollout_writer( Ok(()) } -async fn rewrite_thread_name( - writer: &mut JsonlWriter, - rollout_path: &Path, - name: &str, -) -> std::io::Result<()> { - // Flush and close the writer's file handle before swapping the on-disk file, - // otherwise subsequent appends would keep writing to the old inode/handle. - writer.file.flush().await?; - - // Compute the rewritten contents first so any read/parse/legacy-format errors - // don't disturb the active writer handle. - let rewritten_contents = rewrite_first_session_meta_line_name(rollout_path, name).await?; - - // Close the active handle using a portable placeholder. - let placeholder = tokio::fs::File::from_std(tempfile::tempfile()?); - let old_file = std::mem::replace(&mut writer.file, placeholder); - drop(old_file); - - if let Err(e) = replace_rollout_file(rollout_path, rewritten_contents).await { - // Best-effort: ensure the writer keeps pointing at the rollout file, not the placeholder. - let reopened = tokio::fs::OpenOptions::new() - .append(true) - .create(true) - .open(rollout_path) - .await; - if let Ok(reopened) = reopened { - let placeholder = std::mem::replace(&mut writer.file, reopened); - drop(placeholder); - } - return Err(e); - } - - // Re-open the rollout for appends and drop the placeholder handle. - let reopened = tokio::fs::OpenOptions::new() - .append(true) - .open(rollout_path) - .await?; - let placeholder = std::mem::replace(&mut writer.file, reopened); - drop(placeholder); - - Ok(()) -} - -async fn rewrite_first_session_meta_line_name( - rollout_path: &Path, - name: &str, -) -> std::io::Result { - let text = tokio::fs::read_to_string(rollout_path).await?; - let mut rewritten = false; - - // Rewrite the first non-empty line only. Since 43809a454 ("Introduce rollout items", - // 2025-09-09), rollouts we write always start with a RolloutLine wrapping - // RolloutItem::SessionMeta(_). - let mut out = String::with_capacity(text.len() + 32); - for line in text.lines() { - if !rewritten && !line.trim().is_empty() { - out.push_str(&rewrite_session_meta_line_name(line, name)?); - rewritten = true; - } else { - out.push_str(line); - } - out.push('\n'); - } - - if !rewritten { - return Err(IoError::other( - "failed to set thread name: rollout has no SessionMeta line", - )); - } - - Ok(out) -} - -fn rewrite_session_meta_line_name(line: &str, name: &str) -> std::io::Result { - let mut rollout_line = serde_json::from_str::(line).map_err(IoError::other)?; - let RolloutItem::SessionMeta(meta_line) = &mut rollout_line.item else { - return Err(IoError::other( - "failed to set thread name: rollout has no SessionMeta line", - )); - }; - - meta_line.meta.name = Some(name.to_string()); - serde_json::to_string(&rollout_line).map_err(IoError::other) -} - -async fn replace_rollout_file(path: &Path, contents: String) -> std::io::Result<()> { - let Some(dir) = path.parent() else { - return Err(IoError::other("rollout path has no parent directory")); - }; - - let mut tmp = tempfile::NamedTempFile::new_in(dir)?; - use std::io::Write as _; - tmp.write_all(contents.as_bytes())?; - tmp.flush()?; - - let (_file, tmp_path) = tmp.keep()?; - drop(_file); - - #[cfg(windows)] - { - let _ = std::fs::remove_file(path); - std::fs::rename(&tmp_path, path)?; - } - - #[cfg(not(windows))] - { - std::fs::rename(&tmp_path, path)?; - } - - Ok(()) -} - struct JsonlWriter { file: tokio::fs::File, } @@ -552,62 +421,3 @@ impl JsonlWriter { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - use tokio::io::AsyncWriteExt; - - #[tokio::test] - async fn set_thread_name_rewrites_first_session_meta_line() -> std::io::Result<()> { - let config = crate::config::test_config(); - - let conversation_id = ThreadId::new(); - let recorder = RolloutRecorder::new( - &config, - RolloutRecorderParams::new(conversation_id, None, SessionSource::Cli), - ) - .await?; - - recorder.set_thread_name("my-thread".to_string()).await?; - - let text = tokio::fs::read_to_string(&recorder.rollout_path).await?; - let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); - let rollout_line: RolloutLine = serde_json::from_str(first_line)?; - let RolloutItem::SessionMeta(meta_line) = rollout_line.item else { - panic!("expected SessionMeta as first rollout line"); - }; - assert_eq!(meta_line.meta.name.as_deref(), Some("my-thread")); - Ok(()) - } - - #[tokio::test] - async fn set_thread_name_failure_does_not_redirect_future_writes() -> std::io::Result<()> { - let dir = tempfile::tempdir()?; - let rollout_path = dir.path().join("rollout.jsonl"); - - // Invalid JSON as the first non-empty line triggers a parse error in the rewrite step. - tokio::fs::write(&rollout_path, "{\n").await?; - - let file = tokio::fs::OpenOptions::new() - .append(true) - .open(&rollout_path) - .await?; - let mut writer = JsonlWriter { file }; - - assert!( - rewrite_thread_name(&mut writer, &rollout_path, "name") - .await - .is_err() - ); - - writer.file.write_all(b"AFTER\n").await?; - writer.file.flush().await?; - - let text = tokio::fs::read_to_string(&rollout_path).await?; - assert!(text.trim_end().ends_with("AFTER")); - Ok(()) - } -} 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..e7a9775561d --- /dev/null +++ b/codex-rs/core/src/rollout/session_index.rs @@ -0,0 +1,243 @@ +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)) +} + +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 2e4395956a5..f3bef21abf5 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: CancellationToken, pub(crate) unified_exec_manager: UnifiedExecProcessManager, diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index f8302a08568..ffa90b23a15 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -88,7 +88,7 @@ pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> } fn needs_shell_escaping(value: &str) -> bool { - value.starts_with('-') || value.chars().any(|ch| ch.is_whitespace()) || value.contains('\'') + value.starts_with('-') || value.chars().any(char::is_whitespace) || value.contains('\'') } fn shell_escape(value: &str) -> String { diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 82989432458..2e69244a58d 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -15,25 +15,19 @@ use uuid::Uuid; /// This test file covers the low-level "find rollout by X" helpers, so the /// minimal rollout writer lives here to keep the lookup tests concise. fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { - write_minimal_rollout(codex_home, "2024/01/01", "2024-01-01T00-00-00", id, None) + write_minimal_rollout(codex_home, "2024/01/01", "2024-01-01T00-00-00", id) } // Helper for name lookup tests: lets us create older/newer rollouts without // duplicating JSONL construction logic. -fn write_minimal_rollout( - codex_home: &Path, - subdir: &str, - filename_ts: &str, - id: Uuid, - name: Option<&str>, -) -> PathBuf { +fn write_minimal_rollout(codex_home: &Path, subdir: &str, filename_ts: &str, id: Uuid) -> PathBuf { let sessions = codex_home.join(format!("sessions/{subdir}")); std::fs::create_dir_all(&sessions).unwrap(); let file = sessions.join(format!("rollout-{filename_ts}-{id}.jsonl")); let mut f = std::fs::File::create(&file).unwrap(); // Minimal first line: session_meta with the id so content search can find it - let mut payload = serde_json::json!({ + let payload = serde_json::json!({ "id": id, "timestamp": "2024-01-01T00:00:00Z", "instructions": null, @@ -42,9 +36,6 @@ fn write_minimal_rollout( "cli_version": "test", "model_provider": "test-provider" }); - if let Some(name) = name { - payload["name"] = serde_json::Value::String(name.to_string()); - } writeln!( f, "{}", @@ -59,6 +50,21 @@ fn write_minimal_rollout( file } +fn append_session_index_entry(codex_home: &Path, id: Uuid, name: &str) { + let entry = serde_json::json!({ + "id": id, + "thread_name": name, + "updated_at": "2024-01-01T00:00:00Z" + }); + let path = codex_home.join("session_index.jsonl"); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{entry}").unwrap(); +} + #[tokio::test] async fn find_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); @@ -105,42 +111,29 @@ async fn find_ignores_granular_gitignore_rules() { #[tokio::test] async fn find_locates_rollout_file_by_name_latest_first() { // This test lives here because it verifies the core "find rollout by name" - // helper, including newest-first ordering and filename timestamp parsing. + // helper, including newest-first index lookup behavior. let home = TempDir::new().unwrap(); let name = "release-notes"; - let older = write_minimal_rollout( - home.path(), - "2024/01/01", - "2024-01-01T00-00-00", - Uuid::new_v4(), - Some(name), - ); - let newer = write_minimal_rollout( - home.path(), - "2024/01/02", - "2024-01-02T00-00-00", - Uuid::new_v4(), - Some(name), - ); + let older_id = Uuid::new_v4(); + let newer_id = Uuid::new_v4(); + let _older = write_minimal_rollout(home.path(), "2024/01/01", "2024-01-01T00-00-00", older_id); + let newer = write_minimal_rollout(home.path(), "2024/01/02", "2024-01-02T00-00-00", newer_id); + append_session_index_entry(home.path(), older_id, name); + append_session_index_entry(home.path(), newer_id, name); let found = find_thread_path_by_name_str(home.path(), name) .await .unwrap(); assert_eq!(found, Some(newer)); - assert_ne!(found, Some(older)); } #[tokio::test] async fn find_returns_none_for_unknown_name() { let home = TempDir::new().unwrap(); - write_minimal_rollout( - home.path(), - "2024/01/01", - "2024-01-01T00-00-00", - Uuid::new_v4(), - Some("known"), - ); + let id = Uuid::new_v4(); + write_minimal_rollout(home.path(), "2024/01/01", "2024-01-01T00-00-00", id); + append_session_index_entry(home.path(), id, "known"); let found = find_thread_path_by_name_str(home.path(), "missing") .await diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 6dbbe627efe..0592e6f5c62 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,7 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` when the thread name changes. When unset, `thread_name` is omitted (not sent as `null`). Thread names accept any non-empty string. +Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` 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 From 8d57df0fbad6af70a90ec65a073a1c64d8094850 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 00:09:36 +0000 Subject: [PATCH 13/23] removing name from SessionMeta --- codex-rs/app-server/src/bespoke_event_handling.rs | 1 - codex-rs/app-server/tests/common/rollout.rs | 1 - codex-rs/cli/src/main.rs | 6 +++--- codex-rs/core/src/rollout/recorder.rs | 4 +--- codex-rs/core/src/rollout/tests.rs | 1 - codex-rs/protocol/src/protocol.rs | 5 ----- 6 files changed, 4 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5435f72d5c1..d18a7878f47 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -115,7 +115,6 @@ pub(crate) async fn apply_bespoke_event_handling( ) .await; } - EventMsg::SessionMetaUpdated(_) => {} EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 40aab240bd6..b5829716af6 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -44,7 +44,6 @@ pub fn create_fake_rollout( id: conversation_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), - name: None, originator: "codex".to_string(), cli_version: "0.0.0".to_string(), instructions: None, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 02fb4c8a187..cda88355896 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -307,7 +307,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec, mut meta: Option, cwd: std::path::PathBuf, - _rollout_path: std::path::PathBuf, ) -> std::io::Result<()> { let mut writer = JsonlWriter { file }; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 51f842eecef..f7c13c70f8a 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -588,7 +588,6 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { timestamp: ts.to_string(), instructions: None, cwd: ".".into(), - name: None, originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65a7ff908db..fc40031667e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1299,8 +1299,6 @@ pub struct SessionMeta { pub id: ThreadId, pub timestamp: String, pub cwd: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, pub originator: String, pub cli_version: String, pub instructions: Option, @@ -1315,7 +1313,6 @@ impl Default for SessionMeta { id: ThreadId::default(), timestamp: String::new(), cwd: PathBuf::new(), - name: None, originator: String::new(), cli_version: String::new(), instructions: None, @@ -1825,7 +1822,6 @@ 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). @@ -1868,7 +1864,6 @@ pub struct SessionConfiguredEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionMetaUpdatedEvent { - /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] From d9c675bf4def7a860a86318bc4f826761ee9ddf3 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 00:29:37 +0000 Subject: [PATCH 14/23] ThreadNameUpdated instead of SessionMetaUpdated --- codex-rs/core/src/codex.rs | 4 ++-- codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/rollout/policy.rs | 2 +- .../core/tests/suite/rollout_list_find.rs | 19 +++++++++---------- codex-rs/docs/protocol_v1.md | 2 +- .../src/event_processor_with_human_output.rs | 2 +- .../src/event_processor_with_jsonl_output.rs | 2 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/protocol/src/protocol.rs | 4 ++-- codex-rs/tui/src/chatwidget.rs | 6 +++--- codex-rs/tui2/src/chatwidget.rs | 6 +++--- 11 files changed, 25 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dab3832f9d6..e1f03b3587a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1780,8 +1780,8 @@ mod handlers { use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; - use codex_protocol::protocol::SessionMetaUpdatedEvent; 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; @@ -2190,7 +2190,7 @@ mod handlers { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::SessionMetaUpdated(SessionMetaUpdatedEvent { + msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { session_id: sess.conversation_id, thread_name: Some(name), }), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 73bbf0e1152..e0bcfb7113f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -202,7 +202,7 @@ async fn forward_events( } => {} Event { id: _, - msg: EventMsg::SessionMetaUpdated(_), + msg: EventMsg::ThreadNameUpdated(_), } => {} Event { id, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 750a63e1920..8d9e79dcad9 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -58,7 +58,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) - | EventMsg::SessionMetaUpdated(_) + | EventMsg::ThreadNameUpdated(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::WebSearchBegin(_) diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 2e69244a58d..2bdb45134d3 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -27,22 +27,21 @@ fn write_minimal_rollout(codex_home: &Path, subdir: &str, filename_ts: &str, id: let file = sessions.join(format!("rollout-{filename_ts}-{id}.jsonl")); let mut f = std::fs::File::create(&file).unwrap(); // Minimal first line: session_meta with the id so content search can find it - let payload = serde_json::json!({ - "id": id, - "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, - "cwd": ".", - "originator": "test", - "cli_version": "test", - "model_provider": "test-provider" - }); writeln!( f, "{}", serde_json::json!({ "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", - "payload": payload + "payload": { + "id": id, + "timestamp": "2024-01-01T00:00:00Z", + "instructions": null, + "cwd": ".", + "originator": "test", + "cli_version": "test", + "model_provider": "test-provider" + } }) ) .unwrap(); diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0592e6f5c62..ecea0e1d481 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -133,7 +133,7 @@ sequenceDiagram ### Session Metadata -Sessions may include an optional user-facing name. `Event::SessionConfigured` can include `thread_name` when available, and clients may receive `Event::SessionMetaUpdated` 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. +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 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 bee47d8eccc..9d58eebcbd3 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -573,7 +573,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::WebSearchBegin(_) - | EventMsg::SessionMetaUpdated(_) + | 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 51994bc8c94..e1ac4949a8a 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -107,7 +107,7 @@ impl EventProcessorWithJsonOutput { pub fn collect_thread_events(&mut self, event: &Event) -> Vec { match &event.msg { EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), - EventMsg::SessionMetaUpdated(_) => Vec::new(), + EventMsg::ThreadNameUpdated(_) => Vec::new(), EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4ad896b9e2d..b99fb8f1932 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -253,7 +253,7 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } - EventMsg::SessionMetaUpdated(_) => { + EventMsg::ThreadNameUpdated(_) => { // Ignore session metadata updates in MCP tool runner. } EventMsg::AgentMessageDelta(_) => { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index fc40031667e..f28fb9ed4af 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -592,7 +592,7 @@ pub enum EventMsg { SessionConfigured(SessionConfiguredEvent), /// Updated session metadata (e.g., thread name changes). - SessionMetaUpdated(SessionMetaUpdatedEvent), + ThreadNameUpdated(ThreadNameUpdatedEvent), /// Incremental MCP startup progress updates. McpStartupUpdate(McpStartupUpdateEvent), @@ -1863,7 +1863,7 @@ pub struct SessionConfiguredEvent { } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SessionMetaUpdatedEvent { +pub struct ThreadNameUpdatedEvent { pub session_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d1753d909c..f3dddd5da7f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -469,7 +469,7 @@ impl ChatWidget { } } - fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.session_id) { self.thread_name = event.thread_name; } @@ -2095,7 +2095,7 @@ impl ChatWidget { for msg in events { if matches!( msg, - EventMsg::SessionConfigured(_) | EventMsg::SessionMetaUpdated(_) + EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) ) { continue; } @@ -2132,7 +2132,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), - EventMsg::SessionMetaUpdated(e) => self.on_session_meta_updated(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) diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 6e213fe8c84..6d40c935707 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -435,7 +435,7 @@ impl ChatWidget { } } - fn on_session_meta_updated(&mut self, event: codex_core::protocol::SessionMetaUpdatedEvent) { + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { if self.conversation_id == Some(event.session_id) { self.thread_name = event.thread_name; } @@ -1901,7 +1901,7 @@ impl ChatWidget { for msg in events { if matches!( msg, - EventMsg::SessionConfigured(_) | EventMsg::SessionMetaUpdated(_) + EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) ) { continue; } @@ -1938,7 +1938,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), - EventMsg::SessionMetaUpdated(e) => self.on_session_meta_updated(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) From 1e568e64239efb2775ac632816d21f6e5cc3d103 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 00:33:04 +0000 Subject: [PATCH 15/23] moving crate up --- codex-rs/core/src/codex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e1f03b3587a..39ae7e78919 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1766,6 +1766,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; @@ -2138,7 +2139,6 @@ mod handlers { } pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { - use crate::rollout::session_index; let name = name.trim().to_string(); if name.is_empty() { let event = Event { From b385eb807957482511c62e9f0dc0dde59833baa1 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 00:49:09 +0000 Subject: [PATCH 16/23] reverting changes in codex-rs/core/src/rollout/list.rs --- codex-rs/core/src/rollout/list.rs | 189 +++++++++------------ codex-rs/core/src/rollout/mod.rs | 2 +- codex-rs/core/src/rollout/session_index.rs | 12 ++ 3 files changed, 93 insertions(+), 110 deletions(-) diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 9e6aa2820b1..487304ddc80 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -14,7 +14,6 @@ use time::macros::format_description; use uuid::Uuid; use super::SESSIONS_SUBDIR; -use super::session_index::find_thread_id_by_name; use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; @@ -158,6 +157,7 @@ async fn traverse_directories_for_paths( provider_matcher: Option<&ProviderMatcher<'_>>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); + let mut scanned_files = 0usize; let mut anchor_passed = anchor.is_none(); let (anchor_ts, anchor_id) = match anchor { Some(c) => (c.ts, c.id), @@ -165,65 +165,93 @@ async fn traverse_directories_for_paths( }; let mut more_matches_available = false; - let (files, reached_scan_cap) = newest_rollout_files(&root).await?; - let mut scanned_files = 0usize; + let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; - for (ts, sid, path) in files.into_iter() { - scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { - more_matches_available = true; + 'outer: for (_year, year_path) in year_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { break; } - if !anchor_passed { - if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { - anchor_passed = true; - } else { - continue; + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; } - } - if items.len() == page_size { - more_matches_available = true; - break; - } - // Read head and detect message events; stop once meta + user are found. - let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) - .await - .unwrap_or_default(); - if !allowed_sources.is_empty() - && !summary - .source - .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) - { - continue; - } - if let Some(matcher) = provider_matcher - && !matcher.matches(summary.model_provider.as_deref()) - { - continue; - } - // Apply filters: must have session meta and at least one user message event - if summary.saw_session_meta && summary.saw_user_event { - let HeadTailSummary { - head, - created_at, - mut updated_at, - .. - } = summary; - if updated_at.is_none() { - updated_at = file_modified_rfc3339(&path) - .await - .unwrap_or(None) - .or_else(|| created_at.clone()); + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str) + .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) + }) + .await?; + // Stable ordering within the same second: (timestamp desc, uuid desc) + day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); + for (ts, sid, _name_str, path) in day_files.into_iter() { + scanned_files += 1; + if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + more_matches_available = true; + break 'outer; + } + if !anchor_passed { + if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { + anchor_passed = true; + } else { + continue; + } + } + if items.len() == page_size { + more_matches_available = true; + break 'outer; + } + // Read head and detect message events; stop once meta + user are found. + let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) + { + continue; + } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + continue; + } + // Apply filters: must have session meta and at least one user message event + if summary.saw_session_meta && summary.saw_user_event { + let HeadTailSummary { + head, + created_at, + mut updated_at, + .. + } = summary; + if updated_at.is_none() { + updated_at = file_modified_rfc3339(&path) + .await + .unwrap_or(None) + .or_else(|| created_at.clone()); + } + items.push(ThreadItem { + path, + head, + created_at, + updated_at, + }); + } + } } - items.push(ThreadItem { - path, - head, - created_at, - updated_at, - }); } } + let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } @@ -312,51 +340,6 @@ where Ok(collected) } -async fn newest_rollout_files( - root: &Path, -) -> io::Result<(Vec<(OffsetDateTime, Uuid, PathBuf)>, bool)> { - let mut files = Vec::new(); - let mut reached_scan_cap = false; - let year_dirs = collect_dirs_desc(root, |s| s.parse::().ok()).await?; - - 'outer: for (_year, year_path) in year_dirs.iter() { - if files.len() >= MAX_SCAN_FILES { - break; - } - let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; - for (_month, month_path) in month_dirs.iter() { - if files.len() >= MAX_SCAN_FILES { - break 'outer; - } - let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; - for (_day, day_path) in day_dirs.iter() { - if files.len() >= MAX_SCAN_FILES { - break 'outer; - } - let mut day_files = collect_files(day_path, |name_str, path| { - if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { - return None; - } - - parse_timestamp_uuid_from_filename(name_str) - .map(|(ts, id)| (ts, id, path.to_path_buf())) - }) - .await?; - day_files.sort_by_key(|(ts, id, _path)| (Reverse(*ts), Reverse(*id))); - for (ts, sid, path) in day_files.into_iter() { - files.push((ts, sid, path)); - if files.len() >= MAX_SCAN_FILES { - reached_scan_cap = true; - break 'outer; - } - } - } - } - } - - Ok((files, reached_scan_cap)) -} - fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; @@ -479,18 +462,6 @@ async fn file_modified_rfc3339(path: &Path) -> io::Result> { Ok(dt.format(&Rfc3339).ok()) } -/// 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, -) -> io::Result> { - let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else { - return Ok(None); - }; - find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await -} - /// Locate a recorded thread rollout file by its UUID string using the existing /// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present /// or the id is invalid. diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 57a4a3f0b8c..5cd26c7ff55 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -19,9 +19,9 @@ pub(crate) use error::map_session_init_error; pub use list::find_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; -pub use list::find_thread_path_by_name_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/session_index.rs b/codex-rs/core/src/rollout/session_index.rs index e7a9775561d..d811c07de7c 100644 --- a/codex-rs/core/src/rollout/session_index.rs +++ b/codex-rs/core/src/rollout/session_index.rs @@ -89,6 +89,18 @@ pub async fn find_thread_id_by_name( 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) } From 2da5bf25c40e1aeac5a511fac58cbe2051eae3c6 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 01:09:48 +0000 Subject: [PATCH 17/23] revert rollout changes --- .../core/tests/suite/rollout_list_find.rs | 63 +------------------ 1 file changed, 2 insertions(+), 61 deletions(-) diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 2bdb45134d3..e29141e6688 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -4,27 +4,17 @@ use std::path::Path; use std::path::PathBuf; use codex_core::find_thread_path_by_id_str; -use codex_core::find_thread_path_by_name_str; use pretty_assertions::assert_eq; use tempfile::TempDir; use uuid::Uuid; /// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the /// provided conversation id in the SessionMeta line. Returns the absolute path. -/// -/// This test file covers the low-level "find rollout by X" helpers, so the -/// minimal rollout writer lives here to keep the lookup tests concise. fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { - write_minimal_rollout(codex_home, "2024/01/01", "2024-01-01T00-00-00", id) -} - -// Helper for name lookup tests: lets us create older/newer rollouts without -// duplicating JSONL construction logic. -fn write_minimal_rollout(codex_home: &Path, subdir: &str, filename_ts: &str, id: Uuid) -> PathBuf { - let sessions = codex_home.join(format!("sessions/{subdir}")); + let sessions = codex_home.join("sessions/2024/01/01"); std::fs::create_dir_all(&sessions).unwrap(); - let file = sessions.join(format!("rollout-{filename_ts}-{id}.jsonl")); + let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); let mut f = std::fs::File::create(&file).unwrap(); // Minimal first line: session_meta with the id so content search can find it writeln!( @@ -49,21 +39,6 @@ fn write_minimal_rollout(codex_home: &Path, subdir: &str, filename_ts: &str, id: file } -fn append_session_index_entry(codex_home: &Path, id: Uuid, name: &str) { - let entry = serde_json::json!({ - "id": id, - "thread_name": name, - "updated_at": "2024-01-01T00:00:00Z" - }); - let path = codex_home.join("session_index.jsonl"); - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .unwrap(); - writeln!(file, "{entry}").unwrap(); -} - #[tokio::test] async fn find_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); @@ -106,37 +81,3 @@ async fn find_ignores_granular_gitignore_rules() { assert_eq!(found, Some(expected)); } - -#[tokio::test] -async fn find_locates_rollout_file_by_name_latest_first() { - // This test lives here because it verifies the core "find rollout by name" - // helper, including newest-first index lookup behavior. - let home = TempDir::new().unwrap(); - let name = "release-notes"; - let older_id = Uuid::new_v4(); - let newer_id = Uuid::new_v4(); - let _older = write_minimal_rollout(home.path(), "2024/01/01", "2024-01-01T00-00-00", older_id); - let newer = write_minimal_rollout(home.path(), "2024/01/02", "2024-01-02T00-00-00", newer_id); - append_session_index_entry(home.path(), older_id, name); - append_session_index_entry(home.path(), newer_id, name); - - let found = find_thread_path_by_name_str(home.path(), name) - .await - .unwrap(); - - assert_eq!(found, Some(newer)); -} - -#[tokio::test] -async fn find_returns_none_for_unknown_name() { - let home = TempDir::new().unwrap(); - let id = Uuid::new_v4(); - write_minimal_rollout(home.path(), "2024/01/01", "2024-01-01T00-00-00", id); - append_session_index_entry(home.path(), id, "known"); - - let found = find_thread_path_by_name_str(home.path(), "missing") - .await - .unwrap(); - - assert_eq!(found, None); -} From e62459e073b963dd028b12af2130d2b9b7a85e34 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 01:10:19 +0000 Subject: [PATCH 18/23] adding test for resume command with id --- codex-rs/core/src/util.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index ffa90b23a15..1b6f467ea5f 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -153,6 +153,22 @@ mod tests { 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); From b8de95664e758bfa7d972d8188a9d228e8990328 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 01:11:24 +0000 Subject: [PATCH 19/23] adding hint on uuid or name on exit --- codex-rs/tui/src/lib.rs | 6 ++++-- ...us_snapshot_cached_limits_hide_credits_without_flag.snap | 2 +- ..._tests__status_snapshot_includes_credits_and_limits.snap | 2 +- ...atus__tests__status_snapshot_includes_monthly_limit.snap | 2 +- ...__tests__status_snapshot_includes_reasoning_details.snap | 2 +- ...__tests__status_snapshot_shows_empty_limits_message.snap | 2 +- ...tests__status_snapshot_shows_missing_limits_message.snap | 2 +- ...__tests__status_snapshot_shows_stale_limits_message.snap | 2 +- ...tests__status_snapshot_truncates_in_narrow_terminal.snap | 2 +- codex-rs/tui2/src/lib.rs | 6 ++++-- 10 files changed, 16 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1362a1e5e29..e52ef732e04 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -434,7 +434,8 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - let resume_path = if Uuid::parse_str(id_str).is_ok() { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let resume_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? @@ -446,9 +447,10 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); let _ = tui.terminal.clear(); + let selector_hint = if is_uuid { "UUID" } else { "name" }; if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID or name {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with {selector_hint} {id_str}. Run `codex resume` without an ID to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } 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 9cfd49871d3..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 37987220777..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 e81452168b7..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 b3cecdcafc9..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: workspace-write │ │ Agents.md: │ -│ Thread name: │ +│ 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 39e047ff194..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 39e047ff194..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 e0655046f3a..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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 7cce9f9074e..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,7 +15,7 @@ expression: sanitized │ Approval: on-request │ │ Sandbox: read-only │ │ Agents.md: │ -│ Thread name: │ +│ 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/lib.rs b/codex-rs/tui2/src/lib.rs index 28de2ab4b22..b499af2811d 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -454,7 +454,8 @@ async fn run_ratatui_app( // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { - let resume_path = if Uuid::parse_str(id_str).is_ok() { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let resume_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? @@ -466,9 +467,10 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); let _ = tui.terminal.clear(); + let selector_hint = if is_uuid { "UUID" } else { "name" }; if let Err(err) = writeln!( std::io::stdout(), - "No saved session found with ID or name {id_str}. Run `codex resume` without an ID to choose from existing sessions." + "No saved session found with {selector_hint} {id_str}. Run `codex resume` without an ID to choose from existing sessions." ) { error!("Failed to write resume error message: {err}"); } From 9de9d3ef0fd60ec897abd8b6c3c59f18ee9043f1 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 01:14:19 +0000 Subject: [PATCH 20/23] naming clarity --- codex-rs/cli/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index cda88355896..3da56589b37 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -825,7 +825,7 @@ mod tests { ) } - fn sample_exit_info(conversation: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, total_tokens: 2, @@ -833,7 +833,9 @@ 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, } From 61a047730bc61fc96c31ee024dd020a9c44f75b4 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 01:23:23 +0000 Subject: [PATCH 21/23] documenting public function + thread_id rename --- codex-rs/core/src/codex.rs | 7 +++++++ codex-rs/protocol/src/protocol.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 39ae7e78919..6929f235378 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2138,6 +2138,13 @@ 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() { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f28fb9ed4af..4b5110d5b60 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,7 +1864,7 @@ pub struct SessionConfiguredEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ThreadNameUpdatedEvent { - pub session_id: ThreadId, + pub thread_id: ThreadId, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub thread_name: Option, From 00ec8d18118b87da8bb7839dfb153451ef740d74 Mon Sep 17 00:00:00 2001 From: pap Date: Tue, 13 Jan 2026 09:07:02 +0000 Subject: [PATCH 22/23] app-server v2 + thread_id --- .../src/protocol/common.rs | 5 +++ .../app-server-protocol/src/protocol/v2.rs | 23 +++++++++++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 12 ++++++ .../app-server/src/codex_message_processor.rs | 41 +++++++++++++++++++ codex-rs/core/src/codex.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui2/src/chatwidget.rs | 2 +- 8 files changed, 85 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 16ee3b98242..e65942f2787 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, @@ -540,6 +544,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 348df069fc0..6d92da851b3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1133,6 +1133,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/")] @@ -1744,6 +1757,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 04e97ed6227..ee4f518e24b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -76,6 +76,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 d18a7878f47..67cc8a88261 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -48,6 +48,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; @@ -776,6 +777,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 92a1a67f87b..87a58f96a1a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -98,6 +98,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; @@ -380,6 +382,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; } @@ -1537,6 +1542,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/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6929f235378..c9674cfe0f4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2198,7 +2198,7 @@ mod handlers { sess.send_event_raw(Event { id: sub_id, msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - session_id: sess.conversation_id, + thread_id: sess.conversation_id, thread_name: Some(name), }), }) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f3dddd5da7f..b42e23d2e94 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -470,7 +470,7 @@ impl ChatWidget { } fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { - if self.thread_id == Some(event.session_id) { + if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; } self.request_redraw(); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 6d40c935707..8dd26f6cd46 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -436,7 +436,7 @@ impl ChatWidget { } fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { - if self.conversation_id == Some(event.session_id) { + if self.conversation_id == Some(event.thread_id) { self.thread_name = event.thread_name; } self.request_redraw(); From c12882c6330f14ab458843a2385e2201ba301ed1 Mon Sep 17 00:00:00 2001 From: pap Date: Thu, 15 Jan 2026 10:41:13 +0000 Subject: [PATCH 23/23] adding thread_name in fork --- codex-rs/tui/src/app.rs | 2 ++ codex-rs/tui2/src/app.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a4e380b7825..b13d69aae7a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -752,6 +752,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 @@ -1896,6 +1897,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, diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 8c8292ba2f2..b055b527857 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1550,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 @@ -2685,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,