mirror of
https://github.com/openai/codex.git
synced 2026-04-25 17:01:01 +03:00
Compare commits
2 Commits
dev/cc/fix
...
pap/featur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
748bfa05dd | ||
|
|
c09cdc3ae2 |
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user