From b8e8454b3f5b1229901877c4f17af3e10272665a Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Thu, 28 Aug 2025 19:16:39 -0700 Subject: [PATCH] Custom /prompts (#2696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- codex-rs/core/src/codex.rs | 23 +++ codex-rs/core/src/custom_prompts.rs | 127 ++++++++++++ codex-rs/core/src/lib.rs | 1 + .../src/event_processor_with_human_output.rs | 3 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/custom_prompts.rs | 10 + codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 13 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 99 +++++++-- codex-rs/tui/src/bottom_pane/command_popup.rs | 189 ++++++++++++++---- codex-rs/tui/src/bottom_pane/mod.rs | 7 + codex-rs/tui/src/chatwidget.rs | 11 + docs/prompts.md | 15 ++ 13 files changed, 448 insertions(+), 52 deletions(-) create mode 100644 codex-rs/core/src/custom_prompts.rs create mode 100644 codex-rs/protocol/src/custom_prompts.rs create mode 100644 docs/prompts.md diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8443c534..40bcdc2c 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 00000000..4974e7c5 --- /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 ae183320..9f23420c 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 a3558f74..6d1bc123 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 3a26c26c..72cac591 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 00000000..8567c1c9 --- /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 d7aceeb9..d5e7c6bd 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 7f317bbf..8707e1a8 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 c21d5a4b..49a34785 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 9ae7ada8..4e60e7ea 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)); } } - // 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()))); + 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 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 c1e84bee..b94db79f 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 5e1fd45f..26928545 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 00000000..b98240d2 --- /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.