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.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-29 11:07:19 -07:00
1 parent 1333f4a689
commit 36cd36626d
9 files changed
+168

No files matched your search

@@ -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;