Compare commits

...

2 Commits

Author SHA1 Message Date
pap
748bfa05dd adding codex resume <name> 2026-01-05 22:23:52 +00:00
pap
c09cdc3ae2 adding session naming with /rename and metadata storage in SessionMeta 2026-01-05 21:23:07 +00:00
19 changed files with 592 additions and 98 deletions

View File

@@ -44,6 +44,7 @@ pub fn create_fake_rollout(
id: conversation_id,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
title: None,
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
instructions: None,

View File

@@ -284,6 +284,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let AppExitInfo {
token_usage,
conversation_id,
resume_selector,
..
} = exit_info;
@@ -296,8 +297,10 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
codex_core::protocol::FinalOutput::from(token_usage)
)];
if let Some(session_id) = conversation_id {
let resume_cmd = format!("codex resume {session_id}");
let selector = resume_selector.or_else(|| conversation_id.as_ref().map(ToString::to_string));
if let Some(selector) = selector {
let selector = format_resume_selector_arg(&selector);
let resume_cmd = format!("codex resume {selector}");
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
@@ -309,6 +312,15 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
lines
}
fn format_resume_selector_arg(selector: &str) -> String {
if !selector.contains(char::is_whitespace) && !selector.contains('"') {
return selector.to_string();
}
let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
let update_action = exit_info.update_action;
@@ -823,11 +835,13 @@ mod tests {
total_tokens: 2,
..Default::default()
};
let resume_selector = conversation.map(ToString::to_string);
AppExitInfo {
token_usage,
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
resume_selector,
update_action: None,
}
}
@@ -837,6 +851,7 @@ mod tests {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
@@ -865,6 +880,32 @@ mod tests {
assert!(lines[1].contains("\u{1b}[36m"));
}
#[test]
fn format_exit_messages_prefers_resume_selector_when_present() {
let token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
let exit_info = AppExitInfo {
token_usage,
conversation_id: Some(
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
),
resume_selector: Some("pap".to_string()),
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run codex resume pap".to_string(),
]
);
}
#[test]
fn resume_model_flag_applies_when_no_root_flags() {
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());

View File

@@ -1658,6 +1658,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::Compact => {
handlers::compact(&sess, sub.id.clone()).await;
}
Op::SetSessionTitle { title } => {
handlers::set_session_title(&sess, sub.id.clone(), title).await;
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(
&sess,
@@ -2025,6 +2028,48 @@ mod handlers {
.await;
}
pub async fn set_session_title(sess: &Arc<Session>, 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<Session>, sub_id: String) -> bool {
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
sess.services

View File

@@ -87,11 +87,13 @@ pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_conversation_path_by_selector;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::read_rollout_session_title;
mod function_tool;
mod state;
mod tasks;

View File

@@ -158,88 +158,65 @@ async fn traverse_directories_for_paths(
};
let mut more_matches_available = false;
let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u16>().ok()).await?;
'outer: for (_year, year_path) in year_dirs.iter() {
let day_dirs = collect_day_dirs_desc(&root).await?;
'outer: for day_path in day_dirs {
if scanned_files >= MAX_SCAN_FILES {
break;
}
let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs.iter() {
let day_files = collect_rollout_files_desc(&day_path).await?;
for (ts, sid, path) in day_files {
scanned_files += 1;
if items.len() == page_size {
more_matches_available = true;
break 'outer;
}
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in day_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
}
parse_timestamp_uuid_from_filename(name_str)
.map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf()))
})
.await?;
// Stable ordering within the same second: (timestamp desc, uuid desc)
day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid)));
for (ts, sid, _name_str, path) in day_files.into_iter() {
scanned_files += 1;
if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size {
more_matches_available = true;
break 'outer;
}
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
}
if items.len() == page_size {
more_matches_available = true;
break 'outer;
}
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
// Read head and detect message events; stop once meta + user are found.
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
mut updated_at,
..
} = summary;
if updated_at.is_none() {
updated_at = file_modified_rfc3339(&path)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if let Some(matcher) = provider_matcher
&& !matcher.matches(summary.model_provider.as_deref())
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
created_at,
mut updated_at,
..
} = summary;
if updated_at.is_none() {
updated_at = file_modified_rfc3339(&path)
.await
.unwrap_or(None)
.or_else(|| created_at.clone());
}
items.push(ConversationItem {
path,
head,
created_at,
updated_at,
});
}
.unwrap_or(None)
.or_else(|| created_at.clone());
}
items.push(ConversationItem {
path,
head,
created_at,
updated_at,
});
}
}
}
@@ -350,6 +327,35 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
Some((ts, uuid))
}
async fn collect_day_dirs_desc(sessions_root: &Path) -> io::Result<Vec<PathBuf>> {
let mut day_dirs: Vec<PathBuf> = Vec::new();
let year_dirs = collect_dirs_desc(sessions_root, |s| s.parse::<u16>().ok()).await?;
for (_year, year_path) in year_dirs {
let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs {
let days = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in days {
day_dirs.push(day_path);
}
}
}
Ok(day_dirs)
}
async fn collect_rollout_files_desc(
day_path: &Path,
) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> {
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf()))
})
.await?;
day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid)));
Ok(day_files)
}
struct ProviderMatcher<'a> {
filters: &'a [String],
matches_default_provider: bool,
@@ -500,3 +506,81 @@ pub async fn find_conversation_path_by_id_str(
.next()
.map(|m| root.join(m.path)))
}
/// Locate a recorded conversation rollout file by either UUID (fast path) or by matching
/// `SessionMeta.title` in the first JSONL line of each rollout file.
///
/// Assumes the invariant that the first rollout line is always the `session_meta` record.
/// If multiple sessions share the same title, returns the newest matching rollout.
pub async fn find_conversation_path_by_selector(
codex_home: &Path,
selector: &str,
) -> io::Result<Option<PathBuf>> {
if let Some(path) = find_conversation_path_by_id_str(codex_home, selector).await? {
return Ok(Some(path));
}
let normalized = selector.trim().to_lowercase();
if normalized.is_empty() {
return Ok(None);
}
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
if !root.exists() {
return Ok(None);
}
use tokio::io::AsyncBufReadExt;
let day_dirs = collect_day_dirs_desc(&root).await?;
for day_path in day_dirs {
let day_files = collect_rollout_files_desc(&day_path).await?;
for (_ts, _sid, path) in day_files {
let file = tokio::fs::File::open(&path).await?;
let mut lines = tokio::io::BufReader::new(file).lines();
let first_line = lines.next_line().await?;
let Some(first_line) = first_line else {
continue;
};
let rollout_line: RolloutLine = match serde_json::from_str(first_line.trim()) {
Ok(v) => v,
Err(_) => continue,
};
let RolloutItem::SessionMeta(meta_line) = rollout_line.item else {
continue;
};
let Some(title) = meta_line.meta.title.as_deref() else {
continue;
};
if title.trim().to_lowercase() == normalized {
return Ok(Some(path));
}
}
}
Ok(None)
}
/// Read the `SessionMeta.title` from a rollout file.
///
/// Rollout files are expected to begin with a `session_meta` line.
pub async fn read_rollout_session_title(path: &Path) -> io::Result<Option<String>> {
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let mut lines = tokio::io::BufReader::new(file).lines();
let first_line = lines.next_line().await?;
let Some(first_line) = first_line else {
return Ok(None);
};
let rollout_line: RolloutLine =
serde_json::from_str(first_line.trim()).map_err(|e| io::Error::other(e))?;
let RolloutItem::SessionMeta(meta_line) = rollout_line.item else {
return Ok(None);
};
Ok(meta_line.meta.title)
}

