diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cae47cb3..fe875b2c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -88,6 +88,7 @@ use crate::protocol::ReviewDecision; use crate::protocol::ReviewOutputEvent; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::SessionRenamedEvent; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -1507,6 +1508,16 @@ async fn submission_loop( }; sess.send_event(event).await; } + Op::SetSessionName { name } => { + // Persist a rename event and notify the client. We rely on the + // recorder's filtering to include this in the rollout. + let sub_id = sub.id.clone(); + let event = Event { + id: sub_id, + msg: EventMsg::SessionRenamed(SessionRenamedEvent { name }), + }; + sess.send_event(event).await; + } Op::Review { review_request } => { spawn_review_thread( sess.clone(), diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index b27b3382..0276a91a 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -17,6 +17,7 @@ use super::SESSIONS_SUBDIR; use crate::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionRenamedEvent; use codex_protocol::protocol::SessionSource; /// Returned page of conversation summaries. @@ -41,6 +42,8 @@ pub struct ConversationItem { pub head: Vec, /// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON. pub tail: Vec, + /// Latest human-friendly session name, if any. + pub name: Option, /// RFC3339 timestamp string for when the session was created, if available. pub created_at: Option, /// RFC3339 timestamp string for the most recent response in the tail, if available. @@ -56,6 +59,7 @@ struct HeadTailSummary { source: Option, created_at: Option, updated_at: Option, + name: Option, } /// Hard cap to bound worst‑case work per request. @@ -222,6 +226,7 @@ async fn traverse_directories_for_paths( path, head, tail, + name: summary.name, created_at, updated_at, }); @@ -382,14 +387,21 @@ async fn read_head_and_tail( if matches!(ev, EventMsg::UserMessage(_)) { summary.saw_user_event = true; } + if let EventMsg::SessionRenamed(SessionRenamedEvent { name }) = ev { + summary.name = Some(name); + } } } } if tail_limit != 0 { - let (tail, updated_at) = read_tail_records(path, tail_limit).await?; + let (tail, updated_at, latest_name) = read_tail_records(path, tail_limit).await?; summary.tail = tail; summary.updated_at = updated_at; + // Prefer the most recent rename event discovered from the tail scan; fallback to any name seen in head. + if latest_name.is_some() { + summary.name = latest_name; + } } Ok(summary) } @@ -397,13 +409,13 @@ async fn read_head_and_tail( async fn read_tail_records( path: &Path, max_records: usize, -) -> io::Result<(Vec, Option)> { +) -> io::Result<(Vec, Option, Option)> { use std::io::SeekFrom; use tokio::io::AsyncReadExt; use tokio::io::AsyncSeekExt; if max_records == 0 { - return Ok((Vec::new(), None)); + return Ok((Vec::new(), None, None)); } const CHUNK_SIZE: usize = 8192; @@ -411,28 +423,33 @@ async fn read_tail_records( let mut file = tokio::fs::File::open(path).await?; let mut pos = file.seek(SeekFrom::End(0)).await?; if pos == 0 { - return Ok((Vec::new(), None)); + return Ok((Vec::new(), None, None)); } let mut buffer: Vec = Vec::new(); let mut latest_timestamp: Option = None; + let mut latest_name: Option = None; loop { let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) { (true, Some(idx)) => idx + 1, _ => 0, }; - let (tail, newest_ts) = collect_last_response_values(&buffer[slice_start..], max_records); + let (tail, newest_ts, name_opt) = + collect_last_response_values(&buffer[slice_start..], max_records); if latest_timestamp.is_none() { latest_timestamp = newest_ts.clone(); } + if latest_name.is_none() { + latest_name = name_opt.clone(); + } if tail.len() >= max_records || pos == 0 { - return Ok((tail, latest_timestamp.or(newest_ts))); + return Ok((tail, latest_timestamp.or(newest_ts), latest_name)); } let read_size = CHUNK_SIZE.min(pos as usize); if read_size == 0 { - return Ok((tail, latest_timestamp.or(newest_ts))); + return Ok((tail, latest_timestamp.or(newest_ts), latest_name)); } pos -= read_size as u64; file.seek(SeekFrom::Start(pos)).await?; @@ -446,16 +463,17 @@ async fn read_tail_records( fn collect_last_response_values( buffer: &[u8], max_records: usize, -) -> (Vec, Option) { +) -> (Vec, Option, Option) { use std::borrow::Cow; if buffer.is_empty() || max_records == 0 { - return (Vec::new(), None); + return (Vec::new(), None, None); } let text: Cow<'_, str> = String::from_utf8_lossy(buffer); let mut collected_rev: Vec = Vec::new(); let mut latest_timestamp: Option = None; + let mut latest_name: Option = None; for line in text.lines().rev() { let trimmed = line.trim(); if trimmed.is_empty() { @@ -464,20 +482,30 @@ fn collect_last_response_values( let parsed: serde_json::Result = serde_json::from_str(trimmed); let Ok(rollout_line) = parsed else { continue }; let RolloutLine { timestamp, item } = rollout_line; - if let RolloutItem::ResponseItem(item) = item - && let Ok(val) = serde_json::to_value(&item) - { - if latest_timestamp.is_none() { - latest_timestamp = Some(timestamp.clone()); + match item { + RolloutItem::ResponseItem(item) => { + if let Ok(val) = serde_json::to_value(&item) { + if latest_timestamp.is_none() { + latest_timestamp = Some(timestamp.clone()); + } + collected_rev.push(val); + if collected_rev.len() == max_records { + break; + } + } } - collected_rev.push(val); - if collected_rev.len() == max_records { - break; + RolloutItem::EventMsg(ev) => { + if latest_name.is_none() + && let EventMsg::SessionRenamed(SessionRenamedEvent { name }) = ev + { + latest_name = Some(name); + } } + _ => {} } } collected_rev.reverse(); - (collected_rev, latest_timestamp) + (collected_rev, latest_timestamp, latest_name) } /// Locate a recorded conversation rollout file by its UUID string using the existing diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 8fc39e79..3ff25e59 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -39,6 +39,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentMessage(_) | EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningRawContent(_) + | EventMsg::SessionRenamed(_) | EventMsg::TokenCount(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index b1f34e3a..f3e2c82f 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -196,6 +196,7 @@ async fn test_list_conversations_latest_first() { path: p1, head: head_3, tail: Vec::new(), + name: None, created_at: Some("2025-01-03T12-00-00".into()), updated_at: Some("2025-01-03T12-00-00".into()), }, @@ -203,6 +204,7 @@ async fn test_list_conversations_latest_first() { path: p2, head: head_2, tail: Vec::new(), + name: None, created_at: Some("2025-01-02T12-00-00".into()), updated_at: Some("2025-01-02T12-00-00".into()), }, @@ -210,6 +212,7 @@ async fn test_list_conversations_latest_first() { path: p3, head: head_1, tail: Vec::new(), + name: None, created_at: Some("2025-01-01T12-00-00".into()), updated_at: Some("2025-01-01T12-00-00".into()), }, @@ -317,6 +320,7 @@ async fn test_pagination_cursor() { path: p5, head: head_5, tail: Vec::new(), + name: None, created_at: Some("2025-03-05T09-00-00".into()), updated_at: Some("2025-03-05T09-00-00".into()), }, @@ -324,6 +328,7 @@ async fn test_pagination_cursor() { path: p4, head: head_4, tail: Vec::new(), + name: None, created_at: Some("2025-03-04T09-00-00".into()), updated_at: Some("2025-03-04T09-00-00".into()), }, @@ -380,6 +385,7 @@ async fn test_pagination_cursor() { path: p3, head: head_3, tail: Vec::new(), + name: None, created_at: Some("2025-03-03T09-00-00".into()), updated_at: Some("2025-03-03T09-00-00".into()), }, @@ -387,6 +393,7 @@ async fn test_pagination_cursor() { path: p2, head: head_2, tail: Vec::new(), + name: None, created_at: Some("2025-03-02T09-00-00".into()), updated_at: Some("2025-03-02T09-00-00".into()), }, @@ -427,6 +434,7 @@ async fn test_pagination_cursor() { path: p1, head: head_1, tail: Vec::new(), + name: None, created_at: Some("2025-03-01T09-00-00".into()), updated_at: Some("2025-03-01T09-00-00".into()), }], @@ -475,6 +483,7 @@ async fn test_get_conversation_contents() { path: expected_path, head: expected_head, tail: Vec::new(), + name: None, created_at: Some(ts.into()), updated_at: Some(ts.into()), }], @@ -823,6 +832,7 @@ async fn test_stable_ordering_same_second_pagination() { path: p3, head: head(u3), tail: Vec::new(), + name: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), }, @@ -830,6 +840,7 @@ async fn test_stable_ordering_same_second_pagination() { path: p2, head: head(u2), tail: Vec::new(), + name: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), }, @@ -860,6 +871,7 @@ async fn test_stable_ordering_same_second_pagination() { path: p1, head: head(u1), tail: Vec::new(), + name: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), }], diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f9aa3f85..465be95c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -511,6 +511,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { }, EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::ConversationPath(_) => {} + EventMsg::SessionRenamed(_) => {} EventMsg::UserMessage(_) => {} EventMsg::EnteredReviewMode(_) => {} EventMsg::ExitedReviewMode(_) => {} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f09dc98c..a5a3f4f7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner( | EventMsg::TurnAborted(_) | EventMsg::ConversationPath(_) | EventMsg::UserMessage(_) + | EventMsg::SessionRenamed(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1ae32e51..100de2c7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -171,6 +171,11 @@ 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 as an event so that UIs can + /// surface it when listing sessions. + SetSessionName { name: String }, + /// Request a code review from the agent. Review { review_request: ReviewRequest }, @@ -458,6 +463,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), @@ -895,6 +903,11 @@ pub struct WebSearchEndEvent { pub query: String, } +#[derive(Debug, Clone, Deserialize, Serialize, 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, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e..8ff57e6a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -381,6 +381,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(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae..e0bdd039 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -85,6 +85,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), } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index db13a041..4eca199b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,7 +25,7 @@ pub mod custom_prompt_view; mod file_search_popup; mod footer; mod list_selection_view; -mod prompt_args; +pub mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; pub mod popup_consts; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 973b0e3b..8da2ca63 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1104,6 +1104,7 @@ impl ChatWidget { return; } match cmd { + SlashCommand::Name => self.open_name_popup(), SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } @@ -1251,6 +1252,29 @@ impl ChatWidget { return; } + // Intercept '/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 ".to_string(), + None, + )); + self.request_redraw(); + } else { + // Send the rename op; persistence and ack come as an event. + 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; + } + self.capture_ghost_snapshot(); let mut items: Vec = Vec::new(); @@ -1443,6 +1467,13 @@ impl ChatWidget { self.app_event_tx .send(crate::app_event::AppEvent::ConversationHistory(ev)); } + EventMsg::SessionRenamed(ev) => { + self.add_to_history(history_cell::new_info_event( + format!("Named this chat: {}", ev.name), + None, + )); + self.request_redraw(); + } EventMsg::EnteredReviewMode(review_request) => { self.on_entered_review_mode(review_request) } @@ -2081,6 +2112,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}")); + } + /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index eb2a9cd4..1ef580ad 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -167,6 +167,7 @@ struct PickerState { next_search_token: usize, page_loader: PageLoader, view_rows: Option, + // No additional per-path state; names are embedded in rollouts. } struct PaginationState { @@ -586,9 +587,14 @@ fn head_to_row(item: &ConversationItem) -> Row { .and_then(parse_timestamp_str) .or(created_at); - let preview = preview_from_head(&item.head) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + let preview = item + .name + .clone() + .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 { @@ -958,6 +964,7 @@ mod tests { path: PathBuf::from(path), head: head_with_ts_and_user_text(ts, &[preview]), tail: Vec::new(), + name: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), } @@ -1020,6 +1027,7 @@ mod tests { path: PathBuf::from("/tmp/a.jsonl"), head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]), tail: Vec::new(), + name: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), }; @@ -1027,6 +1035,7 @@ mod tests { path: PathBuf::from("/tmp/b.jsonl"), head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]), tail: Vec::new(), + name: None, created_at: Some("2025-01-02T00:00:00Z".into()), updated_at: Some("2025-01-02T00:00:00Z".into()), }; @@ -1055,6 +1064,7 @@ mod tests { path: PathBuf::from("/tmp/a.jsonl"), head, tail, + name: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T01:00:00Z".into()), }; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a73..92afe247 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,6 +15,7 @@ pub enum SlashCommand { Model, Approvals, Review, + Name, New, Init, Compact, @@ -33,6 +34,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Name => "set a name for this chat", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", @@ -60,6 +62,8 @@ impl SlashCommand { /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { match self { + // Naming is a local UI action; allow during tasks. + SlashCommand::Name => true, SlashCommand::New | SlashCommand::Init | SlashCommand::Compact