# PR #2696: Custom /prompts - URL: https://github.com/openai/codex/pull/2696 - Author: dedrisian-oai - Created: 2025-08-26 01:23:07 UTC - Updated: 2025-08-29 02:16:51 UTC - Changes: +448/-52, Files changed: 13, Commits: 25 ## Description Adds custom `/prompts` to `~/.codex/prompts/.md`. Screenshot 2025-08-25 at 6 22 42 PM --- Details: 1. Adds `Op::ListCustomPrompts` to core. 2. Returns `ListCustomPromptsResponse` with list of `CustomPrompt` (name, content). 3. TUI calls the operation on load, and populates the custom prompts (excluding prompts that collide with builtins). 4. Selecting the custom prompt automatically sends the prompt to the agent. ## Full Diff ```diff diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8443c534fb..40bcdc2c94 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -89,6 +89,7 @@ use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::FileChange; use crate::protocol::InputItem; +use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; use crate::protocol::PatchApplyBeginEvent; use crate::protocol::PatchApplyEndEvent; @@ -110,6 +111,7 @@ use crate::user_notification::UserNotification; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::LocalShellAction; @@ -1286,6 +1288,27 @@ async fn submission_loop( warn!("failed to send McpListToolsResponse event: {e}"); } } + Op::ListCustomPrompts => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + let custom_prompts: Vec = + if let Some(dir) = crate::custom_prompts::default_prompts_dir() { + crate::custom_prompts::discover_prompts_in(&dir).await + } else { + Vec::new() + }; + + let event = Event { + id: sub_id, + msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { + custom_prompts, + }), + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send ListCustomPromptsResponse event: {e}"); + } + } Op::Compact => { // Create a summarization request as user input const SUMMARIZATION_PROMPT: &str = include_str!("prompt_for_compact_command.md"); diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs new file mode 100644 index 0000000000..4974e7c52d --- /dev/null +++ b/codex-rs/core/src/custom_prompts.rs @@ -0,0 +1,127 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; + +/// Return the default prompts directory: `$CODEX_HOME/prompts`. +/// If `CODEX_HOME` cannot be resolved, returns `None`. +pub fn default_prompts_dir() -> Option { + crate::config::find_codex_home() + .ok() + .map(|home| home.join("prompts")) +} + +/// Discover prompt files in the given directory, returning entries sorted by name. +/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. +pub async fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()).await +} + +/// Discover prompt files in the given directory, excluding any with names in `exclude`. +/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. +pub async fn discover_prompts_in_excluding( + dir: &Path, + exclude: &HashSet, +) -> Vec { + let mut out: Vec = Vec::new(); + let mut entries = match fs::read_dir(dir).await { + Ok(entries) => entries, + Err(_) => return out, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let is_file = entry + .file_type() + .await + .map(|ft| ft.is_file()) + .unwrap_or(false); + if !is_file { + continue; + } + // Only include Markdown files with a .md extension. + let is_md = path + .extension() + .and_then(|s| s.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + continue; + } + let Some(name) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + else { + continue; + }; + if exclude.contains(&name) { + continue; + } + let content = match fs::read_to_string(&path).await { + Ok(s) => s, + Err(_) => continue, + }; + out.push(CustomPrompt { + name, + path, + content, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[tokio::test] + async fn empty_when_dir_missing() { + let tmp = tempdir().expect("create TempDir"); + let missing = tmp.path().join("nope"); + let found = discover_prompts_in(&missing).await; + assert!(found.is_empty()); + } + + #[tokio::test] + async fn discovers_and_sorts_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("b.md"), b"b").unwrap(); + fs::write(dir.join("a.md"), b"a").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[tokio::test] + async fn excludes_builtins() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + fs::write(dir.join("init.md"), b"ignored").unwrap(); + fs::write(dir.join("foo.md"), b"ok").unwrap(); + let mut exclude = HashSet::new(); + exclude.insert("init".to_string()); + let found = discover_prompts_in_excluding(dir, &exclude).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["foo"]); + } + + #[tokio::test] + async fn skips_non_utf8_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + // Valid UTF-8 file + fs::write(dir.join("good.md"), b"hello").unwrap(); + // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) + fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["good"]); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ae18332087..9f23420c7e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -17,6 +17,7 @@ pub mod config; pub mod config_profile; pub mod config_types; mod conversation_history; +pub mod custom_prompts; mod environment_context; pub mod error; pub mod exec; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a3558f7407..6d1bc123fd 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -533,6 +533,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::McpListToolsResponse(_) => { // Currently ignored in exec output. } + EventMsg::ListCustomPromptsResponse(_) => { + // Currently ignored in exec output. + } EventMsg::TurnAborted(abort_reason) => match abort_reason.reason { TurnAbortReason::Interrupted => { ts_println!(self, "task interrupted"); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 3a26c26cd3..72cac591e4 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -264,6 +264,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::McpListToolsResponse(_) + | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs new file mode 100644 index 0000000000..8567c1c944 --- /dev/null +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CustomPrompt { + pub name: String, + pub path: PathBuf, + pub content: String, +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index d7aceeb9c8..d5e7c6bde1 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,4 +1,5 @@ pub mod config_types; +pub mod custom_prompts; pub mod mcp_protocol; pub mod message_history; pub mod models; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7f317bbfba..8707e1a836 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; +use crate::custom_prompts::CustomPrompt; use mcp_types::CallToolResult; use mcp_types::Tool as McpTool; use serde::Deserialize; @@ -146,6 +147,9 @@ pub enum Op { /// Reply is delivered via `EventMsg::McpListToolsResponse`. ListMcpTools, + /// Request the list of available custom prompts. + ListCustomPrompts, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -472,6 +476,9 @@ pub enum EventMsg { /// List of MCP tools available to the agent. McpListToolsResponse(McpListToolsResponseEvent), + /// List of custom prompts available to the agent. + ListCustomPromptsResponse(ListCustomPromptsResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -806,6 +813,12 @@ pub struct McpListToolsResponseEvent { pub tools: std::collections::HashMap, } +/// Response payload for `Op::ListCustomPrompts`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListCustomPromptsResponseEvent { + pub custom_prompts: Vec, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct SessionConfiguredEvent { /// Unique id for this session. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c21d5a4b5d..49a3478582 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -22,11 +22,13 @@ use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; +use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::slash_command::SlashCommand; +use codex_protocol::custom_prompts::CustomPrompt; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -47,6 +49,7 @@ use std::time::Instant; const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. +#[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), Command(SlashCommand), @@ -94,6 +97,7 @@ pub(crate) struct ChatComposer { paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, + custom_prompts: Vec, } /// Popup state – at most one can be visible at any time. @@ -131,6 +135,7 @@ impl ChatComposer { placeholder_text, paste_burst: PasteBurst::default(), disable_paste_burst: false, + custom_prompts: Vec::new(), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -391,16 +396,27 @@ impl ChatComposer { KeyEvent { code: KeyCode::Tab, .. } => { - if let Some(cmd) = popup.selected_command() { + if let Some(sel) = popup.selected_item() { let first_line = self.textarea.text().lines().next().unwrap_or(""); - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - - if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); - self.textarea.set_cursor(self.textarea.text().len()); + match sel { + CommandItem::Builtin(cmd) => { + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea.set_text(&format!("/{} ", cmd.command())); + } + } + CommandItem::UserPrompt(idx) => { + if let Some(name) = popup.prompt_name(idx) { + let starts_with_cmd = + first_line.trim_start().starts_with(&format!("/{name}")); + if !starts_with_cmd { + self.textarea.set_text(&format!("/{name} ")); + } + } + } } // After completing the command, move cursor to the end. if !self.textarea.text().is_empty() { @@ -415,16 +431,30 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(cmd) = popup.selected_command() { + if let Some(sel) = popup.selected_item() { // Clear textarea so no residual text remains. self.textarea.set_text(""); - - let result = (InputResult::Command(*cmd), true); - - // Hide popup since the command has been dispatched. + // Capture any needed data from popup before clearing it. + let prompt_content = match sel { + CommandItem::UserPrompt(idx) => { + popup.prompt_content(idx).map(|s| s.to_string()) + } + _ => None, + }; + // Hide popup since an action has been dispatched. self.active_popup = ActivePopup::None; - return result; + match sel { + CommandItem::Builtin(cmd) => { + return (InputResult::Command(cmd), true); + } + CommandItem::UserPrompt(_) => { + if let Some(contents) = prompt_content { + return (InputResult::Submitted(contents), true); + } + return (InputResult::None, true); + } + } } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -1117,7 +1147,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); } @@ -1125,6 +1155,13 @@ impl ChatComposer { } } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + 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) { @@ -2098,6 +2135,38 @@ mod tests { assert_eq!(imgs, vec![tmp_path.clone()]); } + #[test] + fn selecting_custom_prompt_submits_file_contents() { + let prompt_text = "Hello from saved prompt"; + + 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(), + false, + ); + + // Inject prompts as if received via event. + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + }]); + + type_chars_humanlike( + &mut composer, + &['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + } + #[test] fn burst_paste_fast_small_buffers_and_flushes_on_stop() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 9ae7ada81a..4e60e7eaa1 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -9,22 +9,58 @@ 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; +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; + +/// A selectable item in the popup: either a built-in command or a user prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + UserPrompt(usize), +} pub(crate) struct CommandPopup { command_filter: String, - all_commands: Vec<(&'static str, SlashCommand)>, + builtins: Vec<(&'static str, SlashCommand)>, + 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(); + // Exclude prompts that collide with builtin command names and sort by name. + let exclude: 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(), - all_commands: built_in_slash_commands(), + builtins, + prompts, state: ScrollState::new(), } } + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: 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()) + } + + pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> { + self.prompts.get(idx).map(|p| p.content.as_str()) + } + /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/" on the *first* line becomes the active filter that is used @@ -50,7 +86,7 @@ impl CommandPopup { } // Reset or clamp selected index based on new filtered list. - let matches_len = self.filtered_commands().len(); + let matches_len = self.filtered_items().len(); self.state.clamp_selection(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); @@ -59,56 +95,76 @@ impl CommandPopup { /// Determine the preferred height of the popup. This is the number of /// rows required to show at most MAX_POPUP_ROWS commands. pub(crate) fn calculate_required_height(&self) -> u16 { - self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16 + self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16 } - /// Compute fuzzy-filtered matches paired with optional highlight indices and score. - /// Sorted by ascending score, then by command name for stability. - fn filtered(&self) -> Vec<(&SlashCommand, Option>, i32)> { + /// Compute fuzzy-filtered matches over built-in commands and user prompts, + /// paired with optional highlight indices and score. Sorted by ascending + /// score, then by name for stability. + fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); - let mut out: Vec<(&SlashCommand, Option>, i32)> = Vec::new(); + let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); if filter.is_empty() { - for (_, cmd) in self.all_commands.iter() { - out.push((cmd, None, 0)); + // Built-ins first, in presentation order. + for (_, cmd) in self.builtins.iter() { + out.push((CommandItem::Builtin(*cmd), None, 0)); + } + // Then prompts, already sorted by name. + for idx in 0..self.prompts.len() { + out.push((CommandItem::UserPrompt(idx), None, 0)); } - // Keep the original presentation order when no filter is applied. return out; - } else { - for (_, cmd) in self.all_commands.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((cmd, Some(indices), score)); - } + } + + for (_, cmd) in self.builtins.iter() { + if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { + out.push((CommandItem::Builtin(*cmd), Some(indices), score)); + } + } + for (idx, p) in self.prompts.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&p.name, filter) { + out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by command for stability. - out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command()))); + // When filtering, sort by ascending score and then by name for stability. + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = match a.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + let bn = match b.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + an.cmp(bn) + }) + }); out } - fn filtered_commands(&self) -> Vec<&SlashCommand> { + fn filtered_items(&self) -> Vec { self.filtered().into_iter().map(|(c, _, _)| c).collect() } /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { - let matches = self.filtered_commands(); - let len = matches.len(); + let len = self.filtered_items().len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { - let matches = self.filtered_commands(); - let matches_len = matches.len(); + let matches_len = self.filtered_items().len(); self.state.move_down_wrap(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } /// Return currently selected command, if any. - pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { - let matches = self.filtered_commands(); + pub(crate) fn selected_item(&self) -> Option { + let matches = self.filtered_items(); self.state .selected_idx .and_then(|idx| matches.get(idx).copied()) @@ -123,11 +179,19 @@ impl WidgetRef for CommandPopup { } else { matches .into_iter() - .map(|(cmd, indices, _)| GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), + .map(|(item, indices, _)| match item { + CommandItem::Builtin(cmd) => GenericDisplayRow { + name: format!("/{}", cmd.command()), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(cmd.description().to_string()), + }, + CommandItem::UserPrompt(i) => GenericDisplayRow { + name: format!("/{}", self.prompts[i].name), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some("send saved prompt".to_string()), + }, }) .collect() }; @@ -141,31 +205,82 @@ mod tests { #[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()); // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. - let matches = popup.filtered_commands(); + let matches = popup.filtered_items(); + let has_init = matches.iter().any(|item| match item { + CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::UserPrompt(_) => false, + }); assert!( - matches.iter().any(|cmd| cmd.command() == "init"), + has_init, "expected '/init' to appear among filtered commands" ); } #[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 // command by default. - let selected = popup.selected_command(); + let selected = popup.selected_item(); match selected { - Some(cmd) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), None => panic!("expected a selected command for exact match"), } } + + #[test] + fn prompt_discovery_lists_custom_prompts() { + let prompts = vec![ + CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "hello from foo".to_string(), + }, + CustomPrompt { + name: "bar".to_string(), + path: "/tmp/bar.md".to_string().into(), + content: "hello from bar".to_string(), + }, + ]; + let popup = CommandPopup::new(prompts); + let items = popup.filtered_items(); + let mut prompt_names: Vec = items + .into_iter() + .filter_map(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt_name(i).map(|s| s.to_string()), + _ => None, + }) + .collect(); + prompt_names.sort(); + assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); + } + + #[test] + fn prompt_name_collision_with_builtin_is_ignored() { + // Create a prompt named like a builtin (e.g. "init"). + let popup = CommandPopup::new(vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + }]); + let items = popup.filtered_items(); + let has_collision_prompt = items.into_iter().any(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"), + _ => false, + }); + assert!( + !has_collision_prompt, + "prompt with builtin name should be ignored" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c1e84beeef..b94db79f1c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -36,6 +36,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; @@ -336,6 +337,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 5e1fd45fc6..2692854590 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::InputItem; +use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; @@ -153,6 +154,8 @@ impl ChatWidget { event, self.show_welcome_banner, )); + // Ask codex-core to enumerate custom prompts for this session. + self.submit_op(Op::ListCustomPrompts); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -991,6 +994,7 @@ impl ChatWidget { EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), + EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { @@ -1220,6 +1224,13 @@ impl ChatWidget { self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } + fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { + let len = ev.custom_prompts.len(); + debug!("received {len} custom prompts"); + // Forward to bottom pane so the slash popup can show them now. + self.bottom_pane.set_custom_prompts(ev.custom_prompts); + } + /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000000..b98240d2ad --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,15 @@ +## Custom Prompts + +Save frequently used prompts as Markdown files and reuse them quickly from the slash menu. + +- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). +- File type: Only Markdown files with the `.md` extension are recognized. +- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. +- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. +- How to use: + - Start a new session (Codex loads custom prompts on session start). + - In the composer, type `/` to open the slash popup and begin typing your prompt name. + - Use Up/Down to select it. Press Enter to submit its contents, or Tab to autocomplete the name. +- Notes: + - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. + - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. ``` ## Review Comments ### codex-rs/core/src/codex.rs - Created: 2025-08-28 21:34:03 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308602762 ```diff @@ -1286,6 +1287,31 @@ async fn submission_loop( warn!("failed to send McpListToolsResponse event: {e}"); } } + Op::ListCustomPrompts => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + // Discover prompts under the default prompts dir (includes content). + let custom_prompts: Vec = + tokio::task::spawn_blocking( + || match crate::custom_prompts::default_prompts_dir() { + Some(dir) => crate::custom_prompts::discover_prompts_in(&dir), + None => Vec::new(), + }, + ) + .await + .unwrap_or_default(); ``` > I was surprised to see the use of `tokio::task::spawn_blocking()`. I see we depend on `find_codex_home()`, which I guess technically does a small amount of blocking I/O. I don't know it merits scheduling this on a special thread pool, though... > > https://github.com/openai/codex/blob/6209d49520ee7b9f3262e44089c96ce69ca6e5cc/codex-rs/core/src/config.rs#L887-L904 > > I guess we could look into making `find_codex_home()` async in a follow-up PR... - Created: 2025-08-28 21:37:57 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308608340 ```diff @@ -1286,6 +1287,31 @@ async fn submission_loop( warn!("failed to send McpListToolsResponse event: {e}"); } } + Op::ListCustomPrompts => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + // Discover prompts under the default prompts dir (includes content). + let custom_prompts: Vec = + tokio::task::spawn_blocking( + || match crate::custom_prompts::default_prompts_dir() { + Some(dir) => crate::custom_prompts::discover_prompts_in(&dir), + None => Vec::new(), + }, + ) + .await + .unwrap_or_default(); + + let event = Event { + id: sub_id, + msg: EventMsg::ListCustomPromptsResponse( + crate::protocol::ListCustomPromptsResponseEvent { custom_prompts }, ``` > I would import above so you can just do: > > ```suggestion > ListCustomPromptsResponseEvent { custom_prompts }, > ``` ### codex-rs/core/src/custom_prompts.rs - Created: 2025-08-28 21:40:18 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308611377 ```diff @@ -0,0 +1,95 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +/// Return the default prompts directory: `$CODEX_HOME/prompts`. +/// If `CODEX_HOME` cannot be resolved, returns `None`. +pub fn default_prompts_dir() -> Option { + crate::config::find_codex_home() + .ok() + .map(|home| home.join("prompts")) +} + +/// Discover prompt files in the given directory, returning entries sorted by name. +/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. +pub fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()) +} + +/// Discover prompt files in the given directory, excluding any with names in `exclude`. +/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. +pub fn discover_prompts_in_excluding(dir: &Path, exclude: &HashSet) -> Vec { ``` > Prefer `tokio::fs` and async operations. - Created: 2025-08-28 21:41:32 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308612920 ```diff @@ -0,0 +1,95 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +/// Return the default prompts directory: `$CODEX_HOME/prompts`. +/// If `CODEX_HOME` cannot be resolved, returns `None`. +pub fn default_prompts_dir() -> Option { + crate::config::find_codex_home() + .ok() + .map(|home| home.join("prompts")) +} + +/// Discover prompt files in the given directory, returning entries sorted by name. +/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. +pub fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()) +} + +/// Discover prompt files in the given directory, excluding any with names in `exclude`. +/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. +pub fn discover_prompts_in_excluding(dir: &Path, exclude: &HashSet) -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + // Only include Markdown files with a .md extension. + let is_md = path + .extension() + .and_then(|s| s.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + continue; + } + let Some(name) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + else { + continue; + }; + if exclude.contains(&name) { + continue; + } + let content = std::fs::read_to_string(&path).unwrap_or_default(); + out.push(CustomPrompt { name, content }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] ``` > This will likely end up being `#[tokio::test]`. ### codex-rs/protocol/src/custom_prompts.rs - Created: 2025-08-28 21:42:46 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308614620 ```diff @@ -0,0 +1,8 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CustomPrompt { + pub name: String, + pub content: String, ``` > Maybe send the `Path` back now, as well, so it's easier to add support for opening the custom prompt's file from the client going forward. - Created: 2025-08-29 01:55:05 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308928151 ```diff @@ -0,0 +1,9 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CustomPrompt { + pub name: String, + pub path: String, ``` > Please use `PathBuf` rather than `String`. ### codex-rs/tui/src/bottom_pane/chat_composer.rs - Created: 2025-08-28 21:44:42 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308618124 ```diff @@ -2098,6 +2134,40 @@ mod tests { assert_eq!(imgs, vec![tmp_path.clone()]); } + #[test] + fn selecting_custom_prompt_submits_file_contents() { + let prompt_text = "Hello from saved prompt"; + + 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(), + false, + ); ``` > Maybe it's time to introduce `struct ChatComposerParams` in a follow-up... - Created: 2025-08-28 21:45:43 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308619668 ```diff @@ -2098,6 +2134,40 @@ mod tests { assert_eq!(imgs, vec![tmp_path.clone()]); } + #[test] + fn selecting_custom_prompt_submits_file_contents() { + let prompt_text = "Hello from saved prompt"; + + 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(), + false, + ); + + // Inject prompts as if received via event. + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + content: prompt_text.to_string(), + }]); + + type_chars_humanlike( + &mut composer, + &['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Submitted(s) => assert_eq!(s, prompt_text), + _ => panic!("expected Submitted with prompt contents"), + } ``` > one line? > > ```suggestion > assert_eq!(InputResult::Submitted(prompt_text), result); > ``` ### codex-rs/tui/src/bottom_pane/command_popup.rs - Created: 2025-08-28 21:46:22 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308620675 ```diff @@ -9,22 +9,58 @@ 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; +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)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + Prompt(usize), ``` > Can we name this `UserPrompt` instead of just `Prompt` because `Prompt` can mean a lot of things in this codebase. - Created: 2025-08-28 21:46:47 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308621244 ```diff @@ -9,22 +9,58 @@ 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; +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)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + Prompt(usize), +} pub(crate) struct CommandPopup { command_filter: String, - all_commands: Vec<(&'static str, SlashCommand)>, + builtins: Vec<(&'static str, SlashCommand)>, + 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(); + // Exclude prompts that collide with builtin command names and sort by name. + let exclude: std::collections::HashSet = ``` > `use HashSet` above? - Created: 2025-08-28 21:47:00 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308621589 ```diff @@ -9,22 +9,58 @@ 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; +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)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + Prompt(usize), +} pub(crate) struct CommandPopup { command_filter: String, - all_commands: Vec<(&'static str, SlashCommand)>, + builtins: Vec<(&'static str, SlashCommand)>, + 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(); + // 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(), - all_commands: built_in_slash_commands(), + builtins, + prompts, state: ScrollState::new(), } } + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: std::collections::HashSet = self ``` > here too - Created: 2025-08-28 21:50:17 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308626116 ```diff @@ -59,56 +95,73 @@ impl CommandPopup { /// Determine the preferred height of the popup. This is the number of /// rows required to show at most MAX_POPUP_ROWS commands. pub(crate) fn calculate_required_height(&self) -> u16 { - self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16 + self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16 } /// Compute fuzzy-filtered matches paired with optional highlight indices and score. /// Sorted by ascending score, then by command name for stability. - fn filtered(&self) -> Vec<(&SlashCommand, Option>, i32)> { + fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { ``` > Does the docstring need to be updated? Make it clear that user prompts and built-in commands are mixed together? That we don't show built-in commands first (score is the dominating thing?) - Created: 2025-08-28 21:51:32 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308627858 ```diff @@ -59,56 +95,73 @@ impl CommandPopup { /// Determine the preferred height of the popup. This is the number of /// rows required to show at most MAX_POPUP_ROWS commands. pub(crate) fn calculate_required_height(&self) -> u16 { - self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16 + self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16 } /// Compute fuzzy-filtered matches paired with optional highlight indices and score. /// Sorted by ascending score, then by command name for stability. - fn filtered(&self) -> Vec<(&SlashCommand, Option>, i32)> { + fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); - let mut out: Vec<(&SlashCommand, Option>, i32)> = Vec::new(); + let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); if filter.is_empty() { - for (_, cmd) in self.all_commands.iter() { - out.push((cmd, None, 0)); + // Built-ins first, in presentation order. + for (_, cmd) in self.builtins.iter() { + out.push((CommandItem::Builtin(*cmd), None, 0)); + } + // Then prompts, already sorted by name. + for idx in 0..self.prompts.len() { + out.push((CommandItem::Prompt(idx), None, 0)); } - // Keep the original presentation order when no filter is applied. return out; - } else { - for (_, cmd) in self.all_commands.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((cmd, Some(indices), score)); - } + } + + for (_, cmd) in self.builtins.iter() { + if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { + out.push((CommandItem::Builtin(*cmd), Some(indices), score)); + } + } + for (idx, p) in self.prompts.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&p.name, filter) { + out.push((CommandItem::Prompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by command for stability. - out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command()))); + // When filtering, sort by ascending score and then by name for stability. + out.sort_by(|a, b| { + let an = match a.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::Prompt(i) => &self.prompts[i].name, + }; + let bn = match b.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::Prompt(i) => &self.prompts[i].name, + }; + a.2.cmp(&b.2).then_with(|| an.cmp(bn)) ``` > Compute `an` and `bn` only if necessary. > > ```suggestion > a.2.cmp(&b.2).then_with(|| { > let an = match a.0 { > CommandItem::Builtin(c) => c.command(), > CommandItem::Prompt(i) => &self.prompts[i].name, > }; > let bn = match b.0 { > CommandItem::Builtin(c) => c.command(), > CommandItem::Prompt(i) => &self.prompts[i].name, > }; > an.cmp(bn) > ) > ``` - Created: 2025-08-28 21:51:59 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308628546 ```diff @@ -123,11 +176,19 @@ impl WidgetRef for CommandPopup { } else { matches .into_iter() - .map(|(cmd, indices, _)| GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), + .map(|(item, indices, _)| match item { + CommandItem::Builtin(cmd) => GenericDisplayRow { + name: format!("/{}", cmd.command()), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(cmd.description().to_string()), + }, + CommandItem::Prompt(i) => GenericDisplayRow { + name: format!("/{}", self.prompts[i].name), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some("send saved prompt".to_string()), ``` > Why don't we take the first line of the prompt? - Created: 2025-08-28 21:53:37 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308631536 ```diff @@ -141,31 +202,79 @@ mod tests { #[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()); // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. - let matches = popup.filtered_commands(); + let matches = popup.filtered_items(); + let has_init = matches.iter().any(|item| match item { + CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::Prompt(_) => false, + }); assert!( - matches.iter().any(|cmd| cmd.command() == "init"), + has_init, "expected '/init' to appear among filtered commands" ); } #[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 // command by default. - let selected = popup.selected_command(); + let selected = popup.selected_item(); match selected { - Some(cmd) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::Prompt(_)) => panic!("unexpected prompt selected for '/init'"), None => panic!("expected a selected command for exact match"), } } + + #[test] + fn prompt_discovery_lists_custom_prompts() { + 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() + .filter_map(|it| match it { + CommandItem::Prompt(i) => popup.prompt_name(i).map(|s| s.to_string()), + _ => None, + }) + .collect(); + prompt_names.sort(); + assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); + } + + #[test] + fn prompt_name_collision_with_builtin_is_ignored() { ``` > 👍 - Created: 2025-08-29 01:56:15 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308929431 ```diff @@ -123,11 +176,19 @@ impl WidgetRef for CommandPopup { } else { matches .into_iter() - .map(|(cmd, indices, _)| GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), + .map(|(item, indices, _)| match item { + CommandItem::Builtin(cmd) => GenericDisplayRow { + name: format!("/{}", cmd.command()), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(cmd.description().to_string()), + }, + CommandItem::Prompt(i) => GenericDisplayRow { + name: format!("/{}", self.prompts[i].name), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some("send saved prompt".to_string()), ``` > We can try it this way and change it if it doesn't feel right, I guess. ### codex-rs/tui/src/chatwidget.rs - Created: 2025-08-28 21:54:36 UTC | Link: https://github.com/openai/codex/pull/2696#discussion_r2308632989 ```diff @@ -1220,6 +1224,13 @@ impl ChatWidget { self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } + fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { + let len = ev.custom_prompts.len(); + debug!("received {} custom prompts", len); ``` > I believe our stricter lint checks should reject this now? Maybe it doesn't know about `tracing::debug()`. > > ```suggestion > debug!("received {len} custom prompts"); > ```