View File

@@ -15,6 +15,8 @@ pub mod recorder;
pub use codex_protocol::protocol::SessionMeta;
pub(crate) use error::map_session_init_error;
pub use list::find_conversation_path_by_id_str;
pub use list::find_conversation_path_by_selector;
pub use list::read_rollout_session_title;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;

View File

@@ -67,6 +67,11 @@ enum RolloutCmd {
Flush {
ack: oneshot::Sender<()>,
},
/// Rewrite the first SessionMeta line in the rollout file to include a title.
SetSessionTitle {
title: String,
ack: oneshot::Sender<std::io::Result<()>>,
},
Shutdown {
ack: oneshot::Sender<()>,
},
@@ -143,6 +148,7 @@ impl RolloutRecorder {
id: session_id,
timestamp,
cwd: config.cwd.clone(),
title: None,
originator: originator().value.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
@@ -172,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 })
}
@@ -207,6 +213,16 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}")))
}
pub async fn set_session_title(&self, title: String) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(RolloutCmd::SetSessionTitle { title, ack: tx })
.await
.map_err(|e| IoError::other(format!("failed to queue session title update: {e}")))?;
rx.await
.map_err(|e| IoError::other(format!("failed waiting for session title update: {e}")))?
}
pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
info!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
@@ -351,6 +367,7 @@ async fn rollout_writer(
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
rollout_path: PathBuf,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
@@ -386,6 +403,10 @@ async fn rollout_writer(
}
let _ = ack.send(());
}
RolloutCmd::SetSessionTitle { title, ack } => {
let result = rewrite_session_title(&mut writer, &rollout_path, &title).await;
let _ = ack.send(result);
}
RolloutCmd::Shutdown { ack } => {
let _ = ack.send(());
}
@@ -395,6 +416,102 @@ 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?;
// 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);
update_first_session_meta_line_title(rollout_path, title).await?;
// 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 update_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",
));
}
replace_rollout_file(rollout_path, out).await
}
fn rewrite_session_meta_line_title(line: &str, title: &str) -> std::io::Result<String> {
let mut rollout_line = serde_json::from_str::<RolloutLine>(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,
}
@@ -422,3 +539,36 @@ impl JsonlWriter {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
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(())
}
}

