diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7bdf6bcd..f71130db 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5507](https://github.com/earendil-works/pi/issues/5507)). - Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). - Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 00450858..d8990bea 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -64,10 +64,11 @@ Type `/` followed by the template name in the editor. Autocomplete shows availab ## Arguments -Templates support positional arguments and simple slicing: +Templates support positional arguments, defaults, and simple slicing: - `$1`, `$2`, ... positional args - `$@` or `$ARGUMENTS` for all args joined +- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default` - `${@:N}` for args from the Nth position (1-indexed) - `${@:N:L}` for `L` args starting at N @@ -80,6 +81,12 @@ description: Create a component Create a React component named $1 with features: $@ ``` +Default values are useful for optional arguments: + +```markdown +Summarize the current state in ${1:-7} bullet points. +``` + Usage: `/component Button "onClick handler" "disabled support"` ## Loading Rules diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 581a34eb..6b5b1e24 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -59,46 +59,45 @@ export function parseCommandArgs(argsString: string): string[] { * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args + * - ${N:-default} for positional arg N with default when missing/empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * - * Note: Replacement happens on the template string only. Argument values + * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */ export function substituteArgs(content: string, args: string[]): string { - let result = content; - - // Replace $1, $2, etc. with positional args FIRST (before wildcards) - // This prevents wildcard replacement values containing $ patterns from being re-substituted - result = result.replace(/\$(\d+)/g, (_, num) => { - const index = parseInt(num, 10) - 1; - return args[index] ?? ""; - }); - - // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) - // Process BEFORE simple $@ to avoid conflicts - result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { - let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) - // Treat 0 as 1 (bash convention: args start at 1) - if (start < 0) start = 0; - - if (lengthStr) { - const length = parseInt(lengthStr, 10); - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }); - - // Pre-compute all args joined (optimization) const allArgs = args.join(" "); - // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) - result = result.replace(/\$ARGUMENTS/g, allArgs); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultNum) { + const index = parseInt(defaultNum, 10) - 1; + const value = args[index]; + return value ? value : defaultValue; + } - // Replace $@ with all args joined (existing syntax) - result = result.replace(/\$@/g, allArgs); + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; - return result; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); } function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 1e4bcf5c..681a7c62 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -190,6 +190,52 @@ describe("substituteArgs", () => { }); }); +// ============================================================================ +// substituteArgs - Positional Defaults +// ============================================================================ + +describe("substituteArgs - positional defaults", () => { + test("should use default when positional arg is missing", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, [])).toBe("List exactly 7 next steps"); + }); + + test("should use positional arg when present", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, ["3"])).toBe("List exactly 3 next steps"); + }); + + test("should use default when positional arg is empty", () => { + expect(substituteArgs(`Mode: \${1:-brief}`, [""])).toBe("Mode: brief"); + }); + + test("should support multiple positional defaults", () => { + expect(substituteArgs(`\${1:-7} \${2:-brief}`, [])).toBe("7 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3"])).toBe("3 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3", "verbose"])).toBe("3 verbose"); + }); + + test("should not recursively substitute patterns in arg values", () => { + expect(substituteArgs(`\${1:-7}`, ["$ARGUMENTS"])).toBe("$ARGUMENTS"); + expect(substituteArgs(`\${1:-7}`, ["$1"])).toBe("$1"); + }); + + test("should not recursively substitute patterns in default values", () => { + expect(substituteArgs(`\${1:-$ARGUMENTS}`, ["a", "b"])).toBe("a"); + expect(substituteArgs(`\${3:-$ARGUMENTS}`, ["a", "b"])).toBe("$ARGUMENTS"); + }); + + test("should support defaults with spaces", () => { + expect(substituteArgs(`\${1:-seven steps}`, [])).toBe("seven steps"); + }); + + test("should support out-of-range positional defaults", () => { + expect(substituteArgs(`\${3:-fallback}`, ["a", "b"])).toBe("fallback"); + }); + + test("should mix positional defaults with existing placeholders", () => { + expect(substituteArgs(`$1 \${2:-x} $ARGUMENTS`, ["a"])).toBe("a x a"); + }); +}); + // ============================================================================ // substituteArgs - Array Slicing (Bash-Style) // ============================================================================