Add session delete commands in CLI and TUI (#27476)

## Summary

The app server exposes `thread/delete`, but users cannot invoke it from
the CLI or TUI. Because deletion is irreversible, the user-facing
commands need deliberate confirmation and safer handling of name-based
targets.

- Add `codex delete <SESSION>` with interactive confirmation,
restricting `--force` to UUID targets.
- Resolve exact names across active and archived sessions, including
renamed sessions, and validate prompted UUID targets before
confirmation.
- Add a `/delete` command with a confirmation popup that warns the
current session and its subagent threads will be permanently deleted.

## Manual testing

- Deleted by UUID with `--force` and verified the rollout, session-index
entry, and database row were removed.
- Exercised name-based confirmation for both cancellation and
affirmative deletion; cancellation preserved the session and
confirmation removed it.
- Verified deletion refuses to proceed without `--force`, while
`--force` rejects names, including duplicate names.
- Verified duplicate-name confirmation displays the concrete UUID
selected.
- Deleted an archived session by name.
- Verified an already-missing UUID fails before displaying a
confirmation prompt.
- Exercised `/delete` in the TUI: the popup defaults to No, cancellation
preserves the session, and confirmation deletes the session and exits.
- Verified that `codex delete` works for both archived and non-archived
sessions.
This commit is contained in:
Eric Traut
2026-06-10 18:04:02 -07:00
committed by GitHub
Unverified
parent 36fc79c6f4
commit 9d87b771ce
11 changed files with 301 additions and 56 deletions
+52 -1
View File
@@ -180,6 +180,9 @@ enum Subcommand {
/// Archive a saved session by id or session name.
Archive(SessionArchiveCommand),
/// Permanently delete a saved session by id or session name.
Delete(DeleteCommand),
/// Unarchive a saved session by id or session name.
Unarchive(SessionArchiveCommand),
@@ -354,6 +357,16 @@ struct SessionArchiveConfigOverrides {
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Args)]
struct DeleteCommand {
#[clap(flatten)]
session: SessionArchiveCommand,
/// Delete without prompting. SESSION must be a UUID.
#[arg(long, default_value_t = false)]
force: bool,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
@@ -829,6 +842,17 @@ async fn run_session_archive_cli_command(
.map_err(|err| anyhow::anyhow!("{err}"))
}
fn delete_action(target: &str, force: bool) -> anyhow::Result<codex_tui::SessionArchiveAction> {
if force && codex_protocol::ThreadId::from_string(target).is_err() {
anyhow::bail!("--force requires a session UUID; names must be confirmed interactively");
}
let confirmation = match force {
true => codex_tui::DeleteConfirmation::Skip,
false => codex_tui::DeleteConfirmation::Prompt,
};
Ok(codex_tui::SessionArchiveAction::Delete(confirmation))
}
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
match cmd.subcommand {
DebugAppServerSubcommand::SendMessageV2(cmd) => {
@@ -1233,6 +1257,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
println!("{output}");
}
Some(Subcommand::Delete(DeleteCommand { session, force })) => {
let action = delete_action(&session.target, force)?;
let output = run_session_archive_cli_command(
action,
session,
interactive,
root_config_overrides.clone(),
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
println!("{output}");
}
Some(Subcommand::Unarchive(cmd)) => {
let output = run_session_archive_cli_command(
codex_tui::SessionArchiveAction::Unarchive,
@@ -1597,6 +1635,7 @@ fn profile_v2_for_subcommand<'a>(
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Archive(_)
| Subcommand::Delete(_)
| Subcommand::Unarchive(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
@@ -1605,7 +1644,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex delete`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -2026,6 +2065,7 @@ fn unsupported_subcommand_name_for_strict_config(
| Some(Subcommand::ExecServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Archive(_))
| Some(Subcommand::Delete(_))
| Some(Subcommand::Unarchive(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
@@ -2868,6 +2908,17 @@ mod tests {
assert!(interactive.bypass_hook_trust);
}
#[test]
fn delete_force_requires_uuid() {
assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", true).is_ok());
let err = delete_action("my-thread", true).expect_err("name should require prompt");
assert_eq!(
err.to_string(),
"--force requires a session UUID; names must be confirmed interactively"
);
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_parses_permissions_profile() {
+17
View File
@@ -0,0 +1,17 @@
use predicates::prelude::*;
#[test]
fn missing_session_fails_before_delete_confirmation() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home.path())
.args(["delete", "123e4567-e89b-12d3-a456-426614174000"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains(
"No active or archived session found matching",
))
.stderr(predicate::str::contains("cannot confirm").not());
Ok(())
}
+30
View File
@@ -153,6 +153,9 @@ impl App {
AppEvent::ArchiveCurrentThread => {
return Ok(self.archive_current_thread(app_server).await);
}
AppEvent::DeleteCurrentThread => {
return Ok(self.delete_current_thread(app_server).await);
}
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
@@ -2273,4 +2276,31 @@ impl App {
}
}
}
pub(super) async fn delete_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 deleted.".to_string());
return AppRunControl::Continue;
};
if self.side_threads.contains_key(&thread_id) {
self.chat_widget.add_error_message(
"'/delete' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."
.to_string(),
);
return AppRunControl::Continue;
}
match app_server.thread_delete(thread_id).await {
Ok(()) => AppRunControl::Exit(ExitReason::UserRequested),
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to delete current thread: {err}"));
AppRunControl::Continue
}
}
}
}
+3
View File
@@ -230,6 +230,9 @@ pub(crate) enum AppEvent {
/// Archive the current active main thread and exit after it succeeds.
ArchiveCurrentThread,
/// Permanently delete the current active main thread and exit after it succeeds.
DeleteCurrentThread,
/// Fork the current session into a new thread.
ForkCurrentSession,
+17
View File
@@ -53,6 +53,8 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoalClearParams;
@@ -626,6 +628,21 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_delete(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadDeleteResponse = self
.client
.request_typed(ClientRequest::ThreadDelete {
request_id,
params: ThreadDeleteParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("failed to delete session")?;
Ok(())
}
pub(crate) async fn thread_unarchive(&mut self, thread_id: ThreadId) -> Result<Thread> {
let request_id = self.next_request_id();
let response: ThreadUnarchiveResponse = self
@@ -193,6 +193,34 @@ impl ChatWidget {
});
self.request_redraw();
}
SlashCommand::Delete => {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Delete this session?".to_string()),
subtitle: Some(
"Cannot be undone. Subagent threads will also be deleted.".to_string(),
),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: "No, keep this session".to_string(),
description: Some("Return to the current session".to_string()),
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Yes, delete and exit".to_string(),
description: Some("Permanently delete this session now".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::DeleteCurrentThread);
})],
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
});
self.request_redraw();
}
SlashCommand::Clear => {
self.app_event_tx.send(AppEvent::ClearUi);
}
@@ -986,6 +1014,7 @@ impl ChatWidget {
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Archive
| SlashCommand::Delete
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
Delete this session?
Cannot be undone. Subagent threads will also be deleted.
1. No, keep this session Return to the current session
2. Yes, delete and exit Permanently delete this session now
Press enter to confirm or esc to go back
@@ -1891,6 +1891,24 @@ async fn slash_archive_confirmation_requests_current_thread_archive() {
assert_matches!(rx.try_recv(), Ok(AppEvent::ArchiveCurrentThread));
}
#[tokio::test]
async fn slash_delete_confirmation_requests_current_thread_delete() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Delete);
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_delete_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::DeleteCurrentThread));
}
#[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;
+1
View File
@@ -64,6 +64,7 @@ use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::WrapErr;
use cwd_prompt::CwdPromptAction;
pub use session_archive_commands::DeleteConfirmation;
pub use session_archive_commands::SessionArchiveAction;
pub use session_archive_commands::SessionArchiveCommandOptions;
pub use session_archive_commands::run_session_archive_command;
+120 -55
View File
@@ -1,8 +1,10 @@
//! Shared implementation for `codex archive` and `codex unarchive`.
//! Shared implementation for `codex archive`, `codex delete`, and `codex unarchive`.
//!
//! The CLI commands are thin app-server clients: resolve a user-provided UUID or exact session
//! name, then call the existing `thread/archive` or `thread/unarchive` RPC.
//! name, then call the corresponding app-server RPC.
use std::io::IsTerminal;
use std::io::Write;
use std::sync::Arc;
use crate::Cli;
@@ -26,16 +28,22 @@ use codex_protocol::ThreadId;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_home_dir::find_codex_home;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use color_eyre::eyre::eyre;
use super::RemoteAppServerEndpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteConfirmation {
Prompt,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionArchiveAction {
Archive,
Delete(DeleteConfirmation),
Unarchive,
}
@@ -52,6 +60,7 @@ fn success_message(
) -> String {
let action = match action {
SessionArchiveAction::Archive => "Archived",
SessionArchiveAction::Delete(_) => "Deleted",
SessionArchiveAction::Unarchive => "Unarchived",
};
match session_name {
@@ -80,25 +89,30 @@ async fn run_session_archive_action_with_app_server(
target: &str,
) -> Result<String> {
let resolved = resolve_session_target(app_server, action, target).await?;
match action {
let session_name = match action {
SessionArchiveAction::Archive => {
app_server.thread_archive(resolved.session_id).await?;
Ok(success_message(
action,
resolved.session_id,
resolved.session_name.as_deref(),
))
resolved.session_name
}
SessionArchiveAction::Delete(confirmation) => {
if matches!(confirmation, DeleteConfirmation::Prompt)
&& !confirm_session_delete(&resolved)?
{
return Ok("Delete cancelled.".to_string());
}
app_server.thread_delete(resolved.session_id).await?;
resolved.session_name
}
SessionArchiveAction::Unarchive => {
let thread = app_server.thread_unarchive(resolved.session_id).await?;
let session_name = thread.name.or(resolved.session_name);
Ok(success_message(
action,
resolved.session_id,
session_name.as_deref(),
))
thread.name.or(resolved.session_name)
}
}
};
Ok(success_message(
action,
resolved.session_id,
session_name.as_deref(),
))
}
async fn resolve_session_target(
@@ -107,61 +121,83 @@ async fn resolve_session_target(
target: &str,
) -> Result<ResolvedSessionTarget> {
if let Ok(session_id) = ThreadId::from_string(target) {
if matches!(
action,
SessionArchiveAction::Delete(DeleteConfirmation::Prompt)
) {
let thread = app_server
.thread_read(session_id, /*include_turns*/ false)
.await
.with_context(|| {
format!("No active or archived session found matching '{target}'.")
})?;
return Ok(ResolvedSessionTarget {
session_id,
session_name: thread.name,
});
}
return Ok(ResolvedSessionTarget {
session_id,
session_name: None,
});
}
let search_scope = match action {
SessionArchiveAction::Archive => "active",
SessionArchiveAction::Unarchive => "archived",
let (search_scope, archived_values): (&str, &[bool]) = match action {
SessionArchiveAction::Archive => ("active", &[false]),
SessionArchiveAction::Delete(_) => ("active or archived", &[false, true]),
SessionArchiveAction::Unarchive => ("archived", &[true]),
};
let resolved = lookup_session_by_exact_name(app_server, action, target)
.await?
.map(session_target_from_app_server_thread)
.transpose()?;
resolved.with_context(|| format!("No {search_scope} session found matching '{target}'."))
for &archived in archived_values {
if let Some(thread) = lookup_session_by_exact_name(app_server, target, archived).await? {
return session_target_from_app_server_thread(thread);
}
}
Err(eyre!(
"No {search_scope} session found matching '{target}'."
))
}
async fn lookup_session_by_exact_name(
app_server: &mut AppServerSession,
action: SessionArchiveAction,
name: &str,
archived: bool,
) -> Result<Option<AppServerThread>> {
let mut cursor = None;
loop {
let response = app_server
.thread_list(ThreadListParams {
cursor: cursor.clone(),
limit: Some(100),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: None,
model_providers: None,
source_kinds: Some(super::resume_source_kinds(
/*include_non_interactive*/ false,
)),
archived: Some(matches!(action, SessionArchiveAction::Unarchive)),
cwd: None,
use_state_db_only: false,
search_term: Some(name.to_string()),
})
.await
.wrap_err("failed to list sessions while resolving session name")?;
// Search is the fast path, but some stores attach renamed titles after applying the filter.
for search_term in [Some(name), None] {
let mut cursor = None;
loop {
let response = app_server
.thread_list(ThreadListParams {
cursor: cursor.clone(),
limit: Some(100),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: None,
model_providers: None,
source_kinds: Some(super::resume_source_kinds(
/*include_non_interactive*/ false,
)),
archived: Some(archived),
cwd: None,
use_state_db_only: false,
search_term: search_term.map(str::to_string),
})
.await
.wrap_err("failed to list sessions while resolving session name")?;
if let Some(thread) = response
.data
.into_iter()
.find(|thread| thread.name.as_deref() == Some(name))
{
return Ok(Some(thread));
if let Some(thread) = response
.data
.into_iter()
.find(|thread| thread.name.as_deref() == Some(name))
{
return Ok(Some(thread));
}
let Some(next_cursor) = response.next_cursor else {
break;
};
cursor = Some(next_cursor);
}
if response.next_cursor.is_none() {
return Ok(None);
}
cursor = response.next_cursor;
}
Ok(None)
}
fn session_target_from_app_server_thread(thread: AppServerThread) -> Result<ResolvedSessionTarget> {
@@ -173,6 +209,35 @@ fn session_target_from_app_server_thread(thread: AppServerThread) -> Result<Reso
})
}
fn confirm_session_delete(target: &ResolvedSessionTarget) -> Result<bool> {
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return Err(eyre!(
"cannot confirm session deletion without an interactive terminal; rerun with --force and a session UUID"
));
}
let mut stderr = std::io::stderr().lock();
match target.session_name.as_deref() {
Some(name) => writeln!(
stderr,
"Permanently delete session '{name}' ({})?",
target.session_id
),
None => writeln!(stderr, "Permanently delete session {}?", target.session_id),
}?;
writeln!(
stderr,
"This cannot be undone. Subagent threads will also be deleted."
)?;
write!(stderr, "Continue? [y/N]: ")?;
stderr.flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let answer = input.trim();
Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
}
async fn start_app_server_for_archive_command(
options: SessionArchiveCommandOptions,
) -> Result<AppServerSession> {
+3
View File
@@ -32,6 +32,7 @@ pub enum SlashCommand {
Rename,
New,
Archive,
Delete,
Resume,
Fork,
App,
@@ -90,6 +91,7 @@ impl SlashCommand {
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Archive => "archive this session and exit",
SlashCommand::Delete => "permanently delete this session and exit",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::App => "continue this session in Codex Desktop",
@@ -189,6 +191,7 @@ impl SlashCommand {
match self {
SlashCommand::New
| SlashCommand::Archive
| SlashCommand::Delete
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init