diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 42dbcae5a4..d2930bd363 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -22,10 +22,10 @@ impl App { event: AppEvent, ) -> Result { match event { - AppEvent::NewSession => { + AppEvent::NewSession { name } => { self.start_fresh_session_with_summary_hint( tui, app_server, /*session_start_source*/ None, - /*initial_user_message*/ None, + /*initial_user_message*/ None, name, ) .await; } @@ -33,7 +33,7 @@ impl App { self.handle_startup_thread_started(app_server, result) .await?; } - AppEvent::ClearUi => { + AppEvent::ClearUi { name } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); @@ -42,6 +42,7 @@ impl App { app_server, Some(ThreadStartSource::Clear), /*initial_user_message*/ None, + name, ) .await; } @@ -61,6 +62,7 @@ impl App { Vec::new(), Vec::new(), ), + /*new_thread_name*/ None, ) .await; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index ac473d98ae..f31c63c96b 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -604,6 +604,7 @@ impl App { app_server: &mut AppServerSession, session_start_source: Option, initial_user_message: Option, + new_thread_name: Option, ) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it @@ -637,7 +638,21 @@ impl App { .start_thread_with_session_start_source(&config, session_start_source) .await { - Ok(started) => { + Ok(mut started) => { + let name_error = if let Some(name) = new_thread_name { + match app_server + .thread_set_name(started.session.thread_id, name.clone()) + .await + { + Ok(()) => { + started.session.thread_name = Some(name); + None + } + Err(err) => Some(format!("Failed to name the new session: {err}")), + } + } else { + None + }; if let Err(err) = self .replace_chat_widget_with_app_server_thread( tui, @@ -650,16 +665,22 @@ impl App { self.chat_widget.add_error_message(format!( "Failed to attach to fresh app-server thread: {err}" )); - } else if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.into()); + } else { + if let Some(err) = name_error { + self.chat_widget.add_error_message(err); } - if let Some(command) = summary.resume_hint { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); + if let Some(summary) = summary { + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.into()); + } + if let Some(command) = summary.resume_hint { + let spans = + vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); } - self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 9c90e23af0..6f70535874 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -137,6 +137,62 @@ async fn start_recording_app_server( )) } +#[test] +fn fresh_session_applies_requested_name() -> Result<()> { + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + std::thread::Builder::new() + .name("tui-named-fresh-session".to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite_home = codex_home.path().to_path_buf(); + let (mut app_server, requests, proxy) = + start_recording_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut app_server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ Some("Add User".to_string()), + ) + .await; + + let thread_id = app + .chat_widget + .thread_id() + .expect("fresh session should have a thread id"); + assert_eq!(app.chat_widget.thread_name(), Some("Add User".to_string())); + assert!( + requests + .lock() + .expect("request recorder lock") + .iter() + .any(|method| method == "thread/name/set"), + "fresh session should be named through the app server" + ); + let thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await?; + assert_eq!(thread.name.as_deref(), Some("Add User")); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) + }) + })? + .join() + .expect("named fresh session test thread") +} + #[test] fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; @@ -231,6 +287,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { &mut app_server, /*session_start_source*/ None, /*initial_user_message*/ None, + /*new_thread_name*/ None, ) .await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0bb4990ff2..4f731958c3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -235,8 +235,10 @@ pub(crate) enum AppEvent { log_id: u64, }, - /// Start a new session. - NewSession, + /// Start a new session, optionally assigning it a name. + NewSession { + name: Option, + }, /// Result of the fresh startup thread that is attached after the input UI is live. StartupThreadStarted { @@ -245,7 +247,9 @@ pub(crate) enum AppEvent { /// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the /// previous chat resumable. - ClearUi, + ClearUi { + name: Option, + }, /// Re-render the transcript using the selected scrollback rendering mode. RawOutputModeChanged { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 877d2a846c..e28cfd7043 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -171,7 +171,7 @@ impl ChatWidget { self.request_redraw(); } SlashCommand::New => { - self.app_event_tx.send(AppEvent::NewSession); + self.app_event_tx.send(AppEvent::NewSession { name: None }); } SlashCommand::Archive => { self.bottom_pane.show_selection_view(SelectionViewParams { @@ -231,7 +231,7 @@ impl ChatWidget { self.request_redraw(); } SlashCommand::Clear => { - self.app_event_tx.send(AppEvent::ClearUi); + self.app_event_tx.send(AppEvent::ClearUi { name: None }); } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); @@ -725,6 +725,16 @@ impl ChatWidget { }; self.app_event_tx.set_thread_name(name); } + SlashCommand::New if !trimmed.is_empty() => { + self.app_event_tx.send(AppEvent::NewSession { + name: Some(trimmed.to_string()), + }); + } + SlashCommand::Clear if !trimmed.is_empty() => { + self.app_event_tx.send(AppEvent::ClearUi { + name: Some(trimmed.to_string()), + }); + } SlashCommand::Plan if !trimmed.is_empty() => { if !self.apply_plan_slash_command() { return; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 04a303291c..47d597db92 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2104,7 +2104,39 @@ async fn slash_clear_requests_ui_clear_when_idle() { chat.dispatch_command(SlashCommand::Clear); - assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi { name: None })); +} + +#[tokio::test] +async fn slash_new_with_name_requests_named_session() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane + .set_composer_text("/new Add User ".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::NewSession { + name: Some(name) + }) if name == "Add User" + ); +} + +#[tokio::test] +async fn slash_clear_with_name_requests_named_session() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane + .set_composer_text("/clear Add User ".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ClearUi { + name: Some(name) + }) if name == "Add User" + ); } #[tokio::test] @@ -2130,7 +2162,7 @@ async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi { name: None })); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), stashed_draft); diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 369e19d7fd..0994367685 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -126,7 +126,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { } match event { - AppEvent::NewSession => { + AppEvent::NewSession { .. } => { let value = json!({ "ts": now_ts(), "dir": "to_tui", @@ -134,7 +134,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } - AppEvent::ClearUi => { + AppEvent::ClearUi { .. } => { let value = json!({ "ts": now_ts(), "dir": "to_tui", diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b3a2ddc519..abd1386932 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -155,6 +155,8 @@ impl SlashCommand { self, SlashCommand::Review | SlashCommand::Rename + | SlashCommand::New + | SlashCommand::Clear | SlashCommand::Plan | SlashCommand::Goal | SlashCommand::Ide