diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b46ace362..13f737161 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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::(&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; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 91f00b99b..a0476640b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9c4546c4f..3ce12d243 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8bef8ddbc..29da003dc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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] diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs new file mode 100644 index 000000000..51b0ef81b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -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, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + header: Box, + 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 { + 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(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b2d080b04..85020d713 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1334b6e2f..e9f56a0ef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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) { 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>) { 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) { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index e6e93d17f..ee4d22e95 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap new file mode 100644 index 000000000..f9b0daefc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap @@ -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 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 new file mode 100644 index 000000000..9785ef2bb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_settings_popup.snap @@ -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 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 e2635d322..0edea7339 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb95934..7174b28b8 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 635dc317f..759bedbad 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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 " } 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