mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
544b4e39e3
commit
9402347f34
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
+15
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user