diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13f7371615..026bd05d7c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1561,6 +1561,18 @@ impl App { } } + async fn reset_memories_with_app_server(&mut self, app_server: &mut AppServerSession) { + if let Err(err) = app_server.memory_reset().await { + tracing::error!(error = %err, "failed to reset memories"); + self.chat_widget + .add_error_message(format!("Failed to reset memories: {err}")); + return; + } + + self.chat_widget + .add_info_message("Reset local memories.".to_string(), /*hint*/ None); + } + fn open_url_in_browser(&mut self, url: String) { if let Err(err) = webbrowser::open(&url) { self.chat_widget @@ -5413,6 +5425,9 @@ impl App { ) .await; } + AppEvent::ResetMemories => { + self.reset_memories_with_app_server(app_server).await; + } AppEvent::SkipNextWorldWritableScan => { self.windows_sandbox.skip_world_writable_scan_once = true; } @@ -8121,6 +8136,35 @@ mod tests { Ok(()) } + #[tokio::test] + async fn reset_memories_clears_local_memory_directories() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite_home = codex_home.path().to_path_buf(); + + let memory_root = codex_home.path().join("memories"); + let extensions_root = codex_home.path().join("memories_extensions"); + std::fs::create_dir_all(memory_root.join("rollout_summaries"))?; + std::fs::create_dir_all(&extensions_root)?; + std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?; + std::fs::write( + memory_root.join("rollout_summaries").join("stale.md"), + "stale summary\n", + )?; + std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?; + + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + + app.reset_memories_with_app_server(&mut app_server).await; + + assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0); + assert_eq!(std::fs::read_dir(&extensions_root)?.count(), 0); + + app_server.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn update_feature_flags_enabling_guardian_selects_guardian_approvals() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a0476640b2..a3b31aa988 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -470,6 +470,9 @@ pub(crate) enum AppEvent { generate_memories: bool, }, + /// Clear all persisted local memory artifacts via the app-server. + ResetMemories, + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 3ce12d2439..47a06738e4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; @@ -535,6 +536,19 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn memory_reset(&mut self) -> Result<()> { + let request_id = self.next_request_id(); + let _: MemoryResetResponse = self + .client + .request_typed(ClientRequest::MemoryReset { + request_id, + params: None, + }) + .await + .wrap_err("memory/reset failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadUnsubscribeResponse = self diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 51b0ef81ba..d2d3c00ce3 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -12,6 +12,7 @@ use ratatui::widgets::Widget; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::key_hint; use crate::render::Insets; use crate::render::RectExt as _; @@ -35,21 +36,32 @@ enum MemoriesSetting { Generate, } -struct MemoriesSettingItem { - setting: MemoriesSetting, - name: &'static str, - description: &'static str, - enabled: bool, +#[derive(Clone, Copy, PartialEq, Eq)] +enum MemoriesAction { + Reset, +} + +enum MemoriesMenuItem { + Setting { + setting: MemoriesSetting, + name: &'static str, + description: &'static str, + enabled: bool, + }, + Action { + action: MemoriesAction, + name: &'static str, + description: &'static str, + }, } pub(crate) struct MemoriesSettingsView { - items: Vec, + items: Vec, state: ScrollState, + reset_confirmation: Option, complete: bool, app_event_tx: AppEventSender, - header: Box, docs_link: Line<'static>, - footer_hint: Line<'static>, } impl MemoriesSettingsView { @@ -58,36 +70,34 @@ impl MemoriesSettingsView { generate_memories: bool, app_event_tx: AppEventSender, ) -> Self { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Memories".bold())); - header.push(Line::from( - "Choose how Codex uses and creates memories. Changes are saved to config.toml".dim(), - )); - let mut view = Self { items: vec![ - MemoriesSettingItem { + MemoriesMenuItem::Setting { setting: MemoriesSetting::Use, name: "Use memories", description: "Use memories in the following threads. Applied at next thread.", enabled: use_memories, }, - MemoriesSettingItem { + MemoriesMenuItem::Setting { setting: MemoriesSetting::Generate, name: "Generate memories", description: "Generate memories from the following threads. Current thread included.", enabled: generate_memories, }, + MemoriesMenuItem::Action { + action: MemoriesAction::Reset, + name: "Reset all memories", + description: "Clear local memory files and summaries. Existing threads stay intact.", + }, ], state: ScrollState::new(), + reset_confirmation: None, complete: false, app_event_tx, - header: Box::new(header), docs_link: Line::from(vec![ "Learn more: ".dim(), MEMORIES_DOC_URL.cyan().underlined(), ]), - footer_hint: memories_settings_hint_line(), }; view.initialize_selection(); view @@ -97,29 +107,93 @@ impl MemoriesSettingsView { self.state.selected_idx = (!self.items.is_empty()).then_some(0); } + fn settings_header(&self) -> ColumnRenderable<'_> { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Memories".bold())); + header.push(Line::from( + "Choose how Codex uses and creates memories. Changes are saved to config.toml".dim(), + )); + header + } + + fn reset_confirmation_header(&self) -> ColumnRenderable<'_> { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Reset all memories?".bold())); + header.push(Line::from( + "This clears local memory files and rollout summaries for the current Codex home." + .dim(), + )); + header + } + + fn active_state(&self) -> &ScrollState { + self.reset_confirmation.as_ref().unwrap_or(&self.state) + } + + fn active_state_mut(&mut self) -> &mut ScrollState { + self.reset_confirmation.as_mut().unwrap_or(&mut self.state) + } + fn visible_len(&self) -> usize { - self.items.len() + if self.reset_confirmation.is_some() { + 2 + } else { + self.items.len() + } } fn build_rows(&self) -> Vec { - let mut rows = Vec::with_capacity(self.items.len()); - let selected_idx = self.state.selected_idx; - for (idx, item) in self.items.iter().enumerate() { - let prefix = if selected_idx == Some(idx) { - '›' - } else { - ' ' - }; - let marker = if item.enabled { 'x' } else { ' ' }; - let name = format!("{prefix} [{marker}] {}", item.name); - rows.push(GenericDisplayRow { - name, - description: Some(item.description.to_string()), - ..Default::default() - }); + if let Some(state) = self.reset_confirmation.as_ref() { + return ["Reset all memories", "Go back"] + .into_iter() + .enumerate() + .map(|(idx, name)| GenericDisplayRow { + name: if state.selected_idx == Some(idx) { + format!("› {name}") + } else { + format!(" {name}") + }, + description: Some(match idx { + 0 => "Delete local memory files and rollout summaries.".to_string(), + 1 => "Return to memory settings.".to_string(), + _ => unreachable!("reset confirmation only renders two rows"), + }), + ..Default::default() + }) + .collect(); } - rows + let selected_idx = self.state.selected_idx; + self.items + .iter() + .enumerate() + .map(|(idx, item)| { + let prefix = if selected_idx == Some(idx) { + '›' + } else { + ' ' + }; + let (name, description) = match item { + MemoriesMenuItem::Setting { + name, + description, + enabled, + .. + } => ( + format!("{prefix} [{}] {name}", if *enabled { 'x' } else { ' ' }), + description, + ), + MemoriesMenuItem::Action { + name, description, .. + } => (format!("{prefix} {name}"), description), + }; + GenericDisplayRow { + name, + description: Some((*description).to_string()), + ..Default::default() + } + }) + .collect() } fn move_up(&mut self) { @@ -127,8 +201,9 @@ impl MemoriesSettingsView { if len == 0 { return; } - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + let state = self.active_state_mut(); + state.move_up_wrap(len); + state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } fn move_down(&mut self) { @@ -136,17 +211,22 @@ impl MemoriesSettingsView { if len == 0 { return; } - self.state.move_down_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + let state = self.active_state_mut(); + state.move_down_wrap(len); + state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } fn toggle_selected(&mut self) { + if self.reset_confirmation.is_some() { + return; + } + let Some(selected_idx) = self.state.selected_idx else { return; }; - if let Some(item) = self.items.get_mut(selected_idx) { - item.enabled = !item.enabled; + if let Some(MemoriesMenuItem::Setting { enabled, .. }) = self.items.get_mut(selected_idx) { + *enabled = !*enabled; } } @@ -157,8 +237,34 @@ impl MemoriesSettingsView { fn current_setting(&self, setting: MemoriesSetting) -> bool { self.items .iter() - .find(|item| item.setting == setting) - .is_some_and(|item| item.enabled) + .find_map(|item| match item { + MemoriesMenuItem::Setting { + setting: item_setting, + enabled, + .. + } if *item_setting == setting => Some(*enabled), + _ => None, + }) + .unwrap_or(false) + } + + fn open_reset_confirmation(&mut self) { + let mut state = ScrollState::new(); + state.selected_idx = Some(0); + self.reset_confirmation = Some(state); + } + + fn close_reset_confirmation(&mut self) { + self.reset_confirmation = None; + self.state.selected_idx = self.items.len().checked_sub(1); + } + + fn footer_hint(&self) -> Line<'static> { + if self.reset_confirmation.is_some() { + standard_popup_hint_line() + } else { + memories_settings_hint_line() + } } } @@ -231,15 +337,39 @@ impl BottomPaneView for MemoriesSettingsView { impl MemoriesSettingsView { fn save(&mut self) { - self.app_event_tx.send(AppEvent::UpdateMemorySettings { - use_memories: self.current_setting(MemoriesSetting::Use), - generate_memories: self.current_setting(MemoriesSetting::Generate), - }); - self.complete = true; + if let Some(state) = self.reset_confirmation.as_ref() { + match state.selected_idx { + Some(0) => { + self.app_event_tx.send(AppEvent::ResetMemories); + self.complete = true; + } + Some(1) | None => self.close_reset_confirmation(), + Some(other) => unreachable!("unexpected reset confirmation row: {other}"), + } + return; + } + + match self.state.selected_idx.and_then(|idx| self.items.get(idx)) { + Some(MemoriesMenuItem::Action { + action: MemoriesAction::Reset, + .. + }) => self.open_reset_confirmation(), + _ => { + self.app_event_tx.send(AppEvent::UpdateMemorySettings { + use_memories: self.current_setting(MemoriesSetting::Use), + generate_memories: self.current_setting(MemoriesSetting::Generate), + }); + self.complete = true; + } + } } fn cancel(&mut self) { - self.complete = true; + if self.reset_confirmation.is_some() { + self.close_reset_confirmation(); + } else { + self.complete = true; + } } } @@ -256,14 +386,17 @@ impl Renderable for MemoriesSettingsView { .style(user_message_style()) .render(content_area, buf); - let header_height = self - .header - .desired_height(content_area.width.saturating_sub(4)); + let header = if self.reset_confirmation.is_some() { + self.reset_confirmation_header() + } else { + self.settings_header() + }; + let header_height = header.desired_height(content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(content_area.width); let rows_height = measure_rows_height( &rows, - &self.state, + self.active_state(), MAX_POPUP_ROWS, rows_width.saturating_add(1), ); @@ -276,7 +409,7 @@ impl Renderable for MemoriesSettingsView { ]) .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); - self.header.render(header_area, buf); + header.render(header_area, buf); if list_area.height > 0 { let render_area = Rect { @@ -289,12 +422,14 @@ impl Renderable for MemoriesSettingsView { render_area, buf, &rows, - &self.state, + self.active_state(), MAX_POPUP_ROWS, " No memory settings available", ); } - self.docs_link.clone().render(docs_area, buf); + if self.reset_confirmation.is_none() { + self.docs_link.clone().render(docs_area, buf); + } let hint_area = Rect { x: footer_area.x + 2, @@ -302,21 +437,31 @@ impl Renderable for MemoriesSettingsView { width: footer_area.width.saturating_sub(2), height: footer_area.height, }; - self.footer_hint.clone().dim().render(hint_area, buf); + self.footer_hint().render(hint_area, buf); } fn desired_height(&self, width: u16) -> u16 { + let header = if self.reset_confirmation.is_some() { + self.reset_confirmation_header() + } else { + self.settings_header() + }; let rows = self.build_rows(); let rows_width = Self::rows_width(width); let rows_height = measure_rows_height( &rows, - &self.state, + self.active_state(), MAX_POPUP_ROWS, rows_width.saturating_add(1), ); - let mut height = self.header.desired_height(width.saturating_sub(4)); - height = height.saturating_add(rows_height + 5); + let docs_height = if self.reset_confirmation.is_some() { + 0 + } else { + 1 + }; + let mut height = header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 4 + docs_height); height.saturating_add(1) } } @@ -327,6 +472,6 @@ fn memories_settings_hint_line() -> Line<'static> { key_hint::plain(KeyCode::Char(' ')).into(), " to toggle; ".into(), key_hint::plain(KeyCode::Enter).into(), - " to save".into(), + " to save or select".into(), ]) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_reset_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_reset_confirmation.snap new file mode 100644 index 0000000000..2538e2b461 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_reset_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Reset all memories? + +› Reset all memories Delete local memory files and rollout summaries. + Go back Return to memory settings. + + + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_settings_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_settings_popup.snap index 9785ef2bb3..2fbe641e29 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_settings_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_settings_popup.snap @@ -9,7 +9,9 @@ expression: popup next thread. [ ] Generate memories Generate memories from the following threads. Current thread included. + Reset all memories Clear local memory files and summaries. Existing + threads stay intact. Learn more: https://developers.openai.com/codex/memories - Press space to toggle; enter to save + Press space to toggle; enter to save or select diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 0edea73391..375c37314b 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1543,6 +1543,22 @@ async fn memories_settings_popup_snapshot() { assert_chatwidget_snapshot!("memories_settings_popup", popup); } +#[tokio::test] +async fn memories_reset_confirmation_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); + chat.config.memories.use_memories = true; + chat.config.memories.generate_memories = false; + + chat.open_memories_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("memories_reset_confirmation", popup); +} + #[tokio::test] async fn memories_settings_toggle_saves_on_enter() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1564,6 +1580,22 @@ async fn memories_settings_toggle_saves_on_enter() { ); } +#[tokio::test] +async fn memories_reset_confirmation_sends_event_on_confirm() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); + chat.config.memories.use_memories = true; + chat.config.memories.generate_memories = false; + + chat.open_memories_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ResetMemories)); +} + #[tokio::test] async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;