Compare commits

..

4 Commits

Author SHA1 Message Date
Omer Strulovich
7269a2ef40 Adjust terminal title spinner formatting and clearing
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 16:30:44 -05:00
Omer Strulovich
95ca63a688 Load terminal title from thread name automatically
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:50 -05:00
Omer Strulovich
67b7db7684 Add terminal title spinner while running
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:43 -05:00
Omer Strulovich
d1e20cdcac Add /title terminal title override
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:23 -05:00
10 changed files with 392 additions and 369 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 =

View File

@@ -161,6 +161,7 @@ pub(crate) enum AppEvent {
},
InsertHistoryCell(Box<dyn HistoryCell>),
SetTitle(Option<String>),
/// Apply rollback semantics to local transcript cells.
///

View File

@@ -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());

View File

@@ -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();

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()));
}
}