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 @@ - + + + + + + + + + + - + + + - + + + - + + + + + + + + + + - + + + - + + +