From ca077d6b9694f811367da252a3c77f9c67bd0cf8 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 16 Feb 2026 11:02:54 -0500 Subject: [PATCH] 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") +})