diff --git a/src/OpenClaw.Shared/CommandCatalogPresentation.cs b/src/OpenClaw.Shared/CommandCatalogPresentation.cs index d8e3be33a..c3bac8ba0 100644 --- a/src/OpenClaw.Shared/CommandCatalogPresentation.cs +++ b/src/OpenClaw.Shared/CommandCatalogPresentation.cs @@ -110,6 +110,19 @@ private static string Normalize(string value) /// A named group of commands sharing a category, in display order. public sealed record CommandCategoryGroup(string Category, IReadOnlyList Commands); +/// +/// A grouped command palette: the display ; the same commands +/// in group/display order (the keyboard-navigation list); +/// and — the index in +/// of the GLOBAL best search match, i.e. the row keyboard selection should default +/// to so that display grouping never demotes a strong later-bucket match behind a +/// weak earlier-bucket one for Enter/Tab. +/// +public sealed record GroupedPalette( + IReadOnlyList Groups, + IReadOnlyList Flattened, + int DefaultSelectionIndex); + /// /// Ranked search + category grouping over a set of gateway commands for the chat /// command palette. Distinct from (a boolean @@ -161,20 +174,79 @@ public IReadOnlyList Search(string? query) } /// - /// Groups commands by category (falling back to source label, then "Other"), - /// optionally filtered by the same search used in . - /// Groups and their members are returned in a stable, display-friendly order. + /// Groups commands by their raw category (falling back to source label, then + /// "Other"), optionally filtered/ordered. See . + /// + public IReadOnlyList GroupByCategory( + string? query = null, IReadOnlyList? categoryOrder = null) + => GroupBy(CategoryKey, query, categoryOrder); + + /// + /// Groups commands using a caller-supplied category selector, optionally + /// filtered by the same search used in . Members within a + /// group preserve the relevance order produced by + /// (alphabetical when the query is empty), so the top match stays first. When + /// is supplied, groups are ordered by that + /// sequence first (unlisted categories sort last, alphabetically); otherwise + /// groups are ordered alphabetically. The selector lets the palette group by a + /// mapped bucket (e.g. Mac's Session/Model/Tools/Agents) rather than the raw + /// wire category. /// - public IReadOnlyList GroupByCategory(string? query = null) + public IReadOnlyList GroupBy( + Func categorySelector, + string? query = null, + IReadOnlyList? categoryOrder = null) { var matched = Search(query); - return matched - .GroupBy(CategoryKey, StringComparer.OrdinalIgnoreCase) - .Select(g => new CommandCategoryGroup(g.Key, Ordered(g).ToList())) + // GroupBy preserves source (relevance) order within each group; do NOT + // re-sort the members — that would demote a strong match below a weaker + // one and change the default Enter target. + var groups = matched + .GroupBy(categorySelector, StringComparer.OrdinalIgnoreCase) + .Select(g => new CommandCategoryGroup(g.Key, g.ToList())); + + if (categoryOrder is { Count: > 0 }) + { + int Rank(string category) + { + for (int i = 0; i < categoryOrder.Count; i++) + if (string.Equals(categoryOrder[i], category, StringComparison.OrdinalIgnoreCase)) + return i; + return int.MaxValue; + } + + return groups + .OrderBy(g => Rank(g.Category)) + .ThenBy(g => g.Category, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + return groups .OrderBy(g => g.Category, StringComparer.OrdinalIgnoreCase) .ToList(); } + /// + /// Groups commands for the chat command palette (visual grouping) and also + /// computes — the index, + /// within the flattened group order, of the GLOBAL best + /// match. Display grouping orders by bucket, which can render a strong + /// later-bucket match after a weak earlier-bucket one; keyboard selection + /// should default to this index (not 0) to preserve the flat-search Enter/Tab + /// target. + /// + public GroupedPalette GroupForPalette( + Func categorySelector, + string? query = null, + IReadOnlyList? categoryOrder = null) + { + var groups = GroupBy(categorySelector, query, categoryOrder); + var flattened = groups.SelectMany(g => g.Commands).ToList(); + var top = Search(query).FirstOrDefault(); + var defaultIndex = top is null ? 0 : flattened.IndexOf(top); + return new GroupedPalette(groups, flattened, defaultIndex < 0 ? 0 : defaultIndex); + } + private static string CategoryKey(GatewayCommand cmd) { if (!string.IsNullOrWhiteSpace(cmd.Category)) return cmd.Category!.Trim(); @@ -216,3 +288,114 @@ void Consider(string? token, int exact, int prefix, int contains) return best; } } + +/// +/// Mac/web parity for the slash command palette grouping (slash-commands.ts): +/// maps each command into one of four display buckets — Session, Model, Tools, +/// Agents — via per-command name overrides first, then a raw-category fallback +/// (session→Session, options→Model, management→Tools, everything else→Tools). +/// +public static class CommandCategories +{ + /// Bucket display order, mirroring Mac's CATEGORY_ORDER. + public static readonly IReadOnlyList DisplayOrder = new[] + { + "session", "model", "tools", "agents", + }; + + private static readonly Dictionary Labels = new(StringComparer.OrdinalIgnoreCase) + { + ["session"] = "Session", + ["model"] = "Model", + ["tools"] = "Tools", + ["agents"] = "Agents", + }; + + // Per-command bucket overrides keyed by normalized command name. Mirrors + // Mac's CATEGORY_OVERRIDES, applied before the raw-category fallback so e.g. + // /usage (raw category "options") lands in Tools rather than Model. This is a + // deliberate SUPERSET of Mac's map: it additionally pins export/kill/focus/ + // unfocus, which Mac leaves to its raw-category fallback. + private static readonly Dictionary BucketOverrides = new(StringComparer.OrdinalIgnoreCase) + { + ["help"] = "tools", + ["commands"] = "tools", + ["tools"] = "tools", + ["skill"] = "tools", + ["status"] = "tools", + ["export_session"] = "tools", + ["export"] = "tools", + ["usage"] = "tools", + ["tts"] = "tools", + ["agents"] = "agents", + ["subagents"] = "agents", + ["kill"] = "agents", + ["steer"] = "agents", + ["redirect"] = "agents", + ["session"] = "session", + ["stop"] = "session", + ["reset"] = "session", + ["new"] = "session", + ["compact"] = "session", + ["focus"] = "session", + ["unfocus"] = "session", + ["model"] = "model", + ["models"] = "model", + ["think"] = "model", + ["verbose"] = "model", + ["fast"] = "model", + ["reasoning"] = "model", + ["elevated"] = "model", + ["queue"] = "model", + }; + + /// + /// Display bucket (session/model/tools/agents) for a command, mirroring Mac's + /// mapCategory: name override first, then raw-category fallback. + /// + public static string Bucket(GatewayCommand command) + { + if (command is null) return "tools"; + var key = NormalizeKey(command); + if (!string.IsNullOrEmpty(key) && BucketOverrides.TryGetValue(key, out var bucket)) + return bucket; + + return (command.Category?.Trim().ToLowerInvariant()) switch + { + "session" => "session", + "options" => "model", + "management" => "tools", + _ => "tools", + }; + } + + /// Friendly heading for a bucket key; Title-cases unknowns. + public static string Label(string? bucket) + { + if (string.IsNullOrWhiteSpace(bucket)) return "Other"; + var key = bucket!.Trim(); + if (Labels.TryGetValue(key, out var label)) return label; + return char.ToUpperInvariant(key[0]) + (key.Length > 1 ? key[1..] : ""); + } + + // Normalizes a command name to an override key the way Mac's normalizeUiKey + // does (lowercase, strip a leading slash, ':' '.' '-' → '_'). Mac keys off + // command.key; the wire command carries no key here, and the catalog is + // requested with text scope, so the text-surface Name is the closest analog — + // fall back to the first text alias, then the native name. + private static string NormalizeKey(GatewayCommand command) + { + var raw = !string.IsNullOrWhiteSpace(command.Name) + ? command.Name + : command.TextAliases?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)); + if (string.IsNullOrWhiteSpace(raw)) raw = command.NativeName; + raw = (raw ?? "").Trim(); + if (raw.Length == 0) return ""; + if (raw[0] == '/') raw = raw[1..]; + raw = raw.ToLowerInvariant(); + var chars = raw.ToCharArray(); + for (int i = 0; i < chars.Length; i++) + if (chars[i] is ':' or '.' or '-') chars[i] = '_'; + return new string(chars); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index af2f1bf3f..4567feeed 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -454,16 +454,23 @@ public override Element Render() }, needsCatalog); IReadOnlyList slashResults = Array.Empty(); + IReadOnlyList slashGroups = Array.Empty(); + // Index (within the flattened group order) of the GLOBAL best search match + // — the default keyboard selection, so display grouping never demotes a + // strong later-bucket match behind a weak earlier-bucket one for Enter/Tab. + var slashDefaultIndex = 0; if (slashActive && !slash.ArgsMode && Props.AvailableCommands is { } slashCmds) { - // Relevance-ranked; index 0 is the top match and is what Enter inserts - // when the user hasn't navigated. (No category re-sort — that would - // demote an exact match below a weakly-matched command in an - // earlier-ordered category.) - slashResults = new ChatCommandCatalogView(slashCmds) - .Search(slash.Query) - .Take(SlashMenuMaxItems) - .ToList(); + // Category-grouped palette (Mac/web parity): commands render under + // their Mac display bucket (Session, Model, Tools, Agents). Within a + // bucket the relevance order from Search is preserved; Flattened drives + // keyboard navigation, and DefaultSelectionIndex pins the global top + // match as the default Enter/Tab target. + var palette = new ChatCommandCatalogView(slashCmds) + .GroupForPalette(CommandCategories.Bucket, slash.Query, CommandCategories.DisplayOrder); + slashGroups = palette.Groups; + slashResults = palette.Flattened; + slashDefaultIndex = palette.DefaultSelectionIndex; } // Args-mode: the command (parsed from the composer text) plus its static @@ -483,9 +490,13 @@ public override Element Render() var inArgsMode = slash.ArgsMode && slashArgCmd is not null && slashArgResults.Count > 0; var slashSelectableCount = inArgsMode ? slashArgResults.Count : slashResults.Count; + // slash.Index < 0 means "not navigated yet": default to the global best + // command match (slashDefaultIndex) in command mode, or the first arg + // choice in args mode. Up/Down then make it a concrete index. + var slashDefault = inArgsMode ? 0 : slashDefaultIndex; var slashIndex = slashSelectableCount == 0 ? 0 - : Math.Clamp(slash.Index, 0, slashSelectableCount - 1); + : Math.Clamp(slash.Index < 0 ? slashDefault : slash.Index, 0, slashSelectableCount - 1); // Pushes a new composer value into the textbox and restores the caret to // the end (shared by command insertion and arg-choice insertion). @@ -534,7 +545,9 @@ public override Element Render() var (active, query, argsMode) = ComputeSlashState(v, Props.AvailableCommands); var cur = slashMenuState.Value; if (active != cur.Active || query != cur.Query || argsMode != cur.ArgsMode) - slashMenuState.Set((active, query, 0, argsMode)); + // -1 = "not navigated": selection resolves to the global best + // match (see slashIndex) until the user presses Up/Down. + slashMenuState.Set((active, query, -1, argsMode)); }) .Set(tb => { @@ -640,11 +653,11 @@ public override Element Render() return; } } - else if (slashActive) + else if (slashActive && Props.AvailableCommands is null) { - // Popup is up but nothing is selectable: either the catalog is - // still loading or no command matches the typed text. - var slashLoading = Props.AvailableCommands is null; + // The loading popup is visible but nothing is selectable yet. + // No-match input hides the popup, so it must fall through as + // ordinary composer text instead of trapping Tab/Escape/arrows. if (key == global::Windows.System.VirtualKey.Escape || key == global::Windows.System.VirtualKey.Tab) { @@ -661,12 +674,10 @@ public override Element Render() e.Handled = true; return; } - if (slashLoading && key == global::Windows.System.VirtualKey.Enter) + if (key == global::Windows.System.VirtualKey.Enter) { // We don't yet know whether "/token" is a real command, so // don't let Enter send it as raw text and race the fetch. - // (Once loaded with no match it's not a command, so Enter - // falls through below and sends as an ordinary message.) e.Handled = true; return; } @@ -1088,12 +1099,13 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = } else if (slashResults.Count == 0) { - slashPopupContent = BuildSlashHintPopup("No matching commands"); - slashMenuVisible = true; + // No command matches the typed text — hide the palette entirely + // (no "no matches" hint) so the composer reads as normal. + slashMenuVisible = false; } else { - slashPopupContent = BuildSlashPopup(slashResults, slashIndex, insertSlashCommand); + slashPopupContent = BuildSlashPopup(slashGroups, slashIndex, slash.Query, insertSlashCommand); slashMenuVisible = true; } } @@ -1302,15 +1314,27 @@ private static Border BuildSlashHintPopup(string text) } private static Border BuildSlashPopup( - IReadOnlyList results, int selectedIndex, Action onPick) + IReadOnlyList groups, int selectedIndex, string query, Action onPick) { var primary = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"]; var secondary = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorSecondaryBrush"]; var selectedBg = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SubtleFillColorSecondaryBrush"]; + var headerBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorTertiaryBrush"]; var list = new StackPanel { Orientation = Orientation.Vertical }; - for (int i = 0; i < results.Count; i++) - list.Children.Add(SlashRow(results[i], i == selectedIndex, primary, secondary, selectedBg, onPick)); + // Each group leads with a non-interactive category subheading; command + // rows follow. A single running index across all groups keeps the + // rendered rows aligned with the flattened keyboard-navigation list. + var idx = 0; + foreach (var group in groups) + { + list.Children.Add(SlashCategoryHeader(CommandCategories.Label(group.Category), headerBrush)); + foreach (var cmd in group.Commands) + { + list.Children.Add(SlashRow(cmd, idx == selectedIndex, query, primary, secondary, selectedBg, onPick)); + idx++; + } + } var scroll = new ScrollViewer { VerticalScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility.Auto, @@ -1321,6 +1345,22 @@ private static Border BuildSlashPopup( return SlashShell(scroll); } + /// + /// Non-interactive category subheading for the slash palette: uppercase and + /// letter-spaced like Mac's .slash-menu-group__label, rendered in a muted + /// tone (Mac tints its label with the accent color; we keep it subdued to + /// match the reference design). + /// + private static TextBlock SlashCategoryHeader(string text, Brush foreground) => new() + { + Text = (text ?? "").ToUpperInvariant(), + FontSize = 11, + FontWeight = Microsoft.UI.Text.FontWeights.Bold, + CharacterSpacing = 60, // ~0.06em (units are 1/1000 em) + Foreground = foreground, + Margin = new Thickness(8, 8, 8, 2), + }; + private static Border BuildSlashArgPopup( GatewayCommand cmd, IReadOnlyList choices, int selectedIndex, Action onPick) @@ -1405,13 +1445,32 @@ private static Border SlashShell(UIElement child) // Floating, opaque, elevated container for the slash menu. Uses the same // 8px corner radius + default surface stroke as the composer card, with a // soft shadow so the menu reads as a distinct layer over the chat content. + var res = Microsoft.UI.Xaml.Application.Current.Resources; + + // Match the composer input's background, but OPAQUE. TextControlBackground + // is a semi-transparent overlay (fine over the solid composer card, but it + // would show chat content through this floating popup), so composite it + // over the base surface into a solid color. + Brush background; + if (res["TextControlBackground"] as SolidColorBrush is { } overlay + && res["SolidBackgroundFillColorBaseBrush"] as SolidColorBrush is { } baseBrush) + { + var a = overlay.Color.A / 255.0; + byte Mix(byte b, byte o) => (byte)Math.Round(b * (1 - a) + o * a); + var o = overlay.Color; + var b = baseBrush.Color; + background = new SolidColorBrush( + global::Windows.UI.Color.FromArgb(255, Mix(b.R, o.R), Mix(b.G, o.G), Mix(b.B, o.B))); + } + else + { + background = (Brush)res["SolidBackgroundFillColorBaseBrush"]; + } + var shell = new Border { - // Elevated flyout surface: Tertiary reads lighter than the chat - // background (in dark theme Secondary is actually darker than Base), - // so the menu lifts off the content instead of sinking into it. - Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SolidBackgroundFillColorTertiaryBrush"], - BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SurfaceStrokeColorDefaultBrush"], + Background = background, + BorderBrush = (Brush)res["SurfaceStrokeColorDefaultBrush"], BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(4), @@ -1422,63 +1481,150 @@ private static Border SlashShell(UIElement child) return shell; } + // Highlights every case-insensitive occurrence of the typed query inside a + // row TextBlock (command name / description) with a soft accent tint, so it's + // clear what the filter matched. Uses TextHighlighter (rectangular) because it + // is the only WinUI text-highlight that doesn't disturb the line layout — + // inline rounded "chip" elements (InlineUIContainer) render as superscript and + // break the baseline. No-op when the query is empty or shorter than the text. + private static void ApplyQueryHighlight(TextBlock tb, string? query) + { + tb.TextHighlighters.Clear(); + var text = tb.Text ?? ""; + var q = (query ?? "").Trim().TrimStart('/').Trim(); + if (q.Length == 0 || text.Length < q.Length) return; + + var accent = Microsoft.UI.Xaml.Application.Current.Resources["AccentFillColorDefaultBrush"] as SolidColorBrush + ?? new SolidColorBrush(Microsoft.UI.Colors.SteelBlue); + // IMPORTANT: TextHighlighter ignores SolidColorBrush.Opacity (unlike a + // normal element background), so the alpha must be baked into the Color + // itself — otherwise it renders the full, vivid accent. ~12% alpha gives + // the same soft, muted blue-grey the old padded chip had. + var ac = accent.Color; + var tint = global::Windows.UI.Color.FromArgb(31, ac.R, ac.G, ac.B); + var highlighter = new Microsoft.UI.Xaml.Documents.TextHighlighter + { + Background = new SolidColorBrush(tint), + // Make the matched text pop over the tint (white in dark theme, near- + // black in light) — theme-aware via the primary text brush. + Foreground = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"], + }; + + var i = 0; + while (i <= text.Length - q.Length) + { + var found = text.IndexOf(q, i, StringComparison.OrdinalIgnoreCase); + if (found < 0) break; + highlighter.Ranges.Add(new Microsoft.UI.Xaml.Documents.TextRange { StartIndex = found, Length = q.Length }); + i = found + q.Length; + } + + if (highlighter.Ranges.Count > 0) tb.TextHighlighters.Add(highlighter); + } + private static Button SlashRow( - GatewayCommand cmd, bool selected, Brush primary, Brush secondary, Brush selectedBg, Action onPick) + GatewayCommand cmd, bool selected, string query, Brush primary, Brush secondary, Brush selectedBg, Action onPick) { - var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - row.Children.Add(new FontIcon + // Row layout mirrors Mac's .slash-menu-item: icon · /name · [args] on the + // left; description + "N options" badge pushed to the right edge (the + // description column is star-sized and right-aligned). Args use the mono + // font; the name keeps the default font (bold) for readability. + var mono = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"); + + var grid = new Microsoft.UI.Xaml.Controls.Grid { ColumnSpacing = 8, VerticalAlignment = VerticalAlignment.Center }; + grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto }); // icon + grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto }); // name + grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto }); // args + grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1, Microsoft.UI.Xaml.GridUnitType.Star) }); // desc + grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto }); // badge + + var icon = new FontIcon { FontFamily = (Microsoft.UI.Xaml.Media.FontFamily)Microsoft.UI.Xaml.Application.Current.Resources["SymbolThemeFontFamily"], Glyph = SlashGlyph(cmd), FontSize = 14, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center, - }); - row.Children.Add(new TextBlock + }; + Microsoft.UI.Xaml.Controls.Grid.SetColumn(icon, 0); + grid.Children.Add(icon); + + var name = new TextBlock { Text = cmd.DisplayName(), FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = primary, VerticalAlignment = VerticalAlignment.Center, - }); + }; + Microsoft.UI.Xaml.Controls.Grid.SetColumn(name, 1); + grid.Children.Add(name); + ApplyQueryHighlight(name, query); + var args = cmd.ArgTemplate(); if (!string.IsNullOrWhiteSpace(args)) { - row.Children.Add(new TextBlock + var argBlock = new TextBlock { Text = args, FontSize = 12, + FontFamily = mono, Foreground = secondary, + Opacity = 0.75, VerticalAlignment = VerticalAlignment.Center, - }); + }; + Microsoft.UI.Xaml.Controls.Grid.SetColumn(argBlock, 2); + grid.Children.Add(argBlock); } + if (!string.IsNullOrWhiteSpace(cmd.Description)) { - row.Children.Add(new TextBlock + var desc = new TextBlock { Text = cmd.Description!, FontSize = 12, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Right, + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Right, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1, - }); + }; + Microsoft.UI.Xaml.Controls.Grid.SetColumn(desc, 3); + grid.Children.Add(desc); + ApplyQueryHighlight(desc, query); } + var opts = cmd.OptionCount(); - if (opts > 0) row.Children.Add(SlashBadge($"{opts} options", secondary)); + if (opts > 0) + { + var badge = SlashBadge($"{opts} options"); + Microsoft.UI.Xaml.Controls.Grid.SetColumn(badge, 4); + grid.Children.Add(badge); + } var btn = new Button { - Content = row, + Content = grid, Padding = new Thickness(8, 7, 8, 7), HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, + // Stretch the content so the star-sized description column fills the + // row and its right-alignment actually pushes desc/badge to the edge. + HorizontalContentAlignment = HorizontalAlignment.Stretch, CornerRadius = new CornerRadius(6), Background = selected ? selectedBg : new SolidColorBrush(Colors.Transparent), BorderThickness = new Thickness(0), }; btn.Click += (_, _) => onPick(cmd); + // Auto-scroll: when this is the keyboard-selected row, bring it into view + // once it mounts so arrow navigation past the visible fold follows the + // selection (the popup content is rebuilt each render, so Loaded fires per + // navigation step). No animation to keep keypress scrolling snappy. + if (selected) + { + btn.Loaded += (_, _) => + btn.StartBringIntoView(new Microsoft.UI.Xaml.BringIntoViewOptions { AnimationDesired = false }); + } return btn; } @@ -1509,16 +1655,27 @@ private static string SlashGlyph(GatewayCommand cmd) }; } - private static Border SlashBadge(string text, Brush foreground) => new() + // Accent-tinted pill mirroring Mac's .slash-menu-badge (accent text on a + // ~14% accent fill). + private static Border SlashBadge(string text) { - Padding = new Thickness(5, 1, 5, 1), - CornerRadius = new CornerRadius(4), - Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SubtleFillColorSecondaryBrush"], - BorderThickness = new Thickness(1), - BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["ControlStrokeColorDefaultBrush"], - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock { Text = text, FontSize = 10, Foreground = foreground }, - }; + var accent = Microsoft.UI.Xaml.Application.Current.Resources["AccentFillColorDefaultBrush"] as SolidColorBrush + ?? new SolidColorBrush(Microsoft.UI.Colors.SteelBlue); + return new Border + { + Padding = new Thickness(6, 1, 6, 1), + CornerRadius = new CornerRadius(4), + Background = new SolidColorBrush(accent.Color) { Opacity = 0.14 }, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = text, + FontSize = 10, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + Foreground = accent, + }, + }; + } /// /// Creates (once) and drives the floating slash-menu Popup so it overlays diff --git a/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs b/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs index 75aca6d10..7d7309927 100644 --- a/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs +++ b/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs @@ -258,6 +258,159 @@ public void GroupByCategory_RespectsSearchFilter() Assert.Equal("model", all[0].Name); } + [Fact] + public void GroupBy_WithExplicitCategoryOrder_OrdersBySequenceThenAlphabetical() + { + var view = new ChatCommandCatalogView(new[] + { + new GatewayCommand { Name = "verbose", NativeName = "/verbose", Category = "options" }, + new GatewayCommand { Name = "stop", NativeName = "/stop", Category = "session" }, + new GatewayCommand { Name = "send", NativeName = "/send", Category = "management" }, + new GatewayCommand { Name = "zeta", NativeName = "/zeta", Category = "zzz-unknown" }, + }); + + // Listed categories follow the supplied order; the unlisted one sorts last. + var order = new[] { "session", "options", "management" }; + var categories = view.GroupByCategory(null, order).Select(g => g.Category).ToList(); + + Assert.Equal(new[] { "session", "options", "management", "zzz-unknown" }, categories); + } + + [Fact] + public void GroupByCategory_NullCategoryOrder_StillOrdersAlphabetically() + { + var view = new ChatCommandCatalogView(Sample()); + var categories = view.GroupByCategory(null, null).Select(g => g.Category).ToList(); + Assert.Equal(categories.OrderBy(c => c, System.StringComparer.OrdinalIgnoreCase).ToList(), categories); + } + + // ── CommandCategories: Mac 4-bucket mapping ── + + [Fact] + public void GroupBy_MacBucket_GroupsRunOptionsUnderModel() + { + var view = new ChatCommandCatalogView(new[] + { + new GatewayCommand { Name = "verbose", NativeName = "/verbose", Category = "options" }, + new GatewayCommand { Name = "exec", NativeName = "/exec", Category = "options" }, + new GatewayCommand { Name = "usage", NativeName = "/usage", Category = "options" }, // override → tools + new GatewayCommand { Name = "stop", NativeName = "/stop", Category = "session" }, + new GatewayCommand { Name = "send", NativeName = "/send", Category = "management" }, // → tools + new GatewayCommand { Name = "steer", NativeName = "/steer", Category = "tools" }, // override → agents + }); + + var groups = view.GroupBy(CommandCategories.Bucket, null, CommandCategories.DisplayOrder); + var keys = groups.Select(g => g.Category).ToList(); + + // Mac order: session, model, tools, agents. + Assert.Equal(new[] { "session", "model", "tools", "agents" }, keys); + Assert.Equal(new[] { "/exec", "/verbose" }, groups.Single(g => g.Category == "model").Commands.Select(c => c.DisplayName()).ToArray()); + Assert.Contains(groups.Single(g => g.Category == "tools").Commands, c => c.Name == "usage"); + Assert.Contains(groups.Single(g => g.Category == "tools").Commands, c => c.Name == "send"); + Assert.Contains(groups.Single(g => g.Category == "agents").Commands, c => c.Name == "steer"); + } + + [Fact] + public void GroupBy_PreservesSearchRelevanceWithinGroup_NotAlphabetical() + { + // Both commands bucket under "model" (raw category options). For query + // "model", "model" is an exact match while "amodel" is only a contains + // match — relevance must keep "model" first even though "amodel" sorts + // first alphabetically. Guards against re-introducing a within-group sort. + var view = new ChatCommandCatalogView(new[] + { + new GatewayCommand { Name = "amodel", NativeName = "/amodel", Category = "options" }, + new GatewayCommand { Name = "model", NativeName = "/model", Category = "options" }, + }); + + var model = view.GroupBy(CommandCategories.Bucket, "model", CommandCategories.DisplayOrder) + .Single(g => g.Category == "model"); + + Assert.Equal(new[] { "/model", "/amodel" }, model.Commands.Select(c => c.DisplayName()).ToArray()); + } + + [Fact] + public void GroupForPalette_DefaultSelection_PinsGlobalBestAcrossBuckets() + { + // "exec" is an exact match in the Model bucket; "reexec" is only a contains + // match in the earlier Session bucket. Display grouping renders Session + // first, so the weak match is row 0 — but DefaultSelectionIndex must point + // at the global best (exec), so Enter/Tab still inserts the strongest match + // rather than the first visible row. Regression guard for grouped selection. + var view = new ChatCommandCatalogView(new[] + { + new GatewayCommand { Name = "reexec", NativeName = "/reexec", Category = "session" }, + new GatewayCommand { Name = "exec", NativeName = "/exec", Category = "options" }, + }); + + var palette = view.GroupForPalette(CommandCategories.Bucket, "exec", CommandCategories.DisplayOrder); + + // Flattened (render/nav) order follows bucket order: weak Session match first. + Assert.Equal(new[] { "reexec", "exec" }, palette.Flattened.Select(c => c.Name).ToArray()); + // But the default keyboard selection is the global best match, not row 0. + Assert.Equal(1, palette.DefaultSelectionIndex); + Assert.Equal("exec", palette.Flattened[palette.DefaultSelectionIndex].Name); + } + + [Fact] + public void GroupForPalette_DefaultSelection_IsZeroWhenNoMatches() + { + var view = new ChatCommandCatalogView(new[] + { + new GatewayCommand { Name = "exec", NativeName = "/exec", Category = "options" }, + }); + var palette = view.GroupForPalette(CommandCategories.Bucket, "zzznope", CommandCategories.DisplayOrder); + Assert.Empty(palette.Flattened); + Assert.Equal(0, palette.DefaultSelectionIndex); + } + + [Fact] + public void GroupForPalette_FlattenedMatchesGroupedOrder() + { + // Flattened must equal the groups concatenated in order, so the composer's + // running render index stays aligned with the keyboard-navigation list. + var view = new ChatCommandCatalogView(Sample()); + var palette = view.GroupForPalette(CommandCategories.Bucket, null, CommandCategories.DisplayOrder); + Assert.Equal( + palette.Groups.SelectMany(g => g.Commands).Select(c => c.Name).ToArray(), + palette.Flattened.Select(c => c.Name).ToArray()); + } + + [Theory] + [InlineData("verbose", "options", "model")] // run option → Model + [InlineData("exec", "options", "model")] + [InlineData("trace", "options", "model")] // no override; raw "options" → model + [InlineData("usage", "options", "tools")] // override wins over raw category + [InlineData("think", "options", "model")] + [InlineData("stop", "session", "session")] + [InlineData("send", "management", "tools")] // raw "management" → tools + [InlineData("steer", "tools", "agents")] // override → agents + [InlineData("mystery", "weirdcat", "tools")] // unknown name + unknown category → tools + public void Bucket_MapsCommandToMacDisplayBucket(string name, string category, string expected) + { + var cmd = new GatewayCommand { Name = name, NativeName = "/" + name, Category = category }; + Assert.Equal(expected, CommandCategories.Bucket(cmd)); + } + + [Theory] + [InlineData("session", "Session")] + [InlineData("model", "Model")] + [InlineData("tools", "Tools")] + [InlineData("agents", "Agents")] + [InlineData("custom", "Custom")] // unknown → Title-cased + [InlineData("", "Other")] + [InlineData(null, "Other")] + public void CommandCategories_Label_MapsKnownAndTitleCasesUnknown(string? bucket, string expected) + { + Assert.Equal(expected, CommandCategories.Label(bucket)); + } + + [Fact] + public void CommandCategories_DisplayOrder_MatchesMac() + { + Assert.Equal(new[] { "session", "model", "tools", "agents" }, CommandCategories.DisplayOrder.ToArray()); + } + [Fact] public void View_NullCommands_IsEmpty() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index a5ad0264b..9b1019706 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -447,6 +447,17 @@ public void SandboxPage_RejectsTurningOnWhenMxcIsDefinitivelyUnavailable() Assert.Contains("usable MXC backend", reject); } + [Fact] + public void ChatSlashPalette_HiddenNoMatchStateDoesNotTrapKeys() + { + var source = ReadOpenClawComposerSource(); + + Assert.Contains("else if (slashActive && Props.AvailableCommands is null)", source); + Assert.Contains("No-match input hides the popup", source); + Assert.Contains("ordinary composer text", source); + Assert.DoesNotContain("var slashLoading = Props.AvailableCommands is null;", source); + } + private static string ReadCoordinatorSource() { var root = TestRepositoryPaths.GetRepositoryRoot(); @@ -480,6 +491,13 @@ private static string ReadSandboxPageSource() root, "src", "OpenClaw.Tray.WinUI", "Pages", "SandboxPage.xaml.cs")); } + private static string ReadOpenClawComposerSource() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + return File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawComposer.cs")); + } + private static string ExtractMethod(string source, string methodName) { var match = Regex.Match(