View File

@@ -588,6 +588,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
timestamp: ts.to_string(),
instructions: None,
cwd: ".".into(),
title: None,
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,

View File

@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use tempfile::TempDir;
use uuid::Uuid;
@@ -38,6 +39,42 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
file
}
fn write_minimal_rollout_with_id_and_title(
codex_home: &Path,
id: Uuid,
timestamp_fragment: &str,
title: &str,
) -> PathBuf {
let sessions = codex_home.join("sessions/2024/01/01");
std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!(
"rollout-2024-01-01T{timestamp_fragment}-{id}.jsonl"
));
let mut f = std::fs::File::create(&file).unwrap();
writeln!(
f,
"{}",
serde_json::json!({
"timestamp": "2024-01-01T00:00:00.000Z",
"type": "session_meta",
"payload": {
"id": id,
"timestamp": "2024-01-01T00:00:00Z",
"cwd": ".",
"title": title,
"originator": "test",
"cli_version": "test",
"instructions": null,
"model_provider": "test-provider"
}
})
)
.unwrap();
file
}
#[tokio::test]
async fn find_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
@@ -67,6 +104,34 @@ async fn find_handles_gitignore_covering_codex_home_directory() {
assert_eq!(found, Some(expected));
}
#[tokio::test]
async fn resolve_selector_finds_rollout_file_by_title() {
let home = TempDir::new().unwrap();
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id_and_title(home.path(), id, "00-00-00", "My Title");
let resolved = find_conversation_path_by_selector(home.path(), "my title")
.await
.unwrap();
assert_eq!(resolved, Some(expected));
}
#[tokio::test]
async fn resolve_selector_picks_newest_when_titles_duplicate() {
let home = TempDir::new().unwrap();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let _older = write_minimal_rollout_with_id_and_title(home.path(), id1, "00-00-00", "Dup");
let newer = write_minimal_rollout_with_id_and_title(home.path(), id2, "00-00-01", "Dup");
let resolved = find_conversation_path_by_selector(home.path(), "dup")
.await
.unwrap();
assert_eq!(resolved, Some(newer));
}
#[tokio::test]
async fn find_ignores_granular_gitignore_rules() {
let home = TempDir::new().unwrap();

View File

@@ -55,7 +55,7 @@ use crate::cli::Command as ExecCommand;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use codex_core::default_client::set_default_originator;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
enum InitialOperation {
UserTurn {
@@ -501,8 +501,7 @@ async fn resolve_resume_path(
}
}
} else if let Some(id_str) = args.session_id.as_deref() {
let path = find_conversation_path_by_id_str(&config.codex_home, id_str).await?;
Ok(path)
Ok(find_conversation_path_by_selector(&config.codex_home, id_str).await?)
} else {
Ok(None)
}

View File

@@ -204,6 +204,11 @@ pub enum Op {
/// to generate a summary which will be returned as an AgentMessage event.
Compact,
/// Set a user-facing session title in the persisted rollout metadata.
/// This is a local-only operation handled by codex-core; it does not
/// involve the model.
SetSessionTitle { title: String },
/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
Undo,
@@ -1255,6 +1260,8 @@ pub struct SessionMeta {
pub id: ConversationId,
pub timestamp: String,
pub cwd: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
@@ -1269,6 +1276,7 @@ impl Default for SessionMeta {
id: ConversationId::default(),
timestamp: String::new(),
cwd: PathBuf::new(),
title: None,
originator: String::new(),
cli_version: String::new(),
instructions: None,

View File

@@ -71,20 +71,21 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
/// Preferred selector for `codex resume`: title when set, otherwise UUID.
pub resume_selector: Option<String>,
pub update_action: Option<UpdateAction>,
}
fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
resume_selector: Option<&str>,
) -> Option<SessionSummary> {
if token_usage.is_zero() {
return None;
}
let usage_line = FinalOutput::from(token_usage).to_string();
let resume_command =
conversation_id.map(|conversation_id| format!("codex resume {conversation_id}"));
let resume_command = resume_selector.map(|selector| format!("codex resume {selector}"));
Some(SessionSummary {
usage_line,
resume_command,
@@ -276,6 +277,7 @@ async fn handle_model_migration_prompt_if_needed(
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -499,9 +501,23 @@ impl App {
}
} {}
tui.terminal.clear()?;
let conversation_id = app.chat_widget.conversation_id();
let mut resume_selector = conversation_id.as_ref().map(ToString::to_string);
if let Some(conversation_id) = &conversation_id
&& let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str(
&app.config.codex_home,
&conversation_id.to_string(),
)
.await
&& let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await
&& !title.trim().is_empty()
{
resume_selector = Some(title);
}
Ok(AppExitInfo {
token_usage: app.token_usage(),
conversation_id: app.chat_widget.conversation_id(),
conversation_id,
resume_selector,
update_action: app.pending_update_action,
})
}
@@ -562,10 +578,13 @@ impl App {
.await;
match event {
AppEvent::NewSession => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
);
let resume_selector = self
.chat_widget
.conversation_id()
.as_ref()
.map(ToString::to_string);
let summary =
session_summary(self.chat_widget.token_usage(), resume_selector.as_deref());
self.shutdown_current_conversation().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
@@ -602,9 +621,14 @@ impl App {
.await?
{
ResumeSelection::Resume(path) => {
let resume_selector = self
.chat_widget
.conversation_id()
.as_ref()
.map(ToString::to_string);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
resume_selector.as_deref(),
);
match self
.server
@@ -1643,10 +1667,8 @@ mod tests {
total_tokens: 12,
..Default::default()
};
let conversation =
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let summary = session_summary(usage, Some(conversation)).expect("summary");
let summary =
session_summary(usage, Some("123e4567-e89b-12d3-a456-426614174000")).expect("summary");
assert_eq!(
summary.usage_line,
"Token usage: total=12 input=10 output=2"

View File

@@ -1709,6 +1709,10 @@ impl ChatWidget {
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Rename => {
self.show_rename_prompt();
self.request_redraw();
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
@@ -1835,6 +1839,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);
}

View File

@@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
@@ -371,6 +371,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: Some(action),
});
}
@@ -410,6 +411,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -429,7 +431,7 @@ async fn run_ratatui_app(
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_conversation_path_by_id_str(&config.codex_home, id_str).await? {
match find_conversation_path_by_selector(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
@@ -438,13 +440,14 @@ async fn run_ratatui_app(
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
"No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}
@@ -483,6 +486,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
});
}

View File

@@ -19,6 +19,7 @@ pub enum SlashCommand {
Review,
New,
Resume,
Rename,
Init,
Compact,
// Undo,
@@ -45,6 +46,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Rename => "rename the current session",
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
@@ -83,6 +85,7 @@ impl SlashCommand {
| SlashCommand::Logout => false,
SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Rename
| SlashCommand::Skills
| SlashCommand::Status
| SlashCommand::Ps

View File

@@ -87,6 +87,8 @@ use crate::history_cell::UpdateAvailableHistoryCell;
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
/// Preferred selector for `codex resume`: title when set, otherwise UUID.
pub resume_selector: Option<String>,
pub update_action: Option<UpdateAction>,
/// ANSI-styled transcript lines to print after the TUI exits.
///
@@ -101,6 +103,7 @@ impl From<AppExitInfo> for codex_tui::AppExitInfo {
codex_tui::AppExitInfo {
token_usage: info.token_usage,
conversation_id: info.conversation_id,
resume_selector: info.resume_selector,
update_action: info.update_action.map(Into::into),
}
}
@@ -304,6 +307,7 @@ async fn handle_model_migration_prompt_if_needed(
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -581,9 +585,23 @@ impl App {
};
tui.terminal.clear()?;
let conversation_id = app.chat_widget.conversation_id();
let mut resume_selector = conversation_id.as_ref().map(ToString::to_string);
if let Some(conversation_id) = &conversation_id
&& let Ok(Some(path)) = codex_core::find_conversation_path_by_id_str(
&app.config.codex_home,
&conversation_id.to_string(),
)
.await
&& let Ok(Some(title)) = codex_core::read_rollout_session_title(&path).await
&& !title.trim().is_empty()
{
resume_selector = Some(title);
}
Ok(AppExitInfo {
token_usage: app.token_usage(),
conversation_id: app.chat_widget.conversation_id(),
conversation_id,
resume_selector,
update_action: app.pending_update_action,
session_lines,
})

View File

@@ -1545,6 +1545,10 @@ impl ChatWidget {
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Rename => {
self.show_rename_prompt();
self.request_redraw();
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
@@ -1665,6 +1669,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);
}

View File

@@ -19,7 +19,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::find_conversation_path_by_id_str;
use codex_core::find_conversation_path_by_selector;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
@@ -386,6 +386,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: Some(action),
session_lines: Vec::new(),
});
@@ -426,6 +427,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -446,7 +448,7 @@ async fn run_ratatui_app(
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_conversation_path_by_id_str(&config.codex_home, id_str).await? {
match find_conversation_path_by_selector(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
@@ -455,13 +457,14 @@ async fn run_ratatui_app(
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
"No saved session found with ID or title {id_str}. Run `codex resume` without an argument to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});
@@ -501,6 +504,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
resume_selector: None,
update_action: None,
session_lines: Vec::new(),
});

View File

@@ -18,6 +18,7 @@ pub enum SlashCommand {
Review,
New,
Resume,
Rename,
Init,
Compact,
// Undo,
@@ -43,6 +44,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Rename => "rename the current session",
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
@@ -78,6 +80,7 @@ impl SlashCommand {
| SlashCommand::Logout => false,
SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Rename
| SlashCommand::Skills
| SlashCommand::Status
| SlashCommand::Mcp