Compare commits

...

1 Commits

Author SHA1 Message Date
Javier Soto
1364a81040 [WIP] Name conversations 2026-01-05 11:08:53 -08:00
16 changed files with 223 additions and 4 deletions

View File

@@ -116,6 +116,7 @@ pub struct ConversationSummary {
pub conversation_id: ConversationId,
pub path: PathBuf,
pub preview: String,
pub name: Option<String>,
pub timestamp: Option<String>,
pub model_provider: String,
pub cwd: PathBuf,

View File

@@ -1168,6 +1168,8 @@ pub struct Thread {
pub id: String,
/// Usually the first user message in the thread, if available.
pub preview: String,
/// Optional human-friendly name for the thread.
pub name: Option<String>,
/// Model provider used for this thread (for example, 'openai').
pub model_provider: String,
/// Unix timestamp (in seconds) when the thread was created.

View File

@@ -3407,6 +3407,7 @@ async fn read_summary_from_rollout(
timestamp,
path: path.to_path_buf(),
preview: String::new(),
name: session_meta.name.clone(),
model_provider,
cwd: session_meta.cwd,
cli_version: session_meta.cli_version,
@@ -3452,6 +3453,7 @@ fn extract_conversation_summary(
timestamp,
path,
preview: preview.to_string(),
name: session_meta.name.clone(),
model_provider,
cwd: session_meta.cwd.clone(),
cli_version: session_meta.cli_version.clone(),
@@ -3481,6 +3483,7 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread {
conversation_id,
path,
preview,
name,
timestamp,
model_provider,
cwd,
@@ -3499,6 +3502,7 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread {
Thread {
id: conversation_id.to_string(),
preview,
name,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
path,
@@ -3564,6 +3568,7 @@ mod tests {
timestamp,
path,
preview: "Count to 5".to_string(),
name: None,
model_provider: "test-provider".to_string(),
cwd: PathBuf::from("/"),
cli_version: "0.0.0".to_string(),
@@ -3612,6 +3617,7 @@ mod tests {
timestamp: Some(timestamp),
path: path.clone(),
preview: String::new(),
name: None,
model_provider: "fallback".to_string(),
cwd: PathBuf::new(),
cli_version: String::new(),

View File

@@ -47,6 +47,7 @@ pub fn create_fake_rollout(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
instructions: None,
name: None,
source: SessionSource::Cli,
model_provider: model_provider.map(str::to_string),
};

View File

@@ -110,6 +110,7 @@ use crate::protocol::ReasoningRawContentDeltaEvent;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
use crate::protocol::SessionRenamedEvent;
use crate::protocol::SkillErrorInfo;
use crate::protocol::SkillMetadata as ProtocolSkillMetadata;
use crate::protocol::StreamErrorEvent;
@@ -1319,6 +1320,17 @@ impl Session {
}
}
pub(crate) async fn set_session_name(&self, name: String) -> std::io::Result<()> {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
};
let Some(rec) = recorder else {
return Err(std::io::Error::other("rollout recorder unavailable"));
};
rec.set_session_name(name).await
}
pub(crate) async fn clone_history(&self) -> ContextManager {
let state = self.state.lock().await;
state.clone_history()
@@ -1658,6 +1670,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::Compact => {
handlers::compact(&sess, sub.id.clone()).await;
}
Op::SetSessionName { name } => {
handlers::set_session_name(&sess, sub.id.clone(), name).await;
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(
&sess,
@@ -1714,6 +1729,7 @@ mod handlers {
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::SessionRenamedEvent;
use codex_protocol::protocol::SkillsListEntry;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
@@ -2034,6 +2050,32 @@ mod handlers {
.await;
}
pub async fn set_session_name(sess: &Arc<Session>, sub_id: String, name: String) {
let trimmed = name.trim();
if trimmed.is_empty() {
return;
}
if let Err(err) = sess.set_session_name(trimmed.to_string()).await {
let message = format!("Failed to update session name: {err}");
warn!("{message}");
let event = Event {
id: sub_id,
msg: EventMsg::Warning(WarningEvent { message }),
};
sess.send_event_raw(event).await;
return;
}
let event = Event {
id: sub_id,
msg: EventMsg::SessionRenamed(SessionRenamedEvent {
name: trimmed.to_string(),
}),
};
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

@@ -55,6 +55,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::SessionRenamed(_)
| EventMsg::RawResponseItem(_)
| EventMsg::SessionConfigured(_)
| EventMsg::McpToolCallBegin(_)

View File

@@ -11,6 +11,7 @@ use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;
use tokio::io::AsyncSeekExt;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::{self};
@@ -67,6 +68,11 @@ enum RolloutCmd {
Flush {
ack: oneshot::Sender<()>,
},
/// Update the session meta name stored at the head of the rollout file.
UpdateSessionName {
name: String,
ack: oneshot::Sender<std::io::Result<()>>,
},
Shutdown {
ack: oneshot::Sender<()>,
},
@@ -146,6 +152,7 @@ impl RolloutRecorder {
originator: originator().value.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
name: None,
source,
model_provider: Some(config.model_provider_id.clone()),
}),
@@ -172,7 +179,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 +214,16 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}")))
}
pub async fn set_session_name(&self, name: String) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(RolloutCmd::UpdateSessionName { name, ack: tx })
.await
.map_err(|e| IoError::other(format!("failed to queue session name update: {e}")))?;
rx.await
.map_err(|e| IoError::other(format!("failed waiting for session name update: {e}")))?
}
pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
info!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
@@ -351,6 +368,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 };
@@ -389,6 +407,10 @@ async fn rollout_writer(
RolloutCmd::Shutdown { ack } => {
let _ = ack.send(());
}
RolloutCmd::UpdateSessionName { name, ack } => {
let result = update_session_name(&mut writer, &rollout_path, name).await;
let _ = ack.send(result);
}
}
}
@@ -422,3 +444,41 @@ impl JsonlWriter {
Ok(())
}
}
async fn update_session_name(
writer: &mut JsonlWriter,
rollout_path: &Path,
name: String,
) -> std::io::Result<()> {
writer.file.flush().await?;
let contents = tokio::fs::read(rollout_path).await?;
let newline_idx = contents
.iter()
.position(|&b| b == b'\n')
.ok_or_else(|| IoError::other("rollout is missing a SessionMeta line"))?;
let first_line = std::str::from_utf8(&contents[..newline_idx])
.map_err(|e| IoError::other(format!("invalid utf8 in session meta line: {e}")))?;
let mut rollout_line: RolloutLine = serde_json::from_str(first_line)
.map_err(|e| IoError::other(format!("invalid session meta line: {e}")))?;
let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else {
return Err(IoError::other("first rollout line is not session metadata"));
};
let trimmed = name.trim();
session_meta_line.meta.name = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
rollout_line.item = RolloutItem::SessionMeta(session_meta_line);
let mut updated = serde_json::to_vec(&rollout_line)?;
updated.push(b'\n');
updated.extend_from_slice(&contents[newline_idx + 1..]);
writer.file.set_len(0).await?;
writer.file.seek(std::io::SeekFrom::Start(0)).await?;
writer.file.write_all(&updated).await?;
writer.file.flush().await?;
Ok(())
}

View File

@@ -587,6 +587,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
id: conversation_id,
timestamp: ts.to_string(),
instructions: None,
name: None,
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),

View File

@@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::ListSkillsResponse(_)
| EventMsg::RawResponseItem(_)
| EventMsg::UserMessage(_)
| EventMsg::SessionRenamed(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::AgentMessageDelta(_)

View File

@@ -297,6 +297,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
| EventMsg::UserMessage(_)
| EventMsg::SessionRenamed(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::RawResponseItem(_)

View File

@@ -207,6 +207,10 @@ pub enum Op {
/// to generate a summary which will be returned as an AgentMessage event.
Compact,
/// Set a human-friendly name for the current session.
/// The agent will persist this to the rollout so UIs can surface it.
SetSessionName { name: String },
/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
Undo,
@@ -574,6 +578,9 @@ pub enum EventMsg {
/// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
/// Session was given a human-friendly name by the user.
SessionRenamed(SessionRenamedEvent),
/// Ack the client's configure message.
SessionConfigured(SessionConfiguredEvent),
@@ -1150,6 +1157,11 @@ pub struct WebSearchEndEvent {
pub query: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct SessionRenamedEvent {
pub name: String,
}
/// Response payload for `Op::GetHistory` containing the current session's
/// in-memory transcript.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -1261,6 +1273,8 @@ pub struct SessionMeta {
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub source: SessionSource,
pub model_provider: Option<String>,
@@ -1275,6 +1289,7 @@ impl Default for SessionMeta {
originator: String::new(),
cli_version: String::new(),
instructions: None,
name: None,
source: SessionSource::default(),
model_provider: None,
}

View File

@@ -1087,6 +1087,9 @@ impl App {
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::SetSessionName(name) => {
self.chat_widget.begin_set_session_name(name);
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();

View File

@@ -168,6 +168,9 @@ pub(crate) enum AppEvent {
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Begin setting a human-readable name for the current session.
SetSessionName(String),
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),

View File

@@ -1728,6 +1728,9 @@ impl ChatWidget {
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Name => {
self.open_name_popup();
}
SlashCommand::Model => {
self.open_model_popup();
}
@@ -1914,6 +1917,30 @@ impl ChatWidget {
return;
}
// Intercept '/name <new name>' as a local rename command (no images allowed).
if image_paths.is_empty()
&& let Some((cmd, rest)) = crate::bottom_pane::prompt_args::parse_slash_name(&text)
&& cmd == "name"
{
let name = rest.trim();
if name.is_empty() {
// Provide a brief usage hint.
self.add_to_history(history_cell::new_info_event(
"Usage: /name <new name>".to_string(),
None,
));
self.request_redraw();
} else {
let name_str = name.to_string();
self.codex_op_tx
.send(Op::SetSessionName { name: name_str })
.unwrap_or_else(|e| {
tracing::error!("failed to send SetSessionName op: {e}");
});
}
return;
}
let mut items: Vec<UserInput> = Vec::new();
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
@@ -2058,6 +2085,13 @@ impl ChatWidget {
}
},
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
EventMsg::SessionRenamed(ev) => {
self.add_to_history(history_cell::new_info_event(
format!("Named this chat: {}", ev.name),
None,
));
self.request_redraw();
}
EventMsg::ExecApprovalRequest(ev) => {
// For replayed events, synthesize an empty id (these should not occur).
self.on_exec_approval_request(id.unwrap_or_default(), ev)
@@ -3501,6 +3535,33 @@ impl ChatWidget {
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn open_name_popup(&mut self) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"Name this chat".to_string(),
"Type a name and press Enter".to_string(),
None,
Box::new(move |name: String| {
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return;
}
tx.send(AppEvent::SetSessionName(trimmed));
}),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn begin_set_session_name(&mut self, name: String) {
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return;
}
self.codex_op_tx
.send(Op::SetSessionName { name: trimmed })
.unwrap_or_else(|e| tracing::error!("failed to send SetSessionName op: {e}"));
}
pub(crate) fn token_usage(&self) -> TokenUsage {
self.token_info
.as_ref()

View File

@@ -644,9 +644,12 @@ fn head_to_row(item: &ConversationItem) -> Row {
.or(created_at);
let (cwd, git_branch) = extract_session_meta_from_head(&item.head);
let preview = preview_from_head(&item.head)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
let preview = extract_session_name_from_head(&item.head)
.or_else(|| {
preview_from_head(&item.head)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| String::from("(no message yet)"));
Row {
@@ -670,6 +673,20 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf
(None, None)
}
fn extract_session_name_from_head(head: &[serde_json::Value]) -> Option<String> {
for value in head {
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
if let Some(name) = meta_line.meta.name {
let trimmed = name.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
}
None
}
fn paths_match(a: &Path, b: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(a),

View File

@@ -17,6 +17,7 @@ pub enum SlashCommand {
Experimental,
Skills,
Review,
Name,
New,
Resume,
Init,
@@ -44,6 +45,7 @@ impl SlashCommand {
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Name => "set a name for this chat",
SlashCommand::Resume => "resume a saved chat",
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
@@ -81,6 +83,8 @@ impl SlashCommand {
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Logout => false,
// Naming is a local UI action; allow during tasks.
SlashCommand::Name => true,
SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Skills