Skip to content

Commit 08a7a81

Browse files
omers-oaicodex
andcommitted
Add /title terminal title override
Co-authored-by: Codex <noreply@openai.com>
1 parent 8159f05 commit 08a7a81

8 files changed

Lines changed: 227 additions & 11 deletions

File tree

codex-rs/tui/src/app.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,14 @@ fn normalize_harness_overrides_for_cwd(
718718
Ok(overrides)
719719
}
720720

721+
fn normalize_title_context(title_override: Option<String>) -> Option<String> {
722+
title_override
723+
.as_deref()
724+
.map(str::trim)
725+
.filter(|name| !name.is_empty())
726+
.map(ToString::to_string)
727+
}
728+
721729
impl App {
722730
pub fn chatwidget_init_for_forked_or_resumed_thread(
723731
&self,
@@ -1747,6 +1755,8 @@ impl App {
17471755
primary_session_configured: None,
17481756
pending_primary_events: VecDeque::new(),
17491757
};
1758+
let title_context = normalize_title_context(app.chat_widget.title_override());
1759+
tui.set_title_context(title_context.as_deref())?;
17501760

17511761
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
17521762
#[cfg(target_os = "windows")]
@@ -1785,6 +1795,7 @@ impl App {
17851795
)
17861796
.await?;
17871797
if let AppRunControl::Exit(exit_reason) = control {
1798+
tui.set_title_context(None)?;
17881799
return Ok(AppExitInfo {
17891800
token_usage: app.token_usage(),
17901801
thread_id: app.chat_widget.thread_id(),
@@ -1845,6 +1856,8 @@ impl App {
18451856
AppRunControl::Continue
18461857
}
18471858
};
1859+
let title_context = normalize_title_context(app.chat_widget.title_override());
1860+
tui.set_title_context(title_context.as_deref())?;
18481861
if App::should_stop_waiting_for_initial_session(
18491862
waiting_for_initial_session_configured,
18501863
app.primary_thread_id,
@@ -1856,6 +1869,7 @@ impl App {
18561869
AppRunControl::Exit(reason) => break reason,
18571870
}
18581871
};
1872+
tui.set_title_context(None)?;
18591873
tui.terminal.clear()?;
18601874
Ok(AppExitInfo {
18611875
token_usage: app.token_usage(),
@@ -2124,6 +2138,10 @@ impl App {
21242138
}
21252139
}
21262140
}
2141+
AppEvent::SetTitle(title) => {
2142+
self.chat_widget.set_title_override(title);
2143+
tui.frame_requester().schedule_frame();
2144+
}
21272145
AppEvent::ApplyThreadRollback { num_turns } => {
21282146
if self.apply_non_pending_thread_rollback(num_turns) {
21292147
tui.frame_requester().schedule_frame();
@@ -3731,6 +3749,14 @@ mod tests {
37313749
);
37323750
}
37333751

3752+
#[test]
3753+
fn normalize_title_context_uses_manual_title_when_present() {
3754+
assert_eq!(
3755+
normalize_title_context(Some("Named thread".to_string())),
3756+
Some("Named thread".to_string())
3757+
);
3758+
}
3759+
37343760
#[test]
37353761
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
37363762
let mut wait_for_initial_session =

codex-rs/tui/src/app_event.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ pub(crate) enum AppEvent {
161161
},
162162

163163
InsertHistoryCell(Box<dyn HistoryCell>),
164+
SetTitle(Option<String>),
164165

165166
/// Apply rollback semantics to local transcript cells.
166167
///

codex-rs/tui/src/bottom_pane/command_popup.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,18 @@ mod tests {
477477
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
478478
}
479479

480+
#[test]
481+
fn title_shown_in_empty_filter_and_for_prefix() {
482+
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
483+
popup.on_composer_text_change("/".to_string());
484+
let items = popup.filtered_items();
485+
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title)));
486+
487+
popup.on_composer_text_change("/ti".to_string());
488+
let items = popup.filtered_items();
489+
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title)));
490+
}
491+
480492
#[test]
481493
fn collab_command_hidden_when_collaboration_modes_disabled() {
482494
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());

codex-rs/tui/src/bottom_pane/slash_commands.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,17 @@ mod tests {
6969
}
7070

