From 36cd36626db4c16244957d9b4dfa6d041ca440f7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 29 May 2026 11:07:19 -0700 Subject: [PATCH] Add `/archive` slash command (#25027) ## Why TUI users can archive saved sessions from other surfaces, but there is no in-session command for archiving the active session. Since archiving the active session also exits the TUI, the command should ask for explicit confirmation instead of firing immediately. I'm also working on [a companion PR](https://github.com/openai/codex/pull/25021) that adds `codex archive` and `codex unarchive` top-level CLI commands. ## What changed - Adds a new `/archive` slash command described as `archive this session and exit`. - Shows a confirmation dialog with `No, don't archive` selected first and `Yes, archive and exit` as the explicit action. - On confirmation, calls the existing `thread/archive` app-server RPC for the active main session and exits after success. - Keeps `/archive` disabled while a task is running and unavailable in side conversations. ## Verification Added focused TUI coverage for the `/archive` confirmation flow, disabled-while-task-running behavior, and the `/ar` slash-command popup snapshot. --- codex-rs/tui/src/app/event_dispatch.rs | 30 ++++++++++++++ codex-rs/tui/src/app_event.rs | 3 ++ codex-rs/tui/src/app_server_session.rs | 17 ++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 26 +++++++++++++ ..._chat_composer__tests__slash_popup_ar.snap | 9 +++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 30 ++++++++++++++ ...sts__slash_archive_confirmation_popup.snap | 11 ++++++ .../src/chatwidget/tests/slash_commands.rs | 39 +++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 3 ++ 9 files changed, 168 insertions(+) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 417844acc..f9683e26e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -121,6 +121,9 @@ impl App { } } } + AppEvent::ArchiveCurrentThread => { + return Ok(self.archive_current_thread(app_server).await); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -2146,4 +2149,31 @@ impl App { } } } + + pub(super) async fn archive_current_thread( + &mut self, + app_server: &mut AppServerSession, + ) -> AppRunControl { + let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else { + self.chat_widget + .add_error_message("A thread must start before it can be archived.".to_string()); + return AppRunControl::Continue; + }; + if self.side_threads.contains_key(&thread_id) { + self.chat_widget.add_error_message( + "'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." + .to_string(), + ); + return AppRunControl::Continue; + } + + match app_server.thread_archive(thread_id).await { + Ok(()) => AppRunControl::Exit(ExitReason::UserRequested), + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to archive current thread: {err}")); + AppRunControl::Continue + } + } + } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b37aee64e..f1e612dd5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -209,6 +209,9 @@ pub(crate) enum AppEvent { /// Resume a thread by UUID or thread name inside the running TUI session. ResumeSessionByIdOrName(String), + /// Archive the current active main thread and exit after it succeeds. + ArchiveCurrentThread, + /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2b7b1cd88..e66b5ee8a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -46,6 +46,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; @@ -564,6 +566,21 @@ impl AppServerSession { Ok(response.thread) } + pub(crate) async fn thread_archive(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadArchiveResponse = self + .client + .request_typed(ClientRequest::ThreadArchive { + request_id, + params: ThreadArchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/archive failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_metadata_update_branch( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 721824ed6..ec3284866 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7885,6 +7885,32 @@ mod tests { insta::assert_snapshot!("slash_popup_res", terminal.backend()); } + #[test] + fn slash_popup_archive_for_ar_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'a', 'r']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_ar", terminal.backend()); + } + #[test] fn slash_popup_resume_for_res_logic() { use super::super::command_popup::CommandItem; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap new file mode 100644 index 000000000..5014c35d2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /ar " +" " +" " +" /archive archive this session and exit " diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index b47971830..99833da8a 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -164,6 +164,35 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Archive => { + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Archive this session?".to_string()), + subtitle: Some( + "Are you sure? This will archive the current session and exit Codex" + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "No, don't archive".to_string(), + description: Some("Return to the current session".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, archive and exit".to_string(), + description: Some("Archive this session now".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ArchiveCurrentThread); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + self.request_redraw(); + } SlashCommand::Clear => { self.app_event_tx.send(AppEvent::ClearUi); } @@ -949,6 +978,7 @@ impl ChatWidget { | SlashCommand::TestApproval => QueueDrain::Continue, SlashCommand::Feedback | SlashCommand::New + | SlashCommand::Archive | SlashCommand::Clear | SlashCommand::Resume | SlashCommand::Fork diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap new file mode 100644 index 000000000..cdc895d7b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- + Archive this session? + Are you sure? This will archive the current session and exit Codex + +› 1. No, don't archive Return to the current session + 2. Yes, archive and exit Archive this session now + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index ff1feb084..4f425c25c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1727,6 +1727,27 @@ async fn slash_clear_is_disabled_while_task_running() { assert!(rx.try_recv().is_err(), "expected no follow-up events"); } +#[tokio::test] +async fn slash_archive_is_disabled_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.dispatch_command(SlashCommand::Archive); + + let event = rx.try_recv().expect("expected disabled command error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/archive' is disabled while a task is in progress."), + "expected /archive task-running error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); +} + #[tokio::test] async fn slash_memory_drop_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1846,6 +1867,24 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_archive_confirmation_requests_current_thread_archive() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Archive); + + assert!(chat.bottom_pane.has_active_view()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_archive_confirmation_popup", popup); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ArchiveCurrentThread)); +} + #[tokio::test] async fn slash_resume_with_arg_requests_named_session() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4a4340929..3fccdf596 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -30,6 +30,7 @@ pub enum SlashCommand { Review, Rename, New, + Archive, Resume, Fork, Init, @@ -86,6 +87,7 @@ impl SlashCommand { SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", + SlashCommand::Archive => "archive this session and exit", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", @@ -180,6 +182,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Archive | SlashCommand::Resume | SlashCommand::Fork | SlashCommand::Init