This commit is contained in:
Daniel Edrisian
2025-08-26 07:53:34 -07:00
parent 0ac35f89c6
commit 85bcd57596
4 changed files with 65 additions and 51 deletions

View File

@@ -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<CustomPrompt>,
}
/// 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<CustomPrompt>) {
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::<AppEvent>();
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));

View File

@@ -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<PromptEntry>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
impl CommandPopup {
pub(crate) fn new() -> Self {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> 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<String> =
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<CustomPrompt>) {
let exclude: std::collections::HashSet<String> = 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<String> = 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"),

View File

@@ -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<CustomPrompt>) {
self.composer.set_custom_prompts(prompts);
self.request_redraw();
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.composer.is_empty()
}

View File

@@ -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