From 85bcd57596e4d3282e29dcb51aa3459ada6eae7d Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Tue, 26 Aug 2025 07:53:34 -0700 Subject: [PATCH] fix --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 27 ++++--- codex-rs/tui/src/bottom_pane/command_popup.rs | 79 +++++++++---------- codex-rs/tui/src/bottom_pane/mod.rs | 7 ++ codex-rs/tui/src/chatwidget.rs | 3 + 4 files changed, 65 insertions(+), 51 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a0a1b3801a..ef7daa1774 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -25,6 +25,7 @@ use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use crate::slash_command::SlashCommand; +use codex_protocol::custom_prompts::CustomPrompt; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -100,6 +101,7 @@ pub(crate) struct ChatComposer { // Buffer to accumulate characters during a detected non-bracketed paste burst. paste_burst_buffer: String, in_paste_burst_mode: bool, + custom_prompts: Vec, } /// Popup state – at most one can be visible at any time. @@ -139,6 +141,7 @@ impl ChatComposer { paste_burst_until: None, paste_burst_buffer: String::new(), in_paste_burst_mode: false, + custom_prompts: Vec::new(), } } @@ -1104,7 +1107,7 @@ impl ChatComposer { } _ => { if input_starts_with_slash { - let mut command_popup = CommandPopup::new(); + let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1112,6 +1115,14 @@ impl ChatComposer { } } + pub(crate) fn set_custom_prompts(&mut self, mut prompts: Vec) { + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.custom_prompts = prompts.clone(); + if let ActivePopup::Command(popup) = &mut self.active_popup { + popup.set_prompts(prompts); + } + } + /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. fn sync_file_search_popup(&mut self) { @@ -1982,21 +1993,19 @@ mod tests { #[test] fn selecting_custom_prompt_submits_file_contents() { - let tmp = tempdir().expect("create TempDir"); - let home = tmp.path(); - let prompts_dir = home.join(".codex").join("prompts"); - std::fs::create_dir_all(&prompts_dir).expect("mkdir -p ~/.codex/prompts"); - let prompt_path = prompts_dir.join("my-prompt"); let prompt_text = "Hello from saved prompt"; - std::fs::write(&prompt_path, prompt_text).unwrap(); - - unsafe { std::env::set_var("HOME", home) }; let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + // Inject prompts as if received via event. + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + content: prompt_text.to_string(), + }]); + // Type the prompt name to focus it in the slash popup and press Enter. for ch in ['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index e55ac7dea0..b2f5e2f1bb 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -9,11 +9,7 @@ use super::selection_popup_common::render_rows; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use codex_common::fuzzy_match::fuzzy_match; -#[derive(Clone, Debug)] -pub(crate) struct PromptEntry { - pub name: String, - pub content: String, -} +use codex_protocol::custom_prompts::CustomPrompt; /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -26,24 +22,18 @@ pub(crate) enum CommandItem { pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, - prompts: Vec, + prompts: Vec, state: ScrollState, } impl CommandPopup { - pub(crate) fn new() -> Self { + pub(crate) fn new(mut prompts: Vec) -> Self { let builtins = built_in_slash_commands(); - let mut exclude = std::collections::HashSet::new(); - for (name, _) in builtins.iter() { - exclude.insert((*name).to_string()); - } - let prompts = codex_core::custom_prompts::discover_prompts_excluding(&exclude) - .into_iter() - .map(|p| PromptEntry { - name: p.name, - content: p.content, - }) - .collect(); + // Exclude prompts that collide with builtin command names and sort by name. + let exclude: std::collections::HashSet = + builtins.iter().map(|(n, _)| (*n).to_string()).collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { command_filter: String::new(), builtins, @@ -52,6 +42,17 @@ impl CommandPopup { } } + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: std::collections::HashSet = self + .builtins + .iter() + .map(|(n, _)| (*n).to_string()) + .collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.prompts = prompts; + } + pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> { self.prompts.get(idx).map(|p| p.name.as_str()) } @@ -198,11 +199,10 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(); + let mut popup = CommandPopup::new(Vec::new()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -222,7 +222,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(); + let mut popup = CommandPopup::new(Vec::new()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -237,17 +237,17 @@ mod tests { #[test] fn prompt_discovery_lists_custom_prompts() { - let tmp = tempdir().expect("create TempDir"); - let home = tmp.path(); - let prompts_dir = home.join(".codex").join("prompts"); - std::fs::create_dir_all(&prompts_dir).expect("mkdir -p ~/.codex/prompts"); - std::fs::write(prompts_dir.join("foo"), b"hello from foo").unwrap(); - std::fs::write(prompts_dir.join("bar"), b"hello from bar").unwrap(); - - // Point HOME to the temp dir so discovery uses our fixtures. - unsafe { std::env::set_var("HOME", home) }; - - let popup = CommandPopup::new(); + let prompts = vec![ + CustomPrompt { + name: "foo".to_string(), + content: "hello from foo".to_string(), + }, + CustomPrompt { + name: "bar".to_string(), + content: "hello from bar".to_string(), + }, + ]; + let popup = CommandPopup::new(prompts); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -262,16 +262,11 @@ mod tests { #[test] fn prompt_name_collision_with_builtin_is_ignored() { - let tmp = tempdir().expect("create TempDir"); - let home = tmp.path(); - let prompts_dir = home.join(".codex").join("prompts"); - std::fs::create_dir_all(&prompts_dir).expect("mkdir -p ~/.codex/prompts"); - // Create a prompt with the same name as a builtin command (e.g. "init"). - std::fs::write(prompts_dir.join("init"), b"should be ignored").unwrap(); - - unsafe { std::env::set_var("HOME", home) }; - - let popup = CommandPopup::new(); + // Create a prompt named like a builtin (e.g. "init"). + let popup = CommandPopup::new(vec![CustomPrompt { + name: "init".to_string(), + content: "should be ignored".to_string(), + }]); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { CommandItem::Prompt(i) => popup.prompt_name(i) == Some("init"), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b9b05d559b..cad1c59762 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -34,6 +34,7 @@ pub(crate) enum CancellationEvent { pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; +use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; use approval_modal_view::ApprovalModalView; @@ -329,6 +330,12 @@ impl BottomPane { self.request_redraw(); } + /// Update custom prompts available for the slash popup. + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + self.composer.set_custom_prompts(prompts); + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2d47b19e40..0577a67085 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1169,6 +1169,9 @@ impl ChatWidget { // Cache prompts locally; UI surfaces them from this store. self.custom_prompts = ev.custom_prompts; debug!("received {} custom prompts", self.custom_prompts.len()); + // Forward to bottom pane so the slash popup can show them now. + self.bottom_pane + .set_custom_prompts(self.custom_prompts.clone()); } /// Programmatically submit a user text message as if typed in the