Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
77 changes: 61 additions & 16 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/command/hints.test.ts
Original file line number Diff line number Diff line change
@@ -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:}"])
})
})
123 changes: 123 additions & 0 deletions packages/opencode/test/session/prompt-substitute.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})