Compare commits

...

2 Commits

Author SHA1 Message Date
Omer Strulovich
1b4406bb1e Add /title slash command alias
Co-authored-by: Codex <noreply@openai.com>
2026-03-03 16:15:39 -05:00
Omer Strulovich
fc52615bb2 Update terminal title from thread name
Co-authored-by: Codex <noreply@openai.com>
2026-03-03 16:15:33 -05:00
6 changed files with 123 additions and 3 deletions

View File

@@ -1747,6 +1747,8 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
};
let thread_name = app.chat_widget.thread_name();
tui.set_title_context(thread_name.as_deref())?;
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
@@ -1785,6 +1787,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 +1848,8 @@ impl App {
AppRunControl::Continue
}
};
let thread_name = app.chat_widget.thread_name();
tui.set_title_context(thread_name.as_deref())?;
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
@@ -1856,6 +1861,7 @@ impl App {
AppRunControl::Exit(reason) => break reason,
}
};
tui.set_title_context(None)?;
tui.terminal.clear()?;
Ok(AppExitInfo {
token_usage: app.token_usage(),

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

@@ -3603,7 +3603,7 @@ impl ChatWidget {
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Rename => {
SlashCommand::Rename | SlashCommand::Title => {
self.otel_manager.counter("codex.thread.rename", 1, &[]);
self.show_rename_prompt();
}
@@ -3918,7 +3918,7 @@ impl ChatWidget {
}
}
}
SlashCommand::Rename if !trimmed.is_empty() => {
SlashCommand::Rename | SlashCommand::Title if !trimmed.is_empty() => {
self.otel_manager.counter("codex.thread.rename", 1, &[]);
let Some((prepared_args, _prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)

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 current thread 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,39 @@ 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 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) => 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 +289,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 +318,7 @@ impl Tui {
enhanced_keys_supported,
notification_backend: Some(detect_backend(NotificationMethod::default())),
alt_screen_enabled: true,
current_title: None,
}
}
@@ -295,6 +331,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 +586,47 @@ 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_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()));
}
}