feat: reset memories button (#17937)

<img width="720" height="175" alt="Screenshot 2026-04-15 at 14 35 02"
src="https://github.com/user-attachments/assets/041d73ff-8c16-42a9-8e92-c245805084f0"
/>
This commit is contained in:
jif-oai
2026-04-15 15:34:25 +01:00
committed by GitHub
Unverified
parent ec13aaac89
commit da86cedbd4
7 changed files with 314 additions and 62 deletions
+44
View File
@@ -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;
+3
View File
@@ -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),
+14
View File
@@ -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
@@ -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<MemoriesSettingItem>,
items: Vec<MemoriesMenuItem>,
state: ScrollState,
reset_confirmation: Option<ScrollState>,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
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<GenericDisplayRow> {
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(),
])
}
@@ -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
@@ -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
@@ -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;