7171
#[test]
72-
fn debug_command_still_resolves_for_dispatch() {
73-
let cmd = find_builtin_command("debug-config", all_enabled_flags());
74-
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
75-
}
76-
77-
#[test]
78-
fn clear_command_resolves_for_dispatch() {
79-
assert_eq!(
80-
find_builtin_command("clear", all_enabled_flags()),
81-
Some(SlashCommand::Clear)
82-
);
72+
fn known_commands_resolve_for_dispatch() {
73+
for (name, expected) in [
74+
("debug-config", SlashCommand::DebugConfig),
75+
("clear", SlashCommand::Clear),
76+
("title", SlashCommand::Title),
77+
] {
78+
assert_eq!(
79+
find_builtin_command(name, all_enabled_flags()),
80+
Some(expected)
81+
);
82+
}
8383
}
8484

8585
#[test]

codex-rs/tui/src/chatwidget.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ pub(crate) struct ChatWidget {
607607
pending_status_indicator_restore: bool,
608608
thread_id: Option<ThreadId>,
609609
thread_name: Option<String>,
610+
title_override: Option<String>,
610611
forked_from: Option<ThreadId>,
611612
frame_requester: FrameRequester,
612613
// Whether to include the initial welcome banner on session configured
@@ -2913,6 +2914,7 @@ impl ChatWidget {
29132914
pending_status_indicator_restore: false,
29142915
thread_id: None,
29152916
thread_name: None,
2917+
title_override: None,
29162918
forked_from: None,
29172919
queued_user_messages: VecDeque::new(),
29182920
queued_message_edit_binding,
@@ -3093,6 +3095,7 @@ impl ChatWidget {
30933095
pending_status_indicator_restore: false,
30943096
thread_id: None,
30953097
thread_name: None,
3098+
title_override: None,
30963099
forked_from: None,
30973100
saw_plan_update_this_turn: false,
30983101
saw_plan_item_this_turn: false,
@@ -3262,6 +3265,7 @@ impl ChatWidget {
32623265
pending_status_indicator_restore: false,
32633266
thread_id: None,
32643267
thread_name: None,
3268+
title_override: None,
32653269
forked_from: None,
32663270
queued_user_messages: VecDeque::new(),
32673271
queued_message_edit_binding,
@@ -3607,6 +3611,9 @@ impl ChatWidget {
36073611
self.otel_manager.counter("codex.thread.rename", 1, &[]);
36083612
self.show_rename_prompt();
36093613
}
3614+
SlashCommand::Title => {
3615+
self.show_title_prompt();
3616+
}
36103617
SlashCommand::Model => {
36113618
self.open_model_popup();
36123619
}
@@ -3936,6 +3943,13 @@ impl ChatWidget {
39363943
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
39373944
self.bottom_pane.drain_pending_submission_state();
39383945
}
3946+
SlashCommand::Title => {
3947+
let title = codex_core::util::normalize_thread_name(trimmed);
3948+
let cell = Self::title_confirmation_cell(title.as_deref());
3949+
self.add_boxed_history(Box::new(cell));
3950+
self.set_title_override(title);
3951+
self.request_redraw();
3952+
}
39393953
SlashCommand::Plan if !trimmed.is_empty() => {
39403954
self.dispatch_command(cmd);
39413955
if self.active_mode_kind() != ModeKind::Plan {
@@ -4030,6 +4044,23 @@ impl ChatWidget {
40304044
self.bottom_pane.show_view(Box::new(view));
40314045
}
40324046

4047+
fn show_title_prompt(&mut self) {
4048+
let tx = self.app_event_tx.clone();
4049+
let view = CustomPromptView::new(
4050+
"Set title".to_string(),
4051+
"Type a title and press Enter. Leave blank to clear it.".to_string(),
4052+
None,
4053+
Box::new(move |name: String| {
4054+
let title = codex_core::util::normalize_thread_name(&name);
4055+
let cell = Self::title_confirmation_cell(title.as_deref());
4056+
tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
4057+
tx.send(AppEvent::SetTitle(title));
4058+
}),
4059+
);
4060+
4061+
self.bottom_pane.show_view(Box::new(view));
4062+
}
4063+
40334064
pub(crate) fn handle_paste(&mut self, text: String) {
40344065
self.bottom_pane.handle_paste(text);
40354066
}
@@ -7306,6 +7337,18 @@ impl ChatWidget {
73067337
PlainHistoryCell::new(vec![line.into()])
73077338
}
73087339

7340+
fn title_confirmation_cell(title: Option<&str>) -> PlainHistoryCell {
7341+
let line = match title {
7342+
Some(title) => vec![
7343+
"• ".into(),
7344+
"Title set to ".into(),
7345+
title.to_string().cyan(),
7346+
],
7347+
None => vec!["• ".into(), "Title cleared".into()],
7348+
};
7349+
PlainHistoryCell::new(vec![line.into()])
7350+
}
7351+
73097352
pub(crate) fn add_mcp_output(&mut self) {
73107353
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
73117354
self.config.codex_home.clone(),
@@ -7984,6 +8027,14 @@ impl ChatWidget {
79848027
self.thread_name.clone()
79858028
}
79868029

8030+
pub(crate) fn title_override(&self) -> Option<String> {
8031+
self.title_override.clone()
8032+
}
8033+
8034+
pub(crate) fn set_title_override(&mut self, title: Option<String>) {
8035+
self.title_override = title;
8036+
}
8037+
79878038
/// Returns the current thread's precomputed rollout path.
79888039
///
79898040
/// For fresh non-ephemeral threads this path may exist before the file is

codex-rs/tui/src/chatwidget/tests.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,7 @@ async fn make_chatwidget_manual(
17151715
pending_status_indicator_restore: false,
17161716
thread_id: None,
17171717
thread_name: None,
1718+
title_override: None,
17181719
forked_from: None,
17191720
frame_requester: FrameRequester::test_dummy(),
17201721
show_welcome_banner: true,
@@ -1754,6 +1755,41 @@ async fn make_chatwidget_manual(
17541755
(widget, rx, op_rx)
17551756
}
17561757

1758+
#[tokio::test]
1759+
async fn title_command_sets_manual_title_without_renaming_thread() {
1760+
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
1761+
1762+
chat.dispatch_command_with_args(SlashCommand::Title, "manual title".to_string(), Vec::new());
1763+
1764+
assert_eq!(chat.title_override(), Some("manual title".to_string()));
1765+
assert_eq!(chat.thread_name(), None);
1766+
1767+
while let Ok(op) = op_rx.try_recv() {
1768+
assert!(
1769+
!matches!(op, Op::SetThreadName { .. }),
1770+
"unexpected rename op: {op:?}"
1771+
);
1772+
}
1773+
}
1774+
1775+
#[tokio::test]
1776+
async fn empty_title_command_clears_manual_title() {
1777+
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
1778+
chat.set_title_override(Some("manual title".to_string()));
1779+
1780+
chat.dispatch_command_with_args(SlashCommand::Title, String::new(), Vec::new());
1781+
1782+
assert_eq!(chat.title_override(), None);
1783+
assert_eq!(chat.thread_name(), None);
1784+
1785+
while let Ok(op) = op_rx.try_recv() {
1786+
assert!(
1787+
!matches!(op, Op::SetThreadName { .. }),
1788+
"unexpected rename op: {op:?}"
1789+
);
1790+
}
1791+
}
1792+
17571793
// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper
17581794
// filters until we see a submission op.
17591795
fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {

codex-rs/tui/src/slash_command.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub enum SlashCommand {
2424
Skills,
2525
Review,
2626
Rename,
27+
Title,
2728
New,
2829
Resume,
2930
Fork,
@@ -72,6 +73,7 @@ impl SlashCommand {
7273
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
7374
SlashCommand::Review => "review my current changes and find issues",
7475
SlashCommand::Rename => "rename the current thread",
76+
SlashCommand::Title => "set the terminal title",
7577
SlashCommand::Resume => "resume a saved chat",
7678
SlashCommand::Clear => "clear the terminal and start a new chat",
7779
SlashCommand::Fork => "fork the current chat",
@@ -124,6 +126,7 @@ impl SlashCommand {
124126
self,
125127
SlashCommand::Review
126128
| SlashCommand::Rename
129+
| SlashCommand::Title
127130
| SlashCommand::Plan
128131
| SlashCommand::Fast
129132
| SlashCommand::SandboxReadRoot
@@ -156,6 +159,7 @@ impl SlashCommand {
156159
SlashCommand::Diff
157160
| SlashCommand::Copy
158161
| SlashCommand::Rename
162+
| SlashCommand::Title
159163
| SlashCommand::Mention
160164
| SlashCommand::Skills
161165
| SlashCommand::Status

0 commit comments

Comments
 (0)