diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8b10d224dd..c02e4cdf18 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 9ae7ada81a..30c8126850 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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, 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 { + // 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>, i32)> { + 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::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 { 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 +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"), } }