Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d6c67dd
Implemented changes to allow arguments in custom prompts
vultuk Sep 4, 2025
9f493dc
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 4, 2025
45b2e42
Addressing [P1] and ensuring arguments are passed through for a parti…
vultuk Sep 4, 2025
df96f5b
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 4, 2025
b836834
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 5, 2025
23c4fed
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 6, 2025
ab1b0cd
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 6, 2025
1901b21
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 8, 2025
28d168d
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 8, 2025
56d9d94
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 9, 2025
7485e80
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 9, 2025
d3a2fe1
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 9, 2025
8c970d4
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 10, 2025
c64611a
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 10, 2025
f2fcf6e
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 10, 2025
17bfdcd
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 11, 2025
5b43a46
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 11, 2025
99d8ad2
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 12, 2025
0f04ddc
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 13, 2025
8ed900a
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 14, 2025
048bfdc
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 15, 2025
48b93e9
Merge branch 'main' into feat/arguments-in-custom-prompts
vultuk Sep 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions codex-rs/core/src/custom_prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,52 @@ pub async fn discover_prompts_in_excluding(
out
}

/// Parse a slash-style invocation like "/name args..." and, if a matching
/// `CustomPrompt` exists in `prompts`, return the prompt content with
/// occurrences of `$ARGUMENTS` replaced by the raw text after the command
/// token. If the prompt is unknown, returns `None`.
pub fn expand_prompt_invocation_for_tests(text: &str, prompts: &[CustomPrompt]) -> Option<String> {
// Accept only slash-prefixed commands, e.g. "/hello world".
let trimmed_leading = text.trim_start_matches(' ');
let rest = trimmed_leading.strip_prefix('/')?;

// Split into command name and the raw arguments; preserve args verbatim
// (including leading/trailing spaces after the command token).
let rest_bytes = rest.as_bytes();
let mut name_len = 0usize;
for &b in rest_bytes.iter() {
if b.is_ascii_whitespace() {
break;
}
name_len += 1;
}
let name = &rest[..name_len];
let raw_args = if name_len >= rest.len() {
""
} else {
// Drop exactly one leading ASCII whitespace separating the command name
// from its arguments; preserve any additional whitespace verbatim.
let s = &rest[name_len..];
if let Some(first) = s.as_bytes().first()
&& first.is_ascii_whitespace()
{
&s[1..]
} else {
s
}
};

// Find matching prompt.
let prompt = prompts.iter().find(|p| p.name == name)?;

// Replace all occurrences of "$ARGUMENTS" with the raw args.
if prompt.content.contains("$ARGUMENTS") {
Some(prompt.content.replace("$ARGUMENTS", raw_args))
} else {
Some(prompt.content.clone())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -124,4 +170,38 @@ mod tests {
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["good"]);
}

// --- Argument substitution behavior: tests define desired contract ---
fn p(name: &str, content: &str) -> CustomPrompt {
CustomPrompt {
name: name.to_string(),
path: PathBuf::from(format!("/tmp/{name}.md")),
content: content.to_string(),
}
}

#[test]
fn arg_substitution_basic_fails_until_implemented() {
let prompts = vec![p("hello", "Hi $ARGUMENTS!")];
let out = expand_prompt_invocation_for_tests("/hello world", &prompts)
.expect("should match prompt");
// Expected: "Hi world!"; current stub returns content unchanged, so this will fail.
assert_eq!(out, "Hi world!");
}

#[test]
fn arg_substitution_multiple_occurrences_fails_until_implemented() {
let prompts = vec![p("echo", "A:$ARGUMENTS B:$ARGUMENTS")];
let out = expand_prompt_invocation_for_tests("/echo foo bar", &prompts)
.expect("should match prompt");
assert_eq!(out, "A:foo bar B:foo bar");
}

#[test]
fn arg_substitution_empty_args_fails_until_implemented() {
let prompts = vec![p("hello", "<$ARGUMENTS>")];
let out =
expand_prompt_invocation_for_tests("/hello", &prompts).expect("should match prompt");
assert_eq!(out, "<>");
}
}
210 changes: 208 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::slash_command::SlashCommand;
use codex_core::custom_prompts::expand_prompt_invocation_for_tests;
use codex_protocol::custom_prompts::CustomPrompt;

