feat: support name=value macro arguments with variable tab completion
Macro invocations (.name and .macro name) accept leading name=value assignments before positional args: assignments set declared variables directly so earlier variables can keep their defaults, remaining positionals fill unassigned variables in declaration order, and the free text after -- is never scanned for assignments. Identifier-shaped keys that match no declared variable error with the declared list to catch typos; non-identifier tokens containing = stay positional. MacroVariable gains an optional description field, and tab completion after a macro name offers name= candidates showing each variable's description and default until the assignment prefix ends.
This commit is contained in:
@@ -39,6 +39,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
|||||||
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros). Macros are Coyote's custom commands: invoke any macro directly by name (e.g. `.review main`), with tab-completion, right alongside the built-in REPL commands.
|
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros). Macros are Coyote's custom commands: invoke any macro directly by name (e.g. `.review main`), with tab-completion, right alongside the built-in REPL commands.
|
||||||
* Give a macro a `description` (shown in `.list macros` and completions) and set `isolated: false` to run its steps on the live session, exactly as if you typed them. Note that non-isolated steps are recorded in the session, and mutating steps (`.role`, `.model`, ...) persist after the macro ends — by design. Steps are fail-fast: an error aborts the remaining steps, but completed steps' effects remain. A non-isolated macro step cannot invoke another macro, and a `.exit` step never exits the REPL.
|
* Give a macro a `description` (shown in `.list macros` and completions) and set `isolated: false` to run its steps on the live session, exactly as if you typed them. Note that non-isolated steps are recorded in the session, and mutating steps (`.role`, `.model`, ...) persist after the macro ends — by design. Steps are fail-fast: an error aborts the remaining steps, but completed steps' effects remain. A non-isolated macro step cannot invoke another macro, and a `.exit` step never exits the REPL.
|
||||||
* Commit project-specific macros to `.coyote/macros/` in your repo — they shadow same-named global macros (opt out with `--no-workspace-macros`).
|
* Commit project-specific macros to `.coyote/macros/` in your repo — they shadow same-named global macros (opt out with `--no-workspace-macros`).
|
||||||
|
* Pass variables positionally or by name: leading `name=value` args set declared variables directly (letting earlier variables keep their defaults), and remaining args fill the rest in order. Tab completion after a macro name lists each variable with its description and default.
|
||||||
* Scope which macros are invocable with `enabled_macros` in the global config, a role, an agent, or a session (most specific wins; an empty list disables all macros), and toggle at runtime with `.macro enable|disable <name>`.
|
* Scope which macros are invocable with `enabled_macros` in the global config, a role, an agent, or a session (most specific wins; an empty list disables all macros), and toggle at runtime with `.macro enable|disable <name>`.
|
||||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||||
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||||
|
|||||||
+283
-21
@@ -26,13 +26,17 @@ pub async fn macro_execute(
|
|||||||
bail!("nested macros not allowed in non-isolated mode");
|
bail!("nested macros not allowed in non-isolated mode");
|
||||||
}
|
}
|
||||||
let macro_value = Macro::load(name, ctx.app.config.no_workspace_macros)?;
|
let macro_value = Macro::load(name, ctx.app.config.no_workspace_macros)?;
|
||||||
let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
let (new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
||||||
if !text.is_empty() {
|
|
||||||
new_args.push(text.to_string());
|
|
||||||
}
|
|
||||||
let variables = macro_value
|
let variables = macro_value
|
||||||
.resolve_variables(&new_args)
|
.resolve_variables(&new_args, text)
|
||||||
.map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?;
|
.map_err(|err| {
|
||||||
|
let kv_hint = if macro_value.variables.is_empty() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (variables can also be set by name: name=value, before any positional args)"
|
||||||
|
};
|
||||||
|
anyhow!("{err}. Usage: {}{kv_hint}", macro_value.usage(name))
|
||||||
|
})?;
|
||||||
|
|
||||||
if !macro_value.isolated {
|
if !macro_value.isolated {
|
||||||
let mut live = MacroModeGuard::new(ctx);
|
let mut live = MacroModeGuard::new(ctx);
|
||||||
@@ -181,19 +185,43 @@ impl Macro {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_variables(&self, args: &[String]) -> Result<IndexMap<String, String>> {
|
/// Leading `name=value` tokens assign declared variables by name; the
|
||||||
|
/// first token that is not such an assignment starts the positional
|
||||||
|
/// args, which fill the remaining unassigned variables in declaration
|
||||||
|
/// order. `trailing_text` (the free text after `--`) is always the last
|
||||||
|
/// positional and is never scanned for assignments.
|
||||||
|
pub fn resolve_variables(
|
||||||
|
&self,
|
||||||
|
args: &[String],
|
||||||
|
trailing_text: &str,
|
||||||
|
) -> Result<IndexMap<String, String>> {
|
||||||
|
let (assignments, positional_start) = self.leading_assignments(args)?;
|
||||||
|
let mut positionals: Vec<&str> = args[positional_start..]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
|
if !trailing_text.is_empty() {
|
||||||
|
positionals.push(trailing_text);
|
||||||
|
}
|
||||||
|
|
||||||
let mut output = IndexMap::new();
|
let mut output = IndexMap::new();
|
||||||
|
let mut pos_index = 0;
|
||||||
for (i, variable) in self.variables.iter().enumerate() {
|
for (i, variable) in self.variables.iter().enumerate() {
|
||||||
let value = if variable.rest && i == self.variables.len() - 1 {
|
let is_rest = variable.rest && i == self.variables.len() - 1;
|
||||||
if args.len() > i {
|
let value = if let Some(value) = assignments.get(variable.name.as_str()) {
|
||||||
Some(args[i..].join(" "))
|
Some(value.clone())
|
||||||
|
} else if is_rest {
|
||||||
|
if pos_index < positionals.len() {
|
||||||
|
Some(positionals[pos_index..].join(" "))
|
||||||
} else {
|
} else {
|
||||||
variable.default.clone()
|
variable.default.clone()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
args.get(i)
|
let positional = positionals.get(pos_index).map(|v| v.to_string());
|
||||||
.map(|v| v.to_string())
|
if positional.is_some() {
|
||||||
.or_else(|| variable.default.clone())
|
pos_index += 1;
|
||||||
|
}
|
||||||
|
positional.or_else(|| variable.default.clone())
|
||||||
};
|
};
|
||||||
let value =
|
let value =
|
||||||
value.ok_or_else(|| anyhow!("Missing value for variable '{}'", variable.name))?;
|
value.ok_or_else(|| anyhow!("Missing value for variable '{}'", variable.name))?;
|
||||||
@@ -202,6 +230,61 @@ impl Macro {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn leading_assignments(&self, args: &[String]) -> Result<(IndexMap<String, String>, usize)> {
|
||||||
|
let mut assignments = IndexMap::new();
|
||||||
|
let mut positional_start = args.len();
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
let Some((key, value)) = parse_assignment(arg) else {
|
||||||
|
positional_start = i;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !self.variables.iter().any(|v| v.name == key) {
|
||||||
|
let declared: Vec<&str> = self.variables.iter().map(|v| v.name.as_str()).collect();
|
||||||
|
bail!(
|
||||||
|
"Unknown variable '{key}' (declared variables: {})",
|
||||||
|
declared.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if assignments
|
||||||
|
.insert(key.to_string(), value.to_string())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
bail!("Variable '{key}' was assigned more than once");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((assignments, positional_start))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion candidates for the assignment prefix: one `name=` entry per
|
||||||
|
/// variable not yet assigned in `completed_args`. Empty once a
|
||||||
|
/// non-assignment token has ended the prefix.
|
||||||
|
pub fn variable_completions(&self, completed_args: &[&str]) -> Vec<(String, Option<String>)> {
|
||||||
|
let mut assigned: Vec<&str> = Vec::new();
|
||||||
|
for arg in completed_args {
|
||||||
|
match parse_assignment(arg) {
|
||||||
|
Some((key, _)) if self.variables.iter().any(|v| v.name == key) => {
|
||||||
|
assigned.push(key);
|
||||||
|
}
|
||||||
|
_ => return vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.variables
|
||||||
|
.iter()
|
||||||
|
.filter(|v| !assigned.contains(&v.name.as_str()))
|
||||||
|
.map(|v| {
|
||||||
|
let requirement = match &v.default {
|
||||||
|
Some(default) => format!("(default: {default})"),
|
||||||
|
None => "(required)".to_string(),
|
||||||
|
};
|
||||||
|
let hint = match &v.description {
|
||||||
|
Some(description) => format!("{description} {requirement}"),
|
||||||
|
None => requirement,
|
||||||
|
};
|
||||||
|
(format!("{}=", v.name), Some(hint))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn usage(&self, name: &str) -> String {
|
pub fn usage(&self, name: &str) -> String {
|
||||||
let mut parts = vec![name.to_string()];
|
let mut parts = vec![name.to_string()];
|
||||||
for (i, variable) in self.variables.iter().enumerate() {
|
for (i, variable) in self.variables.iter().enumerate() {
|
||||||
@@ -233,12 +316,30 @@ impl Macro {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct MacroVariable {
|
pub struct MacroVariable {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rest: bool,
|
pub rest: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub default: Option<String>,
|
pub default: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A token is an assignment only when the key before `=` is
|
||||||
|
/// identifier-shaped: a letter or underscore followed by letters, digits,
|
||||||
|
/// underscores, or hyphens. Anything else (paths, URLs, prose) is positional.
|
||||||
|
pub(crate) fn parse_assignment(arg: &str) -> Option<(&str, &str)> {
|
||||||
|
let (key, value) = arg.split_once('=')?;
|
||||||
|
let mut chars = key.chars();
|
||||||
|
let first = chars.next()?;
|
||||||
|
if !first.is_ascii_alphabetic() && first != '_' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((key, value))
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -399,6 +500,7 @@ mod tests {
|
|||||||
fn var(name: &str, rest: bool, default: Option<&str>) -> MacroVariable {
|
fn var(name: &str, rest: bool, default: Option<&str>) -> MacroVariable {
|
||||||
MacroVariable {
|
MacroVariable {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
description: None,
|
||||||
rest,
|
rest,
|
||||||
default: default.map(String::from),
|
default: default.map(String::from),
|
||||||
}
|
}
|
||||||
@@ -417,7 +519,7 @@ mod tests {
|
|||||||
fn resolve_no_variables() {
|
fn resolve_no_variables() {
|
||||||
let m = macro_with_vars(vec![]);
|
let m = macro_with_vars(vec![]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&[]).unwrap();
|
let result = m.resolve_variables(&[], "").unwrap();
|
||||||
|
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
@@ -426,7 +528,7 @@ mod tests {
|
|||||||
fn resolve_required_variable_provided() {
|
fn resolve_required_variable_provided() {
|
||||||
let m = macro_with_vars(vec![var("name", false, None)]);
|
let m = macro_with_vars(vec![var("name", false, None)]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&["Alice".into()]).unwrap();
|
let result = m.resolve_variables(&["Alice".into()], "").unwrap();
|
||||||
|
|
||||||
assert_eq!(result["name"], "Alice");
|
assert_eq!(result["name"], "Alice");
|
||||||
}
|
}
|
||||||
@@ -435,7 +537,7 @@ mod tests {
|
|||||||
fn resolve_required_variable_missing_errors() {
|
fn resolve_required_variable_missing_errors() {
|
||||||
let m = macro_with_vars(vec![var("name", false, None)]);
|
let m = macro_with_vars(vec![var("name", false, None)]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&[]);
|
let result = m.resolve_variables(&[], "");
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().to_string().contains("name"));
|
assert!(result.unwrap_err().to_string().contains("name"));
|
||||||
@@ -445,7 +547,7 @@ mod tests {
|
|||||||
fn resolve_default_variable_uses_default() {
|
fn resolve_default_variable_uses_default() {
|
||||||
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
|
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&[]).unwrap();
|
let result = m.resolve_variables(&[], "").unwrap();
|
||||||
|
|
||||||
assert_eq!(result["color"], "blue");
|
assert_eq!(result["color"], "blue");
|
||||||
}
|
}
|
||||||
@@ -454,7 +556,7 @@ mod tests {
|
|||||||
fn resolve_default_variable_overridden() {
|
fn resolve_default_variable_overridden() {
|
||||||
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
|
let m = macro_with_vars(vec![var("color", false, Some("blue"))]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&["red".into()]).unwrap();
|
let result = m.resolve_variables(&["red".into()], "").unwrap();
|
||||||
|
|
||||||
assert_eq!(result["color"], "red");
|
assert_eq!(result["color"], "red");
|
||||||
}
|
}
|
||||||
@@ -464,7 +566,7 @@ mod tests {
|
|||||||
let m = macro_with_vars(vec![var("first", false, None), var("rest", true, None)]);
|
let m = macro_with_vars(vec![var("first", false, None), var("rest", true, None)]);
|
||||||
|
|
||||||
let result = m
|
let result = m
|
||||||
.resolve_variables(&["a".into(), "b".into(), "c".into()])
|
.resolve_variables(&["a".into(), "b".into(), "c".into()], "")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(result["first"], "a");
|
assert_eq!(result["first"], "a");
|
||||||
@@ -475,7 +577,7 @@ mod tests {
|
|||||||
fn resolve_rest_variable_with_default() {
|
fn resolve_rest_variable_with_default() {
|
||||||
let m = macro_with_vars(vec![var("args", true, Some("default text"))]);
|
let m = macro_with_vars(vec![var("args", true, Some("default text"))]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&[]).unwrap();
|
let result = m.resolve_variables(&[], "").unwrap();
|
||||||
|
|
||||||
assert_eq!(result["args"], "default text");
|
assert_eq!(result["args"], "default text");
|
||||||
}
|
}
|
||||||
@@ -488,13 +590,173 @@ mod tests {
|
|||||||
var("c", false, Some("default_c")),
|
var("c", false, Some("default_c")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let result = m.resolve_variables(&["x".into(), "y".into()]).unwrap();
|
let result = m.resolve_variables(&["x".into(), "y".into()], "").unwrap();
|
||||||
|
|
||||||
assert_eq!(result["a"], "x");
|
assert_eq!(result["a"], "x");
|
||||||
assert_eq!(result["b"], "y");
|
assert_eq!(result["b"], "y");
|
||||||
assert_eq!(result["c"], "default_c");
|
assert_eq!(result["c"], "default_c");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_assignment_skips_earlier_defaults() {
|
||||||
|
let m = macro_with_vars(vec![
|
||||||
|
var("a", false, Some("da")),
|
||||||
|
var("b", false, Some("db")),
|
||||||
|
var("c", false, None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = m.resolve_variables(&["c=x".into()], "").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["a"], "da");
|
||||||
|
assert_eq!(result["b"], "db");
|
||||||
|
assert_eq!(result["c"], "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_positionals_fill_unassigned_variables_after_assignments() {
|
||||||
|
let m = macro_with_vars(vec![
|
||||||
|
var("a", false, None),
|
||||||
|
var("b", false, None),
|
||||||
|
var("c", false, None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = m
|
||||||
|
.resolve_variables(&["b=middle".into(), "first".into(), "last".into()], "")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["a"], "first");
|
||||||
|
assert_eq!(result["b"], "middle");
|
||||||
|
assert_eq!(result["c"], "last");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_rest_variable_by_assignment_and_by_positionals() {
|
||||||
|
let m = macro_with_vars(vec![
|
||||||
|
var("scope", false, Some("all")),
|
||||||
|
var("text", true, None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = m
|
||||||
|
.resolve_variables(&["text=hello world".into()], "")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["scope"], "all");
|
||||||
|
assert_eq!(result["text"], "hello world");
|
||||||
|
|
||||||
|
let result = m
|
||||||
|
.resolve_variables(
|
||||||
|
&["scope=one".into(), "fix".into(), "the".into(), "bug".into()],
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["scope"], "one");
|
||||||
|
assert_eq!(result["text"], "fix the bug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_assignment_prefix_ends_at_first_positional() {
|
||||||
|
let m = macro_with_vars(vec![var("a", false, None), var("b", true, Some(""))]);
|
||||||
|
|
||||||
|
let result = m
|
||||||
|
.resolve_variables(&["value".into(), "a=literal".into()], "")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["a"], "value");
|
||||||
|
assert_eq!(result["b"], "a=literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_trailing_text_is_never_scanned_for_assignments() {
|
||||||
|
let m = macro_with_vars(vec![var("text", true, None)]);
|
||||||
|
|
||||||
|
let result = m
|
||||||
|
.resolve_variables(&[], "text=looks like an assignment")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["text"], "text=looks like an assignment");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_rejects_unknown_and_duplicate_assignments() {
|
||||||
|
let m = macro_with_vars(vec![var("scope", false, None)]);
|
||||||
|
|
||||||
|
let err = m.resolve_variables(&["scpe=all".into()], "").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Unknown variable 'scpe'"));
|
||||||
|
assert!(err.to_string().contains("scope"));
|
||||||
|
|
||||||
|
let err = m
|
||||||
|
.resolve_variables(&["scope=a".into(), "scope=b".into()], "")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("assigned more than once"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_non_identifier_equals_tokens_are_positional() {
|
||||||
|
let m = macro_with_vars(vec![var("a", false, None)]);
|
||||||
|
|
||||||
|
let result = m.resolve_variables(&["path/x=1".into()], "").unwrap();
|
||||||
|
assert_eq!(result["a"], "path/x=1");
|
||||||
|
|
||||||
|
let result = m.resolve_variables(&["1x=2".into()], "").unwrap();
|
||||||
|
assert_eq!(result["a"], "1x=2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_assignment_requires_an_identifier_shaped_key() {
|
||||||
|
assert_eq!(parse_assignment("scope=all"), Some(("scope", "all")));
|
||||||
|
assert_eq!(parse_assignment("_x-1=v"), Some(("_x-1", "v")));
|
||||||
|
assert_eq!(parse_assignment("k="), Some(("k", "")));
|
||||||
|
assert_eq!(parse_assignment("noequals"), None);
|
||||||
|
assert_eq!(parse_assignment("=v"), None);
|
||||||
|
assert_eq!(parse_assignment("1a=v"), None);
|
||||||
|
assert_eq!(parse_assignment("a/b=v"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variable_completions_list_unassigned_variables_with_hints() {
|
||||||
|
let mut m = macro_with_vars(vec![
|
||||||
|
var("scope", false, Some("all")),
|
||||||
|
var("text", true, None),
|
||||||
|
]);
|
||||||
|
m.variables[0].description = Some("What to review".to_string());
|
||||||
|
|
||||||
|
let values = m.variable_completions(&[]);
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"scope=".to_string(),
|
||||||
|
Some("What to review (default: all)".to_string())
|
||||||
|
),
|
||||||
|
("text=".to_string(), Some("(required)".to_string())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let values = m.variable_completions(&["scope=one"]);
|
||||||
|
assert_eq!(
|
||||||
|
values,
|
||||||
|
vec![("text=".to_string(), Some("(required)".to_string()))]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(m.variable_completions(&["positional"]).is_empty());
|
||||||
|
assert!(
|
||||||
|
m.variable_completions(&["scope=one", "free", "text=x"])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macro_variable_description_parses_from_yaml_and_defaults_to_none() {
|
||||||
|
let parsed: Macro = serde_yaml::from_str(
|
||||||
|
"variables:\n - name: scope\n description: What to review\n default: all\n - name: text\n rest: true\nsteps:\n - .file {{scope}} -- {{text}}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed.variables[0].description.as_deref(),
|
||||||
|
Some("What to review")
|
||||||
|
);
|
||||||
|
assert!(parsed.variables[1].description.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn usage_no_variables() {
|
fn usage_no_variables() {
|
||||||
let m = macro_with_vars(vec![]);
|
let m = macro_with_vars(vec![]);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};
|
|||||||
|
|
||||||
use super::install_remote::DEFAULT_GIT_HOST;
|
use super::install_remote::DEFAULT_GIT_HOST;
|
||||||
use super::instructions;
|
use super::instructions;
|
||||||
|
use super::macros::Macro;
|
||||||
use super::memory::{
|
use super::memory::{
|
||||||
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
|
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
|
||||||
};
|
};
|
||||||
@@ -2518,6 +2519,27 @@ impl RequestContext {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn macro_variable_completions(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
completed_args: &[&str],
|
||||||
|
) -> Vec<(String, Option<String>)> {
|
||||||
|
let Some(name) = cmd.strip_prefix('.') else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
if !self
|
||||||
|
.visible_macro_completions()
|
||||||
|
.iter()
|
||||||
|
.any(|(macro_name, _)| macro_name == name)
|
||||||
|
{
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
match Macro::load(name, self.app.config.no_workspace_macros) {
|
||||||
|
Ok(macro_value) => macro_value.variable_completions(completed_args),
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn macro_lock_owner(&self, level: MacroAllowlistLevel) -> String {
|
pub fn macro_lock_owner(&self, level: MacroAllowlistLevel) -> String {
|
||||||
let name = match level {
|
let name = match level {
|
||||||
MacroAllowlistLevel::Session => self.session.as_ref().map(|s| s.name()),
|
MacroAllowlistLevel::Session => self.session.as_ref().map(|s| s.name()),
|
||||||
@@ -3378,7 +3400,7 @@ impl RequestContext {
|
|||||||
.map(|v| (format!("{v} "), None))
|
.map(|v| (format!("{v} "), None))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => self.macro_variable_completions(cmd, &[]),
|
||||||
};
|
};
|
||||||
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
|
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
|
||||||
if let Some(mcp_config) = &self.app.mcp_config {
|
if let Some(mcp_config) = &self.app.mcp_config {
|
||||||
@@ -3505,6 +3527,14 @@ impl RequestContext {
|
|||||||
})
|
})
|
||||||
.map(|row| (row.name, row.description))
|
.map(|row| (row.name, row.description))
|
||||||
.collect();
|
.collect();
|
||||||
|
} else if cmd == ".macro"
|
||||||
|
&& args.len() >= 2
|
||||||
|
&& args.first() != Some(&"enable")
|
||||||
|
&& args.first() != Some(&"disable")
|
||||||
|
{
|
||||||
|
if let Ok(macro_value) = Macro::load(args[0], app.no_workspace_macros) {
|
||||||
|
values = macro_value.variable_completions(&args[1..args.len() - 1]);
|
||||||
|
}
|
||||||
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
||||||
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
||||||
{
|
{
|
||||||
@@ -3676,6 +3706,8 @@ impl RequestContext {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
values.extend(super::complete_agent_variables(args[0]));
|
values.extend(super::complete_agent_variables(args[0]));
|
||||||
|
} else if args.len() >= 2 {
|
||||||
|
values = self.macro_variable_completions(cmd, &args[..args.len() - 1]);
|
||||||
};
|
};
|
||||||
fuzzy_filter(values, |v| v.0.as_str(), filter)
|
fuzzy_filter(values, |v| v.0.as_str(), filter)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user