feat: Set chat name (#4974)
Set chat name with `/name` so they appear in the codex resume page: https://github.com/user-attachments/assets/c0252bba-3a53-44c7-a740-f4690a3ad405
This commit is contained in:
@@ -88,6 +88,7 @@ use crate::protocol::ReviewDecision;
|
|||||||
use crate::protocol::ReviewOutputEvent;
|
use crate::protocol::ReviewOutputEvent;
|
||||||
use crate::protocol::SandboxPolicy;
|
use crate::protocol::SandboxPolicy;
|
||||||
use crate::protocol::SessionConfiguredEvent;
|
use crate::protocol::SessionConfiguredEvent;
|
||||||
|
use crate::protocol::SessionRenamedEvent;
|
||||||
use crate::protocol::StreamErrorEvent;
|
use crate::protocol::StreamErrorEvent;
|
||||||
use crate::protocol::Submission;
|
use crate::protocol::Submission;
|
||||||
use crate::protocol::TokenCountEvent;
|
use crate::protocol::TokenCountEvent;
|
||||||
@@ -1507,6 +1508,16 @@ async fn submission_loop(
|
|||||||
};
|
};
|
||||||
sess.send_event(event).await;
|
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 } => {
|
Op::Review { review_request } => {
|
||||||
spawn_review_thread(
|
spawn_review_thread(
|
||||||
sess.clone(),
|
sess.clone(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::SESSIONS_SUBDIR;
|
|||||||
use crate::protocol::EventMsg;
|
use crate::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::RolloutLine;
|
use codex_protocol::protocol::RolloutLine;
|
||||||
|
use codex_protocol::protocol::SessionRenamedEvent;
|
||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
|
|
||||||
/// Returned page of conversation summaries.
|
/// Returned page of conversation summaries.
|
||||||
@@ -41,6 +42,8 @@ pub struct ConversationItem {
|
|||||||
pub head: Vec<serde_json::Value>,
|
pub head: Vec<serde_json::Value>,
|
||||||
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
|
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
|
||||||
pub tail: Vec<serde_json::Value>,
|
pub tail: Vec<serde_json::Value>,
|
||||||
|
/// Latest human-friendly session name, if any.
|
||||||
|
pub name: Option<String>,
|
||||||
/// RFC3339 timestamp string for when the session was created, if available.
|
/// RFC3339 timestamp string for when the session was created, if available.
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
/// RFC3339 timestamp string for the most recent response in the tail, if available.
|
/// RFC3339 timestamp string for the most recent response in the tail, if available.
|
||||||
@@ -56,6 +59,7 @@ struct HeadTailSummary {
|
|||||||
source: Option<SessionSource>,
|
source: Option<SessionSource>,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
updated_at: Option<String>,
|
updated_at: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hard cap to bound worst‑case work per request.
|
/// Hard cap to bound worst‑case work per request.
|
||||||
@@ -222,6 +226,7 @@ async fn traverse_directories_for_paths(
|
|||||||
path,
|
path,
|
||||||
head,
|
head,
|
||||||
tail,
|
tail,
|
||||||
|
name: summary.name,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
});
|
});
|
||||||
@@ -382,14 +387,21 @@ async fn read_head_and_tail(
|
|||||||
if matches!(ev, EventMsg::UserMessage(_)) {
|
if matches!(ev, EventMsg::UserMessage(_)) {
|
||||||
summary.saw_user_event = true;
|
summary.saw_user_event = true;
|
||||||
}
|
}
|
||||||
|
if let EventMsg::SessionRenamed(SessionRenamedEvent { name }) = ev {
|
||||||
|
summary.name = Some(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tail_limit != 0 {
|
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.tail = tail;
|
||||||
summary.updated_at = updated_at;
|
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)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
@@ -397,13 +409,13 @@ async fn read_head_and_tail(
|
|||||||
async fn read_tail_records(
|
async fn read_tail_records(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
max_records: usize,
|
max_records: usize,
|
||||||
) -> io::Result<(Vec<serde_json::Value>, Option<String>)> {
|
) -> io::Result<(Vec<serde_json::Value>, Option<String>, Option<String>)> {
|
||||||
use std::io::SeekFrom;
|
use std::io::SeekFrom;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::io::AsyncSeekExt;
|
use tokio::io::AsyncSeekExt;
|
||||||
|
|
||||||
if max_records == 0 {
|
if max_records == 0 {
|
||||||
return Ok((Vec::new(), None));
|
return Ok((Vec::new(), None, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_SIZE: usize = 8192;
|
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 file = tokio::fs::File::open(path).await?;
|
||||||
let mut pos = file.seek(SeekFrom::End(0)).await?;
|
let mut pos = file.seek(SeekFrom::End(0)).await?;
|
||||||
if pos == 0 {
|
if pos == 0 {
|
||||||
return Ok((Vec::new(), None));
|
return Ok((Vec::new(), None, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut buffer: Vec<u8> = Vec::new();
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
let mut latest_timestamp: Option<String> = None;
|
let mut latest_timestamp: Option<String> = None;
|
||||||
|
let mut latest_name: Option<String> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) {
|
let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) {
|
||||||
(true, Some(idx)) => idx + 1,
|
(true, Some(idx)) => idx + 1,
|
||||||
_ => 0,
|
_ => 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() {
|
if latest_timestamp.is_none() {
|
||||||
latest_timestamp = newest_ts.clone();
|
latest_timestamp = newest_ts.clone();
|
||||||
}
|
}
|
||||||
|
if latest_name.is_none() {
|
||||||
|
latest_name = name_opt.clone();
|
||||||
|
}
|
||||||
if tail.len() >= max_records || pos == 0 {
|
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);
|
let read_size = CHUNK_SIZE.min(pos as usize);
|
||||||
if read_size == 0 {
|
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;
|
pos -= read_size as u64;
|
||||||
file.seek(SeekFrom::Start(pos)).await?;
|
file.seek(SeekFrom::Start(pos)).await?;
|
||||||
@@ -446,16 +463,17 @@ async fn read_tail_records(
|
|||||||
fn collect_last_response_values(
|
fn collect_last_response_values(
|
||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
max_records: usize,
|
max_records: usize,
|
||||||
) -> (Vec<serde_json::Value>, Option<String>) {
|
) -> (Vec<serde_json::Value>, Option<String>, Option<String>) {
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
if buffer.is_empty() || max_records == 0 {
|
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 text: Cow<'_, str> = String::from_utf8_lossy(buffer);
|
||||||
let mut collected_rev: Vec<serde_json::Value> = Vec::new();
|
let mut collected_rev: Vec<serde_json::Value> = Vec::new();
|
||||||
let mut latest_timestamp: Option<String> = None;
|
let mut latest_timestamp: Option<String> = None;
|
||||||
|
let mut latest_name: Option<String> = None;
|
||||||
for line in text.lines().rev() {
|
for line in text.lines().rev() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -464,20 +482,30 @@ fn collect_last_response_values(
|
|||||||
let parsed: serde_json::Result<RolloutLine> = serde_json::from_str(trimmed);
|
let parsed: serde_json::Result<RolloutLine> = serde_json::from_str(trimmed);
|
||||||
let Ok(rollout_line) = parsed else { continue };
|
let Ok(rollout_line) = parsed else { continue };
|
||||||
let RolloutLine { timestamp, item } = rollout_line;
|
let RolloutLine { timestamp, item } = rollout_line;
|
||||||
if let RolloutItem::ResponseItem(item) = item
|
match item {
|
||||||
&& let Ok(val) = serde_json::to_value(&item)
|
RolloutItem::ResponseItem(item) => {
|
||||||
{
|
if let Ok(val) = serde_json::to_value(&item) {
|
||||||
if latest_timestamp.is_none() {
|
if latest_timestamp.is_none() {
|
||||||
latest_timestamp = Some(timestamp.clone());
|
latest_timestamp = Some(timestamp.clone());
|
||||||
|
}
|
||||||
|
collected_rev.push(val);
|
||||||
|
if collected_rev.len() == max_records {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
collected_rev.push(val);
|
RolloutItem::EventMsg(ev) => {
|
||||||
if collected_rev.len() == max_records {
|
if latest_name.is_none()
|
||||||
break;
|
&& let EventMsg::SessionRenamed(SessionRenamedEvent { name }) = ev
|
||||||
|
{
|
||||||
|
latest_name = Some(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collected_rev.reverse();
|
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
|
/// Locate a recorded conversation rollout file by its UUID string using the existing
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
|||||||
| EventMsg::AgentMessage(_)
|
| EventMsg::AgentMessage(_)
|
||||||
| EventMsg::AgentReasoning(_)
|
| EventMsg::AgentReasoning(_)
|
||||||
| EventMsg::AgentReasoningRawContent(_)
|
| EventMsg::AgentReasoningRawContent(_)
|
||||||
|
| EventMsg::SessionRenamed(_)
|
||||||
| EventMsg::TokenCount(_)
|
| EventMsg::TokenCount(_)
|
||||||
| EventMsg::EnteredReviewMode(_)
|
| EventMsg::EnteredReviewMode(_)
|
||||||
| EventMsg::ExitedReviewMode(_)
|
| EventMsg::ExitedReviewMode(_)
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async fn test_list_conversations_latest_first() {
|
|||||||
path: p1,
|
path: p1,
|
||||||
head: head_3,
|
head: head_3,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-03T12-00-00".into()),
|
created_at: Some("2025-01-03T12-00-00".into()),
|
||||||
updated_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,
|
path: p2,
|
||||||
head: head_2,
|
head: head_2,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-02T12-00-00".into()),
|
created_at: Some("2025-01-02T12-00-00".into()),
|
||||||
updated_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,
|
path: p3,
|
||||||
head: head_1,
|
head: head_1,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-01T12-00-00".into()),
|
created_at: Some("2025-01-01T12-00-00".into()),
|
||||||
updated_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,
|
path: p5,
|
||||||
head: head_5,
|
head: head_5,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-03-05T09-00-00".into()),
|
created_at: Some("2025-03-05T09-00-00".into()),
|
||||||
updated_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,
|
path: p4,
|
||||||
head: head_4,
|
head: head_4,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-03-04T09-00-00".into()),
|
created_at: Some("2025-03-04T09-00-00".into()),
|
||||||
updated_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,
|
path: p3,
|
||||||
head: head_3,
|
head: head_3,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-03-03T09-00-00".into()),
|
created_at: Some("2025-03-03T09-00-00".into()),
|
||||||
updated_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,
|
path: p2,
|
||||||
head: head_2,
|
head: head_2,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-03-02T09-00-00".into()),
|
created_at: Some("2025-03-02T09-00-00".into()),
|
||||||
updated_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,
|
path: p1,
|
||||||
head: head_1,
|
head: head_1,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-03-01T09-00-00".into()),
|
created_at: Some("2025-03-01T09-00-00".into()),
|
||||||
updated_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,
|
path: expected_path,
|
||||||
head: expected_head,
|
head: expected_head,
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some(ts.into()),
|
created_at: Some(ts.into()),
|
||||||
updated_at: Some(ts.into()),
|
updated_at: Some(ts.into()),
|
||||||
}],
|
}],
|
||||||
@@ -823,6 +832,7 @@ async fn test_stable_ordering_same_second_pagination() {
|
|||||||
path: p3,
|
path: p3,
|
||||||
head: head(u3),
|
head: head(u3),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some(ts.to_string()),
|
created_at: Some(ts.to_string()),
|
||||||
updated_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,
|
path: p2,
|
||||||
head: head(u2),
|
head: head(u2),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some(ts.to_string()),
|
created_at: Some(ts.to_string()),
|
||||||
updated_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,
|
path: p1,
|
||||||
head: head(u1),
|
head: head(u1),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some(ts.to_string()),
|
created_at: Some(ts.to_string()),
|
||||||
updated_at: Some(ts.to_string()),
|
updated_at: Some(ts.to_string()),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
|||||||
},
|
},
|
||||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||||
EventMsg::ConversationPath(_) => {}
|
EventMsg::ConversationPath(_) => {}
|
||||||
|
EventMsg::SessionRenamed(_) => {}
|
||||||
EventMsg::UserMessage(_) => {}
|
EventMsg::UserMessage(_) => {}
|
||||||
EventMsg::EnteredReviewMode(_) => {}
|
EventMsg::EnteredReviewMode(_) => {}
|
||||||
EventMsg::ExitedReviewMode(_) => {}
|
EventMsg::ExitedReviewMode(_) => {}
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner(
|
|||||||
| EventMsg::TurnAborted(_)
|
| EventMsg::TurnAborted(_)
|
||||||
| EventMsg::ConversationPath(_)
|
| EventMsg::ConversationPath(_)
|
||||||
| EventMsg::UserMessage(_)
|
| EventMsg::UserMessage(_)
|
||||||
|
| EventMsg::SessionRenamed(_)
|
||||||
| EventMsg::ShutdownComplete
|
| EventMsg::ShutdownComplete
|
||||||
| EventMsg::ViewImageToolCall(_)
|
| EventMsg::ViewImageToolCall(_)
|
||||||
| EventMsg::EnteredReviewMode(_)
|
| EventMsg::EnteredReviewMode(_)
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ pub enum Op {
|
|||||||
/// to generate a summary which will be returned as an AgentMessage event.
|
/// to generate a summary which will be returned as an AgentMessage event.
|
||||||
Compact,
|
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.
|
/// Request a code review from the agent.
|
||||||
Review { review_request: ReviewRequest },
|
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).
|
/// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
|
||||||
AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
|
AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
|
||||||
|
|
||||||
|
/// Session was given a human-friendly name by the user.
|
||||||
|
SessionRenamed(SessionRenamedEvent),
|
||||||
|
|
||||||
/// Ack the client's configure message.
|
/// Ack the client's configure message.
|
||||||
SessionConfigured(SessionConfiguredEvent),
|
SessionConfigured(SessionConfiguredEvent),
|
||||||
|
|
||||||
@@ -895,6 +903,11 @@ pub struct WebSearchEndEvent {
|
|||||||
pub query: String,
|
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
|
/// Response payload for `Op::GetHistory` containing the current session's
|
||||||
/// in-memory transcript.
|
/// in-memory transcript.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||||
|
|||||||
@@ -381,6 +381,9 @@ impl App {
|
|||||||
AppEvent::OpenReviewCustomPrompt => {
|
AppEvent::OpenReviewCustomPrompt => {
|
||||||
self.chat_widget.show_review_custom_prompt();
|
self.chat_widget.show_review_custom_prompt();
|
||||||
}
|
}
|
||||||
|
AppEvent::SetSessionName(name) => {
|
||||||
|
self.chat_widget.begin_set_session_name(name);
|
||||||
|
}
|
||||||
AppEvent::FullScreenApprovalRequest(request) => match request {
|
AppEvent::FullScreenApprovalRequest(request) => match request {
|
||||||
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
||||||
let _ = tui.enter_alt_screen();
|
let _ = tui.enter_alt_screen();
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ pub(crate) enum AppEvent {
|
|||||||
/// Open the custom prompt option from the review popup.
|
/// Open the custom prompt option from the review popup.
|
||||||
OpenReviewCustomPrompt,
|
OpenReviewCustomPrompt,
|
||||||
|
|
||||||
|
/// Begin setting a human-readable name for the current session.
|
||||||
|
SetSessionName(String),
|
||||||
|
|
||||||
/// Open the approval popup.
|
/// Open the approval popup.
|
||||||
FullScreenApprovalRequest(ApprovalRequest),
|
FullScreenApprovalRequest(ApprovalRequest),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub mod custom_prompt_view;
|
|||||||
mod file_search_popup;
|
mod file_search_popup;
|
||||||
mod footer;
|
mod footer;
|
||||||
mod list_selection_view;
|
mod list_selection_view;
|
||||||
mod prompt_args;
|
pub mod prompt_args;
|
||||||
pub(crate) use list_selection_view::SelectionViewParams;
|
pub(crate) use list_selection_view::SelectionViewParams;
|
||||||
mod paste_burst;
|
mod paste_burst;
|
||||||
pub mod popup_consts;
|
pub mod popup_consts;
|
||||||
|
|||||||
@@ -1104,6 +1104,7 @@ impl ChatWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match cmd {
|
match cmd {
|
||||||
|
SlashCommand::Name => self.open_name_popup(),
|
||||||
SlashCommand::New => {
|
SlashCommand::New => {
|
||||||
self.app_event_tx.send(AppEvent::NewSession);
|
self.app_event_tx.send(AppEvent::NewSession);
|
||||||
}
|
}
|
||||||
@@ -1251,6 +1252,29 @@ impl ChatWidget {
|
|||||||
return;
|
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 {
|
||||||
|
// 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();
|
self.capture_ghost_snapshot();
|
||||||
|
|
||||||
let mut items: Vec<InputItem> = Vec::new();
|
let mut items: Vec<InputItem> = Vec::new();
|
||||||
@@ -1443,6 +1467,13 @@ impl ChatWidget {
|
|||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
.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) => {
|
EventMsg::EnteredReviewMode(review_request) => {
|
||||||
self.on_entered_review_mode(review_request)
|
self.on_entered_review_mode(review_request)
|
||||||
}
|
}
|
||||||
@@ -2081,6 +2112,33 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.show_view(Box::new(view));
|
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
|
/// Programmatically submit a user text message as if typed in the
|
||||||
/// composer. The text will be added to conversation history and sent to
|
/// composer. The text will be added to conversation history and sent to
|
||||||
/// the agent.
|
/// the agent.
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ struct PickerState {
|
|||||||
next_search_token: usize,
|
next_search_token: usize,
|
||||||
page_loader: PageLoader,
|
page_loader: PageLoader,
|
||||||
view_rows: Option<usize>,
|
view_rows: Option<usize>,
|
||||||
|
// No additional per-path state; names are embedded in rollouts.
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PaginationState {
|
struct PaginationState {
|
||||||
@@ -586,9 +587,14 @@ fn head_to_row(item: &ConversationItem) -> Row {
|
|||||||
.and_then(parse_timestamp_str)
|
.and_then(parse_timestamp_str)
|
||||||
.or(created_at);
|
.or(created_at);
|
||||||
|
|
||||||
let preview = preview_from_head(&item.head)
|
let preview = item
|
||||||
.map(|s| s.trim().to_string())
|
.name
|
||||||
.filter(|s| !s.is_empty())
|
.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)"));
|
.unwrap_or_else(|| String::from("(no message yet)"));
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
@@ -958,6 +964,7 @@ mod tests {
|
|||||||
path: PathBuf::from(path),
|
path: PathBuf::from(path),
|
||||||
head: head_with_ts_and_user_text(ts, &[preview]),
|
head: head_with_ts_and_user_text(ts, &[preview]),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some(ts.to_string()),
|
created_at: Some(ts.to_string()),
|
||||||
updated_at: Some(ts.to_string()),
|
updated_at: Some(ts.to_string()),
|
||||||
}
|
}
|
||||||
@@ -1020,6 +1027,7 @@ mod tests {
|
|||||||
path: PathBuf::from("/tmp/a.jsonl"),
|
path: PathBuf::from("/tmp/a.jsonl"),
|
||||||
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||||
updated_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"),
|
path: PathBuf::from("/tmp/b.jsonl"),
|
||||||
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
||||||
tail: Vec::new(),
|
tail: Vec::new(),
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-02T00:00:00Z".into()),
|
created_at: Some("2025-01-02T00:00:00Z".into()),
|
||||||
updated_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"),
|
path: PathBuf::from("/tmp/a.jsonl"),
|
||||||
head,
|
head,
|
||||||
tail,
|
tail,
|
||||||
|
name: None,
|
||||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||||
updated_at: Some("2025-01-01T01:00:00Z".into()),
|
updated_at: Some("2025-01-01T01:00:00Z".into()),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub enum SlashCommand {
|
|||||||
Model,
|
Model,
|
||||||
Approvals,
|
Approvals,
|
||||||
Review,
|
Review,
|
||||||
|
Name,
|
||||||
New,
|
New,
|
||||||
Init,
|
Init,
|
||||||
Compact,
|
Compact,
|
||||||
@@ -33,6 +34,7 @@ impl SlashCommand {
|
|||||||
/// User-visible description shown in the popup.
|
/// User-visible description shown in the popup.
|
||||||
pub fn description(self) -> &'static str {
|
pub fn description(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
SlashCommand::Name => "set a name for this chat",
|
||||||
SlashCommand::New => "start a new chat during a conversation",
|
SlashCommand::New => "start a new chat during a conversation",
|
||||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
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.
|
/// Whether this command can be run while a task is in progress.
|
||||||
pub fn available_during_task(self) -> bool {
|
pub fn available_during_task(self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
// Naming is a local UI action; allow during tasks.
|
||||||
|
SlashCommand::Name => true,
|
||||||
SlashCommand::New
|
SlashCommand::New
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Compact
|
| SlashCommand::Compact
|
||||||
|
|||||||
Reference in New Issue
Block a user