use crate::app_event::AppEvent;
Expand Down Expand Up @@ -417,11 +418,33 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_item() {
// Clear textarea so no residual text remains.
let first_line = self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.to_string();
self.textarea.set_text("");
// Capture any needed data from popup before clearing it.
// For user prompts, parse arguments independently of the selected prompt name
// and apply them to the selected prompt's content so arguments survive when
// users change selection.
let prompt_content = match sel {
CommandItem::UserPrompt(idx) => {
popup.prompt_content(idx).map(|s| s.to_string())
let base = popup.prompt_content(idx).map(|s| s.to_string());
if let Some(mut content) = base {
if content.contains("$ARGUMENTS") {
if let Some(args) = Self::extract_args_after_slash(&first_line)
{
content = content.replace("$ARGUMENTS", args);
} else {
content = content.replace("$ARGUMENTS", "");
}
}
Some(content)
} else {
None
}
}
_ => None,
};
Expand Down Expand Up @@ -682,6 +705,35 @@ impl ChatComposer {
left_at.or(right_at)
}

/// Extract the raw arguments substring after a leading "/command" token
/// on the first line of input. Drops exactly one ASCII whitespace between
/// the command and its arguments and preserves all remaining whitespace and
/// punctuation. Returns `None` when the line does not start with '/'.
fn extract_args_after_slash(line: &str) -> Option<&str> {
let trimmed = line.trim_start_matches(' ');
let rest = trimmed.strip_prefix('/')?;
// Find the end of the command token (first ASCII whitespace)
let mut name_len = 0usize;
for &b in rest.as_bytes() {
if b.is_ascii_whitespace() {
break;
}
name_len += 1;
}
if name_len >= rest.len() {
// No args present
None
} else {
let mut s = &rest[name_len..];
if let Some(first) = s.as_bytes().first()
&& first.is_ascii_whitespace()
{
s = &s[1..];
}
Some(s)
}
}

/// Replace the active `@token` (the one under the cursor) with `path`.
///
/// The algorithm mirrors `current_at_token` so replacement works no matter
Expand Down Expand Up @@ -813,6 +865,14 @@ impl ChatComposer {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");

// If this is a custom prompt invocation like "/name args",
// expand $ARGUMENTS before proceeding.
if let Some(expanded) =
expand_prompt_invocation_for_tests(&text, &self.custom_prompts)
{
text = expanded;
}

// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
Expand Down Expand Up @@ -1760,6 +1820,152 @@ mod tests {
}
}

#[test]
fn slash_prompt_arguments_substituted_on_submit() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

composer.set_custom_prompts(vec![CustomPrompt {
name: "hello".to_string(),
path: "/tmp/hello.md".to_string().into(),
content: "Hi $ARGUMENTS!".to_string(),
}]);

// Type "/hello world" humanlike to avoid paste-burst suppression.
type_chars_humanlike(
&mut composer,
&['/', 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'],
);

let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(s) => assert_eq!(s, "Hi world!"),
other => panic!("expected Submitted, got {other:?}"),
}
}

#[test]
fn slash_popup_preserves_args_on_partial_name_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

composer.set_custom_prompts(vec![CustomPrompt {
name: "hello".to_string(),
path: "/tmp/hello.md".to_string().into(),
content: "Hi $ARGUMENTS!".to_string(),
}]);

// Type a partial command name followed by arguments, then press Enter to select the
// single matching prompt from the popup.
type_chars_humanlike(
&mut composer,
&['/', 'h', 'e', 'l', ' ', 'w', 'o', 'r', 'l', 'd'],
);

let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(s) => assert_eq!(s, "Hi world!"),
other => panic!("expected Submitted, got {other:?}"),
}
}

#[test]
fn slash_popup_preserves_spacing_and_punctuation_in_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

composer.set_custom_prompts(vec![CustomPrompt {
name: "hello".to_string(),
path: "/tmp/hello.md".to_string().into(),
content: "Args=[$ARGUMENTS]".to_string(),
}]);

type_chars_humanlike(
&mut composer,
&[
'/', 'h', 'e', ' ', ' ', ' ', 'b', 'i', 'g', ' ', ' ', 't', 'h', 'i', 'n', 'g',
' ', ' ', '!', '@', '#', ' ', ' ',
],
);

let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(s) => assert_eq!(s, "Args=[ big thing !@# ]"),
other => panic!("expected Submitted, got {other:?}"),
}
}

#[test]
fn slash_popup_empty_args_replaced_with_empty_string() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

composer.set_custom_prompts(vec![CustomPrompt {
name: "hello".to_string(),
path: "/tmp/hello.md".to_string().into(),
content: "Hi<$ARGUMENTS>".to_string(),
}]);

type_chars_humanlike(&mut composer, &['/', 'h', 'e']);
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(s) => assert_eq!(s, "Hi<>"),
other => panic!("expected Submitted, got {other:?}"),
}
}

#[test]
fn slash_popup_model_first_for_mo_ui() {
use ratatui::Terminal;
Expand Down