Add /prompts folder

This commit is contained in:
Daniel Edrisian
2025-08-25 18:07:21 -07:00
parent 0174e73537
commit 56db77537a
2 changed files with 177 additions and 46 deletions
+49 -14
View File
@@ -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)
+128 -32
View File
@@ -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"),
}
}