Compare commits

...

17 Commits

Author SHA1 Message Date
Daniel Edrisian
1dea5d9887 Fix yolo mode + reverse PR list 2025-08-26 13:39:38 -07:00
Daniel Edrisian
5544f84528 Add release_gen.sh 2025-08-26 13:31:11 -07:00
Daniel Edrisian
06457825ab Use $CODEX_HOME instead 2025-08-26 10:04:53 -07:00
dedrisian-oai
0cc82ec1fe Merge branch 'main' into daniel/prompts 2025-08-26 08:39:41 -07:00
Daniel Edrisian
b7e37c9f1c change tempdir 2025-08-26 08:39:29 -07:00
Daniel Edrisian
d5485d7e17 rm 2025-08-26 08:29:22 -07:00
Daniel Edrisian
5273034e7b only .md files 2025-08-26 08:28:26 -07:00
Daniel Edrisian
c70e1fc42d add another test 2025-08-26 08:27:33 -07:00
Daniel Edrisian
4d7bb516c6 fixes 2025-08-26 08:11:55 -07:00
Daniel Edrisian
ba05216461 readme 2025-08-26 08:06:07 -07:00
Daniel Edrisian
85bcd57596 fix 2025-08-26 07:53:34 -07:00
Daniel Edrisian
0ac35f89c6 fix 2025-08-26 07:47:22 -07:00
Daniel Edrisian
a2985bab76 Wip shift to core 2025-08-26 07:31:08 -07:00
Daniel Edrisian
6714afe2d7 Tests 2025-08-25 18:42:57 -07:00
Daniel Edrisian
1a5c12cf63 Revert "/addprompt"
This reverts commit 0174e73537.
2025-08-25 18:10:27 -07:00
Daniel Edrisian
56db77537a Add /prompts folder 2025-08-25 18:07:21 -07:00
Daniel Edrisian
0174e73537 /addprompt 2025-08-25 17:53:07 -07:00
16 changed files with 772 additions and 50 deletions

1
.gitignore vendored
View File

@@ -81,3 +81,4 @@ CHANGELOG.ignore.md
# nix related
.direnv
.envrc
scripts/releases/

View File

