From ca077d6b9694f811367da252a3c77f9c67bd0cf8 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 16 Feb 2026 11:02:54 -0500 Subject: [PATCH 1/2] refactor: extract substituteArguments to shared module Extracts argument substitution logic from session/prompt.ts to config/substitute.ts for reuse by other features like the expand command. Handles: - $1, $2, etc. positional arguments (last one swallows remaining) - $ARGUMENTS placeholder (all args joined) --- packages/opencode/src/config/substitute.ts | 27 ++++++++++++++ packages/opencode/src/session/prompt.ts | 20 ++--------- .../opencode/test/config/substitute.test.ts | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/config/substitute.ts create mode 100644 packages/opencode/test/config/substitute.test.ts diff --git a/packages/opencode/src/config/substitute.ts b/packages/opencode/src/config/substitute.ts new file mode 100644 index 00000000000..dc736c39fcc --- /dev/null +++ b/packages/opencode/src/config/substitute.ts @@ -0,0 +1,27 @@ +const placeholderRegex = /\$(\d+)/g + +export function substituteArguments( + template: string, + args: string[], +): { result: string; hasPlaceholders: boolean } { + const placeholders = template.match(placeholderRegex) ?? [] + let last = 0 + for (const item of placeholders) { + const value = Number(item.slice(1)) + if (value > last) last = value + } + + const hasPlaceholders = placeholders.length > 0 + + let result = template.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + + result = result.replaceAll("$ARGUMENTS", args.join(" ")) + + return { result, hasPlaceholders } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f705f209aa9..5c74e35ba7c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -33,6 +33,7 @@ import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath, pathToFileURL } from "bun" import { ConfigMarkdown } from "../config/markdown" +import { substituteArguments } from "../config/substitute" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" @@ -1733,7 +1734,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi - const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g /** * Regular expression to match @ file references in text @@ -1751,27 +1751,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the const templateCommand = await command.template - const placeholders = templateCommand.match(placeholderRegex) ?? [] - let last = 0 - for (const item of placeholders) { - const value = Number(item.slice(1)) - if (value > last) last = value - } - - // Let the final placeholder swallow any extra arguments so prompts read naturally - const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) + const { result: withArgs, hasPlaceholders } = substituteArguments(templateCommand, args) const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) // If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders) // but user provided arguments, append them to the template - if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { + if (!hasPlaceholders && !usesArgumentsPlaceholder && input.arguments.trim()) { template = template + "\n\n" + input.arguments } diff --git a/packages/opencode/test/config/substitute.test.ts b/packages/opencode/test/config/substitute.test.ts new file mode 100644 index 00000000000..fc462952a76 --- /dev/null +++ b/packages/opencode/test/config/substitute.test.ts @@ -0,0 +1,35 @@ +import { test, expect } from "bun:test" +import { substituteArguments } from "../../src/config/substitute" + +test("substituteArguments - no placeholders", () => { + const { result, hasPlaceholders } = substituteArguments("hello world", ["a", "b"]) + expect(result).toBe("hello world") + expect(hasPlaceholders).toBe(false) +}) + +test("substituteArguments - single placeholder", () => { + const { result, hasPlaceholders } = substituteArguments("hello $1", ["world"]) + expect(result).toBe("hello world") + expect(hasPlaceholders).toBe(true) +}) + +test("substituteArguments - multiple placeholders", () => { + const { result, hasPlaceholders } = substituteArguments("$1 and $2", ["first", "second"]) + expect(result).toBe("first and second") + expect(hasPlaceholders).toBe(true) +}) + +test("substituteArguments - last placeholder swallows remaining", () => { + const { result } = substituteArguments("$1 $2", ["a", "b", "c", "d"]) + expect(result).toBe("a b c d") +}) + +test("substituteArguments - missing argument returns empty", () => { + const { result } = substituteArguments("$1 and $3", ["only", "two"]) + expect(result).toBe("only and ") +}) + +test("substituteArguments - $ARGUMENTS replaced", () => { + const { result } = substituteArguments("args: $ARGUMENTS", ["a", "b", "c"]) + expect(result).toBe("args: a b c") +}) From 966d75df9ff615babc22f43571a99808b7eb881b Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 22 Feb 2026 23:12:28 -0500 Subject: [PATCH 2/2] feat: add default argument values in slash commands Supports syntax like ${2:foobar} where the default value is used if the argument is missing, empty, or whitespace-only. --- packages/opencode/src/config/substitute.ts | 79 ++++++++- .../opencode/test/config/substitute.test.ts | 150 ++++++++++++++++++ 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/substitute.ts b/packages/opencode/src/config/substitute.ts index dc736c39fcc..a98166b804f 100644 --- a/packages/opencode/src/config/substitute.ts +++ b/packages/opencode/src/config/substitute.ts @@ -1,19 +1,92 @@ const placeholderRegex = /\$(\d+)/g +const defaultPlaceholderRegex = /\$\{(\d+):([^}]*)\}/g +const rangeWithDefaultRegex = /\$\{(\d*)(\.\.\d*):([^}]*)\}/g + +// AGENTS: When merging with feat/argument-range-syntax, maintain this processing order: +// AGENTS: 1. rangeWithDefaultRegex (most specific - requires .. and :) +// AGENTS: 2. defaultPlaceholderRegex (has : but no ..) +// AGENTS: 3. extendedPlaceholderRegex from argument-range-syntax (has .. but no :) +// AGENTS: 4. placeholderRegex ($N syntax) +// AGENTS: These regexes are mutually exclusive by syntax, so order doesn't affect correctness, +// AGENTS: but processing most-specific-first improves clarity and maintainability. +// AGENTS: Insert extendedPlaceholderRegex processing between defaultPlaceholderRegex and placeholderRegex. + +function resolveChainedDefault(expr: string, defaultVal: string, args: string[]): string { + const parts = defaultVal.split(":") + for (const part of parts) { + if (part === "") return expr + const match = part.match(/^\$(\d+)$/) + if (match) { + const argIndex = Number(match[1]) - 1 + if (argIndex < args.length) { + const arg = args[argIndex] + if (arg.trim() !== "") return arg + } + } else { + return part + } + } + return "" +} export function substituteArguments( template: string, args: string[], ): { result: string; hasPlaceholders: boolean } { const placeholders = template.match(placeholderRegex) ?? [] + const defaultPlaceholders = template.match(defaultPlaceholderRegex) ?? [] + const rangeWithDefaultPlaceholders = template.match(rangeWithDefaultRegex) ?? [] + let last = 0 for (const item of placeholders) { const value = Number(item.slice(1)) if (value > last) last = value } + for (const item of defaultPlaceholders) { + const match = item.match(/\$\{(\d+):/) + if (match) { + const value = Number(match[1]) + if (value > last) last = value + } + } + for (const item of rangeWithDefaultPlaceholders) { + const match = item.match(/\$\{(\d*)\.\./) + if (match && match[1]) { + const value = Number(match[1]) + if (value > last) last = value + } + } - const hasPlaceholders = placeholders.length > 0 - - let result = template.replaceAll(placeholderRegex, (_, index) => { + const hasPlaceholders = placeholders.length > 0 || defaultPlaceholders.length > 0 || rangeWithDefaultPlaceholders.length > 0 + + let result = template.replaceAll(rangeWithDefaultRegex, (expr, start, dotsAndEnd, defaultVal) => { + const startIndex = start ? Number(start) : 1 + const endIndex = dotsAndEnd && dotsAndEnd.length > 2 ? Number(dotsAndEnd.slice(2)) : undefined + const argStart = startIndex - 1 + if (argStart < args.length) { + const slice = endIndex !== undefined + ? args.slice(argStart, endIndex) + : args.slice(argStart) + const nonEmpty = slice.filter(arg => arg.trim() !== "") + if (nonEmpty.length > 0) return nonEmpty.join(" ") + } + return resolveChainedDefault(expr, defaultVal, args) + }) + + result = result.replaceAll(defaultPlaceholderRegex, (expr, position, defaultVal) => { + const pos = Number(position) + const argIndex = pos - 1 + if (argIndex < args.length) { + const arg = args[argIndex] + if (arg.trim() !== "") { + if (pos === last) return args.slice(argIndex).join(" ") + return arg + } + } + return resolveChainedDefault(expr, defaultVal, args) + }) + + result = result.replaceAll(placeholderRegex, (_, index) => { const position = Number(index) const argIndex = position - 1 if (argIndex >= args.length) return "" diff --git a/packages/opencode/test/config/substitute.test.ts b/packages/opencode/test/config/substitute.test.ts index fc462952a76..953cc2d2f41 100644 --- a/packages/opencode/test/config/substitute.test.ts +++ b/packages/opencode/test/config/substitute.test.ts @@ -33,3 +33,153 @@ test("substituteArguments - $ARGUMENTS replaced", () => { const { result } = substituteArguments("args: $ARGUMENTS", ["a", "b", "c"]) expect(result).toBe("args: a b c") }) + +test("substituteArguments - ${1:default} with provided arg", () => { + const { result } = substituteArguments("${1:fallback}", ["provided"]) + expect(result).toBe("provided") +}) + +test("substituteArguments - ${1:default} with empty arg", () => { + const { result } = substituteArguments("${1:fallback}", [""]) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${1:default} with whitespace arg", () => { + const { result } = substituteArguments("${1:fallback}", [" "]) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${1:default} with missing arg", () => { + const { result } = substituteArguments("${2:fallback}", ["only-one"]) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${1:multi word default}", () => { + const { result } = substituteArguments("${1:foo bar baz}", []) + expect(result).toBe("foo bar baz") +}) + +test("substituteArguments - mix of $1 and ${2:default}", () => { + const { result } = substituteArguments("$1 and ${2:fallback}", ["first"]) + expect(result).toBe("first and fallback") +}) + +test("substituteArguments - ${2:default} last swallows remaining", () => { + const { result } = substituteArguments("${1:first} ${2:second}", ["a", "b", "c"]) + expect(result).toBe("a b c") +}) + +test("substituteArguments - ${N:default} hasPlaceholders is true", () => { + const { hasPlaceholders } = substituteArguments("${1:default}", []) + expect(hasPlaceholders).toBe(true) +}) + +test("substituteArguments - ${3:$2} with arg3 missing uses arg2", () => { + const { result } = substituteArguments("${3:$2}", ["a", "b"]) + expect(result).toBe("b") +}) + +test("substituteArguments - ${3:$2} with arg3 present uses arg3", () => { + const { result } = substituteArguments("${3:$2}", ["a", "b", "c"]) + expect(result).toBe("c") +}) + +test("substituteArguments - ${2:$1} fallback chain", () => { + const { result } = substituteArguments("${2:$1}", ["only-first"]) + expect(result).toBe("only-first") +}) + +test("substituteArguments - ${1..:default} with args", () => { + const { result } = substituteArguments("${1..:fallback}", ["a", "b", "c"]) + expect(result).toBe("a b c") +}) + +test("substituteArguments - ${1..:default} without args uses default", () => { + const { result } = substituteArguments("${1..:fallback}", []) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${2..3:default} with args", () => { + const { result } = substituteArguments("${2..3:fallback}", ["a", "b", "c", "d"]) + expect(result).toBe("b c") +}) + +test("substituteArguments - ${2..3:default} without args uses default", () => { + const { result } = substituteArguments("${2..3:fallback}", ["a"]) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${..:default} captures all from start", () => { + const { result } = substituteArguments("${..:fallback}", ["a", "b"]) + expect(result).toBe("a b") +}) + +test("substituteArguments - ${..:default} without args uses default", () => { + const { result } = substituteArguments("${..:fallback}", []) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${..3:default} with args", () => { + const { result } = substituteArguments("${..3:fallback}", ["a", "b", "c", "d"]) + expect(result).toBe("a b c") +}) + +test("substituteArguments - ${..3:default} without args uses default", () => { + const { result } = substituteArguments("${..3:fallback}", []) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${1..:multi word default}", () => { + const { result } = substituteArguments("${1..:foo bar baz}", []) + expect(result).toBe("foo bar baz") +}) + +test("substituteArguments - ${N..:default} hasPlaceholders is true", () => { + const { hasPlaceholders } = substituteArguments("${1..:default}", []) + expect(hasPlaceholders).toBe(true) +}) + +test("substituteArguments - ${2:$1:fallback} uses arg2", () => { + const { result } = substituteArguments("${2:$1:fallback}", ["a", "b"]) + expect(result).toBe("b") +}) + +test("substituteArguments - ${2:$1:fallback} falls back to arg1", () => { + const { result } = substituteArguments("${2:$1:fallback}", ["a", ""]) + expect(result).toBe("a") +}) + +test("substituteArguments - ${2:$1:fallback} falls back to literal", () => { + const { result } = substituteArguments("${2:$1:fallback}", ["", ""]) + expect(result).toBe("fallback") +}) + +test("substituteArguments - ${3:$2:$1:final} chained fallback", () => { + const { result } = substituteArguments("${3:$2:$1:final}", ["first"]) + expect(result).toBe("first") +}) + +test("substituteArguments - ${3:$2:$1:final} uses final", () => { + const { result } = substituteArguments("${3:$2:$1:final}", []) + expect(result).toBe("final") +}) + +test("substituteArguments - ${1::fallback} invalid returns unchanged", () => { + const { result } = substituteArguments("${1::fallback}", []) + expect(result).toBe("${1::fallback}") +}) + +test("substituteArguments - ${1:$2:} invalid returns with empty between colons", () => { + const { result } = substituteArguments("${1:$2:}", ["", ""]) + expect(result).toBe("${1::}") +}) + +test("substituteArguments - ${1..:$2:fallback} range with chained fallback", () => { + const { result } = substituteArguments("${1..:$2:fallback}", ["", "b"]) + expect(result).toBe("b") +}) + +test("substituteArguments - ${1..:$2:fallback} range uses final fallback", () => { + const { result } = substituteArguments("${1..:$2:fallback}", []) + expect(result).toBe("fallback") +})