diff --git a/ui/goose2/src/features/projects/lib/projectPromptText.test.ts b/ui/goose2/src/features/projects/lib/projectPromptText.test.ts index a66371fc71..881a13a336 100644 --- a/ui/goose2/src/features/projects/lib/projectPromptText.test.ts +++ b/ui/goose2/src/features/projects/lib/projectPromptText.test.ts @@ -19,38 +19,56 @@ describe("projectPromptText", () => { }); }); - it("keeps blank lines between include lines and prompt content", () => { + it("places includes after the prompt text", () => { + expect(buildEditorText(["/tmp/one"], "My prompt")).toBe( + "My prompt\n\ninclude: /tmp/one", + ); + }); + + it("handles includes-only text (no prompt)", () => { + expect(buildEditorText(["/tmp/one", "/tmp/two"], "")).toBe( + "include: /tmp/one\ninclude: /tmp/two", + ); + }); + + it("collects include lines from anywhere in the editor text", () => { expect( - parseEditorText("include: /tmp/one\n\ninclude: /tmp/two\nprompt"), + parseEditorText("include: /tmp/one\nprompt\n\ninclude: /tmp/two"), ).toEqual({ prompt: "prompt", workingDirs: ["/tmp/one", "/tmp/two"], }); }); - it("keeps include-looking prompt lines after prompt content begins", () => { + it("treats trailing include lines as working directories when saving", () => { expect(parseEditorText("prompt first\ninclude: /tmp/kept")).toEqual({ - prompt: "prompt first\ninclude: /tmp/kept", - workingDirs: [], + prompt: "prompt first", + workingDirs: ["/tmp/kept"], }); }); - it("creates a leading include block for prompt-only text", () => { + it("creates a trailing include block for prompt-only text", () => { expect(insertWorkingDir("Existing prompt", "/tmp/one")).toBe( - "include: /tmp/one\n\nExisting prompt", + "Existing prompt\n\ninclude: /tmp/one", ); }); - it("appends a directory within the leading include block", () => { + it("appends a directory to the trailing include block", () => { expect( - insertWorkingDir("include: /tmp/one\n\nPrompt body", "/tmp/two"), - ).toBe("include: /tmp/one\ninclude: /tmp/two\n\nPrompt body"); + insertWorkingDir("Prompt body\n\ninclude: /tmp/one", "/tmp/two"), + ).toBe("Prompt body\n\ninclude: /tmp/one\ninclude: /tmp/two"); + }); + + it("adds a new directory to the bottom without moving existing prompt text", () => { + expect(insertWorkingDir("include: /tmp/one\nPrompt body", "/tmp/two")).toBe( + "include: /tmp/one\nPrompt body\n\ninclude: /tmp/two", + ); }); it("treats tilde and absolute paths as equivalent when checking duplicates", () => { expect( hasEquivalentWorkingDir( - "include: ~/dev/goose2\n\nPrompt body", + "Prompt body\ninclude: ~/dev/goose2", "/Users/mtoohey/dev/goose2", "/Users/mtoohey", ), @@ -60,7 +78,7 @@ describe("projectPromptText", () => { it("does not match different directories when checking duplicates", () => { expect( hasEquivalentWorkingDir( - "include: ~/dev/goose2\n\nPrompt body", + "Prompt body\ninclude: ~/dev/goose2", "/Users/mtoohey/dev/other", "/Users/mtoohey", ), @@ -70,7 +88,7 @@ describe("projectPromptText", () => { it("treats trailing slashes as equivalent when checking duplicates", () => { expect( hasEquivalentWorkingDir( - "include: /Users/mtoohey/dev/goose2/\n\nPrompt body", + "Prompt body\ninclude: /Users/mtoohey/dev/goose2/", "/Users/mtoohey/dev/goose2", "/Users/mtoohey", ), diff --git a/ui/goose2/src/features/projects/lib/projectPromptText.ts b/ui/goose2/src/features/projects/lib/projectPromptText.ts index 182fe704b0..5c65e83cbb 100644 --- a/ui/goose2/src/features/projects/lib/projectPromptText.ts +++ b/ui/goose2/src/features/projects/lib/projectPromptText.ts @@ -4,12 +4,13 @@ import { INCLUDE_RE } from "./includePattern"; export function buildEditorText(workingDirs: string[], prompt: string): string { const includeLines = workingDirs.map((directory) => `include: ${directory}`); if (includeLines.length === 0) return prompt; - return [...includeLines, "", prompt].join("\n"); + if (prompt === "") return includeLines.join("\n"); + return [prompt, "", ...includeLines].join("\n"); } /** Parse editor text into { prompt, workingDirs }. - * Only `include:` lines at the top of the text (before the first - * non-include, non-blank line) are treated as working directories. */ + * `include:` lines can appear anywhere in the editor text and are + * extracted into workingDirs when the project is saved. */ export function parseEditorText(text: string): { prompt: string; workingDirs: string[]; @@ -17,26 +18,21 @@ export function parseEditorText(text: string): { const lines = text.split("\n"); const workingDirs: string[] = []; const promptLines: string[] = []; - let parsingIncludeBlock = true; for (const line of lines) { const match = line.match(INCLUDE_RE); - if (parsingIncludeBlock && match) { + if (match) { workingDirs.push(match[1].trim()); continue; } - if (line.trim() !== "") { - parsingIncludeBlock = false; - } - promptLines.push(line); } + // Trim leading/trailing blank lines from the prompt. while (promptLines[0]?.trim() === "") { promptLines.shift(); } - while (promptLines[promptLines.length - 1]?.trim() === "") { promptLines.pop(); } @@ -48,12 +44,17 @@ export function parseEditorText(text: string): { } export function insertWorkingDir(text: string, directory: string): string { - if (text === "") { + const trimmedText = text.trimEnd(); + + if (trimmedText === "") { return `include: ${directory}`; } - const { prompt, workingDirs } = parseEditorText(text); - return buildEditorText([...workingDirs, directory], prompt); + const trimmedLines = trimmedText.split("\n"); + const lastLine = trimmedLines[trimmedLines.length - 1] ?? ""; + const separator = lastLine.match(INCLUDE_RE) ? "\n" : "\n\n"; + + return `${trimmedText}${separator}include: ${directory}`; } export function hasEquivalentWorkingDir(