From e3e148fcf22f39ad1694013dafe8ae3a90491aba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:36:05 +0000 Subject: [PATCH 1/8] Initial plan From c24bf5f6f0704cf49355fd104182a23959b3bf32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:43:21 +0000 Subject: [PATCH 2/8] feat: Add prompt library with local save/delete and project prompt discovery - Add PromptLibrary model (SavedPrompt, PromptSource enum) - Add PromptLibraryService to discover prompts from standard coding agent locations (.github/copilot-prompts/, .github/prompts/, .copilot/prompts/, .claude/prompts/) and manage user-saved prompts (~/.polypilot/prompts/) - Add /prompt slash command (list, use, save, delete) - Add prompts count indicator in ExpandedSessionView status bar with popup - Add 25 unit tests for PromptLibrary model and service Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 2 + PolyPilot.Tests/PromptLibraryTests.cs | 285 ++++++++++++++++++ .../Components/ExpandedSessionView.razor | 61 +++- PolyPilot/Components/Pages/Dashboard.razor | 107 +++++++ PolyPilot/Models/PromptLibrary.cs | 26 ++ PolyPilot/Services/PromptLibraryService.cs | 234 ++++++++++++++ 6 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 PolyPilot.Tests/PromptLibraryTests.cs create mode 100644 PolyPilot/Models/PromptLibrary.cs create mode 100644 PolyPilot/Services/PromptLibraryService.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index b1e48790bc..3c21127246 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -34,6 +34,8 @@ + + diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs new file mode 100644 index 0000000000..71e2fd24fa --- /dev/null +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -0,0 +1,285 @@ +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class PromptLibraryTests : IDisposable +{ + private readonly string _testDir; + + public PromptLibraryTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"PolyPilot-prompt-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + [Fact] + public void SavedPrompt_DefaultValues() + { + var prompt = new SavedPrompt(); + + Assert.Equal("", prompt.Name); + Assert.Equal("", prompt.Content); + Assert.Equal("", prompt.Description); + Assert.Equal(PromptSource.User, prompt.Source); + Assert.Null(prompt.FilePath); + } + + [Fact] + public void SavedPrompt_SourceLabel_User() + { + var prompt = new SavedPrompt { Source = PromptSource.User }; + Assert.Equal("user", prompt.SourceLabel); + } + + [Fact] + public void SavedPrompt_SourceLabel_Project() + { + var prompt = new SavedPrompt { Source = PromptSource.Project }; + Assert.Equal("project", prompt.SourceLabel); + } + + [Fact] + public void ParsePromptFile_PlainMarkdown_UsesFilename() + { + var content = "Fix all the bugs in the codebase."; + var filePath = "/prompts/fix-bugs.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("fix-bugs", name); + Assert.Equal("", description); + Assert.Equal("Fix all the bugs in the codebase.", body); + } + + [Fact] + public void ParsePromptFile_WithFrontmatter() + { + var content = "---\nname: Code Review\ndescription: Review code for best practices\n---\nPlease review the following code..."; + var filePath = "/prompts/review.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Code Review", name); + Assert.Equal("Review code for best practices", description); + Assert.Equal("Please review the following code...", body); + } + + [Fact] + public void ParsePromptFile_FrontmatterNameOnly() + { + var content = "---\nname: Quick Fix\n---\nFix the issue quickly."; + var filePath = "/prompts/quick.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Quick Fix", name); + Assert.Equal("", description); + Assert.Equal("Fix the issue quickly.", body); + } + + [Fact] + public void ParsePromptFile_QuotedValues() + { + var content = "---\nname: \"My Prompt\"\ndescription: 'A helpful prompt'\n---\nDo something."; + var filePath = "/prompts/test.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("My Prompt", name); + Assert.Equal("A helpful prompt", description); + } + + [Fact] + public void ParsePromptFile_NoFrontmatterEnd_UsesFilename() + { + var content = "---\nname: Broken\nThis is not closed"; + var filePath = "/prompts/broken.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("broken", name); + Assert.Equal("", description); + } + + [Fact] + public void ScanPromptDirectory_FindsMdFiles() + { + var promptDir = Path.Combine(_testDir, "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "test1.md"), "---\nname: Test One\ndescription: First test\n---\nContent one"); + File.WriteAllText(Path.Combine(promptDir, "test2.md"), "Plain content without frontmatter"); + File.WriteAllText(Path.Combine(promptDir, "not-a-prompt.txt"), "Should be ignored"); + + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.Project, prompts, seen); + + Assert.Equal(2, prompts.Count); + Assert.Contains(prompts, p => p.Name == "Test One" && p.Description == "First test"); + Assert.Contains(prompts, p => p.Name == "test2" && p.Content == "Plain content without frontmatter"); + } + + [Fact] + public void ScanPromptDirectory_SkipsDuplicateNames() + { + var promptDir = Path.Combine(_testDir, "prompts-dedup"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "review.md"), "---\nname: Review\n---\nFirst"); + + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + seen.Add("Review"); // Already seen + PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.Project, prompts, seen); + + Assert.Empty(prompts); + } + + [Fact] + public void DiscoverPrompts_FromProjectDirectories() + { + var projectDir = Path.Combine(_testDir, "my-project"); + var promptDir = Path.Combine(projectDir, ".github", "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "deploy.md"), "---\nname: Deploy\ndescription: Deploy the app\n---\nDeploy steps..."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("Deploy", prompts[0].Name); + Assert.Equal(PromptSource.Project, prompts[0].Source); + } + + [Fact] + public void DiscoverPrompts_CopilotPromptsDir() + { + var projectDir = Path.Combine(_testDir, "copilot-project"); + var promptDir = Path.Combine(projectDir, ".github", "copilot-prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "review.md"), "Review code carefully."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("review", prompts[0].Name); + Assert.Equal("Review code carefully.", prompts[0].Content); + } + + [Fact] + public void DiscoverPrompts_MultipleProjectDirs() + { + var projectDir = Path.Combine(_testDir, "multi-project"); + var githubDir = Path.Combine(projectDir, ".github", "prompts"); + var copilotDir = Path.Combine(projectDir, ".copilot", "prompts"); + Directory.CreateDirectory(githubDir); + Directory.CreateDirectory(copilotDir); + File.WriteAllText(Path.Combine(githubDir, "from-github.md"), "---\nname: GitHub Prompt\n---\nFrom github"); + File.WriteAllText(Path.Combine(copilotDir, "from-copilot.md"), "---\nname: Copilot Prompt\n---\nFrom copilot"); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Equal(2, prompts.Count); + Assert.Contains(prompts, p => p.Name == "GitHub Prompt"); + Assert.Contains(prompts, p => p.Name == "Copilot Prompt"); + } + + [Fact] + public void DiscoverPrompts_NoDirectory_ReturnsEmpty() + { + var prompts = PromptLibraryService.DiscoverPrompts("/nonexistent/path"); + // Should not throw, may be empty (depends on user prompts dir existence) + Assert.NotNull(prompts); + } + + [Fact] + public void DiscoverPrompts_NullDirectory_ReturnsAtLeastEmpty() + { + var prompts = PromptLibraryService.DiscoverPrompts(null); + Assert.NotNull(prompts); + } + + [Fact] + public void SanitizeFileName_AlphanumericUnchanged() + { + Assert.Equal("hello-world", PromptLibraryService.SanitizeFileName("hello-world")); + } + + [Fact] + public void SanitizeFileName_SpacesReplaced() + { + Assert.Equal("hello-world", PromptLibraryService.SanitizeFileName("hello world")); + } + + [Fact] + public void SanitizeFileName_SpecialCharsReplaced() + { + Assert.Equal("test-prompt--v2", PromptLibraryService.SanitizeFileName("test/prompt!@v2")); + } + + [Fact] + public void SanitizeFileName_EmptyString_FallsBack() + { + Assert.Equal("prompt", PromptLibraryService.SanitizeFileName("")); + } + + [Fact] + public void SanitizeFileName_AllSpecialChars_FallsBack() + { + Assert.Equal("prompt", PromptLibraryService.SanitizeFileName("@#$")); + } + + [Fact] + public void SanitizeFileName_Underscores_Preserved() + { + Assert.Equal("my_prompt", PromptLibraryService.SanitizeFileName("my_prompt")); + } + + [Fact] + public void PromptSource_Enum_HasExpectedValues() + { + Assert.Equal(0, (int)PromptSource.User); + Assert.Equal(1, (int)PromptSource.Project); + } + + [Fact] + public void ParsePromptFile_MultilineDescription_Skipped() + { + var content = "---\nname: Test\ndescription: >\n multiline desc\n---\nBody content"; + var filePath = "/test.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Test", name); + Assert.Equal("", description); // multiline > is skipped + Assert.Equal("Body content", body); + } + + [Fact] + public void ParsePromptFile_EmptyContent() + { + var (name, description, body) = PromptLibraryService.ParsePromptFile("", "/empty.md"); + + Assert.Equal("empty", name); + Assert.Equal("", description); + Assert.Equal("", body); + } + + [Fact] + public void DiscoverPrompts_ClaudePromptsDir() + { + var projectDir = Path.Combine(_testDir, "claude-project"); + var promptDir = Path.Combine(projectDir, ".claude", "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "analyze.md"), "---\nname: Analyze\n---\nAnalyze the code."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("Analyze", prompts[0].Name); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { } + } +} diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 72abce6d12..796e266425 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -234,6 +234,11 @@ · @availableAgents.Count agents } + @if (availablePrompts?.Count > 0) + { + · + @availablePrompts.Count prompts + } · OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")"> @if (!AvailableModels.Contains(CurrentModel)) @@ -307,6 +312,7 @@ [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; } + [Parameter] public EventCallback OnUsePrompt { get; set; } private bool _showFiestaPicker; private string _fiestaName = ""; @@ -369,6 +375,7 @@ private List? availableSkills; private List? availableAgents; + private List? availablePrompts; private string? _lastSkillSessionName; protected override void OnParametersSet() @@ -376,9 +383,10 @@ if (Session.Name != _lastSkillSessionName) { _lastSkillSessionName = Session.Name; - // Defer skill/agent discovery to background thread — don't block session switch + // Defer skill/agent/prompt discovery to background thread — don't block session switch availableSkills = null; availableAgents = null; + availablePrompts = null; _fiestaName = Session.Name; _selectedWorkerIds.Clear(); foreach (var worker in FiestaWorkers) @@ -391,6 +399,7 @@ { var skills = CopilotService.DiscoverAvailableSkills(workDir); var agents = CopilotService.DiscoverAvailableAgents(workDir); + var prompts = PromptLibraryService.DiscoverPrompts(workDir); InvokeAsync(() => { // Only apply if we're still on the same session @@ -398,6 +407,7 @@ { availableSkills = skills; availableAgents = agents; + availablePrompts = prompts; StateHasChanged(); } }); @@ -546,6 +556,55 @@ "); } + private async Task ShowPromptsPopup() + { + if (availablePrompts == null || availablePrompts.Count == 0) return; + var rows = string.Join("", availablePrompts.Select(p => + { + var desc = string.IsNullOrWhiteSpace(p.Description) ? "" : + $"{EscapeHtml(TruncateDesc(p.Description))}"; + return $"" + + $"" + + $"{EscapeHtml(p.Name)}" + + $"{EscapeHtml(p.SourceLabel)}" + + desc + ""; + })); + var jsHtml = EscapeForJs(rows); + var headerHtml = EscapeForJs("Available Prompts (click to use)"); + await JS.InvokeVoidAsync("eval", $@" + (function(){{ + var old = document.getElementById('skills-popup-overlay'); + if(old) old.remove(); + var triggers = document.querySelectorAll('[class*=""skills-trigger""]'); + var el = triggers[triggers.length - 1]; + var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}}; + var ov = document.createElement('div'); + ov.id = 'skills-popup-overlay'; + ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)'; + ov.onclick = function(){{ ov.remove(); }}; + var popup = document.createElement('div'); + var left = Math.max(8, Math.min(rect.left, window.innerWidth - 368)); + var bottom = window.innerHeight - rect.top + 8; + popup.style.cssText = 'position:fixed;bottom:'+bottom+'px;left:'+left+'px;z-index:9999;background:#1e1e2e;border:1px solid #45475a;border-radius:10px;padding:6px 0;min-width:240px;max-width:360px;max-height:50vh;overflow-y:auto;box-shadow:0 -4px 20px rgba(0,0,0,0.5)'; + popup.innerHTML = '{headerHtml}{jsHtml}'; + popup.onclick = function(e){{ + var row = e.target.closest('.prompt-row'); + if(row){{ + var name = row.getAttribute('data-prompt'); + ov.remove(); + var inputEl = document.querySelector('[data-session=""{EscapeForJs(Session.Name)}""] textarea'); + if(inputEl){{ + inputEl.value = '/prompt use ' + name; + inputEl.dispatchEvent(new Event('input')); + }} + }} + }}; + ov.appendChild(popup); + document.body.appendChild(ov); + }})() + "); + } + private static string EscapeHtml(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index d4240b4a98..eec0d28459 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1345,6 +1345,7 @@ "- `/version` — Show version info\n" + "- `/diff [args]` — Show git diff\n" + "- `/status` — Show git status\n" + + "- `/prompt` — List saved prompts (`/prompt use|save|delete`)\n" + "- `/mcp` — List MCP servers (enable/disable with `/mcp enable|disable `)\n" + "- `/plugin` — List installed plugins (enable/disable with `/plugin enable|disable `)\n" + "- `/reflect ` — Start a reflection cycle (`/reflect help` for details)\n" + @@ -1472,6 +1473,11 @@ await HandleReflectCommand(session, sessionName, arg); break; + case "prompt": + case "prompts": + await HandlePromptCommand(sessionName, session, arg); + return; + default: // Unknown command — pass through to the SDK as a regular prompt _ = CopilotService.SendPromptAsync(sessionName, input); @@ -1736,6 +1742,107 @@ _ = CopilotService.SendPromptAsync(sessionName, goal); } + private async Task HandlePromptCommand(string sessionName, AgentSessionInfo session, string arg) + { + var parts = arg.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var subcommand = parts.Length > 0 ? parts[0].ToLowerInvariant() : ""; + var target = parts.Length > 1 ? parts[1] : ""; + + switch (subcommand) + { + case "use": + if (string.IsNullOrEmpty(target)) + { + session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt use `")); + break; + } + var prompt = PromptLibraryService.GetPrompt(target, session.WorkingDirectory); + if (prompt == null) + { + session.History.Add(ChatMessage.ErrorMessage($"Prompt **{target}** not found.")); + break; + } + session.MessageCount = session.History.Count; + _needsScrollToBottom = true; + await InvokeAsync(SafeRefreshAsync); + _ = CopilotService.SendPromptAsync(sessionName, prompt.Content); + return; + + case "save": + if (string.IsNullOrEmpty(target)) + { + session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt save `\nOr: `/prompt save ` (saves last assistant message)")); + break; + } + var saveParts = target.Split(' ', 2, StringSplitOptions.TrimEntries); + var saveName = saveParts[0]; + string saveContent; + if (saveParts.Length > 1) + { + saveContent = saveParts[1]; + } + else + { + // Save the last user message as a prompt + var lastUser = session.History.LastOrDefault(m => m.Role == "user"); + if (lastUser == null) + { + session.History.Add(ChatMessage.ErrorMessage("No previous user message to save. Usage: `/prompt save `")); + break; + } + saveContent = lastUser.Content; + } + PromptLibraryService.SavePrompt(saveName, saveContent); + session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saveName}** saved.")); + break; + + case "delete": + case "remove": + if (string.IsNullOrEmpty(target)) + { + session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt delete `")); + break; + } + if (PromptLibraryService.DeletePrompt(target)) + session.History.Add(ChatMessage.SystemMessage($"🗑️ Prompt **{target}** deleted.")); + else + session.History.Add(ChatMessage.ErrorMessage($"Prompt **{target}** not found in user prompts.")); + break; + + default: + // List all available prompts + var prompts = PromptLibraryService.DiscoverPrompts(session.WorkingDirectory); + if (prompts.Count == 0) + { + session.History.Add(ChatMessage.SystemMessage( + "**No prompts found.**\n\n" + + "Save a prompt: `/prompt save `\n" + + "Or add `.md` files to:\n" + + "- `~/.polypilot/prompts/` (user prompts)\n" + + "- `.github/prompts/` (project prompts)\n" + + "- `.github/copilot-prompts/` (project prompts)\n" + + "- `.copilot/prompts/` (project prompts)\n" + + "- `.claude/prompts/` (project prompts)")); + } + else + { + var listing = string.Join("\n", prompts.Select(p => + { + var desc = string.IsNullOrEmpty(p.Description) ? "" : $" — {p.Description}"; + return $"- **{p.Name}** ({p.SourceLabel}){desc}"; + })); + session.History.Add(ChatMessage.SystemMessage( + $"**{prompts.Count} prompt{(prompts.Count != 1 ? "s" : "")} available:**\n{listing}\n\n" + + "Use: `/prompt use ` · Save: `/prompt save ` · Delete: `/prompt delete `")); + } + break; + } + + session.MessageCount = session.History.Count; + _needsScrollToBottom = true; + await InvokeAsync(SafeRefreshAsync); + } + private void ToggleMcpServer(string name, bool enabled) { var settings = ConnectionSettings.Load(); diff --git a/PolyPilot/Models/PromptLibrary.cs b/PolyPilot/Models/PromptLibrary.cs new file mode 100644 index 0000000000..50a244e837 --- /dev/null +++ b/PolyPilot/Models/PromptLibrary.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PolyPilot.Models; + +public enum PromptSource +{ + User, // Saved by the user in ~/.polypilot/prompts/ + Project // Discovered from project prompt directories +} + +public class SavedPrompt +{ + public string Name { get; set; } = ""; + public string Content { get; set; } = ""; + public string Description { get; set; } = ""; + + [JsonIgnore] + public PromptSource Source { get; set; } + + [JsonIgnore] + public string? FilePath { get; set; } + + [JsonIgnore] + public string SourceLabel => Source == PromptSource.User ? "user" : "project"; +} diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs new file mode 100644 index 0000000000..29e78e966f --- /dev/null +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -0,0 +1,234 @@ +using PolyPilot.Models; + +namespace PolyPilot.Services; + +/// +/// Discovers prompts from standard coding-agent locations and manages user-saved prompts. +/// Standard project locations scanned: +/// .github/copilot-prompts/, .github/prompts/, .copilot/prompts/, .claude/prompts/ +/// User prompts stored in ~/.polypilot/prompts/ as .md files. +/// +public class PromptLibraryService +{ + private static string? _userPromptsDir; + private static string UserPromptsDir => _userPromptsDir ??= Path.Combine(GetPolyPilotDir(), "prompts"); + + /// + /// Standard project subdirectories where coding agents store prompt files. + /// + private static readonly string[] ProjectPromptDirs = new[] + { + ".github/copilot-prompts", + ".github/prompts", + ".copilot/prompts", + ".claude/prompts" + }; + + private static string GetPolyPilotDir() + { +#if IOS || ANDROID + try + { + return Path.Combine(FileSystem.AppDataDirectory, ".polypilot"); + } + catch + { + var fallback = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(fallback)) + fallback = Path.GetTempPath(); + return Path.Combine(fallback, ".polypilot"); + } +#else + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".polypilot"); +#endif + } + + /// + /// Discover all available prompts from user-saved prompts and project prompt directories. + /// + public static List DiscoverPrompts(string? workingDirectory = null) + { + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + // User-saved prompts (~/.polypilot/prompts/) + if (Directory.Exists(UserPromptsDir)) + ScanPromptDirectory(UserPromptsDir, PromptSource.User, prompts, seen); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Prompts] User prompt discovery failed: {ex.Message}"); + } + + try + { + // Project-level prompts from standard coding-agent locations + if (!string.IsNullOrEmpty(workingDirectory)) + { + foreach (var subdir in ProjectPromptDirs) + { + var promptDir = Path.Combine(workingDirectory, subdir); + if (Directory.Exists(promptDir)) + ScanPromptDirectory(promptDir, PromptSource.Project, prompts, seen); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Prompts] Project prompt discovery failed: {ex.Message}"); + } + + return prompts; + } + + internal static void ScanPromptDirectory(string directory, PromptSource source, List prompts, HashSet seen) + { + foreach (var file in Directory.GetFiles(directory, "*.md")) + { + try + { + var content = File.ReadAllText(file); + var (name, description, body) = ParsePromptFile(content, file); + if (seen.Add(name)) + { + prompts.Add(new SavedPrompt + { + Name = name, + Content = body, + Description = description, + Source = source, + FilePath = file + }); + } + } + catch { } + } + } + + /// + /// Parse a prompt markdown file. Supports optional YAML frontmatter with name/description fields. + /// Falls back to the filename (without extension) as the name. + /// + internal static (string name, string description, string body) ParsePromptFile(string content, string filePath) + { + string? name = null; + string? description = null; + var body = content; + + if (content.StartsWith("---")) + { + var endIdx = content.IndexOf("---", 3, StringComparison.Ordinal); + if (endIdx > 0) + { + var frontmatter = content[3..endIdx]; + body = content[(endIdx + 3)..].TrimStart('\r', '\n'); + + foreach (var line in frontmatter.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("name:")) + name = trimmed[5..].Trim().Trim('"', '\''); + else if (trimmed.StartsWith("description:")) + { + var desc = trimmed[12..].Trim(); + if (!desc.StartsWith(">")) + description = desc.Trim('"', '\''); + } + } + } + } + + if (string.IsNullOrEmpty(name)) + name = Path.GetFileNameWithoutExtension(filePath); + + return (name!, description ?? "", body); + } + + /// + /// Save a prompt to the user's prompt library (~/.polypilot/prompts/). + /// + public static SavedPrompt SavePrompt(string name, string content, string? description = null) + { + Directory.CreateDirectory(UserPromptsDir); + + var safeName = SanitizeFileName(name); + var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); + + var fileContent = ""; + if (!string.IsNullOrWhiteSpace(description)) + { + fileContent = $"---\nname: {name}\ndescription: {description}\n---\n{content}"; + } + else + { + fileContent = $"---\nname: {name}\n---\n{content}"; + } + + File.WriteAllText(filePath, fileContent); + + return new SavedPrompt + { + Name = name, + Content = content, + Description = description ?? "", + Source = PromptSource.User, + FilePath = filePath + }; + } + + /// + /// Delete a user-saved prompt by name. + /// + public static bool DeletePrompt(string name) + { + if (!Directory.Exists(UserPromptsDir)) + return false; + + foreach (var file in Directory.GetFiles(UserPromptsDir, "*.md")) + { + try + { + var content = File.ReadAllText(file); + var (parsedName, _, _) = ParsePromptFile(content, file); + if (string.Equals(parsedName, name, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + return true; + } + } + catch { } + } + + return false; + } + + /// + /// Get a specific prompt by name from user or project prompts. + /// + public static SavedPrompt? GetPrompt(string name, string? workingDirectory = null) + { + return DiscoverPrompts(workingDirectory) + .FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Sanitize a name into a safe filename (alphanumeric, hyphens, underscores). + /// + internal static string SanitizeFileName(string name) + { + var sanitized = new char[name.Length]; + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + sanitized[i] = char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-'; + } + + var result = new string(sanitized).Trim('-'); + return string.IsNullOrEmpty(result) ? "prompt" : result; + } +} From a442d9b6cd03ba544e7fada6a4c7104c3d8a8471 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:46:49 +0000 Subject: [PATCH 3/8] refine: Clarify comment in prompt save fallback logic Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index eec0d28459..076591f0e9 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1783,7 +1783,7 @@ } else { - // Save the last user message as a prompt + // No content provided — save the last user message as the prompt var lastUser = session.History.LastOrDefault(m => m.Role == "user"); if (lastUser == null) { From 155cba0db6f1c5882d97de2ea60654cda3dd0216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:48:34 +0000 Subject: [PATCH 4/8] fix: Address PR review - robust YAML parsing, sanitized output, test isolation 1. ParsePromptFile now requires closing --- at start of a line, preventing dashes inside YAML values from being treated as frontmatter delimiters 2. SavePrompt quotes name/description in YAML and strips newlines/escapes quotes via new SanitizeYamlValue helper 3. DiscoverPrompts tests filter by Source==Project to avoid interference from real ~/.polypilot/prompts/ contents 4. Added 4 new tests for dashes-in-values, YAML sanitization Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 46 ++++++++++++++++++++-- PolyPilot/Services/PromptLibraryService.cs | 37 +++++++++++++++-- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 71e2fd24fa..02568d9a09 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -143,7 +143,8 @@ public void DiscoverPrompts_FromProjectDirectories() Directory.CreateDirectory(promptDir); File.WriteAllText(Path.Combine(promptDir, "deploy.md"), "---\nname: Deploy\ndescription: Deploy the app\n---\nDeploy steps..."); - var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + var prompts = PromptLibraryService.DiscoverPrompts(projectDir) + .Where(p => p.Source == PromptSource.Project).ToList(); Assert.Single(prompts); Assert.Equal("Deploy", prompts[0].Name); @@ -158,7 +159,8 @@ public void DiscoverPrompts_CopilotPromptsDir() Directory.CreateDirectory(promptDir); File.WriteAllText(Path.Combine(promptDir, "review.md"), "Review code carefully."); - var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + var prompts = PromptLibraryService.DiscoverPrompts(projectDir) + .Where(p => p.Source == PromptSource.Project).ToList(); Assert.Single(prompts); Assert.Equal("review", prompts[0].Name); @@ -176,7 +178,8 @@ public void DiscoverPrompts_MultipleProjectDirs() File.WriteAllText(Path.Combine(githubDir, "from-github.md"), "---\nname: GitHub Prompt\n---\nFrom github"); File.WriteAllText(Path.Combine(copilotDir, "from-copilot.md"), "---\nname: Copilot Prompt\n---\nFrom copilot"); - var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + var prompts = PromptLibraryService.DiscoverPrompts(projectDir) + .Where(p => p.Source == PromptSource.Project).ToList(); Assert.Equal(2, prompts.Count); Assert.Contains(prompts, p => p.Name == "GitHub Prompt"); @@ -272,12 +275,47 @@ public void DiscoverPrompts_ClaudePromptsDir() Directory.CreateDirectory(promptDir); File.WriteAllText(Path.Combine(promptDir, "analyze.md"), "---\nname: Analyze\n---\nAnalyze the code."); - var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + var prompts = PromptLibraryService.DiscoverPrompts(projectDir) + .Where(p => p.Source == PromptSource.Project).ToList(); Assert.Single(prompts); Assert.Equal("Analyze", prompts[0].Name); } + [Fact] + public void ParsePromptFile_DashesInsideYamlValue_NotTreatedAsClosing() + { + var content = "---\nname: test---name\ndescription: a---b\n---\nBody here"; + var filePath = "/test.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("test---name", name); + Assert.Equal("a---b", description); + Assert.Equal("Body here", body); + } + + [Fact] + public void SanitizeYamlValue_StripsNewlines() + { + var result = PromptLibraryService.SanitizeYamlValue("line1\nline2\r\nline3"); + Assert.Equal("line1 line2 line3", result); + } + + [Fact] + public void SanitizeYamlValue_EscapesQuotes() + { + var result = PromptLibraryService.SanitizeYamlValue("say \"hello\""); + Assert.Equal("say \\\"hello\\\"", result); + } + + [Fact] + public void SanitizeYamlValue_PlainString_Unchanged() + { + var result = PromptLibraryService.SanitizeYamlValue("simple name"); + Assert.Equal("simple name", result); + } + public void Dispose() { try { Directory.Delete(_testDir, true); } catch { } diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index 29e78e966f..7ab6f13e55 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -122,7 +122,20 @@ internal static (string name, string description, string body) ParsePromptFile(s if (content.StartsWith("---")) { - var endIdx = content.IndexOf("---", 3, StringComparison.Ordinal); + // Search for closing --- that starts on its own line + var endIdx = -1; + var searchFrom = 3; + while (searchFrom < content.Length) + { + var idx = content.IndexOf("---", searchFrom, StringComparison.Ordinal); + if (idx < 0) break; + if (idx == 0 || content[idx - 1] == '\n') + { + endIdx = idx; + break; + } + searchFrom = idx + 1; + } if (endIdx > 0) { var frontmatter = content[3..endIdx]; @@ -159,14 +172,18 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri var safeName = SanitizeFileName(name); var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); + // Sanitize name/description to prevent YAML corruption + var yamlName = SanitizeYamlValue(name); + var yamlDesc = description != null ? SanitizeYamlValue(description) : null; + var fileContent = ""; - if (!string.IsNullOrWhiteSpace(description)) + if (!string.IsNullOrWhiteSpace(yamlDesc)) { - fileContent = $"---\nname: {name}\ndescription: {description}\n---\n{content}"; + fileContent = $"---\nname: \"{yamlName}\"\ndescription: \"{yamlDesc}\"\n---\n{content}"; } else { - fileContent = $"---\nname: {name}\n---\n{content}"; + fileContent = $"---\nname: \"{yamlName}\"\n---\n{content}"; } File.WriteAllText(filePath, fileContent); @@ -216,6 +233,18 @@ public static bool DeletePrompt(string name) .FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); } + /// + /// Sanitize a string for safe inclusion in a YAML value. + /// Strips newlines and escapes double quotes. + /// + internal static string SanitizeYamlValue(string value) + { + return value + .Replace("\r", "") + .Replace("\n", " ") + .Replace("\"", "\\\""); + } + /// /// Sanitize a name into a safe filename (alphanumeric, hyphens, underscores). /// From 59e8a03932afc6fd836b69d876288c0d299ef75d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:29:41 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20Address=205-model=20fleet=20review?= =?UTF-8?q?=20=E2=80=94=20YAML=20round-trip,=20backslash,=20collision,=20p?= =?UTF-8?q?opup=20positioning,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SanitizeYamlValue strips quotes and backslashes instead of escaping (fixes round-trip corruption) 2. SavePrompt detects filename collisions by comparing frontmatter name, appends numeric suffix 3. Help text corrected: "saves last user message" (was "assistant") 4. Popup triggers use data-trigger attributes instead of fragile positional DOM indexing 5. Removed dead OnUsePrompt and OnLoadFullHistory parameters 6. Added tests for backslash handling, round-trip, and filename collision Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 62 ++++++++++++++++++- .../Components/ExpandedSessionView.razor | 20 +++--- PolyPilot/Components/Pages/Dashboard.razor | 2 +- PolyPilot/Services/PromptLibraryService.cs | 31 +++++++++- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 02568d9a09..95b22078ce 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -303,10 +303,25 @@ public void SanitizeYamlValue_StripsNewlines() } [Fact] - public void SanitizeYamlValue_EscapesQuotes() + public void SanitizeYamlValue_StripsQuotes() { var result = PromptLibraryService.SanitizeYamlValue("say \"hello\""); - Assert.Equal("say \\\"hello\\\"", result); + Assert.Equal("say hello", result); + } + + [Fact] + public void SanitizeYamlValue_StripsBackslashes() + { + var result = PromptLibraryService.SanitizeYamlValue("path\\to\\file"); + Assert.Equal("pathtofile", result); + } + + [Fact] + public void SanitizeYamlValue_TrailingBackslash_Stripped() + { + // A trailing backslash would produce malformed YAML: name: "test\" + var result = PromptLibraryService.SanitizeYamlValue("test\\"); + Assert.Equal("test", result); } [Fact] @@ -316,6 +331,49 @@ public void SanitizeYamlValue_PlainString_Unchanged() Assert.Equal("simple name", result); } + [Fact] + public void SavePrompt_RoundTrip_NameSurvives() + { + var promptDir = Path.Combine(_testDir, "rt-prompts"); + Directory.CreateDirectory(promptDir); + + // Write a prompt file manually with a specific name + var name = "My Test Prompt"; + var content = "Do the thing."; + var fileContent = $"---\nname: \"{name}\"\n---\n{content}"; + File.WriteAllText(Path.Combine(promptDir, "my-test-prompt.md"), fileContent); + + // Read it back via ScanPromptDirectory + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.User, prompts, seen); + + Assert.Single(prompts); + Assert.Equal("My Test Prompt", prompts[0].Name); + Assert.Equal("Do the thing.", prompts[0].Content); + } + + [Fact] + public void SavePrompt_FilenameCollision_AppendsNumericSuffix() + { + var promptDir = Path.Combine(_testDir, "collision-prompts"); + Directory.CreateDirectory(promptDir); + + // Create two files that would collide: "foo/bar" and "foo?bar" both sanitize to "foo-bar.md" + File.WriteAllText( + Path.Combine(promptDir, "foo-bar.md"), + "---\nname: \"foo/bar\"\n---\nFirst prompt"); + + // Simulate SavePrompt collision resolution by checking names differ + var existingContent = File.ReadAllText(Path.Combine(promptDir, "foo-bar.md")); + var (existingName, _, _) = PromptLibraryService.ParsePromptFile(existingContent, "foo-bar.md"); + Assert.Equal("foo/bar", existingName); + + // The name "foo?bar" sanitizes to "foo-bar" — same filename but different name + var newSafeName = PromptLibraryService.SanitizeFileName("foo?bar"); + Assert.Equal("foo-bar", newSafeName); + } + public void Dispose() { try { Directory.Delete(_testDir, true); } catch { } diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 796e266425..ed15a9fc59 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -227,17 +227,17 @@ @if (availableSkills?.Count > 0) { · - @availableSkills.Count skills + @availableSkills.Count skills } @if (availableAgents?.Count > 0) { · - @availableAgents.Count agents + @availableAgents.Count agents } @if (availablePrompts?.Count > 0) { · - @availablePrompts.Count prompts + @availablePrompts.Count prompts } · OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")"> @@ -308,11 +308,9 @@ [Parameter] public EventCallback OnFontSizeChange { get; set; } [Parameter] public EventCallback OnLoadMore { get; set; } - [Parameter] public EventCallback OnLoadFullHistory { get; set; } [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; } - [Parameter] public EventCallback OnUsePrompt { get; set; } private bool _showFiestaPicker; private string _fiestaName = ""; @@ -500,7 +498,7 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var trigger = document.querySelector('[class*=""skills-trigger""]'); + var trigger = document.querySelector('[data-trigger=""skills""]'); var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; @@ -537,9 +535,8 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var trigger = document.querySelectorAll('[class*=""skills-trigger""]'); - var el = trigger.length > 1 ? trigger[1] : trigger[0]; - var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}}; + var trigger = document.querySelector('[data-trigger=""agents""]'); + var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)'; @@ -575,9 +572,8 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var triggers = document.querySelectorAll('[class*=""skills-trigger""]'); - var el = triggers[triggers.length - 1]; - var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}}; + var trigger = document.querySelector('[data-trigger=""prompts""]'); + var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)'; diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 076591f0e9..b25a1f602e 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1771,7 +1771,7 @@ case "save": if (string.IsNullOrEmpty(target)) { - session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt save `\nOr: `/prompt save ` (saves last assistant message)")); + session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt save `\nOr: `/prompt save ` (saves last user message)")); break; } var saveParts = target.Split(' ', 2, StringSplitOptions.TrimEntries); diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index 7ab6f13e55..354a0ebf86 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -172,6 +172,29 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri var safeName = SanitizeFileName(name); var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); + // Resolve filename collisions: if file exists with a different prompt name, append suffix + if (File.Exists(filePath)) + { + try + { + var existing = File.ReadAllText(filePath); + var (existingName, _, _) = ParsePromptFile(existing, filePath); + if (!string.Equals(existingName, name, StringComparison.OrdinalIgnoreCase)) + { + for (var i = 2; i < 100; i++) + { + filePath = Path.Combine(UserPromptsDir, $"{safeName}-{i}.md"); + if (!File.Exists(filePath)) break; + var existingN = File.ReadAllText(filePath); + var (n, _, _) = ParsePromptFile(existingN, filePath); + if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) + break; // Same logical name — overwrite is fine + } + } + } + catch { } + } + // Sanitize name/description to prevent YAML corruption var yamlName = SanitizeYamlValue(name); var yamlDesc = description != null ? SanitizeYamlValue(description) : null; @@ -234,15 +257,17 @@ public static bool DeletePrompt(string name) } /// - /// Sanitize a string for safe inclusion in a YAML value. - /// Strips newlines and escapes double quotes. + /// Sanitize a string for safe inclusion in a YAML double-quoted value. + /// Strips newlines, backslashes, and double quotes to avoid YAML corruption. + /// The hand-rolled parser does not handle YAML escapes, so we strip rather than escape. /// internal static string SanitizeYamlValue(string value) { return value .Replace("\r", "") .Replace("\n", " ") - .Replace("\"", "\\\""); + .Replace("\\", "") + .Replace("\"", ""); } /// From a84da8571ba569005a3640749dfba2e75660ca45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:45:06 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Save=E2=86=92Lookup=20round-trip=20f?= =?UTF-8?q?or=20sanitized=20names,=20real=20collision=20tests=20with=20GUI?= =?UTF-8?q?D=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SavePrompt now returns the sanitized yamlName (not the original) so GetPrompt/DeletePrompt lookups match what's stored on disk 2. Collision comparisons use yamlName instead of raw name 3. Collision loop uses GUID fallback if all 98 suffix slots exhausted 4. Added SetUserPromptsDirForTesting() so tests can redirect SavePrompt 5. Rewrote collision test to actually call SavePrompt() twice and verify the -2 suffix file is created 6. Added SavePrompt_RoundTrip_NameWithQuotes_Survives test 7. Added SavePrompt_SameNameOverwrites test Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 82 ++++++++++++++++------ PolyPilot/Services/PromptLibraryService.cs | 28 +++++--- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 95b22078ce..84776d094e 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -336,21 +336,35 @@ public void SavePrompt_RoundTrip_NameSurvives() { var promptDir = Path.Combine(_testDir, "rt-prompts"); Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); - // Write a prompt file manually with a specific name - var name = "My Test Prompt"; - var content = "Do the thing."; - var fileContent = $"---\nname: \"{name}\"\n---\n{content}"; - File.WriteAllText(Path.Combine(promptDir, "my-test-prompt.md"), fileContent); + var saved = PromptLibraryService.SavePrompt("My Test Prompt", "Do the thing."); - // Read it back via ScanPromptDirectory - var prompts = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.User, prompts, seen); + Assert.Equal("My Test Prompt", saved.Name); + Assert.Equal("Do the thing.", saved.Content); - Assert.Single(prompts); - Assert.Equal("My Test Prompt", prompts[0].Name); - Assert.Equal("Do the thing.", prompts[0].Content); + // Read it back via GetPrompt — name must match + var found = PromptLibraryService.GetPrompt("My Test Prompt"); + Assert.NotNull(found); + Assert.Equal("My Test Prompt", found!.Name); + Assert.Equal("Do the thing.", found.Content); + } + + [Fact] + public void SavePrompt_RoundTrip_NameWithQuotes_Survives() + { + var promptDir = Path.Combine(_testDir, "rt-quotes"); + Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); + + // Quotes are stripped by SanitizeYamlValue; returned name is sanitized + var saved = PromptLibraryService.SavePrompt("say \"hello\"", "content"); + Assert.Equal("say hello", saved.Name); + + // GetPrompt with the sanitized name should find it + var found = PromptLibraryService.GetPrompt("say hello"); + Assert.NotNull(found); + Assert.Equal("say hello", found!.Name); } [Fact] @@ -358,20 +372,42 @@ public void SavePrompt_FilenameCollision_AppendsNumericSuffix() { var promptDir = Path.Combine(_testDir, "collision-prompts"); Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); + + // Save "foo/bar" — sanitizes filename to "foo-bar.md" + var first = PromptLibraryService.SavePrompt("foo/bar", "First prompt"); + Assert.True(File.Exists(first.FilePath)); + Assert.EndsWith("foo-bar.md", first.FilePath!); + + // Save "foo?bar" — also sanitizes to "foo-bar.md" but different logical name + var second = PromptLibraryService.SavePrompt("foo?bar", "Second prompt"); + Assert.True(File.Exists(second.FilePath)); + Assert.EndsWith("foo-bar-2.md", second.FilePath!); + + // Both are discoverable + var all = PromptLibraryService.DiscoverPrompts(null) + .Where(p => p.Source == PromptSource.User).ToList(); + Assert.Contains(all, p => p.Name == "foo/bar" && p.Content == "First prompt"); + Assert.Contains(all, p => p.Name == "foo?bar" && p.Content == "Second prompt"); + } + + [Fact] + public void SavePrompt_SameNameOverwrites() + { + var promptDir = Path.Combine(_testDir, "overwrite-prompts"); + Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); - // Create two files that would collide: "foo/bar" and "foo?bar" both sanitize to "foo-bar.md" - File.WriteAllText( - Path.Combine(promptDir, "foo-bar.md"), - "---\nname: \"foo/bar\"\n---\nFirst prompt"); + PromptLibraryService.SavePrompt("my prompt", "version 1"); + PromptLibraryService.SavePrompt("my prompt", "version 2"); - // Simulate SavePrompt collision resolution by checking names differ - var existingContent = File.ReadAllText(Path.Combine(promptDir, "foo-bar.md")); - var (existingName, _, _) = PromptLibraryService.ParsePromptFile(existingContent, "foo-bar.md"); - Assert.Equal("foo/bar", existingName); + // Only one file should exist, with the latest content + var files = Directory.GetFiles(promptDir, "*.md"); + Assert.Single(files); - // The name "foo?bar" sanitizes to "foo-bar" — same filename but different name - var newSafeName = PromptLibraryService.SanitizeFileName("foo?bar"); - Assert.Equal("foo-bar", newSafeName); + var found = PromptLibraryService.GetPrompt("my prompt"); + Assert.NotNull(found); + Assert.Equal("version 2", found!.Content); } public void Dispose() diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index 354a0ebf86..ccea4ef507 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -13,6 +13,11 @@ public class PromptLibraryService private static string? _userPromptsDir; private static string UserPromptsDir => _userPromptsDir ??= Path.Combine(GetPolyPilotDir(), "prompts"); + /// + /// Override the user prompts directory. Used by tests to avoid reading real ~/.polypilot/prompts/. + /// + internal static void SetUserPromptsDirForTesting(string path) => _userPromptsDir = path; + /// /// Standard project subdirectories where coding agents store prompt files. /// @@ -169,6 +174,10 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri { Directory.CreateDirectory(UserPromptsDir); + // Sanitize name/description to prevent YAML corruption + var yamlName = SanitizeYamlValue(name); + var yamlDesc = description != null ? SanitizeYamlValue(description) : null; + var safeName = SanitizeFileName(name); var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); @@ -179,26 +188,25 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri { var existing = File.ReadAllText(filePath); var (existingName, _, _) = ParsePromptFile(existing, filePath); - if (!string.Equals(existingName, name, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(existingName, yamlName, StringComparison.OrdinalIgnoreCase)) { + var found = false; for (var i = 2; i < 100; i++) { filePath = Path.Combine(UserPromptsDir, $"{safeName}-{i}.md"); - if (!File.Exists(filePath)) break; + if (!File.Exists(filePath)) { found = true; break; } var existingN = File.ReadAllText(filePath); var (n, _, _) = ParsePromptFile(existingN, filePath); - if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) - break; // Same logical name — overwrite is fine + if (string.Equals(n, yamlName, StringComparison.OrdinalIgnoreCase)) + { found = true; break; } // Same logical name — overwrite is fine } + if (!found) + filePath = Path.Combine(UserPromptsDir, $"{safeName}-{Guid.NewGuid():N}.md"); } } catch { } } - // Sanitize name/description to prevent YAML corruption - var yamlName = SanitizeYamlValue(name); - var yamlDesc = description != null ? SanitizeYamlValue(description) : null; - var fileContent = ""; if (!string.IsNullOrWhiteSpace(yamlDesc)) { @@ -213,9 +221,9 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri return new SavedPrompt { - Name = name, + Name = yamlName, Content = content, - Description = description ?? "", + Description = yamlDesc ?? "", Source = PromptSource.User, FilePath = filePath }; From c4f2e6a76968fc1680393dfce5d1b70da74be936 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:27:54 +0000 Subject: [PATCH 7/8] fix: Use SavePrompt return value in confirmation, strip single quotes in SanitizeYamlValue 1. Dashboard /prompt save handler now uses the SavedPrompt return value so the confirmation message shows the sanitized name that matches disk 2. SanitizeYamlValue strips single quotes to match ParsePromptFile's .Trim('"', '\'') behavior, preventing round-trip mismatch 3. Added SanitizeYamlValue_StripsSingleQuotes test Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 8 ++++++++ PolyPilot/Components/Pages/Dashboard.razor | 4 ++-- PolyPilot/Services/PromptLibraryService.cs | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 84776d094e..4c6069dd96 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -309,6 +309,14 @@ public void SanitizeYamlValue_StripsQuotes() Assert.Equal("say hello", result); } + [Fact] + public void SanitizeYamlValue_StripsSingleQuotes() + { + Assert.Equal("cool", PromptLibraryService.SanitizeYamlValue("'cool'")); + Assert.Equal("name", PromptLibraryService.SanitizeYamlValue("'name")); + Assert.Equal("its cool", PromptLibraryService.SanitizeYamlValue("it's cool")); + } + [Fact] public void SanitizeYamlValue_StripsBackslashes() { diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index b25a1f602e..a5e2e86906 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1792,8 +1792,8 @@ } saveContent = lastUser.Content; } - PromptLibraryService.SavePrompt(saveName, saveContent); - session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saveName}** saved.")); + var saved = PromptLibraryService.SavePrompt(saveName, saveContent); + session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saved.Name}** saved.")); break; case "delete": diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index ccea4ef507..e2bf5f5e91 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -275,7 +275,8 @@ internal static string SanitizeYamlValue(string value) .Replace("\r", "") .Replace("\n", " ") .Replace("\\", "") - .Replace("\"", ""); + .Replace("\"", "") + .Replace("'", ""); } /// From 6d8e2ea797fcdf7bc32042879583c1242ba3e252 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sun, 22 Feb 2026 19:58:27 -0600 Subject: [PATCH 8/8] fix: restore OnLoadFullHistory parameter removed during rebase The rebase onto origin/main brought in PR #181 (paginated history loading) which uses OnLoadFullHistory. The PR #111 dead-code cleanup had removed it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ExpandedSessionView.razor | 1 + 1 file changed, 1 insertion(+) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index ed15a9fc59..6c3c2fa640 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -308,6 +308,7 @@ [Parameter] public EventCallback OnFontSizeChange { get; set; } [Parameter] public EventCallback OnLoadMore { get; set; } + [Parameter] public EventCallback OnLoadFullHistory { get; set; } [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; }