adding session naming with /rename and metadata storage in SessionMeta

This commit is contained in:
pap
2026-01-05 21:23:07 +00:00
parent 0b53aed2d0
commit c09cdc3ae2
9 changed files with 254 additions and 1 deletions
@@ -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,
+45
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
+151 -1
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(())
}
}
+1
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,
+8
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,
+21
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);
}
+3
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
+21
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);
}
+3
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