diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index be7a09feb..c9fb18555 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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 { + 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() { diff --git a/codex-rs/cli/tests/delete.rs b/codex-rs/cli/tests/delete.rs new file mode 100644 index 000000000..854b87b56 --- /dev/null +++ b/codex-rs/cli/tests/delete.rs @@ -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(()) +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index dd7be1d84..5ae1b002c 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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 + } + } + } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b282a618c..ee6364c0d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 6b546c4ee..2ef681091 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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 { let request_id = self.next_request_id(); let response: ThreadUnarchiveResponse = self diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 0bcc02601..18820ce46 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap new file mode 100644 index 000000000..de5c0610a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e257db69f..e61ffd48f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 04633a803..bca6dbbdf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 4e5c420f6..7de1e577c 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -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 { 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 { 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> { - 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 { @@ -173,6 +209,35 @@ fn session_target_from_app_server_thread(thread: AppServerThread) -> Result Result { + 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 { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 7f79488da..9ced21b26 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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