feat: memories menu (#17632)

Add menu that:
1. If memories feature is not enabled, propose to enable it
2. Let you choose if you want to generate memories and to use memories
This commit is contained in:
jif-oai
2026-04-15 14:02:35 +01:00
committed by GitHub
Unverified
parent 544b4e39e3
commit 9402347f34
13 changed files with 718 additions and 1 deletions
+177
View File
@@ -96,6 +96,7 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::Turn;
@@ -1384,10 +1385,16 @@ impl App {
}
self.config = next_config;
let show_memory_enable_notice = feature_updates_to_apply
.iter()
.any(|(feature, enabled)| *feature == Feature::MemoryTool && *enabled);
for (feature, effective_enabled) in feature_updates_to_apply {
self.chat_widget
.set_feature_enabled(feature, effective_enabled);
}
if show_memory_enable_notice {
self.chat_widget.add_memories_enable_notice();
}
if approvals_reviewer_override.is_some() {
self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer);
}
@@ -1471,6 +1478,89 @@ impl App {
}
}
async fn update_memory_settings(
&mut self,
use_memories: bool,
generate_memories: bool,
) -> bool {
let active_profile = self.active_profile.clone();
let scoped_memory_segments = |key: &str| {
if let Some(profile) = active_profile.as_deref() {
vec![
"profiles".to_string(),
profile.to_string(),
"memories".to_string(),
key.to_string(),
]
} else {
vec!["memories".to_string(), key.to_string()]
}
};
let edits = [
ConfigEdit::SetPath {
segments: scoped_memory_segments("use_memories"),
value: use_memories.into(),
},
ConfigEdit::SetPath {
segments: scoped_memory_segments("generate_memories"),
value: generate_memories.into(),
},
];
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
tracing::error!(error = %err, "failed to persist memory settings");
self.chat_widget
.add_error_message(format!("Failed to save memory settings: {err}"));
return false;
}
self.config.memories.use_memories = use_memories;
self.config.memories.generate_memories = generate_memories;
self.chat_widget
.set_memory_settings(use_memories, generate_memories);
true
}
async fn update_memory_settings_with_app_server(
&mut self,
app_server: &mut AppServerSession,
use_memories: bool,
generate_memories: bool,
) {
let previous_generate_memories = self.config.memories.generate_memories;
if !self
.update_memory_settings(use_memories, generate_memories)
.await
{
return;
}
if previous_generate_memories == generate_memories {
return;
}
let Some(thread_id) = self.current_displayed_thread_id() else {
return;
};
let mode = if generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
};
if let Err(err) = app_server.thread_memory_mode_set(thread_id, mode).await {
tracing::error!(error = %err, %thread_id, "failed to update thread memory mode");
self.chat_widget.add_error_message(format!(
"Saved memory settings, but failed to update the current thread: {err}"
));
}
}
fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
@@ -5312,6 +5402,17 @@ impl App {
AppEvent::UpdateFeatureFlags { updates } => {
self.update_feature_flags(updates).await;
}
AppEvent::UpdateMemorySettings {
use_memories,
generate_memories,
} => {
self.update_memory_settings_with_app_server(
app_server,
use_memories,
generate_memories,
)
.await;
}
AppEvent::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
@@ -7944,6 +8045,82 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn update_memory_settings_persists_and_updates_widget_config() -> 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();
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ false,
/*generate_memories*/ false,
)
.await;
assert!(!app.config.memories.use_memories);
assert!(!app.config.memories.generate_memories);
assert!(!app.chat_widget.config_ref().memories.use_memories);
assert!(!app.chat_widget.config_ref().memories.generate_memories);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let config_value = toml::from_str::<TomlValue>(&config)?;
let memories = config_value
.as_table()
.and_then(|table| table.get("memories"))
.and_then(TomlValue::as_table)
.expect("memories table should exist");
assert_eq!(
memories.get("use_memories"),
Some(&TomlValue::Boolean(false))
);
assert_eq!(
memories.get("generate_memories"),
Some(&TomlValue::Boolean(false))
);
assert!(
!memories.contains_key("no_memories_if_mcp_or_web_search"),
"the TUI menu should not write the MCP pollution setting"
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn update_memory_settings_updates_current_thread_memory_mode() -> 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();
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.active_thread_id = Some(thread_id);
app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ true,
/*generate_memories*/ false,
)
.await;
let state_db = codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
app.config.model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let memory_mode = state_db
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
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;
+6
View File
@@ -464,6 +464,12 @@ pub(crate) enum AppEvent {
updates: Vec<(Feature, bool)>,
},
/// Update memory settings and persist them to config.toml.
UpdateMemorySettings {
use_memories: bool,
generate_memories: bool,
},
/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),
+23
View File
@@ -38,6 +38,9 @@ use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
@@ -512,6 +515,26 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_memory_mode_set(
&mut self,
thread_id: ThreadId,
mode: ThreadMemoryMode,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadMemoryModeSetResponse = self
.client
.request_typed(ClientRequest::ThreadMemoryModeSet {
request_id,
params: ThreadMemoryModeSetParams {
thread_id: thread_id.to_string(),
mode,
},
})
.await
.wrap_err("thread/memoryMode/set 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
@@ -293,7 +293,7 @@ mod tests {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]);
}
#[test]
@@ -0,0 +1,332 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories";
#[derive(Clone, Copy, PartialEq, Eq)]
enum MemoriesSetting {
Use,
Generate,
}
struct MemoriesSettingItem {
setting: MemoriesSetting,
name: &'static str,
description: &'static str,
enabled: bool,
}
pub(crate) struct MemoriesSettingsView {
items: Vec<MemoriesSettingItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
docs_link: Line<'static>,
footer_hint: Line<'static>,
}
impl MemoriesSettingsView {
pub(crate) fn new(
use_memories: bool,
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 {
setting: MemoriesSetting::Use,
name: "Use memories",
description: "Use memories in the following threads. Applied at next thread.",
enabled: use_memories,
},
MemoriesSettingItem {
setting: MemoriesSetting::Generate,
name: "Generate memories",
description: "Generate memories from the following threads. Current thread included.",
enabled: generate_memories,
},
],
state: ScrollState::new(),
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
}
fn initialize_selection(&mut self) {
self.state.selected_idx = (!self.items.is_empty()).then_some(0);
}
fn visible_len(&self) -> usize {
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()
});
}
rows
}
fn move_up(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn move_down(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn toggle_selected(&mut self) {
let Some(selected_idx) = self.state.selected_idx else {
return;
};
if let Some(item) = self.items.get_mut(selected_idx) {
item.enabled = !item.enabled;
}
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn current_setting(&self, setting: MemoriesSetting) -> bool {
self.items
.iter()
.find(|item| item.setting == setting)
.is_some_and(|item| item.enabled)
}
}
impl BottomPaneView for MemoriesSettingsView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.save(),
KeyEvent {
code: KeyCode::Esc, ..
} => self.cancel(),
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.cancel();
CancellationEvent::Handled
}
}
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;
}
fn cancel(&mut self) {
self.complete = true;
}
}
impl Renderable for MemoriesSettingsView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.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,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let [header_area, _, list_area, _, docs_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
Constraint::Max(1),
Constraint::Length(1),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
self.header.render(header_area, buf);
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows(
render_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
" No memory settings available",
);
}
self.docs_link.clone().render(docs_area, buf);
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
&self.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);
height.saturating_add(1)
}
}
fn memories_settings_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save".into(),
])
}
+2
View File
@@ -81,6 +81,7 @@ mod experimental_features_view;
mod file_search_popup;
mod footer;
mod list_selection_view;
mod memories_settings_view;
mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
@@ -91,6 +92,7 @@ pub(crate) use list_selection_view::SelectionViewParams;
pub(crate) use list_selection_view::SideContentWidth;
pub(crate) use list_selection_view::popup_content_width;
pub(crate) use list_selection_view::side_by_side_layout_widths;
pub(crate) use memories_settings_view::MemoriesSettingsView;
mod feedback_view;
pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_classification;
+69
View File
@@ -251,6 +251,11 @@ const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session.";
const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories";
const MEMORIES_ENABLE_TITLE: &str = "Enable memories?";
const MEMORIES_ENABLE_YES: &str = "Yes, enable";
const MEMORIES_ENABLE_NO: &str = "Not now";
const MEMORIES_ENABLE_NOTICE: &str = "Memories will be enabled in the next session.";
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
@@ -311,6 +316,7 @@ use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::bottom_pane::MemoriesSettingsView;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::SelectionAction;
@@ -2588,6 +2594,61 @@ impl ChatWidget {
});
}
pub(crate) fn open_memories_popup(&mut self) {
if !self.config.features.enabled(Feature::MemoryTool) {
self.open_memories_enable_prompt();
return;
}
let view = MemoriesSettingsView::new(
self.config.memories.use_memories,
self.config.memories.generate_memories,
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn open_memories_enable_prompt(&mut self) {
let items = vec![
SelectionItem {
name: MEMORIES_ENABLE_YES.to_string(),
description: Some(
"Save the setting now. You will need a new session to use it.".to_string(),
),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::UpdateFeatureFlags {
updates: vec![(Feature::MemoryTool, true)],
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: MEMORIES_ENABLE_NO.to_string(),
description: Some("Keep memories disabled.".to_string()),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(MEMORIES_ENABLE_TITLE.to_string()),
subtitle: Some("Memories are currently disabled in your config.".to_string()),
footer_note: Some(Line::from(vec![
"Learn more: ".dim(),
MEMORIES_DOC_URL.cyan().underlined(),
])),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn set_memory_settings(&mut self, use_memories: bool, generate_memories: bool) {
self.config.memories.use_memories = use_memories;
self.config.memories.generate_memories = generate_memories;
}
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
match info {
Some(info) => self.apply_token_info(info),
@@ -9693,6 +9754,13 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn add_memories_enable_notice(&mut self) {
self.add_to_history(history_cell::new_warning_event(
MEMORIES_ENABLE_NOTICE.to_string(),
));
self.request_redraw();
}
pub(crate) fn add_plain_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.add_boxed_history(Box::new(PlainHistoryCell::new(lines)));
self.request_redraw();
@@ -10372,6 +10440,7 @@ impl ChatWidget {
self.config.features = config.features.clone();
self.config.config_layer_stack = config.config_layer_stack.clone();
self.config.realtime = config.realtime.clone();
self.config.memories = config.memories.clone();
}
pub(crate) fn open_review_popup(&mut self) {
@@ -228,6 +228,9 @@ impl ChatWidget {
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Memories => {
self.open_memories_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Enable memories?
Memories are currently disabled in your config.
1. Yes, enable Save the setting now. You will need a new session to use it.
2. Not now Keep memories disabled.
Learn more: https://developers.openai.com/codex/memories
Press enter to confirm or esc to go back
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Memories
Choose how Codex uses and creates memories. Changes are saved to config.toml
[x] Use memories Use memories in the following threads. Applied at
next thread.
[ ] Generate memories Generate memories from the following threads. Current
thread included.
Learn more: https://developers.openai.com/codex/memories
Press space to toggle; enter to save
@@ -1501,6 +1501,69 @@ async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() {
assert!(rendered.contains("Subagents will be enabled in the next session."));
}
#[tokio::test]
async fn memories_enable_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ false);
chat.open_memories_popup();
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("memories_enable_prompt", popup);
}
#[tokio::test]
async fn memories_enable_prompt_updates_feature_without_notice() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ false);
chat.open_memories_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::MemoryTool, true)]
);
assert!(
rx.try_recv().is_err(),
"memory enable prompt should not emit the success notice before persistence succeeds"
);
}
#[tokio::test]
async fn memories_settings_popup_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();
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("memories_settings_popup", 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;
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::Char(' ')));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::UpdateMemorySettings {
use_memories: true,
generate_memories: true,
})
);
}
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
@@ -533,6 +533,18 @@ async fn slash_mcp_requests_inventory_via_app_server() {
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}
#[tokio::test]
async fn slash_memories_opens_memory_menu() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Memories);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Use memories"));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}
#[tokio::test]
async fn slash_memory_update_reports_stubbed_feature() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+3
View File
@@ -21,6 +21,7 @@ pub enum SlashCommand {
#[strum(serialize = "sandbox-add-read-dir")]
SandboxReadRoot,
Experimental,
Memories,
Skills,
Review,
Rename,
@@ -109,6 +110,7 @@ impl SlashCommand {
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
}
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Memories => "configure memory use and generation",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Apps => "manage apps",
SlashCommand::Plugins => "browse plugins",
@@ -154,6 +156,7 @@ impl SlashCommand {
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::Clear