Custom prompt args (numeric) (#4470)
[Cherry picked from /pull/3565] Adds $1, $2, $3, $ARGUMENTS param parsing for custom prompts.
This commit is contained in:
@@ -24,6 +24,10 @@ use super::footer::render_footer;
|
|||||||
use super::paste_burst::CharDecision;
|
use super::paste_burst::CharDecision;
|
||||||
use super::paste_burst::PasteBurst;
|
use super::paste_burst::PasteBurst;
|
||||||
use crate::bottom_pane::paste_burst::FlushResult;
|
use crate::bottom_pane::paste_burst::FlushResult;
|
||||||
|
use crate::bottom_pane::prompt_args::expand_custom_prompt;
|
||||||
|
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
|
||||||
|
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||||
|
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::style::user_message_style;
|
use crate::style::user_message_style;
|
||||||
use crate::terminal_palette;
|
use crate::terminal_palette;
|
||||||
@@ -387,6 +391,7 @@ impl ChatComposer {
|
|||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
popup.on_composer_text_change(first_line.to_string());
|
popup.on_composer_text_change(first_line.to_string());
|
||||||
if let Some(sel) = popup.selected_item() {
|
if let Some(sel) = popup.selected_item() {
|
||||||
|
let mut cursor_target: Option<usize> = None;
|
||||||
match sel {
|
match sel {
|
||||||
CommandItem::Builtin(cmd) => {
|
CommandItem::Builtin(cmd) => {
|
||||||
let starts_with_cmd = first_line
|
let starts_with_cmd = first_line
|
||||||
@@ -395,21 +400,27 @@ impl ChatComposer {
|
|||||||
if !starts_with_cmd {
|
if !starts_with_cmd {
|
||||||
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
||||||
}
|
}
|
||||||
|
if !self.textarea.text().is_empty() {
|
||||||
|
cursor_target = Some(self.textarea.text().len());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CommandItem::UserPrompt(idx) => {
|
CommandItem::UserPrompt(idx) => {
|
||||||
if let Some(name) = popup.prompt_name(idx) {
|
if let Some(prompt) = popup.prompt(idx) {
|
||||||
let starts_with_cmd =
|
let name = prompt.name.clone();
|
||||||
first_line.trim_start().starts_with(&format!("/{name}"));
|
let starts_with_cmd = first_line
|
||||||
|
.trim_start()
|
||||||
|
.starts_with(format!("/{name}").as_str());
|
||||||
if !starts_with_cmd {
|
if !starts_with_cmd {
|
||||||
self.textarea.set_text(&format!("/{name} "));
|
self.textarea.set_text(format!("/{name} ").as_str());
|
||||||
|
}
|
||||||
|
if !self.textarea.text().is_empty() {
|
||||||
|
cursor_target = Some(self.textarea.text().len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// After completing the command, move cursor to the end.
|
if let Some(pos) = cursor_target {
|
||||||
if !self.textarea.text().is_empty() {
|
self.textarea.set_cursor(pos);
|
||||||
let end = self.textarea.text().len();
|
|
||||||
self.textarea.set_cursor(end);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(InputResult::None, true)
|
(InputResult::None, true)
|
||||||
@@ -419,26 +430,49 @@ impl ChatComposer {
|
|||||||
modifiers: KeyModifiers::NONE,
|
modifiers: KeyModifiers::NONE,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Some(sel) = popup.selected_item() {
|
// If the current line starts with a custom prompt name and includes
|
||||||
// Clear textarea so no residual text remains.
|
// positional args for a numeric-style template, expand and submit
|
||||||
|
// immediately regardless of the popup selection.
|
||||||
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
|
if let Some((name, _rest)) = parse_slash_name(first_line)
|
||||||
|
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name)
|
||||||
|
&& let Some(expanded) =
|
||||||
|
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||||
|
{
|
||||||
self.textarea.set_text("");
|
self.textarea.set_text("");
|
||||||
// Capture any needed data from popup before clearing it.
|
return (InputResult::Submitted(expanded), true);
|
||||||
let prompt_content = match sel {
|
}
|
||||||
CommandItem::UserPrompt(idx) => {
|
|
||||||
popup.prompt_content(idx).map(str::to_string)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
// Hide popup since an action has been dispatched.
|
|
||||||
self.active_popup = ActivePopup::None;
|
|
||||||
|
|
||||||
|
if let Some(sel) = popup.selected_item() {
|
||||||
match sel {
|
match sel {
|
||||||
CommandItem::Builtin(cmd) => {
|
CommandItem::Builtin(cmd) => {
|
||||||
|
self.textarea.set_text("");
|
||||||
return (InputResult::Command(cmd), true);
|
return (InputResult::Command(cmd), true);
|
||||||
}
|
}
|
||||||
CommandItem::UserPrompt(_) => {
|
CommandItem::UserPrompt(idx) => {
|
||||||
if let Some(contents) = prompt_content {
|
if let Some(prompt) = popup.prompt(idx) {
|
||||||
return (InputResult::Submitted(contents), true);
|
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||||||
|
|
||||||
|
if !has_numeric {
|
||||||
|
// No placeholders at all: auto-submit the literal content
|
||||||
|
self.textarea.set_text("");
|
||||||
|
return (InputResult::Submitted(prompt.content.clone()), true);
|
||||||
|
}
|
||||||
|
// Numeric placeholders present.
|
||||||
|
// If the user already typed positional args on the first line,
|
||||||
|
// expand immediately and submit; otherwise insert "/name " so
|
||||||
|
// they can type args.
|
||||||
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
|
if let Some(expanded) =
|
||||||
|
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||||
|
{
|
||||||
|
self.textarea.set_text("");
|
||||||
|
return (InputResult::Submitted(expanded), true);
|
||||||
|
} else {
|
||||||
|
let text = format!("/{} ", prompt.name);
|
||||||
|
self.textarea.set_text(&text);
|
||||||
|
self.textarea.set_cursor(self.textarea.text().len());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
@@ -450,6 +484,7 @@ impl ChatComposer {
|
|||||||
input => self.handle_input_basic(input),
|
input => self.handle_input_basic(input),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
|
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
|
||||||
let mut p = pos.min(text.len());
|
let mut p = pos.min(text.len());
|
||||||
@@ -714,16 +749,26 @@ impl ChatComposer {
|
|||||||
.unwrap_or(after_cursor.len());
|
.unwrap_or(after_cursor.len());
|
||||||
let end_idx = safe_cursor + end_rel_idx;
|
let end_idx = safe_cursor + end_rel_idx;
|
||||||
|
|
||||||
|
// If the path contains whitespace, wrap it in double quotes so the
|
||||||
|
// local prompt arg parser treats it as a single argument. Avoid adding
|
||||||
|
// quotes when the path already contains one to keep behavior simple.
|
||||||
|
let needs_quotes = path.chars().any(char::is_whitespace);
|
||||||
|
let inserted = if needs_quotes && !path.contains('"') {
|
||||||
|
format!("\"{path}\"")
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
||||||
let mut new_text =
|
let mut new_text =
|
||||||
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
|
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
|
||||||
new_text.push_str(&text[..start_idx]);
|
new_text.push_str(&text[..start_idx]);
|
||||||
new_text.push_str(path);
|
new_text.push_str(&inserted);
|
||||||
new_text.push(' ');
|
new_text.push(' ');
|
||||||
new_text.push_str(&text[end_idx..]);
|
new_text.push_str(&text[end_idx..]);
|
||||||
|
|
||||||
self.textarea.set_text(&new_text);
|
self.textarea.set_text(&new_text);
|
||||||
let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1);
|
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
|
||||||
self.textarea.set_cursor(new_cursor);
|
self.textarea.set_cursor(new_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +854,7 @@ impl ChatComposer {
|
|||||||
if self
|
if self
|
||||||
.paste_burst
|
.paste_burst
|
||||||
.newline_should_insert_instead_of_submit(now)
|
.newline_should_insert_instead_of_submit(now)
|
||||||
|
&& !in_slash_context
|
||||||
{
|
{
|
||||||
self.textarea.insert_str("\n");
|
self.textarea.insert_str("\n");
|
||||||
self.paste_burst.extend_window(now);
|
self.paste_burst.extend_window(now);
|
||||||
@@ -828,6 +874,13 @@ impl ChatComposer {
|
|||||||
// If there is neither text nor attachments, suppress submission entirely.
|
// If there is neither text nor attachments, suppress submission entirely.
|
||||||
let has_attachments = !self.attached_images.is_empty();
|
let has_attachments = !self.attached_images.is_empty();
|
||||||
text = text.trim().to_string();
|
text = text.trim().to_string();
|
||||||
|
|
||||||
|
if let Some(expanded) =
|
||||||
|
expand_custom_prompt(&text, &self.custom_prompts).unwrap_or_default()
|
||||||
|
{
|
||||||
|
text = expanded;
|
||||||
|
}
|
||||||
|
|
||||||
if text.is_empty() && !has_attachments {
|
if text.is_empty() && !has_attachments {
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
@@ -1149,18 +1202,43 @@ impl ChatComposer {
|
|||||||
/// textarea. This must be called after every modification that can change
|
/// textarea. This must be called after every modification that can change
|
||||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||||
fn sync_command_popup(&mut self) {
|
fn sync_command_popup(&mut self) {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
// Determine whether the caret is inside the initial '/name' token on the first line.
|
||||||
let input_starts_with_slash = first_line.starts_with('/');
|
let text = self.textarea.text();
|
||||||
|
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||||
|
let first_line = &text[..first_line_end];
|
||||||
|
let cursor = self.textarea.cursor();
|
||||||
|
let caret_on_first_line = cursor <= first_line_end;
|
||||||
|
|
||||||
|
let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line {
|
||||||
|
// Compute the end of the initial '/name' token (name may be empty yet).
|
||||||
|
let token_end = first_line
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, c)| c.is_whitespace())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(first_line.len());
|
||||||
|
cursor <= token_end
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// If the cursor is currently positioned within an `@token`, prefer the
|
||||||
|
// file-search popup over the slash popup so users can insert a file path
|
||||||
|
// as an argument to the command (e.g., "/review @docs/...").
|
||||||
|
if Self::current_at_token(&self.textarea).is_some() {
|
||||||
|
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||||
|
self.active_popup = ActivePopup::None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
match &mut self.active_popup {
|
match &mut self.active_popup {
|
||||||
ActivePopup::Command(popup) => {
|
ActivePopup::Command(popup) => {
|
||||||
if input_starts_with_slash {
|
if is_editing_slash_command_name {
|
||||||
popup.on_composer_text_change(first_line.to_string());
|
popup.on_composer_text_change(first_line.to_string());
|
||||||
} else {
|
} else {
|
||||||
self.active_popup = ActivePopup::None;
|
self.active_popup = ActivePopup::None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if input_starts_with_slash {
|
if is_editing_slash_command_name {
|
||||||
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
||||||
command_popup.on_composer_text_change(first_line.to_string());
|
command_popup.on_composer_text_change(first_line.to_string());
|
||||||
self.active_popup = ActivePopup::Command(command_popup);
|
self.active_popup = ActivePopup::Command(command_popup);
|
||||||
@@ -1298,6 +1376,7 @@ mod tests {
|
|||||||
use crate::bottom_pane::InputResult;
|
use crate::bottom_pane::InputResult;
|
||||||
use crate::bottom_pane::chat_composer::AttachedImage;
|
use crate::bottom_pane::chat_composer::AttachedImage;
|
||||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||||
|
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
|
||||||
use crate::bottom_pane::textarea::TextArea;
|
use crate::bottom_pane::textarea::TextArea;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
@@ -1787,6 +1866,18 @@ mod tests {
|
|||||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_args_supports_quoted_paths_single_arg() {
|
||||||
|
let args = extract_positional_args_for_prompt_line("/review \"docs/My File.md\"", "review");
|
||||||
|
assert_eq!(args, vec!["docs/My File.md".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_args_supports_mixed_quoted_and_unquoted() {
|
||||||
|
let args = extract_positional_args_for_prompt_line("/cmd \"with spaces\" simple", "cmd");
|
||||||
|
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_tab_completion_moves_cursor_to_end() {
|
fn slash_tab_completion_moves_cursor_to_end() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -2234,7 +2325,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selecting_custom_prompt_submits_file_contents() {
|
fn selecting_custom_prompt_without_args_submits_content() {
|
||||||
let prompt_text = "Hello from saved prompt";
|
let prompt_text = "Hello from saved prompt";
|
||||||
|
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
@@ -2265,6 +2356,145 @@ mod tests {
|
|||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
||||||
|
assert!(composer.textarea.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_custom_prompt_with_args_expands_placeholders() {
|
||||||
|
// Support $1..$9 and $ARGUMENTS in prompt content.
|
||||||
|
let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n";
|
||||||
|
|
||||||
|
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: "my-prompt".to_string(),
|
||||||
|
path: "/tmp/my-prompt.md".to_string().into(),
|
||||||
|
content: prompt_text.to_string(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Type the slash command with two args and hit Enter to submit.
|
||||||
|
type_chars_humanlike(
|
||||||
|
&mut composer,
|
||||||
|
&[
|
||||||
|
'/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b',
|
||||||
|
'a', 'r',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (result, _needs_redraw) =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
|
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
|
||||||
|
assert_eq!(InputResult::Submitted(expected), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_custom_prompt_with_no_args_inserts_template() {
|
||||||
|
let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]";
|
||||||
|
|
||||||
|
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: "p".to_string(),
|
||||||
|
path: "/tmp/p.md".to_string().into(),
|
||||||
|
content: prompt_text.to_string(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
type_chars_humanlike(&mut composer, &['/', 'p']);
|
||||||
|
let (result, _needs_redraw) =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
|
// With no args typed, selecting the prompt inserts the command template
|
||||||
|
// and does not submit immediately.
|
||||||
|
assert_eq!(InputResult::None, result);
|
||||||
|
assert_eq!("/p ", composer.textarea.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_custom_prompt_preserves_literal_dollar_dollar() {
|
||||||
|
// '$$' should remain untouched.
|
||||||
|
let prompt_text = "Cost: $$ and first: $1";
|
||||||
|
|
||||||
|
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: "price".to_string(),
|
||||||
|
path: "/tmp/price.md".to_string().into(),
|
||||||
|
content: prompt_text.to_string(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
type_chars_humanlike(&mut composer, &['/', 'p', 'r', 'i', 'c', 'e', ' ', 'x']);
|
||||||
|
let (result, _needs_redraw) =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
InputResult::Submitted("Cost: $$ and first: x".to_string()),
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_custom_prompt_reuses_cached_arguments_join() {
|
||||||
|
let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS";
|
||||||
|
|
||||||
|
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: "repeat".to_string(),
|
||||||
|
path: "/tmp/repeat.md".to_string().into(),
|
||||||
|
content: prompt_text.to_string(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
type_chars_humanlike(
|
||||||
|
&mut composer,
|
||||||
|
&[
|
||||||
|
'/', 'r', 'e', 'p', 'e', 'a', 't', ' ', 'o', 'n', 'e', ' ', 't', 'w', 'o',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (result, _needs_redraw) =
|
||||||
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
|
let expected = "First: one two\nSecond: one two".to_string();
|
||||||
|
assert_eq!(InputResult::Submitted(expected), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -53,12 +53,8 @@ impl CommandPopup {
|
|||||||
self.prompts = prompts;
|
self.prompts = prompts;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> {
|
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
||||||
self.prompts.get(idx).map(|p| p.name.as_str())
|
self.prompts.get(idx)
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> {
|
|
||||||
self.prompts.get(idx).map(|p| p.content.as_str())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the filter string based on the current composer text. The text
|
/// Update the filter string based on the current composer text. The text
|
||||||
@@ -218,7 +214,6 @@ impl WidgetRef for CommandPopup {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::string::ToString;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_includes_init_when_typing_prefix() {
|
fn filter_includes_init_when_typing_prefix() {
|
||||||
@@ -292,7 +287,7 @@ mod tests {
|
|||||||
let mut prompt_names: Vec<String> = items
|
let mut prompt_names: Vec<String> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|it| match it {
|
.filter_map(|it| match it {
|
||||||
CommandItem::UserPrompt(i) => popup.prompt_name(i).map(ToString::to_string),
|
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -312,7 +307,7 @@ mod tests {
|
|||||||
}]);
|
}]);
|
||||||
let items = popup.filtered_items();
|
let items = popup.filtered_items();
|
||||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||||
CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"),
|
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||||
_ => false,
|
_ => false,
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod custom_prompt_view;
|
|||||||
mod file_search_popup;
|
mod file_search_popup;
|
||||||
mod footer;
|
mod footer;
|
||||||
mod list_selection_view;
|
mod list_selection_view;
|
||||||
|
mod prompt_args;
|
||||||
pub(crate) use list_selection_view::SelectionViewParams;
|
pub(crate) use list_selection_view::SelectionViewParams;
|
||||||
mod paste_burst;
|
mod paste_burst;
|
||||||
pub mod popup_consts;
|
pub mod popup_consts;
|
||||||
|
|||||||
151
codex-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
151
codex-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
use shlex::Shlex;
|
||||||
|
|
||||||
|
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||||
|
/// Returns `(name, rest_after_name)` if the line begins with `/` and contains
|
||||||
|
/// a non-empty name; otherwise returns `None`.
|
||||||
|
pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> {
|
||||||
|
let stripped = line.strip_prefix('/')?;
|
||||||
|
let mut name_end = stripped.len();
|
||||||
|
for (idx, ch) in stripped.char_indices() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
name_end = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = &stripped[..name_end];
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = stripped[name_end..].trim_start();
|
||||||
|
Some((name, rest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse positional arguments using shlex semantics (supports quoted tokens).
|
||||||
|
pub fn parse_positional_args(rest: &str) -> Vec<String> {
|
||||||
|
Shlex::new(rest).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a message of the form `/name key=value …` using a matching saved prompt.
|
||||||
|
///
|
||||||
|
/// If the text does not start with `/`, 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,
|
||||||
|
custom_prompts: &[CustomPrompt],
|
||||||
|
) -> Result<Option<String>, ()> {
|
||||||
|
let Some((name, rest)) = parse_slash_name(text) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = match custom_prompts.iter().find(|p| p.name == name) {
|
||||||
|
Some(prompt) => prompt,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
// Only support numeric placeholders ($1..$9) and $ARGUMENTS.
|
||||||
|
if prompt_has_numeric_placeholders(&prompt.content) {
|
||||||
|
let pos_args: Vec<String> = Shlex::new(rest).collect();
|
||||||
|
let expanded = expand_numeric_placeholders(&prompt.content, &pos_args);
|
||||||
|
return Ok(Some(expanded));
|
||||||
|
}
|
||||||
|
// No recognized placeholders: return the literal content.
|
||||||
|
Ok(Some(prompt.content.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) -> Vec<String> {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let Some(rest) = trimmed.strip_prefix('/') else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut parts = rest.splitn(2, char::is_whitespace);
|
||||||
|
let cmd = parts.next().unwrap_or("");
|
||||||
|
if cmd != prompt_name {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let args_str = parts.next().unwrap_or("").trim();
|
||||||
|
if args_str.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
parse_positional_args(args_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
) -> Option<String> {
|
||||||
|
if !prompt_has_numeric_placeholders(&prompt.content) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name);
|
||||||
|
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: &[String]) -> String {
|
||||||
|
let mut out = String::with_capacity(content.len());
|
||||||
|
let mut i = 0;
|
||||||
|
let mut cached_joined_args: Option<String> = None;
|
||||||
|
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(val) = args.get(idx) {
|
||||||
|
out.push_str(val);
|
||||||
|
}
|
||||||
|
i = j + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
|
||||||
|
if !args.is_empty() {
|
||||||
|
let joined = cached_joined_args.get_or_insert_with(|| args.join(" "));
|
||||||
|
out.push_str(joined);
|
||||||
|
}
|
||||||
|
i = j + 1 + "ARGUMENTS".len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push('$');
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
out.push_str(&content[i..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ Save frequently used prompts as Markdown files and reuse them quickly from the s
|
|||||||
- File type: Only Markdown files with the `.md` extension are recognized.
|
- File type: Only Markdown files with the `.md` extension are recognized.
|
||||||
- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`.
|
- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`.
|
||||||
- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter.
|
- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter.
|
||||||
|
- Arguments: Local prompts support placeholders in their content:
|
||||||
|
- `$1..$9` expand to the first nine positional arguments typed after the slash name
|
||||||
|
- `$ARGUMENTS` expands to all arguments joined by a single space
|
||||||
|
- `$$` is preserved literally
|
||||||
|
- Quoted args: Wrap a single argument in double quotes to include spaces, e.g. `/review "docs/My File.md"`.
|
||||||
- How to use:
|
- How to use:
|
||||||
- Start a new session (Codex loads custom prompts on session start).
|
- Start a new session (Codex loads custom prompts on session start).
|
||||||
- In the composer, type `/` to open the slash popup and begin typing your prompt name.
|
- In the composer, type `/` to open the slash popup and begin typing your prompt name.
|
||||||
|
|||||||
Reference in New Issue
Block a user