@@ -63,6 +63,22 @@ codex completion zsh
codex completion fish
```
### 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 builtin commands (e.g. `/init`) are ignored and wont 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.
### Experimenting with the Codex Sandbox
To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI:

View File

@@ -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,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<CustomPrompt> =
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 },
),
};
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");

View File

@@ -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<PathBuf> {
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<CustomPrompt> {
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<String>) -> Vec<CustomPrompt> {
let mut out: Vec<CustomPrompt> = 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]
fn empty_when_dir_missing() {
let tmp = tempdir().expect("create TempDir");
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().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);
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
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);
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["foo"]);
}
}

View File

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

View File

@@ -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");

View File

@@ -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(_)

View File

@@ -0,0 +1,8 @@
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CustomPrompt {
pub name: String,
pub content: String,
}

View File

@@ -1,4 +1,5 @@
pub mod config_types;
pub mod custom_prompts;
pub mod mcp_protocol;
pub mod message_history;
pub mod models;

View File

@@ -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<String, McpTool>,
}
/// Response payload for `Op::ListCustomPrompts`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListCustomPromptsResponseEvent {
pub custom_prompts: Vec<CustomPrompt>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct SessionConfiguredEvent {
/// Unique id for this session.

View File

@@ -22,9 +22,11 @@ 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 crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -100,6 +102,7 @@ pub(crate) struct ChatComposer {
// Buffer to accumulate characters during a detected non-bracketed paste burst.
paste_burst_buffer: String,
in_paste_burst_mode: bool,
custom_prompts: Vec<CustomPrompt>,
}
/// Popup state at most one can be visible at any time.
@@ -139,6 +142,7 @@ impl ChatComposer {
paste_burst_until: None,
paste_burst_buffer: String::new(),
in_paste_burst_mode: false,
custom_prompts: Vec::new(),
}
}
@@ -366,16 +370,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::Prompt(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() {
@@ -390,16 +405,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::Prompt(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::Prompt(_) => {
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)
@@ -1088,7 +1117,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);
}
@@ -1096,6 +1125,13 @@ impl ChatComposer {
}
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
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) {
@@ -1963,4 +1999,32 @@ mod tests {
let imgs = composer.take_recent_submission_images();
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::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
// Inject prompts as if received via event.
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
content: prompt_text.to_string(),
}]);
// Type the prompt name to focus it in the slash popup and press Enter.
for ch in ['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
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"),
}
}
}

View File

@@ -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<CustomPrompt>,
state: ScrollState,
}
impl CommandPopup {
pub(crate) fn new() -> Self {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
let builtins = built_in_slash_commands();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: std::collections::HashSet<String> =
builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
builtins,
prompts,
state: ScrollState::new(),
}
}
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: std::collections::HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> {
self.prompts.get(idx).map(|p| p.name.as_str())
}
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,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<Vec<usize>>, i32)> {
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
let filter = self.command_filter.trim();
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
let mut out: Vec<(CommandItem, Option<Vec<usize>>, 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));
}
}
// 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::Prompt(idx), Some(indices), score));
}
}
// 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))
});
out
}
fn filtered_commands(&self) -> Vec<&SlashCommand> {
fn filtered_items(&self) -> Vec<CommandItem> {
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<CommandItem> {
let matches = self.filtered_items();
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
@@ -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()),
},
})
.collect()
};
@@ -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<String> = 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() {
// Create a prompt named like a builtin (e.g. "init").
let popup = CommandPopup::new(vec![CustomPrompt {
name: "init".to_string(),
content: "should be ignored".to_string(),
}]);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::Prompt(i) => popup.prompt_name(i) == Some("init"),
_ => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin name should be ignored"
);
}
}

View File

@@ -34,6 +34,7 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
@@ -329,6 +330,12 @@ impl BottomPane {
self.request_redraw();
}
/// Update custom prompts available for the slash popup.
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.composer.set_custom_prompts(prompts);
self.request_redraw();
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.composer.is_empty()
}

View File

@@ -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);
}
@@ -932,6 +935,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 }) => {
@@ -1161,6 +1165,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);
// 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.

View File

@@ -18,11 +18,14 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::InputItem;
use codex_core::protocol::ListCustomPromptsResponseEvent;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_login::CodexAuth;
use codex_protocol::custom_prompts::CustomPrompt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -214,6 +217,48 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
s
}
#[test]
fn selecting_custom_prompt_sends_user_message() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual();
// Provide a custom prompt via protocol event, as core would do.
let prompt_text = "Hello from saved prompt".to_string();
chat.handle_codex_event(Event {
id: "sub-prompts".into(),
msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent {
custom_prompts: vec![CustomPrompt {
name: "my-prompt".to_string(),
content: prompt_text.clone(),
}],
}),
});
// Type the prompt name to focus it in the slash popup and press Enter.
for ch in ['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'] {
chat.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Expect a UserInput op to be sent containing the prompt's content.
let mut found = false;
while let Ok(op) = op_rx.try_recv() {
if let Op::UserInput { items } = op {
let texts: Vec<String> = items
.into_iter()
.filter_map(|it| match it {
InputItem::Text { text } => Some(text),
_ => None,
})
.collect();
if texts.iter().any(|t| t == &prompt_text) {
found = true;
break;
}
}
}
assert!(found, "expected UserInput op containing prompt content");
}
fn open_fixture(name: &str) -> std::fs::File {
// 1) Prefer fixtures within this crate
{

321
scripts/release_gen.sh Executable file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env bash
set -euo pipefail
# Simple stderr logger
header() { echo "==> $*" >&2; }
# Generate summarized release notes using Codex CLI based on PR dump.
# Can also generate just the dump via --dump-only.
usage() {
cat <<'USAGE'
Usage: scripts/release_gen.sh [--dump-only] [-q|--quiet] [owner/repo] <from_tag> <to_tag> [version]
Examples:
scripts/release_gen.sh openai/codex v0.23.0 v0.24.0
scripts/release_gen.sh v0.23.0 v0.24.0 # auto-detect repo from git remote
scripts/release_gen.sh v0.23.0 v0.24.0 0.24.0 # auto-detect with explicit version
scripts/release_gen.sh --dump-only v0.23.0 v0.24.0 # only generate releases/release_dump_<ver>.txt
scripts/release_gen.sh -q v0.23.0 v0.24.0 # quiet Codex call with progress dots
Notes:
- Requires: gh and jq for dump generation; codex CLI for note generation.
- If release_dump_<ver>.txt does not exist, it will be created automatically.
- Then runs codex to generate <ver>.txt based on the dump (unless --dump-only).
- If you omit tags, the script lists the last 20 releases for the repo.
USAGE
}
# Parse flags (currently: --dump-only, --quiet)
DUMP_ONLY=0
QUIET=0
ARGS=()
for arg in "$@"; do
case "$arg" in
--dump-only)
DUMP_ONLY=1
;;
-q|--quiet)
QUIET=1
;;
-h|--help)
usage
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
done
# Reset positional args safely under set -u, even if ARGS is empty
if ((${#ARGS[@]})); then
set -- "${ARGS[@]}"
else
set --
fi
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
usage
exit 1
fi
# Resolve repo: allow optional first arg; otherwise detect from git remote
detect_repo() {
local remote
remote=$(git remote get-url origin 2>/dev/null || git remote get-url upstream 2>/dev/null || true)
if [[ -z "$remote" ]]; then
echo ""; return 1
fi
# Normalize and extract owner/repo from SSH or HTTPS/HTTP URL
local path="$remote"
# Strip protocols and user@
path="${path#git@}"
path="${path#ssh://}"
path="${path#https://}"
path="${path#http://}"
path="${path#*@}"
# If contains github.com:, take after ':'; else after 'github.com/' if present
if [[ "$path" == *":"* ]]; then
path="${path#*:}"
fi
if [[ "$path" == *github.com/* ]]; then
path="${path#*github.com/}"
fi
# Trim leading slashes
path="${path#/}"
# Drop trailing .git
path="${path%.git}"
# Ensure only owner/repo
echo "$path" | awk -F/ '{print $1"/"$2}'
}
if [[ ${1:-} == */* ]]; then
REPO="$1"; shift
else
REPO="$(detect_repo || true)"
if [[ -z "$REPO" ]]; then
echo "Error: failed to auto-detect repository from git remote. Provide [owner/repo] explicitly." >&2
exit 1
fi
fi
# Show a recent releases list if tags are missing
show_recent_releases_and_exit() {
local repo="$1"
echo "" >&2
echo "Please pass a source/target release." >&2
echo "" >&2
echo "e.g.: ./scripts/release_gen.sh rust-v0.23.0 rust-v0.24.0" >&2
echo "" >&2
header "Recent releases for $repo:"
echo "" >&2
local list
list=$(gh release list --repo "$repo" --limit 20 2>/dev/null || true)
if [[ -z "$list" ]]; then
echo "Error: unable to fetch releases for $repo" >&2
exit 1
fi
# Print only the tag (first column) as bullets to stderr
printf '%s\n' "$list" | awk '{print "- " $1}' >&2
exit 1
}
if [[ $# -lt 2 ]]; then
show_recent_releases_and_exit "$REPO"
fi
FROM_TAG="$1"
TO_TAG="$2"
VER="${3:-$TO_TAG}"
VER="${VER#v}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RELEASES_DIR="$SCRIPT_DIR/releases"
DUMP_FILE="$RELEASES_DIR/release_dump_$VER.txt"
GEN_FILE="$RELEASES_DIR/$VER.txt"
# Ensure releases directory exists (under scripts/)
mkdir -p "$RELEASES_DIR"
abspath() {
local p="$1"
if command -v realpath >/dev/null 2>&1; then
realpath "$p"
else
python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$p" 2>/dev/null || echo "$(pwd)/$p"
fi
}
# ========== Dump generation logic (ported from release_dump_util.sh) ==========
header() { echo "==> $*" >&2; }
# Get an ISO 8601 datetime for a tag. Prefer release publish date; fallback to tag/commit date.
get_tag_datetime_iso() {
local repo="$1" tag="$2"
# Try release by tag
local ts
ts=$(gh release view "$tag" --repo "$repo" --json publishedAt --jq '.publishedAt' 2>/dev/null || true)
if [[ -n "$ts" && "$ts" != "null" ]]; then
echo "$ts"; return 0
fi
# Fallback: tag ref -> (annotated tag ->) commit -> date
local ref obj_type obj_url commit_sha commit
ref=$(gh api "repos/$repo/git/ref/tags/$tag")
obj_type=$(jq -r '.object.type' <<<"$ref")
obj_url=$(jq -r '.object.url' <<<"$ref")
if [[ "$obj_type" == "tag" ]]; then
local tag_obj
tag_obj=$(gh api "$obj_url")
commit_sha=$(jq -r '.object.sha' <<<"$tag_obj")
else
commit_sha=$(jq -r '.object.sha' <<<"$ref")
fi
commit=$(gh api "repos/$repo/commits/$commit_sha")
jq -r '.commit.committer.date' <<<"$commit"
}
collect_prs_within_range() {
local repo="$1" from_iso="$2" to_iso="$3"
gh pr list --repo "$repo" --state merged --limit 1000 \
--json number,title,mergedAt,author,body | \
jq -c --arg from "$from_iso" --arg to "$to_iso" \
'[ .[]
| select(.mergedAt != null and .mergedAt >= $from and .mergedAt <= $to)
| {
number: .number,
title: .title,
merged_at: .mergedAt,
author: (.author.login // "-"),
body: (.body // "")
}
] | sort_by(.merged_at) | reverse | .[]'
}
format_related_issues() {
# shellcheck disable=SC2016
sed 's/\r//g' | \
grep -Eio '(close|closed|closes|fix|fixed|fixes|resolve|resolved|resolves)[[:space:]:]+([[:alnum:]_.-]+\/[[:alnum:]_.-]+)?#[0-9]+' || true | \
grep -Eo '#[0-9]+' | tr -d '#' | sort -n -u | sed 's/^/#/' | paste -sd ', ' -
}
generate_dump() {
local repo="$1" from_tag="$2" to_tag="$3" out_file="$4"
command -v gh >/dev/null 2>&1 || { echo "Error: gh (GitHub CLI) is required" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "Error: jq is required" >&2; exit 1; }
header "Resolving tag dates ($from_tag -> $to_tag)"
local from_iso to_iso
from_iso=$(get_tag_datetime_iso "$repo" "$from_tag")
to_iso=$(get_tag_datetime_iso "$repo" "$to_tag")
if [[ -z "$from_iso" || -z "$to_iso" ]]; then
echo "Error: failed to resolve tag dates. from=$from_tag ($from_iso) to=$to_tag ($to_iso)" >&2
exit 1
fi
header "Collecting merged PRs via gh pr list"
local tmpdir sorted
tmpdir=$(mktemp -d)
sorted="$tmpdir/prs.sorted.ndjson"
collect_prs_within_range "$repo" "$from_iso" "$to_iso" > "$sorted"
local count
count=$(wc -l < "$sorted" | tr -d ' ')
header "Writing $out_file (Total PRs: $count)"
{
echo "Repository: $repo"
echo "Range: $from_tag ($from_iso) -> $to_tag ($to_iso)"
echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Total PRs: $count"
echo ""
} > "$out_file"
if [[ "$count" -eq 0 ]]; then
return 0
fi
while IFS= read -r line; do
local title number merged_at author body issues
title=$(jq -r '.title' <<<"$line")
number=$(jq -r '.number' <<<"$line")
merged_at=$(jq -r '.merged_at' <<<"$line")
author=$(jq -r '.author' <<<"$line")
body=$(jq -r '.body' <<<"$line")
issues=$(printf '%s' "$body" | format_related_issues || true)
[[ -z "$issues" ]] && issues="-"
{
echo "PR #$number: $title"
echo "Merged: $merged_at | Author: $author"
echo "Related issues: $issues"
echo ""
# Skip verbose descriptions for Dependabot PRs
if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" && ! "$author" =~ [Dd]ependabot ]]; then
echo "Description:"
# Limit descriptions to 2000 characters; add ellipses if truncated
local max=2000
if (( ${#body} > max )); then
printf '%s\n' "${body:0:max}..."
else
printf '%s\n' "$body"
fi
echo ""
fi
echo "-----"
echo ""
} >> "$out_file"
done < "$sorted"
header "Done -> $out_file"
}
# ========== Orchestrate dump + optional codex generation ==========
# Create dump if missing
if [[ ! -f "$DUMP_FILE" ]]; then
header "Dump not found: $DUMP_FILE. Generating..."
generate_dump "$REPO" "$FROM_TAG" "$TO_TAG" "$DUMP_FILE"
else
header "Using existing dump: $DUMP_FILE"
fi
if (( DUMP_ONLY )); then
# Dump-only mode: no stdout output
exit 0
fi
# Now run codex to generate notes
command -v codex >/dev/null 2>&1 || { echo "Error: codex CLI is required for generation. Use --dump-only to skip." >&2; exit 1; }
DUMP_PATH="$(abspath "$DUMP_FILE")"
PROMPT="`cat ${DUMP_PATH}`\n\n---\n\nPlease generate a summarized release note based on the list of PRs above. Then, write a file called $GEN_FILE with your suggested release notes. It should follow this structure (+ the style/tone/brevity in this example):\n\n\"- Highlights:\n - New commands and controls: support /mcp in TUI (#2430) and a slash command /approvals to control approvals (#2474).\n - Reasoning controls: change reasoning effort and model at runtime (#2435) /model; add “minimal” effort for GPT5 models (#2326).\n - Auth improvements: show login options when not signed in with ChatGPT (#2440) and autorefresh ChatGPT auth token (#2484).\n - UI/UX polish: Ghostty Ctrlb/Ctrlf fallback (#2427), Ctrl+H as backspace (#2412), cursor position tweak after tab completion (#2442), color/accessibility updates (#2401, #2421).\n - Distribution/infra: zip archived binaries added to releases (#2438) and DotSlash entry for Windows x86_64 (#2361); upgraded to Rust 1.89 (#2465, #2467).\n- Full list of merged PRs:\n\n - #2352 tui: skip identical consecutive entries in local composer history\n - #2355 fix: introduce codex-protocol crate\n...\"\n\nMake sure you limit the highlights to, at most, 5 bullet points."
header "Calling codex to generate $GEN_FILE"
if (( QUIET )); then
# Quiet mode: run Codex silently and show progress dots
(
set +x 2>/dev/null || true
codex exec --sandbox workspace-write "$PROMPT" >/dev/null 2>&1
) &
CODEX_PID=$!
while :; do
kill -0 "$CODEX_PID" 2>/dev/null || break
printf "." >&2
sleep 1
done
wait "$CODEX_PID" || true
CODEX_STATUS=$?
echo "" >&2
else
# Normal mode: stream Codex output to stderr as before
codex exec --sandbox workspace-write "$PROMPT" 1>&2
fi
if [[ -f "$GEN_FILE" ]]; then
# On success, output only the generated release notes to stdout
cat "$GEN_FILE"
else
echo "Warning: $GEN_FILE not created. Check codex output." >&2
exit 1
fi