mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Remove remaining custom prompt support (#16115)
## Summary - remove protocol and core support for discovering and listing custom prompts - simplify the TUI slash-command flow and command popup to built-in commands only - delete obsolete custom prompt tests, helpers, and docs references - clean up downstream event handling for the removed protocol events
This commit is contained in:
committed by
GitHub
Unverified
parent
fce0f76d57
commit
48144a7fa4
@@ -4385,10 +4385,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
handlers::reload_user_config(&sess).await;
|
||||
false
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
false
|
||||
}
|
||||
Op::ListSkills { cwds, force_reload } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
|
||||
false
|
||||
@@ -4514,13 +4510,11 @@ mod handlers {
|
||||
use crate::tasks::UserShellCommandMode;
|
||||
use crate::tasks::UserShellCommandTask;
|
||||
use crate::tasks::execute_user_shell_command;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -4907,23 +4901,6 @@ mod handlers {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_custom_prompts(sess: &Session, sub_id: String) {
|
||||
let custom_prompts: Vec<CustomPrompt> =
|
||||
if let Some(dir) = crate::custom_prompts::default_prompts_dir() {
|
||||
crate::custom_prompts::discover_prompts_in(&dir).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent {
|
||||
custom_prompts,
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_skills(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
@@ -6845,7 +6822,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::PlanUpdate(_)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// 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 async fn discover_prompts_in(dir: &Path) -> Vec<CustomPrompt> {
|
||||
discover_prompts_in_excluding(dir, &HashSet::new()).await
|
||||
}
|
||||
|
||||
/// 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 async fn discover_prompts_in_excluding(
|
||||
dir: &Path,
|
||||
exclude: &HashSet<String>,
|
||||
) -> Vec<CustomPrompt> {
|
||||
let mut out: Vec<CustomPrompt> = Vec::new();
|
||||
let mut entries = match fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return out,
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let is_file_like = fs::metadata(&path)
|
||||
.await
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false);
|
||||
if !is_file_like {
|
||||
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(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if exclude.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let (description, argument_hint, body) = parse_frontmatter(&content);
|
||||
out.push(CustomPrompt {
|
||||
name,
|
||||
path,
|
||||
content: body,
|
||||
description,
|
||||
argument_hint,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse optional YAML-like frontmatter at the beginning of `content`.
|
||||
/// Supported keys:
|
||||
/// - `description`: short description shown in the slash popup
|
||||
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
|
||||
/// Returns (description, argument_hint, body_without_frontmatter).
|
||||
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
|
||||
let mut segments = content.split_inclusive('\n');
|
||||
let Some(first_segment) = segments.next() else {
|
||||
return (None, None, String::new());
|
||||
};
|
||||
let first_line = first_segment.trim_end_matches(['\r', '\n']);
|
||||
if first_line.trim() != "---" {
|
||||
return (None, None, content.to_string());
|
||||
}
|
||||
|
||||
let mut desc: Option<String> = None;
|
||||
let mut hint: Option<String> = None;
|
||||
let mut frontmatter_closed = false;
|
||||
let mut consumed = first_segment.len();
|
||||
|
||||
for segment in segments {
|
||||
let line = segment.trim_end_matches(['\r', '\n']);
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == "---" {
|
||||
frontmatter_closed = true;
|
||||
consumed += segment.len();
|
||||
break;
|
||||
}
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
consumed += segment.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((k, v)) = trimmed.split_once(':') {
|
||||
let key = k.trim().to_ascii_lowercase();
|
||||
let mut val = v.trim().to_string();
|
||||
if val.len() >= 2 {
|
||||
let bytes = val.as_bytes();
|
||||
let first = bytes[0];
|
||||
let last = bytes[bytes.len() - 1];
|
||||
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
|
||||
val = val[1..val.len().saturating_sub(1)].to_string();
|
||||
}
|
||||
}
|
||||
match key.as_str() {
|
||||
"description" => desc = Some(val),
|
||||
"argument-hint" | "argument_hint" => hint = Some(val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
consumed += segment.len();
|
||||
}
|
||||
|
||||
if !frontmatter_closed {
|
||||
// Unterminated frontmatter: treat input as-is.
|
||||
return (None, None, content.to_string());
|
||||
}
|
||||
|
||||
let body = if consumed >= content.len() {
|
||||
String::new()
|
||||
} else {
|
||||
content[consumed..].to_string()
|
||||
};
|
||||
(desc, hint, body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "custom_prompts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,95 +0,0 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_when_dir_missing() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let missing = tmp.path().join("nope");
|
||||
let found = discover_prompts_in(&missing).await;
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async 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).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async 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).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["foo"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_non_utf8_files() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
// Valid UTF-8 file
|
||||
fs::write(dir.join("good.md"), b"hello").unwrap();
|
||||
// Invalid UTF-8 content in .md file (e.g., lone 0xFF byte)
|
||||
fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap();
|
||||
let found = discover_prompts_in(dir).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["good"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn discovers_symlinked_md_files() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create a real file
|
||||
fs::write(dir.join("real.md"), b"real content").unwrap();
|
||||
|
||||
// Create a symlink to the real file
|
||||
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
|
||||
|
||||
let found = discover_prompts_in(dir).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
|
||||
// Both real and link should be discovered, sorted alphabetically
|
||||
assert_eq!(names, vec!["link", "real"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_frontmatter_and_strips_from_body() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
let file = dir.join("withmeta.md");
|
||||
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
|
||||
fs::write(&file, text).unwrap();
|
||||
|
||||
let found = discover_prompts_in(dir).await;
|
||||
assert_eq!(found.len(), 1);
|
||||
let p = &found[0];
|
||||
assert_eq!(p.name, "withmeta");
|
||||
assert_eq!(p.description.as_deref(), Some("Quick review command"));
|
||||
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
|
||||
// Body should not include the frontmatter delimiters.
|
||||
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_preserves_body_newlines() {
|
||||
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
|
||||
let (desc, hint, body) = parse_frontmatter(content);
|
||||
assert_eq!(desc.as_deref(), Some("Line endings"));
|
||||
assert_eq!(hint.as_deref(), Some("[arg]"));
|
||||
assert_eq!(body, "First line\r\nSecond line\r\n");
|
||||
}
|
||||
@@ -30,7 +30,6 @@ pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub use codex_utils_path::env;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
|
||||
@@ -336,7 +336,6 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Base namespace for custom prompt slash commands (without trailing colon).
|
||||
/// Example usage forms constructed in code:
|
||||
/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"`
|
||||
/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"`
|
||||
pub const PROMPTS_CMD_PREFIX: &str = "prompts";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
|
||||
pub struct CustomPrompt {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub content: String,
|
||||
pub description: Option<String>,
|
||||
pub argument_hint: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub use agent_path::AgentPath;
|
||||
pub use thread_id::ThreadId;
|
||||
pub mod approvals;
|
||||
pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
|
||||
@@ -23,7 +23,6 @@ use crate::config_types::Personality;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::config_types::ServiceTier;
|
||||
use crate::config_types::WindowsSandboxLevel;
|
||||
use crate::custom_prompts::CustomPrompt;
|
||||
use crate::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use crate::dynamic_tools::DynamicToolCallRequest;
|
||||
use crate::dynamic_tools::DynamicToolResponse;
|
||||
@@ -451,9 +450,6 @@ pub enum Op {
|
||||
/// enable/disable state) without restarting the thread.
|
||||
ReloadUserConfig,
|
||||
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
/// Request the list of skills for the provided `cwd` values or the session default.
|
||||
ListSkills {
|
||||
/// Working directories to scope repo skills discovery.
|
||||
@@ -595,7 +591,6 @@ impl Op {
|
||||
Self::ListMcpTools => "list_mcp_tools",
|
||||
Self::RefreshMcpServers { .. } => "refresh_mcp_servers",
|
||||
Self::ReloadUserConfig => "reload_user_config",
|
||||
Self::ListCustomPrompts => "list_custom_prompts",
|
||||
Self::ListSkills { .. } => "list_skills",
|
||||
Self::Compact => "compact",
|
||||
Self::DropMemories => "drop_memories",
|
||||
@@ -1370,9 +1365,6 @@ pub enum EventMsg {
|
||||
/// List of MCP tools available to the agent.
|
||||
McpListToolsResponse(McpListToolsResponseEvent),
|
||||
|
||||
/// List of custom prompts available to the agent.
|
||||
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
|
||||
|
||||
/// List of skills available to the agent.
|
||||
ListSkillsResponse(ListSkillsResponseEvent),
|
||||
|
||||
@@ -3102,12 +3094,6 @@ impl fmt::Display for McpAuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListCustomPrompts`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ListCustomPromptsResponseEvent {
|
||||
pub custom_prompts: Vec<CustomPrompt>,
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListSkills`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ListSkillsResponseEvent {
|
||||
|
||||
@@ -161,7 +161,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,27 +10,21 @@ use super::slash_commands;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Hide alias commands in the default popup list so each unique action appears once.
|
||||
// `quit` is an alias of `exit`, so we skip `quit` here.
|
||||
// `approvals` is an alias of `permissions`.
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
/// A selectable item in the popup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CommandItem {
|
||||
Builtin(SlashCommand),
|
||||
// Index into `prompts`
|
||||
UserPrompt(usize),
|
||||
}
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
builtins: Vec<(&'static str, SlashCommand)>,
|
||||
prompts: Vec<CustomPrompt>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
@@ -62,7 +56,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
|
||||
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
|
||||
// Keep built-in availability in sync with the composer.
|
||||
let builtins: Vec<(&'static str, SlashCommand)> =
|
||||
slash_commands::builtins_for_input(flags.into())
|
||||
@@ -70,34 +64,13 @@ impl CommandPopup {
|
||||
.filter(|(name, _)| !name.starts_with("debug"))
|
||||
.filter(|(_, cmd)| *cmd != SlashCommand::Apps)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: 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(),
|
||||
builtins,
|
||||
prompts,
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
||||
let exclude: 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(&self, idx: usize) -> Option<&CustomPrompt> {
|
||||
self.prompts.get(idx)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -145,17 +118,12 @@ impl CommandPopup {
|
||||
let filter = self.command_filter.trim();
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
if filter.is_empty() {
|
||||
// Built-ins first, in presentation order.
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
if ALIAS_COMMANDS.contains(cmd) {
|
||||
continue;
|
||||
}
|
||||
out.push((CommandItem::Builtin(*cmd), None));
|
||||
}
|
||||
// Then prompts, already sorted by name.
|
||||
for idx in 0..self.prompts.len() {
|
||||
out.push((CommandItem::UserPrompt(idx), None));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -163,7 +131,6 @@ impl CommandPopup {
|
||||
let filter_chars = filter.chars().count();
|
||||
let mut exact: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
|
||||
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
|
||||
|
||||
let mut push_match =
|
||||
@@ -190,18 +157,6 @@ impl CommandPopup {
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
|
||||
}
|
||||
// Support both search styles:
|
||||
// - Typing "name" should surface "/prompts:name" results.
|
||||
// - Typing "prompts:name" should also work.
|
||||
for (idx, p) in self.prompts.iter().enumerate() {
|
||||
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
|
||||
push_match(
|
||||
CommandItem::UserPrompt(idx),
|
||||
&display,
|
||||
Some(&p.name),
|
||||
prompt_prefix_len,
|
||||
);
|
||||
}
|
||||
|
||||
out.extend(exact);
|
||||
out.extend(prefix);
|
||||
@@ -219,22 +174,9 @@ impl CommandPopup {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(item, indices)| {
|
||||
let (name, description) = match item {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
(format!("/{}", cmd.command()), cmd.description().to_string())
|
||||
}
|
||||
CommandItem::UserPrompt(i) => {
|
||||
let prompt = &self.prompts[i];
|
||||
let description = prompt
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "send saved prompt".to_string());
|
||||
(
|
||||
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
|
||||
description,
|
||||
)
|
||||
}
|
||||
};
|
||||
let CommandItem::Builtin(cmd) = item;
|
||||
let name = format!("/{}", cmd.command());
|
||||
let description = cmd.description().to_string();
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
name_prefix_spans: Vec::new(),
|
||||
@@ -297,7 +239,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
// Simulate the composer line starting with '/in' so the popup filters
|
||||
// matching commands by prefix.
|
||||
popup.on_composer_text_change("/in".to_string());
|
||||
@@ -307,7 +249,6 @@ mod tests {
|
||||
let matches = popup.filtered_items();
|
||||
let has_init = matches.iter().any(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
||||
CommandItem::UserPrompt(_) => false,
|
||||
});
|
||||
assert!(
|
||||
has_init,
|
||||
@@ -317,7 +258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selecting_init_by_exact_match() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/init".to_string());
|
||||
|
||||
// When an exact match exists, the selected command should be that
|
||||
@@ -325,144 +266,46 @@ mod tests {
|
||||
let selected = popup.selected_item();
|
||||
match selected {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
||||
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
||||
None => panic!("expected a selected command for exact match"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_first_suggestion_for_mo() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/mo".to_string());
|
||||
let matches = popup.filtered_items();
|
||||
match matches.first() {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
||||
Some(CommandItem::UserPrompt(_)) => {
|
||||
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
||||
}
|
||||
None => panic!("expected at least one match for '/mo'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_keep_presentation_order_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/m".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_discovery_lists_custom_prompts() {
|
||||
let prompts = vec![
|
||||
CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "hello from foo".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
CustomPrompt {
|
||||
name: "bar".to_string(),
|
||||
path: "/tmp/bar.md".to_string().into(),
|
||||
content: "hello from bar".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
];
|
||||
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
|
||||
let items = popup.filtered_items();
|
||||
let mut prompt_names: Vec<String> = items
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
||||
_ => 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(),
|
||||
path: "/tmp/init.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let items = popup.filtered_items();
|
||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
!has_collision_prompt,
|
||||
"prompt with builtin name should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_uses_frontmatter_metadata() {
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "draftpr".to_string(),
|
||||
path: "/tmp/draftpr.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: Some("Create feature branch, commit and open draft PR.".to_string()),
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(
|
||||
description,
|
||||
Some("Create feature branch, commit and open draft PR.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_falls_back_when_missing() {
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(description, Some("send saved prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_filter_limits_matches_for_ac() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/ac".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -473,7 +316,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
|
||||
@@ -485,15 +328,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collab_command_hidden_when_collaboration_modes_disabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -508,19 +350,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collab_command_visible_when_collaboration_modes_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/collab".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -531,19 +370,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plan_command_visible_when_collaboration_modes_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/plan".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -554,27 +390,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn personality_command_hidden_when_disabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/pers".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -585,19 +417,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn personality_command_visible_when_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/personality".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -608,27 +437,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/aud".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -640,13 +465,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn debug_commands_are_hidden_from_popup() {
|
||||
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -1,63 +1,3 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use lazy_static::lazy_static;
|
||||
use regex_lite::Regex;
|
||||
use shlex::Shlex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static! {
|
||||
static ref PROMPT_ARG_REGEX: Regex =
|
||||
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptArgsError {
|
||||
MissingAssignment { token: String },
|
||||
MissingKey { token: String },
|
||||
}
|
||||
|
||||
impl PromptArgsError {
|
||||
fn describe(&self, command: &str) -> String {
|
||||
match self {
|
||||
PromptArgsError::MissingAssignment { token } => format!(
|
||||
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
|
||||
),
|
||||
PromptArgsError::MissingKey { token } => {
|
||||
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptExpansionError {
|
||||
Args {
|
||||
command: String,
|
||||
error: PromptArgsError,
|
||||
},
|
||||
MissingArgs {
|
||||
command: String,
|
||||
missing: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptExpansionError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
PromptExpansionError::Args { command, error } => error.describe(command),
|
||||
PromptExpansionError::MissingArgs { command, missing } => {
|
||||
let list = missing.join(", ");
|
||||
format!(
|
||||
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||
/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/`
|
||||
/// and contains a non-empty name; otherwise returns `None`.
|
||||
@@ -80,775 +20,6 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> {
|
||||
let rest_untrimmed = &stripped[name_end_in_stripped..];
|
||||
let rest = rest_untrimmed.trim_start();
|
||||
let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len());
|
||||
// `stripped` is `line` without the leading '/', so add 1 to get the original offset.
|
||||
let rest_offset = rest_start_in_stripped + 1;
|
||||
Some((name, rest, rest_offset))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PromptArg {
|
||||
pub text: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PromptExpansion {
|
||||
pub text: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
/// Parse positional arguments using shlex semantics (supports quoted tokens).
|
||||
///
|
||||
/// `text_elements` must be relative to `rest`.
|
||||
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
|
||||
parse_tokens_with_elements(rest, text_elements)
|
||||
}
|
||||
|
||||
/// Extracts the unique placeholder variable names from a prompt template.
|
||||
///
|
||||
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
|
||||
/// (for example `$USER`). The function returns the variable names without
|
||||
/// the leading `$`, de-duplicated and in the order of first appearance.
|
||||
pub fn prompt_argument_names(content: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
|
||||
continue;
|
||||
}
|
||||
let name = &content[m.start() + 1..m.end()];
|
||||
// Exclude special positional aggregate token from named args.
|
||||
if name == "ARGUMENTS" {
|
||||
continue;
|
||||
}
|
||||
let name = name.to_string();
|
||||
if seen.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Shift a text element's byte range left by `offset`, returning `None` if empty.
|
||||
///
|
||||
/// `offset` is the byte length of the prefix removed from the original text.
|
||||
fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option<TextElement> {
|
||||
if elem.byte_range.end <= offset {
|
||||
return None;
|
||||
}
|
||||
let start = elem.byte_range.start.saturating_sub(offset);
|
||||
let end = elem.byte_range.end.saturating_sub(offset);
|
||||
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
||||
}
|
||||
|
||||
/// Parses the `key=value` pairs that follow a custom prompt name.
|
||||
///
|
||||
/// The input is split using shlex rules, so quoted values are supported
|
||||
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
|
||||
/// arguments, or an error if a token is missing `=` or if the key is empty.
|
||||
pub fn parse_prompt_inputs(
|
||||
rest: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Result<HashMap<String, PromptArg>, PromptArgsError> {
|
||||
let mut map = HashMap::new();
|
||||
if rest.trim().is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
// Tokenize the rest of the command using shlex rules, but keep text element
|
||||
// ranges relative to each emitted token.
|
||||
for token in parse_tokens_with_elements(rest, text_elements) {
|
||||
let Some((key, value)) = token.text.split_once('=') else {
|
||||
return Err(PromptArgsError::MissingAssignment { token: token.text });
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PromptArgsError::MissingKey { token: token.text });
|
||||
}
|
||||
// The token is `key=value`; translate element ranges into the value-only
|
||||
// coordinate space by subtracting the `key=` prefix length.
|
||||
let value_start = key.len() + 1;
|
||||
let value_elements = token
|
||||
.text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| shift_text_element_left(elem, value_start))
|
||||
.collect();
|
||||
map.insert(
|
||||
key.to_string(),
|
||||
PromptArg {
|
||||
text: value.to_string(),
|
||||
text_elements: value_elements,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
|
||||
///
|
||||
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
|
||||
/// the function returns `Ok(None)`. On success it returns
|
||||
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
||||
pub fn expand_custom_prompt(
|
||||
text: &str,
|
||||
text_elements: &[TextElement],
|
||||
custom_prompts: &[CustomPrompt],
|
||||
) -> Result<Option<PromptExpansion>, PromptExpansionError> {
|
||||
let Some((name, rest, rest_offset)) = parse_slash_name(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Only handle custom prompts when using the explicit prompts prefix with a colon.
|
||||
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
|
||||
Some(prompt) => prompt,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// If there are named placeholders, expect key=value inputs.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
let local_elements: Vec<TextElement> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
let mut shifted = shift_text_element_left(elem, rest_offset)?;
|
||||
if shifted.byte_range.start >= rest.len() {
|
||||
return None;
|
||||
}
|
||||
let end = shifted.byte_range.end.min(rest.len());
|
||||
shifted.byte_range.end = end;
|
||||
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
|
||||
})
|
||||
.collect();
|
||||
if !required.is_empty() {
|
||||
let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| {
|
||||
PromptExpansionError::Args {
|
||||
command: format!("/{name}"),
|
||||
error,
|
||||
}
|
||||
})?;
|
||||
let missing: Vec<String> = required
|
||||
.into_iter()
|
||||
.filter(|k| !inputs.contains_key(k))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(PromptExpansionError::MissingArgs {
|
||||
command: format!("/{name}"),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs);
|
||||
return Ok(Some(PromptExpansion {
|
||||
text,
|
||||
text_elements: elements,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
|
||||
let pos_args = parse_positional_args(rest, &local_elements);
|
||||
Ok(Some(expand_numeric_placeholders(
|
||||
&prompt.content,
|
||||
&pos_args,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
|
||||
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
|
||||
if content.contains("$ARGUMENTS") {
|
||||
return true;
|
||||
}
|
||||
let bytes = content.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'$' {
|
||||
let b1 = bytes[i + 1];
|
||||
if (b'1'..=b'9').contains(&b1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
|
||||
/// Returns empty when the command name does not match or when there are no args.
|
||||
pub fn extract_positional_args_for_prompt_line(
|
||||
line: &str,
|
||||
prompt_name: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Vec<PromptArg> {
|
||||
let trimmed = line.trim_start();
|
||||
let trim_offset = line.len() - trimmed.len();
|
||||
let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Require the explicit prompts prefix for custom prompt invocations.
|
||||
let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if after_prefix != prompt_name {
|
||||
return Vec::new();
|
||||
}
|
||||
let rest_trimmed_start = rest.trim_start();
|
||||
let args_str = rest_trimmed_start.trim_end();
|
||||
if args_str.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len());
|
||||
let local_elements: Vec<TextElement> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
let mut shifted = shift_text_element_left(elem, args_offset)?;
|
||||
if shifted.byte_range.start >= args_str.len() {
|
||||
return None;
|
||||
}
|
||||
let end = shifted.byte_range.end.min(args_str.len());
|
||||
shifted.byte_range.end = end;
|
||||
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
|
||||
})
|
||||
.collect();
|
||||
parse_positional_args(args_str, &local_elements)
|
||||
}
|
||||
|
||||
/// If the prompt only uses numeric placeholders and the first line contains
|
||||
/// positional args for it, expand and return Some(expanded); otherwise None.
|
||||
pub fn expand_if_numeric_with_positional_args(
|
||||
prompt: &CustomPrompt,
|
||||
first_line: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Option<PromptExpansion> {
|
||||
if !prompt_argument_names(&prompt.content).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !prompt_has_numeric_placeholders(&prompt.content) {
|
||||
return None;
|
||||
}
|
||||
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements);
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_numeric_placeholders(&prompt.content, &args))
|
||||
}
|
||||
|
||||
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
|
||||
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(off) = content[i..].find('$') {
|
||||
let j = i + off;
|
||||
out.push_str(&content[i..j]);
|
||||
let rest = &content[j..];
|
||||
let bytes = rest.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
match bytes[1] {
|
||||
b'$' => {
|
||||
out.push_str("$$");
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
b'1'..=b'9' => {
|
||||
let idx = (bytes[1] - b'1') as usize;
|
||||
if let Some(arg) = args.get(idx) {
|
||||
append_arg_with_elements(&mut out, &mut out_elements, arg);
|
||||
}
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
|
||||
if !args.is_empty() {
|
||||
append_joined_args_with_elements(&mut out, &mut out_elements, args);
|
||||
}
|
||||
i = j + 1 + "ARGUMENTS".len();
|
||||
continue;
|
||||
}
|
||||
out.push('$');
|
||||
i = j + 1;
|
||||
}
|
||||
out.push_str(&content[i..]);
|
||||
PromptExpansion {
|
||||
text: out,
|
||||
text_elements: out_elements,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
|
||||
let mut elements = text_elements.to_vec();
|
||||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||||
// Keep element placeholders intact across shlex splitting by replacing
|
||||
// each element range with a unique sentinel token first.
|
||||
let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements);
|
||||
Shlex::new(&rest_for_shlex)
|
||||
.map(|token| apply_replacements_to_token(token, &replacements))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ElementReplacement {
|
||||
sentinel: String,
|
||||
text: String,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
/// Replace each text element range with a unique sentinel token.
|
||||
///
|
||||
/// The sentinel is chosen so it will survive shlex tokenization as a single word.
|
||||
fn replace_text_elements_with_sentinels(
|
||||
rest: &str,
|
||||
elements: &[TextElement],
|
||||
) -> (String, Vec<ElementReplacement>) {
|
||||
let mut out = String::with_capacity(rest.len());
|
||||
let mut replacements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
for (idx, elem) in elements.iter().enumerate() {
|
||||
let start = elem.byte_range.start;
|
||||
let end = elem.byte_range.end;
|
||||
out.push_str(&rest[cursor..start]);
|
||||
let mut sentinel = format!("__CODEX_ELEM_{idx}__");
|
||||
// Ensure we never collide with user content so a sentinel can't be mistaken for text.
|
||||
while rest.contains(&sentinel) {
|
||||
sentinel.push('_');
|
||||
}
|
||||
out.push_str(&sentinel);
|
||||
replacements.push(ElementReplacement {
|
||||
sentinel,
|
||||
text: rest[start..end].to_string(),
|
||||
placeholder: elem.placeholder(rest).map(str::to_string),
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
out.push_str(&rest[cursor..]);
|
||||
(out, replacements)
|
||||
}
|
||||
|
||||
/// Rehydrate a shlex token by swapping sentinels back to the original text
|
||||
/// and rebuilding text element ranges relative to the resulting token.
|
||||
fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg {
|
||||
if replacements.is_empty() {
|
||||
return PromptArg {
|
||||
text: token,
|
||||
text_elements: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(token.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
while cursor < token.len() {
|
||||
let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else {
|
||||
out.push_str(&token[cursor..]);
|
||||
break;
|
||||
};
|
||||
let start_in_token = cursor + offset;
|
||||
out.push_str(&token[cursor..start_in_token]);
|
||||
let start = out.len();
|
||||
out.push_str(&replacement.text);
|
||||
let end = out.len();
|
||||
if start < end {
|
||||
out_elements.push(TextElement::new(
|
||||
ByteRange { start, end },
|
||||
replacement.placeholder.clone(),
|
||||
));
|
||||
}
|
||||
cursor = start_in_token + replacement.sentinel.len();
|
||||
}
|
||||
|
||||
PromptArg {
|
||||
text: out,
|
||||
text_elements: out_elements,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the earliest sentinel occurrence at or after `cursor`.
|
||||
fn next_replacement<'a>(
|
||||
token: &str,
|
||||
cursor: usize,
|
||||
replacements: &'a [ElementReplacement],
|
||||
) -> Option<(usize, &'a ElementReplacement)> {
|
||||
let slice = &token[cursor..];
|
||||
let mut best: Option<(usize, &'a ElementReplacement)> = None;
|
||||
for replacement in replacements {
|
||||
if let Some(pos) = slice.find(&replacement.sentinel) {
|
||||
match best {
|
||||
Some((best_pos, _)) if best_pos <= pos => {}
|
||||
_ => best = Some((pos, replacement)),
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn expand_named_placeholders_with_elements(
|
||||
content: &str,
|
||||
args: &HashMap<String, PromptArg>,
|
||||
) -> (String, Vec<TextElement>) {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
let start = m.start();
|
||||
let end = m.end();
|
||||
if start > 0 && content.as_bytes()[start - 1] == b'$' {
|
||||
out.push_str(&content[cursor..end]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
out.push_str(&content[cursor..start]);
|
||||
cursor = end;
|
||||
let key = &content[start + 1..end];
|
||||
if let Some(arg) = args.get(key) {
|
||||
append_arg_with_elements(&mut out, &mut out_elements, arg);
|
||||
} else {
|
||||
out.push_str(&content[start..end]);
|
||||
}
|
||||
}
|
||||
out.push_str(&content[cursor..]);
|
||||
(out, out_elements)
|
||||
}
|
||||
|
||||
fn append_arg_with_elements(
|
||||
out: &mut String,
|
||||
out_elements: &mut Vec<TextElement>,
|
||||
arg: &PromptArg,
|
||||
) {
|
||||
let start = out.len();
|
||||
out.push_str(&arg.text);
|
||||
if arg.text_elements.is_empty() {
|
||||
return;
|
||||
}
|
||||
out_elements.extend(arg.text_elements.iter().map(|elem| {
|
||||
elem.map_range(|range| ByteRange {
|
||||
start: start + range.start,
|
||||
end: start + range.end,
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
fn append_joined_args_with_elements(
|
||||
out: &mut String,
|
||||
out_elements: &mut Vec<TextElement>,
|
||||
args: &[PromptArg],
|
||||
) {
|
||||
// `$ARGUMENTS` joins args with single spaces while preserving element ranges.
|
||||
for (idx, arg) in args.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
append_arg_with_elements(out, out_elements, arg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a command text for a custom prompt with arguments.
|
||||
/// Returns the text and the cursor position (inside the first double quote).
|
||||
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
|
||||
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
|
||||
let mut cursor: usize = text.len();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
text.push_str(format!(" {arg}=\"\"").as_str());
|
||||
if i == 0 {
|
||||
cursor = text.len() - 1; // inside first ""
|
||||
}
|
||||
}
|
||||
(text, cursor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn expand_arguments_basic() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "Review Alice changes on main".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_values_ok() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt(
|
||||
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
|
||||
&[],
|
||||
&prompts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "Pair Alice Smith with dev-main".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arg_token_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.contains("expected key=value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_args_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.to_lowercase().contains("missing required args"));
|
||||
assert!(err.contains("BRANCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_is_ignored() {
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER and $REAL"),
|
||||
vec!["REAL".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_remains_literal() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "literal $$USER".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "literal $$USER".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_args_treat_placeholder_with_spaces_as_single_token() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("alpha {placeholder} beta");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_positional_args(&rest, &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_positional_args_shifts_element_offsets_into_args_str() {
|
||||
let placeholder = "[Image #1]";
|
||||
let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta ");
|
||||
let start = line.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_args_treat_placeholder_with_spaces_as_single_token() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("IMG={placeholder} NOTE=hello");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
|
||||
assert_eq!(
|
||||
args.get("IMG"),
|
||||
Some(&PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
args.get("NOTE"),
|
||||
Some(&PromptArg {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_args_allow_placeholder_inside_quotes() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("alpha \"see {placeholder} here\" beta");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_positional_args(&rest, &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: format!("see {placeholder} here"),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: "see ".len(),
|
||||
end: "see ".len() + placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_args_allow_placeholder_inside_quotes() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
|
||||
assert_eq!(
|
||||
args.get("IMG"),
|
||||
Some(&PromptArg {
|
||||
text: format!("see {placeholder} here"),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: "see ".len(),
|
||||
end: "see ".len() + placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
args.get("NOTE"),
|
||||
Some(&PromptArg {
|
||||
text: "ok".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6821,9 +6821,6 @@ impl ChatWidget {
|
||||
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
|
||||
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
|
||||
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
|
||||
EventMsg::ListCustomPromptsResponse(_) => {
|
||||
tracing::warn!("ignoring unsupported custom prompt list response in TUI");
|
||||
}
|
||||
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
|
||||
EventMsg::SkillsUpdateAvailable => {
|
||||
self.submit_op(AppCommand::list_skills(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Custom prompts
|
||||
|
||||
For an overview of custom prompts, see [this documentation](https://developers.openai.com/codex/custom-prompts).
|
||||
@@ -81,8 +81,6 @@ Key effects when disabled:
|
||||
|
||||
- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`.
|
||||
- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands.
|
||||
- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in
|
||||
`prepare_submission_text`.
|
||||
- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled.
|
||||
- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped.
|
||||
- `ChatWidget` may toggle `image_paste_enabled` at runtime based on the selected model's
|
||||
@@ -107,12 +105,8 @@ the input starts with `!` (shell command).
|
||||
|
||||
1. Expands any pending paste placeholders so element ranges align with the final text.
|
||||
2. Trims whitespace and rebases element ranges to the trimmed buffer.
|
||||
3. Expands `/prompts:` custom prompts:
|
||||
- Named args use key=value parsing.
|
||||
- Numeric args use positional parsing for `$1..$9` and `$ARGUMENTS`.
|
||||
The expansion preserves text elements and yields the final submission payload.
|
||||
4. Prunes attachments so only placeholders that survive expansion are sent.
|
||||
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
3. Prunes attachments so only placeholders that survive trimming are sent.
|
||||
4. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
are no attachments.
|
||||
|
||||
The same preparation path is reused for slash commands with arguments (for example `/plan` and
|
||||
@@ -124,16 +118,6 @@ still available for `Ctrl+Y`. This supports flows where a user kills part of a d
|
||||
composer action such as changing reasoning level, and then yanks that text back into the cleared
|
||||
draft.
|
||||
|
||||
### Numeric auto-submit path
|
||||
|
||||
When the slash popup is open and the first line matches a numeric-only custom prompt with
|
||||
positional args, Enter auto-submits without calling `prepare_submission_text`. That path still:
|
||||
|
||||
- Expands pending pastes before parsing positional args.
|
||||
- Uses expanded text elements for prompt expansion.
|
||||
- Prunes attachments based on expanded placeholders.
|
||||
- Clears pending pastes after a successful auto-submit.
|
||||
|
||||
## Remote image rows (selection/deletion flow)
|
||||
|
||||
Remote image URLs are shown as `[Image #N]` rows above the textarea, inside the same composer box.
|
||||
|
||||
Reference in New Issue
Block a user