From 4db9bc5edb65dbe52eff6e8b926137269c35f257 Mon Sep 17 00:00:00 2001 From: DevMando Date: Sun, 30 Aug 2026 13:15:28 -0700 Subject: [PATCH 1/2] Add tags and bulk actions for skills and MCPs --- CHANGELOG.md | 8 + .../ItemTagStoreTests.cs | 57 ++++++ .../MandoCode.Desktop.Tests.csproj | 1 + src/MandoCode.Desktop/MainWindow.Mcp.cs | 68 ++++++- src/MandoCode.Desktop/MainWindow.Skills.cs | 68 ++++++- src/MandoCode.Desktop/MainWindow.Tags.cs | 78 ++++++++ .../MainWindow.ViewModels.cs | 9 + src/MandoCode.Desktop/MainWindow.xaml | 54 +++++- src/MandoCode.Desktop/MainWindow.xaml.cs | 1 + .../Services/ItemTagStore.cs | 169 ++++++++++++++++++ 10 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Tags.cs create mode 100644 src/MandoCode.Desktop/Services/ItemTagStore.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48445ae..2333e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ the long-running model soak and local-model token measurements finish. Desktop's the engine generation, so it moves 0.14.1 → 0.15.0. ### Added +- **Separate tags for Skills and MCP servers.** Add tags in each item's editor, then use the new + tag dropdown alongside the status filters to narrow the list. The `+` button opens a tag-management + dialog for that surface, so a Skill tag never appears among MCP tags (or vice versa). Tags are + Desktop organization metadata and do not alter shared server configuration or portable SKILL.md + files. +- **Filtered bulk enable/disable.** The Filters group now includes an action that reads **Disable + all** whenever any matching item is active, or **Enable all** when every matching item is disabled. + It applies only to the current search, status, and tag result. - **An Unfinished Plan card appears when an agent has checkpointed work.** Resume continues at the first unsettled step; Discard forgets the saved run. The card reflects current checkpoint state rather than transcript history, so an obsolete Resume button cannot come back after restart. diff --git a/src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs b/src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs new file mode 100644 index 0000000..d58883a --- /dev/null +++ b/src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs @@ -0,0 +1,57 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class ItemTagStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "mandocode-tags-" + Guid.NewGuid().ToString("N")); + private readonly ItemTagStore _store; + + public ItemTagStoreTests() + { + Directory.CreateDirectory(_directory); + _store = new ItemTagStore(Path.Combine(_directory, "tags.json")); + } + + [Fact] + public void Tags_are_separate_for_skills_and_mcps() + { + _store.SetItemTags(TagScope.Skills, "C:\\skills\\review", ["quality"]); + _store.SetItemTags(TagScope.Mcps, "database", ["production"]); + + Assert.Equal(["quality"], _store.GetTags(TagScope.Skills)); + Assert.Equal(["production"], _store.GetTags(TagScope.Mcps)); + Assert.Equal(["quality"], _store.GetItemTags(TagScope.Skills, "C:\\skills\\review")); + Assert.DoesNotContain("quality", _store.GetItemTags(TagScope.Mcps, "database")); + } + + [Fact] + public void Rename_moves_an_items_tags() + { + _store.SetItemTags(TagScope.Mcps, "old-name", ["local", "utility"]); + + _store.RenameItem(TagScope.Mcps, "old-name", "new-name"); + + Assert.Empty(_store.GetItemTags(TagScope.Mcps, "old-name")); + Assert.Equal(["local", "utility"], _store.GetItemTags(TagScope.Mcps, "new-name")); + } + + [Fact] + public void Deleting_a_tag_removes_it_from_all_assignments() + { + _store.SetItemTags(TagScope.Skills, "one", ["review", "shared"]); + _store.SetItemTags(TagScope.Skills, "two", ["shared"]); + + _store.DeleteTag(TagScope.Skills, "shared"); + + Assert.Equal(["review"], _store.GetItemTags(TagScope.Skills, "one")); + Assert.Empty(_store.GetItemTags(TagScope.Skills, "two")); + Assert.Equal(["review"], _store.GetTags(TagScope.Skills)); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 0ddc05b..2231d07 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -72,6 +72,7 @@ + diff --git a/src/MandoCode.Desktop/MainWindow.Mcp.cs b/src/MandoCode.Desktop/MainWindow.Mcp.cs index c3f3fe4..009182f 100644 --- a/src/MandoCode.Desktop/MainWindow.Mcp.cs +++ b/src/MandoCode.Desktop/MainWindow.Mcp.cs @@ -30,6 +30,7 @@ public sealed partial class MainWindow // Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter). private List _allMcpRows = new(); private bool _loadingMcp; + private string? _mcpTagFilter; private async Task RefreshMcpListAsync() { @@ -51,8 +52,10 @@ private async Task RefreshMcpListAsync() Status = r.Status, StatusBrush = r.Connected ? green : gold, Enabled = !r.Disabled, + Tags = _itemTags.GetItemTags(TagScope.Mcps, r.Name), }).ToList(); + PopulateMcpTagFilter(); ApplyMcpFilter(); } @@ -61,6 +64,21 @@ private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChan private string _mcpFilter = "all"; + private void PopulateMcpTagFilter() + { + var choices = new List { new() }; + choices.AddRange(_itemTags.GetTags(TagScope.Mcps).Select(tag => new TagFilterOption { Label = tag, Tag = tag })); + McpTagFilter.ItemsSource = choices; + McpTagFilter.SelectedItem = choices.FirstOrDefault(choice => + string.Equals(choice.Tag, _mcpTagFilter, StringComparison.OrdinalIgnoreCase)) ?? choices[0]; + } + + private void McpTagFilter_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _mcpTagFilter = (McpTagFilter.SelectedItem as TagFilterOption)?.Tag; + ApplyMcpFilter(); + } + private void McpFilter_Click(object sender, RoutedEventArgs e) { _mcpFilter = (string)((FrameworkElement)sender).Tag; @@ -88,6 +106,8 @@ private void ApplyMcpFilter() "failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)), _ => filtered, }; + if (!string.IsNullOrWhiteSpace(_mcpTagFilter)) + filtered = filtered.Where(row => row.Tags.Contains(_mcpTagFilter, StringComparer.OrdinalIgnoreCase)); var shown = filtered.ToList(); var groups = new List(); @@ -103,10 +123,12 @@ private void ApplyMcpFilter() McpEditButton.IsEnabled = false; McpRemoveButton.IsEnabled = false; + McpBulkToggleButton.IsEnabled = shown.Count > 0; + McpBulkToggleButton.Content = shown.Any(row => row.Enabled) ? "Disable all" : "Enable all"; var total = _allMcpRows.Count; var enabledTotal = _allMcpRows.Count(r => r.Enabled); - var active = q.Length > 0 || _mcpFilter != "all"; + var active = q.Length > 0 || _mcpFilter != "all" || !string.IsNullOrWhiteSpace(_mcpTagFilter); if (total == 0) McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one."; else if (active) @@ -115,6 +137,46 @@ private void ApplyMcpFilter() McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled"; } + private async void McpManageTags_Click(object sender, RoutedEventArgs e) + { + await ShowTagManagerAsync(TagScope.Mcps, "MCP tags"); + await RefreshMcpListAsync(); + } + + private async void McpBulkToggle_Click(object sender, RoutedEventArgs e) + { + var targets = FilteredMcpRows(); + var enable = !targets.Any(row => row.Enabled); + McpPageStatus.Text = enable ? "Enabling filtered servers…" : "Disabling filtered servers…"; + foreach (var row in targets) + { + if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) continue; + server.Disabled = !enable; + await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server)); + } + await RefreshMcpListAsync(); + McpPageStatus.Text = enable ? $"Enabled {targets.Count} filtered server(s)." : $"Disabled {targets.Count} filtered server(s)."; + } + + private List FilteredMcpRows() + { + var q = McpSearchBox.Text?.Trim() ?? ""; + IEnumerable rows = _allMcpRows; + if (q.Length > 0) + rows = rows.Where(row => row.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + row.Transport.Contains(q, StringComparison.OrdinalIgnoreCase)); + rows = _mcpFilter switch + { + "enabled" => rows.Where(row => row.Enabled), + "disabled" => rows.Where(row => !row.Enabled), + "failed" => rows.Where(row => row.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)), + _ => rows, + }; + return string.IsNullOrWhiteSpace(_mcpTagFilter) + ? rows.ToList() + : rows.Where(row => row.Tags.Contains(_mcpTagFilter, StringComparer.OrdinalIgnoreCase)).ToList(); + } + /// Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts /// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload). private async void McpEnabled_Toggled(object sender, RoutedEventArgs e) @@ -210,6 +272,7 @@ private void OpenMcpEditor(string? serverName) M_Url.Text = cfg.Url ?? ""; M_Headers.Text = string.Join("\n", cfg.Headers.Select(kv => $"{kv.Key}={kv.Value}")); M_Disabled.IsOn = cfg.Disabled; + M_Tags.Text = string.Join(", ", _itemTags.GetItemTags(TagScope.Mcps, serverName)); } else { @@ -223,6 +286,7 @@ private void OpenMcpEditor(string? serverName) M_Url.Text = ""; M_Headers.Text = ""; M_Disabled.IsOn = false; + M_Tags.Text = ""; } UpdateMcpTransportPanels(); @@ -382,6 +446,8 @@ private async void McpEditorSave_Click(object sender, RoutedEventArgs e) var originalName = _mcpEditOriginalName; var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server)); + if (!string.IsNullOrWhiteSpace(originalName)) _itemTags.RenameItem(TagScope.Mcps, originalName, name); + _itemTags.SetItemTags(TagScope.Mcps, name, SplitTags(M_Tags.Text)); McpPageStatus.Text = message; await RefreshMcpListAsync(); McpPageStatus.Text = message; diff --git a/src/MandoCode.Desktop/MainWindow.Skills.cs b/src/MandoCode.Desktop/MainWindow.Skills.cs index 9940c86..d5f1a19 100644 --- a/src/MandoCode.Desktop/MainWindow.Skills.cs +++ b/src/MandoCode.Desktop/MainWindow.Skills.cs @@ -30,6 +30,7 @@ public sealed partial class MainWindow // Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter). private List _allSkillRows = new(); + private string? _skillTagFilter; private void RefreshSkillsList() { @@ -40,8 +41,10 @@ private void RefreshSkillsList() Body = s.Body, FolderPath = s.FolderPath, Enabled = s.Enabled, + Tags = _itemTags.GetItemTags(TagScope.Skills, s.FolderPath), }).ToList(); + PopulateSkillTagFilter(); ApplySkillFilter(); } @@ -50,6 +53,21 @@ private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextCh private string _skillFilter = "all"; + private void PopulateSkillTagFilter() + { + var choices = new List { new() }; + choices.AddRange(_itemTags.GetTags(TagScope.Skills).Select(tag => new TagFilterOption { Label = tag, Tag = tag })); + SkillTagFilter.ItemsSource = choices; + SkillTagFilter.SelectedItem = choices.FirstOrDefault(choice => + string.Equals(choice.Tag, _skillTagFilter, StringComparison.OrdinalIgnoreCase)) ?? choices[0]; + } + + private void SkillTagFilter_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _skillTagFilter = (SkillTagFilter.SelectedItem as TagFilterOption)?.Tag; + ApplySkillFilter(); + } + private void SkillFilter_Click(object sender, RoutedEventArgs e) { _skillFilter = (string)((FrameworkElement)sender).Tag; @@ -78,6 +96,8 @@ private void ApplySkillFilter() "large" => filtered.Where(r => r.IsLarge), _ => filtered, }; + if (!string.IsNullOrWhiteSpace(_skillTagFilter)) + filtered = filtered.Where(row => row.Tags.Contains(_skillTagFilter, StringComparer.OrdinalIgnoreCase)); var shown = filtered.ToList(); // Group by state — Enabled first, Disabled below; empty sections omitted. @@ -93,10 +113,12 @@ private void ApplySkillFilter() // Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it. SkillEditButton.IsEnabled = false; SkillDeleteButton.IsEnabled = false; + SkillBulkToggleButton.IsEnabled = shown.Count > 0; + SkillBulkToggleButton.Content = shown.Any(row => row.Enabled) ? "Disable all" : "Enable all"; var total = _allSkillRows.Count; var enabledTotal = _allSkillRows.Count(r => r.Enabled); - var active = q.Length > 0 || _skillFilter != "all"; + var active = q.Length > 0 || _skillFilter != "all" || !string.IsNullOrWhiteSpace(_skillTagFilter); if (total == 0) SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one. ({_skillCoordinator.UserSkillsDirectory})"; else if (active) @@ -105,6 +127,43 @@ private void ApplySkillFilter() SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled · {_skillCoordinator.UserSkillsDirectory}"; } + private async void SkillManageTags_Click(object sender, RoutedEventArgs e) + { + await ShowTagManagerAsync(TagScope.Skills, "Skill tags"); + RefreshSkillsList(); + } + + private async void SkillBulkToggle_Click(object sender, RoutedEventArgs e) + { + var targets = FilteredSkillRows(); + var enable = !targets.Any(row => row.Enabled); + foreach (var row in targets) + _skillCoordinator.SetEnabled(row.FolderPath, enable); + + await _skillCoordinator.ReloadAllAsync(); + RefreshSkillsList(); + SkillsPageStatus.Text = enable ? $"Enabled {targets.Count} filtered skill(s)." : $"Disabled {targets.Count} filtered skill(s)."; + } + + private List FilteredSkillRows() + { + var q = SkillSearchBox.Text?.Trim() ?? ""; + IEnumerable rows = _allSkillRows; + if (q.Length > 0) + rows = rows.Where(row => row.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + row.Description.Contains(q, StringComparison.OrdinalIgnoreCase)); + rows = _skillFilter switch + { + "enabled" => rows.Where(row => row.Enabled), + "disabled" => rows.Where(row => !row.Enabled), + "large" => rows.Where(row => row.IsLarge), + _ => rows, + }; + return string.IsNullOrWhiteSpace(_skillTagFilter) + ? rows.ToList() + : rows.Where(row => row.Tags.Contains(_skillTagFilter, StringComparer.OrdinalIgnoreCase)).ToList(); + } + /// Reload every agent's skill set + prompt, then re-render the list and report. private async Task ApplySkillChangeAsync(string status) { @@ -199,6 +258,7 @@ private void OpenSkillEditor(SkillRow? row) Sk_Name.Text = ""; Sk_Description.Text = ""; Sk_Body.Text = ""; + Sk_Tags.Text = ""; } else { @@ -207,6 +267,7 @@ private void OpenSkillEditor(SkillRow? row) Sk_Name.Text = row.Name; Sk_Description.Text = row.Description; Sk_Body.Text = row.Body; + Sk_Tags.Text = string.Join(", ", row.Tags); } // Reset the AI panel and default its model to the active agent's (still changeable). @@ -339,7 +400,10 @@ private async void SkillEditorSave_Click(object sender, RoutedEventArgs e) try { - _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text); + var folder = _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text); + if (!string.IsNullOrWhiteSpace(_editingSkillFolder)) + _itemTags.RenameItem(TagScope.Skills, _editingSkillFolder, folder); + _itemTags.SetItemTags(TagScope.Skills, folder, SplitTags(Sk_Tags.Text)); SkillEditorOverlay.Visibility = Visibility.Collapsed; await ApplySkillChangeAsync($"Saved “{name}”."); } diff --git a/src/MandoCode.Desktop/MainWindow.Tags.cs b/src/MandoCode.Desktop/MainWindow.Tags.cs new file mode 100644 index 0000000..86812b1 --- /dev/null +++ b/src/MandoCode.Desktop/MainWindow.Tags.cs @@ -0,0 +1,78 @@ +using MandoCode.Desktop.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace MandoCode.Desktop; + +public sealed partial class MainWindow +{ + /// Shows the catalog for one management surface. Assignment is handled by the item's + /// editor; this modal owns the reusable tag names exposed by that surface's filter dropdown. + private async Task ShowTagManagerAsync(TagScope scope, string title) + { + var input = new TextBox + { + PlaceholderText = "New tag (for example: database)", + MinWidth = 300, + }; + var list = new ListView + { + SelectionMode = ListViewSelectionMode.Single, + MinHeight = 120, + MaxHeight = 260, + }; + var add = new Button { Content = "Add tag", Style = (Style)Application.Current.Resources["AccentButtonStyle"] }; + var remove = new Button { Content = "Remove selected" }; + + void Refresh() + { + list.ItemsSource = _itemTags.GetTags(scope); + remove.IsEnabled = list.SelectedItem is string; + } + + list.SelectionChanged += (_, _) => remove.IsEnabled = list.SelectedItem is string; + add.Click += (_, _) => + { + _itemTags.AddTag(scope, input.Text); + input.Text = ""; + Refresh(); + input.Focus(FocusState.Programmatic); + }; + remove.Click += (_, _) => + { + if (list.SelectedItem is string tag) + { + _itemTags.DeleteTag(scope, tag); + Refresh(); + } + }; + + var content = new StackPanel { Spacing = 12 }; + content.Children.Add(new TextBlock + { + Text = "Tags are available only on this page type. Removing a tag also removes it from items using it.", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + }); + var addRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + addRow.Children.Add(input); + addRow.Children.Add(add); + content.Children.Add(addRow); + content.Children.Add(list); + content.Children.Add(remove); + Refresh(); + + var dialog = new ContentDialog + { + Title = title, + Content = content, + CloseButtonText = "Done", + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot, + }; + await dialog.ShowAsync(); + } + + private static IEnumerable SplitTags(string text) => + text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs index b843feb..a4d53c1 100644 --- a/src/MandoCode.Desktop/MainWindow.ViewModels.cs +++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs @@ -193,6 +193,14 @@ public sealed class McpRow public SolidColorBrush StatusBrush { get; init; } = new(Colors.Gray); /// Per-server on/off (the config's Disabled flag, inverted). Shared by every agent. public bool Enabled { get; init; } + public IReadOnlyList Tags { get; init; } = []; +} + +/// One choice in a Skills or MCP tag filter. A null tag means no tag constraint. +public sealed class TagFilterOption +{ + public string Label { get; init; } = "All tags"; + public string? Tag { get; init; } } /// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a @@ -219,6 +227,7 @@ public sealed class SkillRow public string Body { get; init; } = ""; public string FolderPath { get; init; } = ""; public bool Enabled { get; init; } + public IReadOnlyList Tags { get; init; } = []; // Size of the instructions body — what gets injected into the prompt on load, so it's the cost // that spins a local model up. ~4 chars/token is the usual rough estimate. diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index a3cdd2d..cfd749f 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -1445,7 +1445,12 @@ - + + + + + + - + + + - + + + - + + + - + + + @@ -1445,12 +1466,16 @@ - + - + + + + + - + + + - @@ -1597,17 +1624,40 @@ + + + + + + + + + + + + + - + - + + + + + - + - -