mirror of
https://github.com/openai/codex.git
synced 2026-03-24 15:43:53 +00:00
Compare commits
4 Commits
codex-auto
...
codex-cli-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7269a2ef40 | ||
|
|
95ca63a688 | ||
|
|
67b7db7684 | ||
|
|
d1e20cdcac |
@@ -3345,7 +3345,6 @@ impl Session {
|
||||
input: &[UserInput],
|
||||
response_item: ResponseItem,
|
||||
) {
|
||||
let auto_thread_name = self.auto_thread_name_candidate(input).await;
|
||||
// Persist the user message to history, but emit the turn item from `UserInput` so
|
||||
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
||||
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
||||
@@ -3355,30 +3354,6 @@ impl Session {
|
||||
self.emit_turn_item_started(turn_context, &turn_item).await;
|
||||
self.emit_turn_item_completed(turn_context, turn_item).await;
|
||||
self.ensure_rollout_materialized().await;
|
||||
if let Some(name) = auto_thread_name {
|
||||
handlers::maybe_set_auto_thread_name(self, turn_context.sub_id.clone(), name).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn auto_thread_name_candidate(&self, input: &[UserInput]) -> Option<String> {
|
||||
if self.services.rollout.lock().await.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = self.state.lock().await;
|
||||
if state.session_configuration.thread_name.is_some() {
|
||||
return None;
|
||||
}
|
||||
if state
|
||||
.history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.any(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
crate::util::auto_thread_name_from_user_input(input)
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_background_event(
|
||||
@@ -3936,57 +3911,6 @@ mod handlers {
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
enum ThreadNameUpdateError {
|
||||
BadRequest(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
async fn update_thread_name(
|
||||
sess: &Session,
|
||||
name: String,
|
||||
) -> std::result::Result<String, ThreadNameUpdateError> {
|
||||
let Some(name) = crate::util::normalize_thread_name(&name) else {
|
||||
return Err(ThreadNameUpdateError::BadRequest(
|
||||
"Thread name cannot be empty.".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let persistence_enabled = {
|
||||
let rollout = sess.services.rollout.lock().await;
|
||||
rollout.is_some()
|
||||
};
|
||||
if !persistence_enabled {
|
||||
return Err(ThreadNameUpdateError::Other(
|
||||
"Session persistence is disabled; cannot rename thread.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let codex_home = sess.codex_home().await;
|
||||
session_index::append_thread_name(&codex_home, sess.conversation_id, &name)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ThreadNameUpdateError::Other(format!("Failed to set thread name: {err}"))
|
||||
})?;
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.session_configuration.thread_name = Some(name.clone());
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn emit_thread_name_updated(sess: &Session, sub_id: String, name: String) {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_name: Some(name),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn interrupt(sess: &Arc<Session>) {
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
@@ -4572,36 +4496,62 @@ mod handlers {
|
||||
///
|
||||
/// Returns an error event if the name is empty or session persistence is disabled.
|
||||
pub async fn set_thread_name(sess: &Arc<Session>, sub_id: String, name: String) {
|
||||
match update_thread_name(sess.as_ref(), name).await {
|
||||
Ok(name) => emit_thread_name_updated(sess.as_ref(), sub_id, name).await,
|
||||
Err(ThreadNameUpdateError::BadRequest(message)) => {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(ThreadNameUpdateError::Other(message)) => {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_set_auto_thread_name(sess: &Session, sub_id: String, name: String) {
|
||||
let Ok(name) = update_thread_name(sess, name).await else {
|
||||
let Some(name) = crate::util::normalize_thread_name(&name) else {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Thread name cannot be empty.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
};
|
||||
emit_thread_name_updated(sess, sub_id, name).await;
|
||||
|
||||
let persistence_enabled = {
|
||||
let rollout = sess.services.rollout.lock().await;
|
||||
rollout.is_some()
|
||||
};
|
||||
if !persistence_enabled {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Session persistence is disabled; cannot rename thread.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let codex_home = sess.codex_home().await;
|
||||
if let Err(e) =
|
||||
session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await
|
||||
{
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: format!("Failed to set thread name: {e}"),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.session_configuration.thread_name = Some(name.clone());
|
||||
}
|
||||
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_name: Some(name),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
@@ -8650,111 +8600,6 @@ mod tests {
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await
|
||||
}
|
||||
|
||||
async fn enable_rollout_persistence(sess: &Arc<Session>) {
|
||||
let session_configuration = { sess.state.lock().await.session_configuration.clone() };
|
||||
let config = Arc::clone(&session_configuration.original_config_do_not_use);
|
||||
let event_persistence_mode = if session_configuration.persist_extended_history {
|
||||
EventPersistenceMode::Extended
|
||||
} else {
|
||||
EventPersistenceMode::Limited
|
||||
};
|
||||
let recorder = RolloutRecorder::new(
|
||||
config.as_ref(),
|
||||
RolloutRecorderParams::new(
|
||||
sess.conversation_id,
|
||||
None,
|
||||
session_configuration.session_source.clone(),
|
||||
BaseInstructions {
|
||||
text: session_configuration.base_instructions.clone(),
|
||||
},
|
||||
session_configuration.dynamic_tools.clone(),
|
||||
event_persistence_mode,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create rollout recorder");
|
||||
*sess.services.rollout.lock().await = Some(recorder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_user_prompt_auto_names_new_thread() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
enable_rollout_persistence(&session).await;
|
||||
|
||||
let input = vec![UserInput::Text {
|
||||
text: "Fix CI on Android app".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
|
||||
|
||||
session
|
||||
.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
|
||||
let thread_name = {
|
||||
session
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.thread_name
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(thread_name.as_deref(), Some("Fix CI Android app"));
|
||||
|
||||
let persisted = session_index::find_thread_name_by_id(
|
||||
turn_context.config.codex_home.as_path(),
|
||||
&session.conversation_id,
|
||||
)
|
||||
.await
|
||||
.expect("read persisted thread name");
|
||||
assert_eq!(persisted.as_deref(), Some("Fix CI Android app"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_user_prompt_does_not_auto_name_thread_with_existing_user_history() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
enable_rollout_persistence(&session).await;
|
||||
|
||||
session
|
||||
.record_into_history(
|
||||
&[user_message("existing thread seed")],
|
||||
turn_context.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let input = vec![UserInput::Text {
|
||||
text: "Fix CI on Android app".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
|
||||
|
||||
session
|
||||
.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
|
||||
let thread_name = {
|
||||
session
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.thread_name
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(thread_name, None);
|
||||
|
||||
let persisted = session_index::find_thread_name_by_id(
|
||||
turn_context.config.codex_home.as_path(),
|
||||
&session.conversation_id,
|
||||
)
|
||||
.await
|
||||
.expect("read persisted thread name");
|
||||
assert_eq!(persisted, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -3,24 +3,14 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use rand::Rng;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
|
||||
use crate::parse_command::shlex_join;
|
||||
|
||||
const AUTO_THREAD_NAME_MAX_WORDS: usize = 4;
|
||||
const AUTO_THREAD_NAME_MAX_CHARS: usize = 40;
|
||||
const INITIAL_DELAY_MS: u64 = 200;
|
||||
const BACKOFF_FACTOR: f64 = 2.0;
|
||||
const AUTO_THREAD_NAME_STOP_WORDS: &[&str] = &[
|
||||
"a", "an", "and", "are", "as", "at", "be", "by", "can", "could", "for", "from", "help", "how",
|
||||
"i", "in", "into", "is", "it", "me", "my", "need", "of", "on", "or", "please", "should",
|
||||
"tell", "that", "the", "this", "to", "us", "want", "we", "what", "why", "with", "would", "you",
|
||||
"your",
|
||||
];
|
||||
|
||||
/// Emit structured feedback metadata as key/value pairs.
|
||||
///
|
||||
@@ -95,105 +85,6 @@ pub fn normalize_thread_name(name: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the desktop app's preference for very short task titles while keeping
|
||||
/// CLI naming deterministic and local.
|
||||
pub fn auto_thread_name_from_user_input(input: &[UserInput]) -> Option<String> {
|
||||
let text = input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
auto_thread_name_from_text(&text)
|
||||
}
|
||||
|
||||
fn auto_thread_name_from_text(text: &str) -> Option<String> {
|
||||
let excerpt = strip_user_message_prefix(text)
|
||||
.split(['\n', '\r'])
|
||||
.next()
|
||||
.unwrap_or(text)
|
||||
.split(['.', '?', '!'])
|
||||
.next()
|
||||
.unwrap_or(text)
|
||||
.trim();
|
||||
if excerpt.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let preferred = collect_title_words(excerpt, true);
|
||||
let words = if preferred.len() >= 2 {
|
||||
preferred
|
||||
} else {
|
||||
collect_title_words(excerpt, false)
|
||||
};
|
||||
if words.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut title = words.join(" ");
|
||||
uppercase_first_letter(&mut title);
|
||||
normalize_thread_name(&title)
|
||||
}
|
||||
|
||||
fn collect_title_words(text: &str, drop_stop_words: bool) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut total_len = 0usize;
|
||||
|
||||
for raw_word in text.split_whitespace() {
|
||||
let word = clean_title_word(raw_word);
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if drop_stop_words && is_auto_thread_name_stop_word(&word) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_len = if words.is_empty() {
|
||||
word.len()
|
||||
} else {
|
||||
total_len + 1 + word.len()
|
||||
};
|
||||
if words.len() >= AUTO_THREAD_NAME_MAX_WORDS
|
||||
|| (!words.is_empty() && next_len > AUTO_THREAD_NAME_MAX_CHARS)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
total_len = next_len;
|
||||
words.push(word);
|
||||
}
|
||||
|
||||
words
|
||||
}
|
||||
|
||||
fn clean_title_word(word: &str) -> String {
|
||||
word.trim_matches(|ch: char| !ch.is_alphanumeric())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_auto_thread_name_stop_word(word: &str) -> bool {
|
||||
AUTO_THREAD_NAME_STOP_WORDS
|
||||
.iter()
|
||||
.any(|stop_word| word.eq_ignore_ascii_case(stop_word))
|
||||
}
|
||||
|
||||
fn uppercase_first_letter(text: &mut String) {
|
||||
let Some((idx, ch)) = text.char_indices().find(|(_, ch)| ch.is_alphabetic()) else {
|
||||
return;
|
||||
};
|
||||
let upper = ch.to_uppercase().to_string();
|
||||
text.replace_range(idx..idx + ch.len_utf8(), &upper);
|
||||
}
|
||||
|
||||
fn strip_user_message_prefix(text: &str) -> &str {
|
||||
match text.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
||||
None => text.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resume_command(thread_name: Option<&str>, thread_id: Option<ThreadId>) -> Option<String> {
|
||||
let resume_target = thread_name
|
||||
.filter(|name| !name.is_empty())
|
||||
@@ -255,56 +146,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_thread_name_prefers_short_content_words() {
|
||||
let input = vec![UserInput::Text {
|
||||
text: "Fix CI on Android app".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
auto_thread_name_from_user_input(&input),
|
||||
Some("Fix CI Android app".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_thread_name_strips_user_message_prefix() {
|
||||
let text = format!("{USER_MESSAGE_BEGIN} can you explain MCP OAuth login flow?");
|
||||
assert_eq!(
|
||||
auto_thread_name_from_text(&text),
|
||||
Some("Explain MCP OAuth login".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_thread_name_preserves_word_boundaries_between_text_items() {
|
||||
let input = vec![
|
||||
UserInput::Text {
|
||||
text: "fix CI".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "on Android".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
auto_thread_name_from_user_input(&input),
|
||||
Some("Fix CI Android".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_thread_name_ignores_non_text_input() {
|
||||
let input = vec![UserInput::Image {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
}];
|
||||
|
||||
assert_eq!(auto_thread_name_from_user_input(&input), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_prefers_name_over_id() {
|
||||
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
|
||||
@@ -115,11 +115,13 @@ use self::pending_interactive_replay::PendingInteractiveReplayState;
|
||||
|
||||
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
||||
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
|
||||
const TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
/// Baseline cadence for periodic stream commit animation ticks.
|
||||
///
|
||||
/// Smooth-mode streaming drains one line per tick, so this interval controls
|
||||
/// perceived typing speed for non-backlogged output.
|
||||
const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
|
||||
const TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppExitInfo {
|
||||
@@ -718,6 +720,46 @@ fn normalize_harness_overrides_for_cwd(
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn decorate_title_context(
|
||||
context: Option<String>,
|
||||
task_running: bool,
|
||||
tick: u128,
|
||||
) -> Option<String> {
|
||||
if !task_running {
|
||||
return context;
|
||||
}
|
||||
|
||||
let frame = TITLE_SPINNER_FRAMES[tick as usize % TITLE_SPINNER_FRAMES.len()];
|
||||
match context {
|
||||
Some(context) => Some(format!("{frame} - {context}")),
|
||||
None => Some(frame.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_title_context(
|
||||
title_override: Option<String>,
|
||||
thread_name: Option<String>,
|
||||
task_running: bool,
|
||||
tick: u128,
|
||||
) -> Option<String> {
|
||||
let context = title_override
|
||||
.or(thread_name)
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
decorate_title_context(context, task_running, tick)
|
||||
}
|
||||
|
||||
fn title_animation_tick() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
/ TITLE_SPINNER_INTERVAL.as_millis()
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
@@ -1747,6 +1789,18 @@ impl App {
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
};
|
||||
let task_running = app.chat_widget.is_task_running();
|
||||
let title_context = compute_title_context(
|
||||
app.chat_widget.title_override(),
|
||||
app.chat_widget.thread_name(),
|
||||
task_running,
|
||||
title_animation_tick(),
|
||||
);
|
||||
tui.set_title_context(title_context.as_deref())?;
|
||||
if task_running {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
|
||||
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1785,6 +1839,7 @@ impl App {
|
||||
)
|
||||
.await?;
|
||||
if let AppRunControl::Exit(exit_reason) = control {
|
||||
tui.set_title_context(None)?;
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
thread_id: app.chat_widget.thread_id(),
|
||||
@@ -1845,6 +1900,18 @@ impl App {
|
||||
AppRunControl::Continue
|
||||
}
|
||||
};
|
||||
let task_running = app.chat_widget.is_task_running();
|
||||
let title_context = compute_title_context(
|
||||
app.chat_widget.title_override(),
|
||||
app.chat_widget.thread_name(),
|
||||
task_running,
|
||||
title_animation_tick(),
|
||||
);
|
||||
tui.set_title_context(title_context.as_deref())?;
|
||||
if task_running {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
if App::should_stop_waiting_for_initial_session(
|
||||
waiting_for_initial_session_configured,
|
||||
app.primary_thread_id,
|
||||
@@ -1856,6 +1923,7 @@ impl App {
|
||||
AppRunControl::Exit(reason) => break reason,
|
||||
}
|
||||
};
|
||||
tui.set_title_context(None)?;
|
||||
tui.terminal.clear()?;
|
||||
Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
@@ -2124,6 +2192,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SetTitle(title) => {
|
||||
self.chat_widget.set_title_override(title);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
if self.apply_non_pending_thread_rollback(num_turns) {
|
||||
tui.frame_requester().schedule_frame();
|
||||
@@ -3731,6 +3803,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorate_title_context_leaves_idle_titles_plain() {
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Named thread".to_string()), false, 3),
|
||||
Some("Named thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorate_title_context_adds_spinner_while_running() {
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 0),
|
||||
Some("⠋ - Working".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 9),
|
||||
Some("⠏ - Working".to_string())
|
||||
);
|
||||
assert_eq!(decorate_title_context(None, true, 0), Some("⠋".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_context_uses_thread_name_when_idle() {
|
||||
assert_eq!(
|
||||
compute_title_context(None, Some("named thread".to_string()), false, 0),
|
||||
Some("named thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_context_prefers_manual_title_when_idle() {
|
||||
assert_eq!(
|
||||
compute_title_context(
|
||||
Some("manual title".to_string()),
|
||||
Some("named thread".to_string()),
|
||||
false,
|
||||
0,
|
||||
),
|
||||
Some("manual title".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
|
||||
let mut wait_for_initial_session =
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
SetTitle(Option<String>),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
///
|
||||
|
||||
@@ -17,7 +17,11 @@ use std::collections::HashSet;
|
||||
// Hide alias commands in the default popup list so each unique action appears once.
|
||||
// `quit` is an alias of `exit`, so we skip `quit` here.
|
||||
// `approvals` is an alias of `permissions`.
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[
|
||||
SlashCommand::Quit,
|
||||
SlashCommand::Approvals,
|
||||
SlashCommand::Title,
|
||||
];
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -477,6 +481,18 @@ mod tests {
|
||||
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_hidden_in_empty_filter_but_shown_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Title)));
|
||||
|
||||
popup.on_composer_text_change("/ti".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_command_hidden_when_collaboration_modes_disabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
|
||||
@@ -82,6 +82,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_command_resolves_for_dispatch() {
|
||||
assert_eq!(
|
||||
find_builtin_command("title", all_enabled_flags()),
|
||||
Some(SlashCommand::Title)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_command_is_hidden_when_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
|
||||
@@ -607,6 +607,7 @@ pub(crate) struct ChatWidget {
|
||||
pending_status_indicator_restore: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
title_override: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
@@ -2913,6 +2914,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_message_edit_binding,
|
||||
@@ -3093,6 +3095,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
@@ -3262,6 +3265,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_message_edit_binding,
|
||||
@@ -3607,6 +3611,9 @@ impl ChatWidget {
|
||||
self.otel_manager.counter("codex.thread.rename", 1, &[]);
|
||||
self.show_rename_prompt();
|
||||
}
|
||||
SlashCommand::Title => {
|
||||
self.show_title_prompt();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
@@ -3936,6 +3943,13 @@ impl ChatWidget {
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Title => {
|
||||
let title = codex_core::util::normalize_thread_name(trimmed);
|
||||
let cell = Self::title_confirmation_cell(title.as_deref());
|
||||
self.add_boxed_history(Box::new(cell));
|
||||
self.set_title_override(title);
|
||||
self.request_redraw();
|
||||
}
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
@@ -4030,6 +4044,23 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn show_title_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Set title".to_string(),
|
||||
"Type a title and press Enter. Leave blank to clear it.".to_string(),
|
||||
None,
|
||||
Box::new(move |name: String| {
|
||||
let title = codex_core::util::normalize_thread_name(&name);
|
||||
let cell = Self::title_confirmation_cell(title.as_deref());
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
tx.send(AppEvent::SetTitle(title));
|
||||
}),
|
||||
);
|
||||
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
@@ -7306,6 +7337,18 @@ impl ChatWidget {
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
fn title_confirmation_cell(title: Option<&str>) -> PlainHistoryCell {
|
||||
let line = match title {
|
||||
Some(title) => vec![
|
||||
"• ".into(),
|
||||
"Title set to ".into(),
|
||||
title.to_string().cyan(),
|
||||
],
|
||||
None => vec!["• ".into(), "Title cleared".into()],
|
||||
};
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
pub(crate) fn add_mcp_output(&mut self) {
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
self.config.codex_home.clone(),
|
||||
@@ -7984,6 +8027,18 @@ impl ChatWidget {
|
||||
self.thread_name.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn title_override(&self) -> Option<String> {
|
||||
self.title_override.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_title_override(&mut self, title: Option<String>) {
|
||||
self.title_override = title;
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
self.bottom_pane.is_task_running()
|
||||
}
|
||||
|
||||
/// Returns the current thread's precomputed rollout path.
|
||||
///
|
||||
/// For fresh non-ephemeral threads this path may exist before the file is
|
||||
|
||||
@@ -1715,6 +1715,7 @@ async fn make_chatwidget_manual(
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
@@ -1754,6 +1755,41 @@ async fn make_chatwidget_manual(
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn title_command_sets_manual_title_without_renaming_thread() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Title, "manual title".to_string(), Vec::new());
|
||||
|
||||
assert_eq!(chat.title_override(), Some("manual title".to_string()));
|
||||
assert_eq!(chat.thread_name(), None);
|
||||
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(op, Op::SetThreadName { .. }),
|
||||
"unexpected rename op: {op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_title_command_clears_manual_title() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_title_override(Some("manual title".to_string()));
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Title, String::new(), Vec::new());
|
||||
|
||||
assert_eq!(chat.title_override(), None);
|
||||
assert_eq!(chat.thread_name(), None);
|
||||
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(op, Op::SetThreadName { .. }),
|
||||
"unexpected rename op: {op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper
|
||||
// filters until we see a submission op.
|
||||
fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum SlashCommand {
|
||||
Skills,
|
||||
Review,
|
||||
Rename,
|
||||
Title,
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
@@ -72,6 +73,7 @@ impl SlashCommand {
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Rename => "rename the current thread",
|
||||
SlashCommand::Title => "set the terminal title",
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
@@ -124,6 +126,7 @@ impl SlashCommand {
|
||||
self,
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Title
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::SandboxReadRoot
|
||||
@@ -156,6 +159,7 @@ impl SlashCommand {
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Title
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Status
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::future::Future;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::Write;
|
||||
use std::io::stdin;
|
||||
use std::io::stdout;
|
||||
use std::panic;
|
||||
@@ -58,6 +59,47 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||
const DEFAULT_TERMINAL_TITLE: &str = "Codex";
|
||||
|
||||
fn has_spinner_prefix(context: &str) -> bool {
|
||||
matches!(
|
||||
context.chars().next(),
|
||||
Some('⠋' | '⠙' | '⠹' | '⠸' | '⠼' | '⠴' | '⠦' | '⠧' | '⠇' | '⠏')
|
||||
)
|
||||
}
|
||||
|
||||
fn format_terminal_title(context: Option<&str>) -> String {
|
||||
let context = context
|
||||
.map(|text| {
|
||||
text.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|text| !text.is_empty());
|
||||
|
||||
match context {
|
||||
Some(context) if has_spinner_prefix(&context) => format!("{DEFAULT_TERMINAL_TITLE} {context}"),
|
||||
Some(context) => format!("{DEFAULT_TERMINAL_TITLE} - {context}"),
|
||||
None => DEFAULT_TERMINAL_TITLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_terminal_title(
|
||||
writer: &mut impl Write,
|
||||
current_title: &mut Option<String>,
|
||||
context: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let title = format_terminal_title(context);
|
||||
if current_title.as_ref() == Some(&title) {
|
||||
return Ok(());
|
||||
}
|
||||
write!(writer, "\x1b]0;{title}\x07")?;
|
||||
writer.flush()?;
|
||||
*current_title = Some(title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
@@ -255,6 +297,7 @@ pub struct Tui {
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
|
||||
alt_screen_enabled: bool,
|
||||
current_title: Option<String>,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
@@ -283,6 +326,7 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
alt_screen_enabled: true,
|
||||
current_title: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +339,12 @@ impl Tui {
|
||||
self.notification_backend = Some(detect_backend(method));
|
||||
}
|
||||
|
||||
pub fn set_title_context(&mut self, context: Option<&str>) -> Result<()> {
|
||||
let current_title = &mut self.current_title;
|
||||
let backend = self.terminal.backend_mut();
|
||||
write_terminal_title(backend, current_title, context)
|
||||
}
|
||||
|
||||
pub fn frame_requester(&self) -> FrameRequester {
|
||||
self.frame_requester.clone()
|
||||
}
|
||||
@@ -544,3 +594,56 @@ impl Tui {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DEFAULT_TERMINAL_TITLE;
|
||||
use super::format_terminal_title;
|
||||
use super::write_terminal_title;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn terminal_title_defaults_to_codex() {
|
||||
assert_eq!(format_terminal_title(None), DEFAULT_TERMINAL_TITLE);
|
||||
assert_eq!(format_terminal_title(Some(" ")), DEFAULT_TERMINAL_TITLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_includes_thread_name() {
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("fix title syncing")),
|
||||
"Codex - fix title syncing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_places_spinner_after_codex() {
|
||||
assert_eq!(format_terminal_title(Some("⠋")), "Codex ⠋");
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("⠋ - fix title syncing")),
|
||||
"Codex ⠋ - fix title syncing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_strips_control_characters() {
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("hello\x1b\t\n\r\u{7}world")),
|
||||
"Codex - helloworld"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_write_is_deduplicated() {
|
||||
let mut output = Vec::new();
|
||||
let mut current_title = None;
|
||||
|
||||
write_terminal_title(&mut output, &mut current_title, Some("plan"))
|
||||
.expect("first title write should succeed");
|
||||
write_terminal_title(&mut output, &mut current_title, Some("plan"))
|
||||
.expect("duplicate title write should succeed");
|
||||
|
||||
assert_eq!(output, b"\x1b]0;Codex - plan\x07");
|
||||
assert_eq!(current_title, Some("Codex - plan".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user