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