mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add /prompts folder
This commit is contained in:
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -365,16 +366,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() {
|
||||
@@ -389,16 +401,39 @@ 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_path = match sel {
|
||||
CommandItem::Prompt(idx) => popup.prompt_path(idx).cloned(),
|
||||
_ => 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(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
self.handle_key_event_without_popup(key_event)
|
||||
|
||||
@@ -9,22 +9,88 @@ 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,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
Prompt(usize),
|
||||
}
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||
builtins: Vec<(&'static str, SlashCommand)>,
|
||||
prompts: Vec<PromptEntry>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
let builtins = built_in_slash_commands();
|
||||
let prompts = Self::discover_prompts(&builtins);
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
all_commands: built_in_slash_commands(),
|
||||
builtins,
|
||||
prompts,
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PromptEntry> {
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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 +116,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 +125,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 +206,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()
|
||||
};
|
||||
@@ -148,9 +239,13 @@ mod tests {
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
@@ -162,9 +257,10 @@ mod tests {
|
||||
|
||||
// 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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user