diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dce7ac8bbc3..ae37cd5da11 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -47,6 +47,12 @@ export namespace Command { if (numbered) { for (const match of [...new Set(numbered)].sort()) result.push(match) } + const extended = template.match(/\$\{[^}]+\}/g) + if (extended) { + for (const match of [...new Set(extended)].sort()) { + if (!result.includes(match)) result.push(match) + } + } if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") return result } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3eec9ed4a00..95daac526e3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1615,7 +1615,62 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Match [Image N] as single token, quoted strings, or non-space sequences const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g + // Matches: ${N}, ${N:M}, ${:M}, ${N:}, ${:} + // Group 1: start index (optional), Group 2: colon+end (e.g., ":3" or ":" or undefined) + const extendedPlaceholderRegex = /\$\{(\d*)(:\d*)?\}/g const quoteTrimRegex = /^["']|["']$/g + + export function substituteArguments( + template: string, + args: string[], + ): { result: string; hasPlaceholders: boolean } { + // Find all placeholders ($N and ${...}) + const simplePlaceholders = template.match(placeholderRegex) ?? [] + const extendedPlaceholders = template.match(extendedPlaceholderRegex) ?? [] + + // Only $N placeholders have swallowing behavior - find the last one + let lastSimple = 0 + for (const item of simplePlaceholders) { + const value = Number(item.slice(1)) + if (value > lastSimple) lastSimple = value + } + + // Process extended placeholders ${...} first, then simple $N placeholders + // ${N} syntax NEVER swallows - use ${N:} for open-ended slice + let withArgs = template.replaceAll(extendedPlaceholderRegex, (_, start, colonAndEnd) => { + const startIndex = start ? Number(start) : 1 + // colonAndEnd is either undefined (for ${N}), ":" (for ${N:}), ":3" (for ${N:3} or ${:3}) + const hasColon = colonAndEnd !== undefined + const endIndex = hasColon + ? colonAndEnd.length > 1 + ? Number(colonAndEnd.slice(1)) + : undefined + : undefined + const argStart = startIndex - 1 + if (argStart >= args.length) return "" + // ${N} without colon: single argument only + // ${N:} with colon but no end: slice to end (open-ended) + // ${N:M} with both: slice from N to M + const actualEndIndex = hasColon ? endIndex : startIndex + const slice = args.slice(argStart, actualEndIndex) + const nonEmpty = slice.filter((arg) => arg.trim() !== "") + return nonEmpty.join(" ") + }) + + // Process simple $N placeholders - these DO have swallowing behavior for the last one + withArgs = withArgs.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === lastSimple) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + + const hasPlaceholders = + simplePlaceholders.length > 0 || extendedPlaceholders.length > 0 + + return { result: withArgs, hasPlaceholders } + } /** * Regular expression to match @ file references in text * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks @@ -1632,27 +1687,17 @@ 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 - } + const { result: withArgs, hasPlaceholders } = substituteArguments( + templateCommand, + args, + ) - // 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 usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - // If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders) + // 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/command/hints.test.ts b/packages/opencode/test/command/hints.test.ts new file mode 100644 index 00000000000..65df7c49b4c --- /dev/null +++ b/packages/opencode/test/command/hints.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { Command } from "../../src/command" + +describe("Command.hints", () => { + test("should extract $N placeholders", () => { + const template = "Hello $1 and $2" + const result = Command.hints(template) + expect(result).toEqual(["$1", "$2"]) + }) + + test("should extract ${...} placeholders", () => { + const template = "Hello ${1} and ${2:3}" + const result = Command.hints(template) + expect(result).toEqual(["${1}", "${2:3}"]) + }) + + test("should extract $ARGUMENTS placeholder", () => { + const template = "Hello $ARGUMENTS" + const result = Command.hints(template) + expect(result).toEqual(["$ARGUMENTS"]) + }) + + test("should extract mixed placeholders", () => { + const template = "Hello $1 and ${2:3} and $ARGUMENTS" + const result = Command.hints(template) + expect(result).toEqual(["$1", "${2:3}", "$ARGUMENTS"]) + }) + + test("should deduplicate placeholders", () => { + const template = "Hello $1 and $1 again" + const result = Command.hints(template) + expect(result).toEqual(["$1"]) + }) + + test("should handle ${:} syntax", () => { + const template = "All args: ${:}" + const result = Command.hints(template) + expect(result).toEqual(["${:}"]) + }) + + test("should handle ${:3} syntax", () => { + const template = "First three: ${:3}" + const result = Command.hints(template) + expect(result).toEqual(["${:3}"]) + }) + + test("should handle ${2:} syntax", () => { + const template = "From second: ${2:}" + const result = Command.hints(template) + expect(result).toEqual(["${2:}"]) + }) +}) diff --git a/packages/opencode/test/session/prompt-substitute.test.ts b/packages/opencode/test/session/prompt-substitute.test.ts new file mode 100644 index 00000000000..679f56ab43b --- /dev/null +++ b/packages/opencode/test/session/prompt-substitute.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { SessionPrompt } from "../../src/session/prompt" + +describe("SessionPrompt.substituteArguments", () => { + test("${1} should return single argument (no swallowing)", () => { + const result = SessionPrompt.substituteArguments("Hello ${1}", ["foo", "bar", "baz"]) + expect(result.result).toBe("Hello foo") + expect(result.hasPlaceholders).toBe(true) + }) + + test("${2} should return single argument (no swallowing)", () => { + const result = SessionPrompt.substituteArguments("Hello ${2}", ["foo", "bar", "baz"]) + expect(result.result).toBe("Hello bar") + }) + + test("${2:3} should return 2nd and 3rd arguments joined by space", () => { + const result = SessionPrompt.substituteArguments("Hello ${2:3}", ["foo", "bar", "baz", "qux"]) + expect(result.result).toBe("Hello bar baz") + }) + + test("${:3} should return 1st through 3rd arguments joined by space", () => { + const result = SessionPrompt.substituteArguments("Hello ${:3}", ["foo", "bar", "baz", "qux"]) + expect(result.result).toBe("Hello foo bar baz") + }) + + test("${2:} should return 2nd through last arguments joined by space", () => { + const result = SessionPrompt.substituteArguments("Hello ${2:}", ["foo", "bar", "baz", "qux"]) + expect(result.result).toBe("Hello bar baz qux") + }) + + test("${:} should return all arguments joined by space", () => { + const result = SessionPrompt.substituteArguments("Hello ${:}", ["foo", "bar", "baz", "qux"]) + expect(result.result).toBe("Hello foo bar baz qux") + }) + + test("should skip empty arguments when joining slices", () => { + const result = SessionPrompt.substituteArguments("Hello ${:}", ["foo", "", "bar", "", "baz"]) + expect(result.result).toBe("Hello foo bar baz") + }) + + test("$1 should swallow remaining args (backward compatibility)", () => { + const result = SessionPrompt.substituteArguments("Hello $1", ["foo", "bar", "baz"]) + expect(result.result).toBe("Hello foo bar baz") + }) + + test("$2 should swallow remaining args (backward compatibility)", () => { + const result = SessionPrompt.substituteArguments("Hello $2", ["foo", "bar", "baz"]) + expect(result.result).toBe("Hello bar baz") + }) + + test("${1} should NOT swallow - different from $1", () => { + const extended = SessionPrompt.substituteArguments("Hello ${1}", ["foo", "bar"]) + const simple = SessionPrompt.substituteArguments("Hello $1", ["foo", "bar"]) + expect(extended.result).toBe("Hello foo") + expect(simple.result).toBe("Hello foo bar") + expect(extended.result).not.toBe(simple.result) + }) + + test("mixed syntax should work together", () => { + const result = SessionPrompt.substituteArguments("First: ${1}, Second: $2, Rest: ${3:}", [ + "a", + "b", + "c", + "d", + ]) + expect(result.result).toBe("First: a, Second: b c d, Rest: c d") + }) + + test("out of bounds should return empty string", () => { + const result = SessionPrompt.substituteArguments("Hello ${5}", ["foo", "bar"]) + expect(result.result).toBe("Hello ") + }) + + test("empty input should return empty for all placeholders", () => { + const result = SessionPrompt.substituteArguments("Hello ${1} and ${2:}", []) + expect(result.result).toBe("Hello and ") + }) + + test("$N syntax: last placeholder should swallow remaining arguments", () => { + const result = SessionPrompt.substituteArguments("First: $1, Rest: $2", ["a", "b", "c", "d"]) + expect(result.result).toBe("First: a, Rest: b c d") + }) + + test("${N:} syntax: open end should include remaining arguments", () => { + const result = SessionPrompt.substituteArguments("First: ${1}, Rest: ${2:}", ["a", "b", "c", "d"]) + expect(result.result).toBe("First: a, Rest: b c d") + }) + + test("${2:3} with insufficient args should return what is available", () => { + const result = SessionPrompt.substituteArguments("Hello ${2:3}", ["foo"]) + expect(result.result).toBe("Hello ") + }) + + test("${:3} with insufficient args should return what is available", () => { + const result = SessionPrompt.substituteArguments("Hello ${:3}", ["foo", "bar"]) + expect(result.result).toBe("Hello foo bar") + }) + + test("${2:} with single arg should return empty", () => { + const result = SessionPrompt.substituteArguments("Hello ${2:}", ["foo"]) + expect(result.result).toBe("Hello ") + }) + + test("should handle whitespace-only args as empty", () => { + const result = SessionPrompt.substituteArguments("Hello ${:}", ["foo", " ", "bar"]) + expect(result.result).toBe("Hello foo bar") + }) + + test("hasPlaceholders should be false when no placeholders", () => { + const result = SessionPrompt.substituteArguments("Hello world", ["foo", "bar"]) + expect(result.hasPlaceholders).toBe(false) + }) + + test("hasPlaceholders should be true with $N syntax", () => { + const result = SessionPrompt.substituteArguments("Hello $1", ["foo"]) + expect(result.hasPlaceholders).toBe(true) + }) + + test("hasPlaceholders should be true with ${...} syntax", () => { + const result = SessionPrompt.substituteArguments("Hello ${1}", ["foo"]) + expect(result.hasPlaceholders).toBe(true) + }) +})