Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,14 @@ fn normalize_harness_overrides_for_cwd(
Ok(overrides)
}

fn normalize_title_context(title_override: Option<String>) -> Option<String> {
title_override
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string)
}

impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
Expand Down Expand Up @@ -1916,6 +1924,8 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
};
let title_context = normalize_title_context(app.chat_widget.title_override());
tui.set_title_context(title_context.as_deref())?;

// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
Expand Down Expand Up @@ -1954,6 +1964,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(),
Expand Down Expand Up @@ -2014,6 +2025,8 @@ impl App {
AppRunControl::Continue
}
};
let title_context = normalize_title_context(app.chat_widget.title_override());
tui.set_title_context(title_context.as_deref())?;
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
Expand All @@ -2025,6 +2038,7 @@ impl App {
AppRunControl::Exit(reason) => break reason,
}
};
tui.set_title_context(None)?;
tui.terminal.clear()?;
Ok(AppExitInfo {
token_usage: app.token_usage(),
Expand Down Expand Up @@ -2296,6 +2310,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();
Expand Down Expand Up @@ -3881,6 +3899,14 @@ mod tests {
);
}

#[test]
fn normalize_title_context_uses_manual_title_when_present() {
assert_eq!(
normalize_title_context(Some("Named thread".to_string())),
Some("Named thread".to_string())
);
}

#[test]
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
let mut wait_for_initial_session =
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ pub(crate) enum AppEvent {
},

InsertHistoryCell(Box<dyn HistoryCell>),
SetTitle(Option<String>),

/// Apply rollback semantics to local transcript cells.
///
Expand Down
12 changes: 12 additions & 0 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,18 @@ mod tests {
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
}

#[test]
fn title_shown_in_empty_filter_and_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());
Expand Down
22 changes: 11 additions & 11 deletions codex-rs/tui/src/bottom_pane/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,17 @@ mod tests {
}

#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", all_enabled_flags());
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}

#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", all_enabled_flags()),
Some(SlashCommand::Clear)
);
fn known_commands_resolve_for_dispatch() {
for (name, expected) in [
("debug-config", SlashCommand::DebugConfig),
("clear", SlashCommand::Clear),
("title", SlashCommand::Title),
] {
assert_eq!(
find_builtin_command(name, all_enabled_flags()),
Some(expected)
);
}
}

#[test]
Expand Down
51 changes: 51 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ pub(crate) struct ChatWidget {
suppress_queue_autosend: 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
Expand Down Expand Up @@ -3216,6 +3217,7 @@ impl ChatWidget {
suppress_queue_autosend: false,
thread_id: None,
thread_name: None,
title_override: None,
forked_from: None,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
Expand Down Expand Up @@ -3399,6 +3401,7 @@ impl ChatWidget {
suppress_queue_autosend: 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,
Expand Down Expand Up @@ -3574,6 +3577,7 @@ impl ChatWidget {
suppress_queue_autosend: false,
thread_id: None,
thread_name: None,
title_override: None,
forked_from: None,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
Expand Down Expand Up @@ -3939,6 +3943,9 @@ impl ChatWidget {
.counter("codex.thread.rename", 1, &[]);
self.show_rename_prompt();
}
SlashCommand::Title => {
self.show_title_prompt();
}
SlashCommand::Model => {
self.open_model_popup();
}
Expand Down Expand Up @@ -4270,6 +4277,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 {
Expand Down Expand Up @@ -4364,6 +4378,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);
}
Expand Down Expand Up @@ -7794,6 +7825,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(),
Expand Down Expand Up @@ -8494,6 +8537,14 @@ 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;
}

/// Returns the current thread's precomputed rollout path.
///
/// For fresh non-ephemeral threads this path may exist before the file is
Expand Down
36 changes: 36 additions & 0 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,7 @@ async fn make_chatwidget_manual(
suppress_queue_autosend: false,
thread_id: None,
thread_name: None,
title_override: None,
forked_from: None,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,
Expand Down Expand Up @@ -1901,6 +1902,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 {
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/tui/src/slash_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub enum SlashCommand {
Skills,
Review,
Rename,
Title,
New,
Resume,
Fork,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -124,6 +126,7 @@ impl SlashCommand {
self,
SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Title
| SlashCommand::Plan
| SlashCommand::Fast
| SlashCommand::SandboxReadRoot
Expand Down Expand Up @@ -156,6 +159,7 @@ impl SlashCommand {
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Rename
| SlashCommand::Title
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Status
Expand Down
Loading
Loading