mirror of
https://github.com/openai/codex.git
synced 2026-04-26 01:11:03 +03:00
Compare commits
1 Commits
dev/steve/
...
dev/javi/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1364a81040 |
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::UserMessage(_)
|
||||
| EventMsg::SessionRenamed(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user