From 4b1d9e70c5a41e6ec4e307860197571927eda2db Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 2 Apr 2026 21:12:47 +1100 Subject: [PATCH] fix: add new project includes to the bottom instead of the top (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add new project includes to the bottom instead of the top Previously, `buildEditorText` placed `include:` lines at the top of the editor text, above the prompt. This meant clicking "Add directory" would insert the new include above any text the user had already typed. Now includes are placed at the bottom of the editor as a trailing block, and `parseEditorText` collects them from the bottom instead of the top. This feels more natural — the user's prompt text stays at the top and directory includes accumulate below it. No migration needed: workingDirs and prompt are stored separately in project.json, and `buildEditorText` reconstructs the editor text in the new layout when the dialog opens. * fix: parse project includes anywhere in prompt editor --- .../projects/lib/projectPromptText.test.ts | 44 +++++++++++++------ .../projects/lib/projectPromptText.ts | 27 ++++++------ 2 files changed, 45 insertions(+), 26 deletions(-) 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(