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..4c6069dd96 --- /dev/null +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -0,0 +1,425 @@ +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) + .Where(p => p.Source == PromptSource.Project).ToList(); + + 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) + .Where(p => p.Source == PromptSource.Project).ToList(); + + 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) + .Where(p => p.Source == PromptSource.Project).ToList(); + + 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) + .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_StripsQuotes() + { + var result = PromptLibraryService.SanitizeYamlValue("say \"hello\""); + 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() + { + 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] + public void SanitizeYamlValue_PlainString_Unchanged() + { + var result = PromptLibraryService.SanitizeYamlValue("simple name"); + Assert.Equal("simple name", result); + } + + [Fact] + public void SavePrompt_RoundTrip_NameSurvives() + { + var promptDir = Path.Combine(_testDir, "rt-prompts"); + Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); + + var saved = PromptLibraryService.SavePrompt("My Test Prompt", "Do the thing."); + + Assert.Equal("My Test Prompt", saved.Name); + Assert.Equal("Do the thing.", saved.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] + 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); + + PromptLibraryService.SavePrompt("my prompt", "version 1"); + PromptLibraryService.SavePrompt("my prompt", "version 2"); + + // Only one file should exist, with the latest content + var files = Directory.GetFiles(promptDir, "*.md"); + Assert.Single(files); + + var found = PromptLibraryService.GetPrompt("my prompt"); + Assert.NotNull(found); + Assert.Equal("version 2", found!.Content); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { } + } +} diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 72abce6d12..6c3c2fa640 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -227,12 +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 } · OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")"> @@ -369,6 +374,7 @@ private List? availableSkills; private List? availableAgents; + private List? availablePrompts; private string? _lastSkillSessionName; protected override void OnParametersSet() @@ -376,9 +382,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 +398,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 +406,7 @@ { availableSkills = skills; availableAgents = agents; + availablePrompts = prompts; StateHasChanged(); } }); @@ -490,7 +499,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'; @@ -527,9 +536,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)'; @@ -546,6 +554,54 @@ "); } + 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 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)'; + 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..a5e2e86906 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 user message)")); + break; + } + var saveParts = target.Split(' ', 2, StringSplitOptions.TrimEntries); + var saveName = saveParts[0]; + string saveContent; + if (saveParts.Length > 1) + { + saveContent = saveParts[1]; + } + else + { + // No content provided — save the last user message as the 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; + } + var saved = PromptLibraryService.SavePrompt(saveName, saveContent); + session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saved.Name}** 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..e2bf5f5e91 --- /dev/null +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -0,0 +1,297 @@ +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"); + + /// + /// 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. + /// + 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("---")) + { + // 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]; + 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); + + // 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"); + + // 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, yamlName, StringComparison.OrdinalIgnoreCase)) + { + var found = false; + for (var i = 2; i < 100; i++) + { + filePath = Path.Combine(UserPromptsDir, $"{safeName}-{i}.md"); + if (!File.Exists(filePath)) { found = true; break; } + var existingN = File.ReadAllText(filePath); + var (n, _, _) = ParsePromptFile(existingN, filePath); + 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 { } + } + + var fileContent = ""; + if (!string.IsNullOrWhiteSpace(yamlDesc)) + { + fileContent = $"---\nname: \"{yamlName}\"\ndescription: \"{yamlDesc}\"\n---\n{content}"; + } + else + { + fileContent = $"---\nname: \"{yamlName}\"\n---\n{content}"; + } + + File.WriteAllText(filePath, fileContent); + + return new SavedPrompt + { + Name = yamlName, + Content = content, + Description = yamlDesc ?? "", + 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 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("'", ""); + } + + /// + /// 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; + } +}