diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48445ae..43060d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,18 @@ 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.** Use the new gear-icon **Tags** button on each row
+ to assign tags from a checkbox menu; assigned tags appear as colored chips beside that button.
+ The `+` button in Filters opens a tag-management dialog where new tags receive a chosen color.
+ Skill tags and MCP tags remain separate, and tags 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. The visible rows stay in place
+ while their switches update; MCP servers are saved and reloaded once for the full filtered set.
+- **Responsive management filters.** The Filters group stays right-aligned beside the action buttons
+ when both fit. On a narrow window, it moves as a complete second row below those actions instead
+ of overlapping or partially wrapping.
- **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..faa2632
--- /dev/null
+++ b/src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs
@@ -0,0 +1,67 @@
+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).Select(tag => tag.Name));
+ Assert.Equal(["production"], _store.GetTags(TagScope.Mcps).Select(tag => tag.Name));
+ 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).Select(tag => tag.Name));
+ }
+
+ [Fact]
+ public void Added_tag_keeps_its_selected_color()
+ {
+ _store.AddTag(TagScope.Skills, "release", "#A855F7");
+
+ var tag = Assert.Single(_store.GetTags(TagScope.Skills));
+ Assert.Equal("release", tag.Name);
+ Assert.Equal("#A855F7", tag.Color);
+ }
+
+ 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..8156657 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()
{
@@ -44,6 +45,7 @@ private async Task RefreshMcpListAsync()
var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
+ var definitions = _itemTags.GetTags(TagScope.Mcps);
_allMcpRows = rows.Select(r => new McpRow
{
Name = r.Name,
@@ -51,8 +53,11 @@ private async Task RefreshMcpListAsync()
Status = r.Status,
StatusBrush = r.Connected ? green : gold,
Enabled = !r.Disabled,
+ Tags = _itemTags.GetItemTags(TagScope.Mcps, r.Name),
+ TagChips = TagChips(_itemTags.GetItemTags(TagScope.Mcps, r.Name), definitions),
}).ToList();
+ PopulateMcpTagFilter();
ApplyMcpFilter();
}
@@ -61,6 +66,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.Name, Tag = tag.Name }));
+ 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 +108,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 +125,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 +139,49 @@ 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…";
+ var result = await _controller.SetMcpServersEnabledAsync(targets.Select(row => row.Name), enable);
+ if (!result.Ok)
+ {
+ McpPageStatus.Text = result.Message;
+ return;
+ }
+
+ // Keep the current filtered layout stable. The next explicit refresh re-groups rows.
+ foreach (var row in targets) row.Enabled = enable;
+ McpBulkToggleButton.Content = enable ? "Disable all" : "Enable all";
+ McpPageStatus.Text = result.Message;
+ }
+
+ 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)
@@ -125,12 +192,20 @@ private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
if (sw.IsOn == row.Enabled) return;
// Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
- if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
- server.Disabled = !sw.IsOn;
+ if (!_configs.Defaults.McpServers.ContainsKey(row.Name)) return;
McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
- await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
- await RefreshMcpListAsync();
+ var result = await _controller.SetMcpServersEnabledAsync([row.Name], sw.IsOn);
+ if (!result.Ok)
+ {
+ sw.IsOn = row.Enabled;
+ McpPageStatus.Text = result.Message;
+ return;
+ }
+
+ row.Enabled = sw.IsOn;
+ McpBulkToggleButton.Content = sw.IsOn ? "Disable all" : "Enable all";
+ McpPageStatus.Text = result.Message;
}
/// Runs a slash command through the normal pipeline (transcript echo, wizard
@@ -382,6 +457,7 @@ 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);
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..d46174f 100644
--- a/src/MandoCode.Desktop/MainWindow.Skills.cs
+++ b/src/MandoCode.Desktop/MainWindow.Skills.cs
@@ -30,9 +30,11 @@ 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()
{
+ var definitions = _itemTags.GetTags(TagScope.Skills);
_allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
{
Name = s.Name,
@@ -40,8 +42,11 @@ private void RefreshSkillsList()
Body = s.Body,
FolderPath = s.FolderPath,
Enabled = s.Enabled,
+ Tags = _itemTags.GetItemTags(TagScope.Skills, s.FolderPath),
+ TagChips = TagChips(_itemTags.GetItemTags(TagScope.Skills, s.FolderPath), definitions),
}).ToList();
+ PopulateSkillTagFilter();
ApplySkillFilter();
}
@@ -50,6 +55,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.Name, Tag = tag.Name }));
+ 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 +98,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 +115,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 +129,45 @@ 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();
+ // Keep the current filtered layout stable. The next explicit refresh re-groups rows.
+ foreach (var row in targets) row.Enabled = enable;
+ SkillBulkToggleButton.Content = enable ? "Disable all" : "Enable all";
+ 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)
{
@@ -138,7 +201,10 @@ private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
try
{
_skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
- await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
+ await _skillCoordinator.ReloadAllAsync();
+ row.Enabled = sw.IsOn;
+ SkillBulkToggleButton.Content = sw.IsOn ? "Disable all" : "Enable all";
+ SkillsPageStatus.Text = sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.";
}
catch (Exception ex)
{
@@ -339,7 +405,9 @@ 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);
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..50080eb
--- /dev/null
+++ b/src/MandoCode.Desktop/MainWindow.Tags.cs
@@ -0,0 +1,209 @@
+using MandoCode.Desktop.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Markup;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.UI;
+
+namespace MandoCode.Desktop;
+
+public sealed partial class MainWindow
+{
+ private static IReadOnlyList TagChips(IEnumerable assignments, IReadOnlyList definitions) =>
+ assignments.Select(name => definitions.FirstOrDefault(tag => string.Equals(tag.Name, name, StringComparison.OrdinalIgnoreCase)))
+ .Where(tag => tag != null)
+ .Select(tag => new TagChip { Name = tag!.Name, Brush = new SolidColorBrush(ParseTagColor(tag.Color)) })
+ .ToList();
+
+ private static Color ParseTagColor(string value)
+ {
+ var hex = value.Trim().TrimStart('#');
+ return uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb) && hex.Length == 6
+ ? Color.FromArgb(255, (byte)(rgb >> 16), (byte)(rgb >> 8), (byte)rgb)
+ : Color.FromArgb(255, 59, 130, 246);
+ }
+
+ private void OpenRowTagMenu(Button anchor, TagScope scope, string itemKey)
+ {
+ var selected = _itemTags.GetItemTags(scope, itemKey)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var menu = new MenuFlyout();
+
+ foreach (var tag in _itemTags.GetTags(scope))
+ {
+ var item = new ToggleMenuFlyoutItem { Text = tag.Name, IsChecked = selected.Contains(tag.Name) };
+ item.Click += async (_, _) =>
+ {
+ if (item.IsChecked) selected.Add(tag.Name);
+ else selected.Remove(tag.Name);
+ _itemTags.SetItemTags(scope, itemKey, selected);
+ if (scope == TagScope.Mcps) await RefreshMcpListAsync();
+ else RefreshSkillsList();
+ };
+ menu.Items.Add(item);
+ }
+
+ if (menu.Items.Count > 0) menu.Items.Add(new MenuFlyoutSeparator());
+ var manage = new MenuFlyoutItem { Text = "Manage tags…" };
+ manage.Click += async (_, _) =>
+ {
+ await ShowTagManagerAsync(scope, scope == TagScope.Mcps ? "MCP tags" : "Skill tags");
+ if (scope == TagScope.Mcps) await RefreshMcpListAsync();
+ else RefreshSkillsList();
+ };
+ menu.Items.Add(manage);
+ menu.ShowAt(anchor);
+ }
+
+ private void McpRowTags_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { DataContext: McpRow row } button)
+ OpenRowTagMenu(button, TagScope.Mcps, row.Name);
+ }
+
+ private void SkillRowTags_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { DataContext: SkillRow row } button)
+ OpenRowTagMenu(button, TagScope.Skills, row.FolderPath);
+ }
+ private bool _managementToolbarLayoutQueued;
+
+ /// Keep the action and filter clusters on one row whenever they genuinely fit. Once
+ /// their measured widths would collide, move the complete Filters cluster below the actions
+ /// rather than shrinking, overlapping, or wrapping individual filter controls unpredictably.
+ private void ManagementToolbar_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_managementToolbarLayoutQueued) return;
+ _managementToolbarLayoutQueued = true;
+ _dispatcher.TryEnqueue(() =>
+ {
+ _managementToolbarLayoutQueued = false;
+ LayoutManagementToolbar(McpToolbar, McpActions, McpFilters);
+ LayoutManagementToolbar(SkillToolbar, SkillActions, SkillFilters);
+ });
+ }
+
+ private static void LayoutManagementToolbar(Grid toolbar, FrameworkElement actions, FrameworkElement filters)
+ {
+ if (toolbar.ActualWidth <= 0) return;
+ const double spacing = 16;
+ var needsSecondRow = actions.DesiredSize.Width + filters.DesiredSize.Width + spacing > toolbar.ActualWidth;
+ var desiredRow = needsSecondRow ? 1 : 0;
+ var desiredColumn = needsSecondRow ? 0 : 1;
+ var desiredSpan = needsSecondRow ? 2 : 1;
+
+ if (Grid.GetRow(filters) == desiredRow && Grid.GetColumn(filters) == desiredColumn &&
+ Grid.GetColumnSpan(filters) == desiredSpan) return;
+
+ Grid.SetRow(filters, desiredRow);
+ Grid.SetColumn(filters, desiredColumn);
+ Grid.SetColumnSpan(filters, desiredSpan);
+ filters.HorizontalAlignment = needsSecondRow ? HorizontalAlignment.Left : HorizontalAlignment.Right;
+ filters.Margin = needsSecondRow ? new Thickness(0, 8, 0, 0) : new Thickness(0);
+ }
+
+ private sealed record TagColorChoice(string Name, string Hex)
+ {
+ public SolidColorBrush Brush { get; } = new(ParseTagColor(Hex));
+ }
+
+ private sealed record TagDisplay(string Name, string Color)
+ {
+ public SolidColorBrush Brush { get; } = new(ParseTagColor(Color));
+ }
+
+ private static DataTemplate TagColorTemplate() => (DataTemplate)XamlReader.Load("""
+
+
+
+
+
+
+ """);
+
+ /// Shows the catalog for one management surface. Assignment is handled directly from
+ /// each row; this modal owns the reusable names and colors exposed by that surface's filter.
+ 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,
+ ItemTemplate = TagColorTemplate(),
+ };
+ var colors = new List
+ {
+ new("Blue", "#3B82F6"), new("Green", "#22C55E"), new("Gold", "#EAB308"),
+ new("Orange", "#F97316"), new("Red", "#EF4444"), new("Purple", "#A855F7"),
+ new("Pink", "#EC4899"), new("Slate", "#64748B"),
+ };
+ var color = new ComboBox
+ {
+ ItemsSource = colors,
+ ItemTemplate = TagColorTemplate(),
+ SelectedIndex = 0,
+ MinWidth = 130,
+ };
+ var add = new Button { Content = "Add tag" };
+ var remove = new Button { Content = "Remove selected" };
+
+ void Refresh()
+ {
+ list.ItemsSource = _itemTags.GetTags(scope)
+ .Select(tag => new TagDisplay(tag.Name, tag.Color))
+ .ToList();
+ remove.IsEnabled = list.SelectedItem is TagDisplay;
+ }
+
+ list.SelectionChanged += (_, _) => remove.IsEnabled = list.SelectedItem is TagDisplay;
+ add.Click += (_, _) =>
+ {
+ _itemTags.AddTag(scope, input.Text, (color.SelectedItem as TagColorChoice)?.Hex ?? "#3B82F6");
+ input.Text = "";
+ Refresh();
+ input.Focus(FocusState.Programmatic);
+ };
+ remove.Click += (_, _) =>
+ {
+ if (list.SelectedItem is TagDisplay tag)
+ {
+ _itemTags.DeleteTag(scope, tag.Name);
+ 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(color);
+ 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();
+ }
+
+}
diff --git a/src/MandoCode.Desktop/MainWindow.ViewModels.cs b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
index b843feb..cd0b62b 100644
--- a/src/MandoCode.Desktop/MainWindow.ViewModels.cs
+++ b/src/MandoCode.Desktop/MainWindow.ViewModels.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using MandoCode.Models;
@@ -185,14 +186,40 @@ public sealed class DiffLineVm
}
/// Row model for the MCP servers page.
-public sealed class McpRow
+public sealed class McpRow : INotifyPropertyChanged
{
public string Name { get; init; } = "";
public string Transport { get; init; } = "";
public string Status { get; init; } = "";
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; }
+ private bool _enabled;
+ public bool Enabled
+ {
+ get => _enabled;
+ set
+ {
+ if (_enabled == value) return;
+ _enabled = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Enabled)));
+ }
+ }
+ public IReadOnlyList Tags { get; init; } = [];
+ public IReadOnlyList TagChips { get; init; } = [];
+ public event PropertyChangedEventHandler? PropertyChanged;
+}
+
+/// 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; }
+}
+
+public sealed class TagChip
+{
+ public string Name { get; init; } = "";
+ public SolidColorBrush Brush { get; init; } = new(Colors.Gray);
}
/// A section of the skills list (e.g. "Enabled (12)"). A List subclass so a
@@ -212,13 +239,26 @@ public sealed class McpRowGroup : List
/// Row model for the global-skills page. FolderPath rides along so per-row actions
/// (the enable toggle) can act on the right skill without leaning on list selection.
-public sealed class SkillRow
+public sealed class SkillRow : INotifyPropertyChanged
{
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public string Body { get; init; } = "";
public string FolderPath { get; init; } = "";
- public bool Enabled { get; init; }
+ private bool _enabled;
+ public bool Enabled
+ {
+ get => _enabled;
+ set
+ {
+ if (_enabled == value) return;
+ _enabled = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Enabled)));
+ }
+ }
+ public IReadOnlyList Tags { get; init; } = [];
+ public IReadOnlyList TagChips { get; init; } = [];
+ public event PropertyChangedEventHandler? PropertyChanged;
// 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..7413b01 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -1424,10 +1424,12 @@
+
+
-
@@ -1437,6 +1439,25 @@
VerticalAlignment="Center"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1445,7 +1466,16 @@
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
+
+
+
+
+
+
-
@@ -1580,12 +1624,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs
index 251e875..57d40ea 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml.cs
+++ b/src/MandoCode.Desktop/MainWindow.xaml.cs
@@ -26,6 +26,7 @@ public sealed partial class MainWindow : Window
private readonly SessionArchiveStore _archive; // app-wide index of closed conversations
private readonly SkillCoordinator _skillCoordinator; // app-wide global-skills manager
private readonly ConfigCoordinator _configs; // owns the app-wide MCP server list (defaults)
+ private readonly ItemTagStore _itemTags = new(); // Desktop-only organization metadata for Skills/MCPs
private readonly TranscriptHtmlBuilder _html; // app-global, stateless formatter
private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher;
diff --git a/src/MandoCode.Desktop/Services/ItemTagStore.cs b/src/MandoCode.Desktop/Services/ItemTagStore.cs
new file mode 100644
index 0000000..aa45850
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/ItemTagStore.cs
@@ -0,0 +1,174 @@
+using System.Text.Json;
+
+namespace MandoCode.Desktop.Services;
+
+/// Which management surface owns a tag catalog. Tags deliberately do not cross this
+/// boundary: a skill category is not implicitly an MCP-server category.
+public enum TagScope { Skills, Mcps }
+
+/// A reusable tag label and its display color.
+public sealed record ItemTag(string Name, string Color);
+
+///
+/// Desktop-owned tags for the Skills and MCP management pages. The shared engine config and a
+/// skill's portable SKILL.md remain unchanged; this is organization metadata for this Desktop
+/// install. Keys are stable item identifiers (skill folder path or MCP server name).
+///
+public sealed class ItemTagStore
+{
+ private readonly string _path;
+ private readonly object _gate = new();
+
+ public ItemTagStore(string? path = null)
+ {
+ _path = path ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MandoCode.Desktop", "item-tags.json");
+ }
+
+ public IReadOnlyList GetTags(TagScope scope)
+ {
+ lock (_gate) return Catalog(Load(), scope).Tags.OrderBy(tag => tag.Name, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ public IReadOnlyList GetItemTags(TagScope scope, string itemKey)
+ {
+ lock (_gate)
+ {
+ var catalog = Catalog(Load(), scope);
+ return catalog.Assignments.TryGetValue(itemKey, out var tags)
+ ? tags.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase).ToList()
+ : [];
+ }
+ }
+
+ public void SetItemTags(TagScope scope, string itemKey, IEnumerable tags)
+ {
+ lock (_gate)
+ {
+ var state = Load();
+ var catalog = Catalog(state, scope);
+ var normalized = Normalize(tags);
+ foreach (var tag in normalized)
+ AddIfMissing(catalog.Tags, new ItemTag(tag, DefaultColor));
+
+ if (normalized.Count == 0) catalog.Assignments.Remove(itemKey);
+ else catalog.Assignments[itemKey] = normalized;
+ Save(state);
+ }
+ }
+
+ public void AddTag(TagScope scope, string tag, string color)
+ {
+ lock (_gate)
+ {
+ var normalized = Normalize([tag]);
+ if (normalized.Count == 0) return;
+ var state = Load();
+ AddIfMissing(Catalog(state, scope).Tags, new ItemTag(normalized[0], color));
+ Save(state);
+ }
+ }
+
+ public void DeleteTag(TagScope scope, string tag)
+ {
+ lock (_gate)
+ {
+ var state = Load();
+ var catalog = Catalog(state, scope);
+ catalog.Tags.RemoveAll(existing => string.Equals(existing.Name, tag, StringComparison.OrdinalIgnoreCase));
+ foreach (var (key, assigned) in catalog.Assignments.ToList())
+ {
+ assigned.RemoveAll(existing => string.Equals(existing, tag, StringComparison.OrdinalIgnoreCase));
+ if (assigned.Count == 0) catalog.Assignments.Remove(key);
+ }
+ Save(state);
+ }
+ }
+
+ public void RenameItem(TagScope scope, string oldKey, string newKey)
+ {
+ if (string.Equals(oldKey, newKey, StringComparison.Ordinal)) return;
+ lock (_gate)
+ {
+ var state = Load();
+ var assignments = Catalog(state, scope).Assignments;
+ if (assignments.Remove(oldKey, out var tags)) assignments[newKey] = tags;
+ Save(state);
+ }
+ }
+
+ public void DeleteItem(TagScope scope, string itemKey)
+ {
+ lock (_gate)
+ {
+ var state = Load();
+ Catalog(state, scope).Assignments.Remove(itemKey);
+ Save(state);
+ }
+ }
+
+ private ItemTagState Load()
+ {
+ try
+ {
+ if (File.Exists(_path))
+ return JsonSerializer.Deserialize(File.ReadAllText(_path))?.Normalize() ?? new ItemTagState();
+ }
+ catch { }
+ return new ItemTagState();
+ }
+
+ private void Save(ItemTagState state)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ File.WriteAllText(_path, JsonSerializer.Serialize(state));
+ }
+ catch { }
+ }
+
+ private static ItemTagCatalog Catalog(ItemTagState state, TagScope scope) =>
+ scope == TagScope.Skills ? state.Skills : state.Mcps;
+
+ private static List Normalize(IEnumerable tags) => tags
+ .Select(tag => tag.Trim())
+ .Where(tag => tag.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ private static void AddIfMissing(List tags, ItemTag tag)
+ {
+ if (!tags.Any(existing => string.Equals(existing.Name, tag.Name, StringComparison.OrdinalIgnoreCase))) tags.Add(tag);
+ }
+
+ private const string DefaultColor = "#3B82F6";
+
+ private sealed class ItemTagState
+ {
+ public ItemTagCatalog Skills { get; set; } = new();
+ public ItemTagCatalog Mcps { get; set; } = new();
+
+ public ItemTagState Normalize()
+ {
+ Skills ??= new();
+ Mcps ??= new();
+ Skills.Normalize();
+ Mcps.Normalize();
+ return this;
+ }
+ }
+
+ private sealed class ItemTagCatalog
+ {
+ public List Tags { get; set; } = [];
+ public Dictionary> Assignments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ public void Normalize()
+ {
+ Tags ??= [];
+ Assignments ??= new(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs
index 3f582ba..54ba9af 100644
--- a/src/MandoCode.Desktop/ViewModels/ChatController.cs
+++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs
@@ -1982,6 +1982,53 @@ private async Task HandleMcpCommandAsync(string rawArgs)
return (true, $"'{name}' saved but did not appear in active clients.");
}
+ ///
+ /// Changes the enabled state for a set of existing MCP servers as one app-wide update.
+ /// Persisting and reloading once matters here: a reload restarts the shared servers and
+ /// refreshes every open agent's tool registration.
+ ///
+ public async Task<(bool Ok, string Message)> SetMcpServersEnabledAsync(IEnumerable names, bool enabled)
+ {
+ var servers = _configs.Defaults.McpServers;
+ var affected = names.Distinct(StringComparer.OrdinalIgnoreCase)
+ .Where(servers.ContainsKey)
+ .ToList();
+ if (affected.Count == 0)
+ return (true, "No matching MCP servers.");
+
+ var prior = affected.ToDictionary(name => name, name => servers[name].Disabled,
+ StringComparer.OrdinalIgnoreCase);
+ foreach (var name in affected)
+ servers[name].Disabled = !enabled;
+
+ try
+ {
+ _configs.SaveDefaults();
+ _configs.SyncMcpServersToAgents();
+ }
+ catch (Exception ex)
+ {
+ foreach (var (name, disabled) in prior)
+ servers[name].Disabled = disabled;
+ _transcript.Append(_html.Error($"Failed to save config: {ex.Message}"));
+ return (false, $"Failed to save config: {ex.Message}");
+ }
+
+ _busy.Start(enabled ? "Enabling MCP servers..." : "Disabling MCP servers...");
+ try
+ {
+ await _mcp.ReloadAllAsync();
+ }
+ finally
+ {
+ _busy.Reset();
+ }
+
+ return (true, enabled
+ ? $"Enabled {affected.Count} MCP server(s)."
+ : $"Disabled {affected.Count} MCP server(s).");
+ }
+
public sealed record McpToolInfo(string Name, string? Description);
public sealed record McpTestResult(bool Ok, string Message, IReadOnlyList Tools);