mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
adding session naming with /rename and metadata storage in SessionMeta
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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