diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..701630e9367 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -42,11 +42,13 @@ export namespace Command { export function hints(template: string): string[] { const result: string[] = [] - const numbered = template.match(/\$\d+/g) + // Remove escaped placeholders before detection + const processed = template.replaceAll("\\$", "\x00") + const numbered = processed.match(/\$\d+/g) if (numbered) { for (const match of [...new Set(numbered)].sort()) result.push(match) } - if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") + if (processed.includes("$ARGUMENTS")) result.push("$ARGUMENTS") return result } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 587f9498008..4e950572c4f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1598,6 +1598,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g + + // Marker for escape processing + const DOLLAR_MARKER = "\x00DLR\x00" + + export function processEscapes(template: string): { processed: string; restore: (s: string) => string } { + const processed = template.replaceAll("\\$", DOLLAR_MARKER) + const restore = (s: string) => s.replaceAll(DOLLAR_MARKER, "$") + return { processed, restore } + } /** * Regular expression to match @ file references in text * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks @@ -1614,7 +1623,10 @@ 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) ?? [] + // Process escapes before substitution + const { processed, restore } = processEscapes(templateCommand) + + const placeholders = processed.match(placeholderRegex) ?? [] let last = 0 for (const item of placeholders) { const value = Number(item.slice(1)) @@ -1622,15 +1634,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } // Let the final placeholder swallow any extra arguments so prompts read naturally - const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { + const withArgs = processed.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) + const usesArgumentsPlaceholder = processed.includes("$ARGUMENTS") + let template = restore(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 diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 00000000000..8817c315787 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { SessionPrompt } from "../../src/session/prompt" +import { Command } from "../../src/command" + +describe("session.prompt", () => { + describe("processEscapes", () => { + test("escapes \\$1 to literal $1", () => { + const { processed, restore } = SessionPrompt.processEscapes("\\$1") + expect(restore(processed)).toBe("$1") + }) + + test("escapes \\$ARGUMENTS to literal $ARGUMENTS", () => { + const { processed, restore } = SessionPrompt.processEscapes("\\$ARGUMENTS") + expect(restore(processed)).toBe("$ARGUMENTS") + }) + + test("leaves unescaped $1 intact for substitution", () => { + const { processed, restore } = SessionPrompt.processEscapes("$1") + expect(processed).toBe("$1") + expect(restore(processed)).toBe("$1") + }) + + test("leaves unescaped $ARGUMENTS intact for substitution", () => { + const { processed, restore } = SessionPrompt.processEscapes("$ARGUMENTS") + expect(processed).toBe("$ARGUMENTS") + expect(restore(processed)).toBe("$ARGUMENTS") + }) + + test("mixed escaped and unescaped placeholders", () => { + const { processed, restore } = SessionPrompt.processEscapes("Use \\$1 for first arg: $1") + expect(processed.includes("$1")).toBe(true) + const substituted = processed.replace("$1", "foo") + expect(restore(substituted)).toBe("Use $1 for first arg: foo") + }) + + test("preserves other escape sequences like \\n", () => { + const { processed, restore } = SessionPrompt.processEscapes("\\n stays \\n") + expect(restore(processed)).toBe("\\n stays \\n") + }) + }) +}) + +describe("command.hints", () => { + test("detects unescaped $1 placeholder", () => { + expect(Command.hints("Use $1 here")).toEqual(["$1"]) + }) + + test("detects unescaped $ARGUMENTS placeholder", () => { + expect(Command.hints("Use $ARGUMENTS here")).toEqual(["$ARGUMENTS"]) + }) + + test("detects multiple placeholders", () => { + expect(Command.hints("Use $1 and $2")).toEqual(["$1", "$2"]) + }) + + test("ignores escaped \\$1 placeholder", () => { + expect(Command.hints("Use \\$1 here")).toEqual([]) + }) + + test("ignores escaped \\$ARGUMENTS placeholder", () => { + expect(Command.hints("Use \\$ARGUMENTS here")).toEqual([]) + }) + + test("mixed escaped and unescaped placeholders", () => { + expect(Command.hints("Use \\$1 for docs but $2 for substitution")).toEqual(["$2"]) + }) +}) diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 1d7e4f1c21a..7fe86eb72a3 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -161,6 +161,15 @@ This replaces: --- +### Escaping placeholders + +To include literal `$1`, `$ARGUMENTS`, etc. in your template without substitution, escape the `$` with a backslash: + +- `\$1` → literal `$1` +- `\$ARGUMENTS` → literal `$ARGUMENTS` + +--- + ### Shell output Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into your prompt.