diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 739a7353..95525d0c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed prompt template argument parsing to split unquoted multiline input on newlines ([#4553](https://github.com/earendil-works/pi/issues/4553)). - Fixed `--resume` session listing to cap in-flight session metadata loads and avoid OOM on large session histories ([#4583](https://github.com/earendil-works/pi/issues/4583)). - Fixed interactive error messages to render with trailing spacing so reload errors do not run into resource listings ([#4510](https://github.com/earendil-works/pi/issues/4510)). - Fixed nested code fences in the Termux setup documentation so the example AGENTS.md renders correctly ([#4503](https://github.com/earendil-works/pi/issues/4503)). diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 8527f911..45e779a1 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -37,7 +37,7 @@ export function parseCommandArgs(argsString: string): string[] { } } else if (char === '"' || char === "'") { inQuote = char; - } else if (char === " " || char === "\t") { + } else if (/\s/.test(char)) { if (current) { args.push(current); current = ""; @@ -282,9 +282,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { if (!text.startsWith("/")) return text; - const spaceIndex = text.indexOf(" "); - const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match) return text; + + const templateName = match[1]; + const argsString = match[2] ?? ""; const template = templates.find((t) => t.name === templateName); if (template) { diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 67be0da4..10cecaca 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -13,7 +13,12 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterAll, describe, expect, test } from "vitest"; import { getAgentDir } from "../src/config.js"; -import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js"; +import { + expandPromptTemplate, + loadPromptTemplates, + parseCommandArgs, + substituteArgs, +} from "../src/core/prompt-templates.js"; // ============================================================================ // substituteArgs @@ -335,10 +340,25 @@ describe("parseCommandArgs", () => { expect(parseCommandArgs("日本語 🎉 café")).toEqual(["日本語", "🎉", "café"]); }); - test("should handle newlines in arguments", () => { + test("should handle newlines in quoted arguments", () => { expect(parseCommandArgs('"line1\nline2" second')).toEqual(["line1\nline2", "second"]); }); + test("should treat unquoted newlines as separators", () => { + expect(parseCommandArgs("label-2\n\nHere is some description #2.")).toEqual([ + "label-2", + "Here", + "is", + "some", + "description", + "#2.", + ]); + }); + + test("should collapse mixed unquoted whitespace", () => { + expect(parseCommandArgs("a\n\n\tb c")).toEqual(["a", "b", "c"]); + }); + test("should handle escaped quotes inside quoted strings", () => { // Note: This implementation doesn't handle escaped quotes - backslash is literal expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]); @@ -357,6 +377,40 @@ describe("parseCommandArgs", () => { // Integration // ============================================================================ +describe("expandPromptTemplate", () => { + test("should split template arguments on unquoted newlines", () => { + const result = expandPromptTemplate("/arg-test label-2\n\nHere is some description #2.", [ + { + name: "arg-test", + description: "test", + content: `- arg1: $1\n- rest: \${@:2}`, + sourceInfo: { path: "/tmp/arg-test.md", source: "local", scope: "temporary", origin: "top-level" }, + filePath: "/tmp/arg-test.md", + }, + ]); + + expect(result).toBe("- arg1: label-2\n- rest: Here is some description #2."); + }); + + test("should support template command separated from args by newline", () => { + const result = expandPromptTemplate("/arg-test\nlabel-2", [ + { + name: "arg-test", + description: "test", + content: "arg1: $1", + sourceInfo: { path: "/tmp/arg-test.md", source: "local", scope: "temporary", origin: "top-level" }, + filePath: "/tmp/arg-test.md", + }, + ]); + + expect(result).toBe("arg1: label-2"); + }); +}); + +// ============================================================================ +// Integration +// ============================================================================ + describe("parseCommandArgs + substituteArgs integration", () => { test("should parse and substitute together correctly", () => { const input = 'Button "onClick handler" "disabled support"';