Skip to content
27 changes: 27 additions & 0 deletions packages/opencode/src/config/substitute.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
20 changes: 3 additions & 17 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/test/config/substitute.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})