diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c5c726fe32..5362d706ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -108,6 +108,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; @@ -1275,6 +1276,28 @@ 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(|| { + let dir = crate::custom_prompts::default_prompts_dir(); + crate::custom_prompts::discover_prompts_in(&dir) + }) + .await + .unwrap_or_default(); + + let event = Event { + id: sub_id, + msg: EventMsg::ListCustomPromptsResponse( + crate::protocol::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..a58a361594 --- /dev/null +++ b/codex-rs/core/src/custom_prompts.rs @@ -0,0 +1,90 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +/// Return the default prompts directory: ~/.codex/prompts based on $HOME. +pub fn default_prompts_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(format!("{home}/.codex/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; + } + 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 +} + +/// Discover prompt files in the default prompts directory, excluding any with names in `exclude`. +pub fn discover_prompts_excluding(exclude: &HashSet) -> Vec { + let dir = default_prompts_dir(); + discover_prompts_in_excluding(&dir, exclude) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn empty_when_dir_missing() { + let tmp = TempDir::new().unwrap(); + let missing = tmp.path().join("nope"); + let found = discover_prompts_in(&missing); + assert!(found.is_empty()); + } + + #[test] + fn discovers_and_sorts_files() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + fs::write(dir.join("b"), b"b").unwrap(); + fs::write(dir.join("a"), b"a").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + let found = discover_prompts_in(dir); + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[test] + fn excludes_builtins() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + fs::write(dir.join("init"), b"ignored").unwrap(); + fs::write(dir.join("foo"), b"ok").unwrap(); + let mut exclude = HashSet::new(); + exclude.insert("init".to_string()); + let found = discover_prompts_in_excluding(dir, &exclude); + let names: Vec = found.into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["foo"]); + } +} 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 cfdba98461..ee40442c02 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 8480e29c53..67fbbb5374 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..074ee91849 --- /dev/null +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CustomPrompt { + pub name: String, + 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 71c1538197..b7d2fae810 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), @@ -801,6 +808,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 6c76f6a1ea..a0a1b3801a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -405,8 +405,10 @@ impl ChatComposer { // Clear textarea so no residual text remains. self.textarea.set_text(""); // Capture any needed data from popup before clearing it. - let prompt_path = match sel { - CommandItem::Prompt(idx) => popup.prompt_path(idx).cloned(), + let prompt_content = match sel { + CommandItem::Prompt(idx) => { + popup.prompt_content(idx).map(|s| s.to_string()) + } _ => None, }; // Hide popup since an action has been dispatched. @@ -417,19 +419,8 @@ impl ChatComposer { return (InputResult::Command(cmd), true); } CommandItem::Prompt(_) => { - if let Some(path) = prompt_path { - match std::fs::read_to_string(&path) { - Ok(contents) => { - return (InputResult::Submitted(contents), true); - } - Err(e) => { - tracing::error!( - "failed to read prompt from {}: {e}", - path.display() - ); - return (InputResult::None, true); - } - } + if let Some(contents) = prompt_content { + return (InputResult::Submitted(contents), true); } return (InputResult::None, true); } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 33ad2e7888..e55ac7dea0 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -9,21 +9,17 @@ 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 std::fs; -use std::path::PathBuf; - -/// A discovered prompt in ~/.codex/prompts. #[derive(Clone, Debug)] pub(crate) struct PromptEntry { pub name: String, - pub path: PathBuf, + pub content: String, } /// 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` vector of `CommandPopup`. + // Index into `prompts` Prompt(usize), } @@ -37,7 +33,17 @@ pub(crate) struct CommandPopup { impl CommandPopup { pub(crate) fn new() -> Self { let builtins = built_in_slash_commands(); - let prompts = Self::discover_prompts(&builtins); + 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(); Self { command_filter: String::new(), builtins, @@ -46,49 +52,12 @@ impl CommandPopup { } } - /// If `idx` refers to a prompt item, return its absolute file path. - pub(crate) fn prompt_path(&self, idx: usize) -> Option<&PathBuf> { - self.prompts.get(idx).map(|p| &p.path) - } - pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> { self.prompts.get(idx).map(|p| p.name.as_str()) } - /// Scan ~/.codex/prompts for files and return them as prompt entries. - fn discover_prompts(builtins: &Vec<(&'static str, SlashCommand)>) -> Vec { - // Build a set of builtin names to avoid collisions. - let mut builtin_names = std::collections::HashSet::new(); - for (name, _) in builtins.iter() { - builtin_names.insert(*name); - } - - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let prompts_dir = PathBuf::from(format!("{home}/.codex/prompts")); - let mut out = Vec::new(); - if let Ok(entries) = fs::read_dir(&prompts_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - let file_name = path.file_stem().and_then(|s| s.to_str()); - let Some(name) = file_name else { - continue; - }; - // Avoid duplicates with built-ins; prefer built-ins. - if builtin_names.contains(name) { - continue; - } - out.push(PromptEntry { - name: name.to_string(), - path, - }); - } - // Sort prompts by name for stable ordering. - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - out + 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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b09e030621..e30cc78d84 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; @@ -930,6 +931,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 }) => { @@ -1156,6 +1158,8 @@ 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) {} + /// 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.