From 6d9a4720874119716ea8747411fa7814ea05e3c2 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 29 Jun 2026 20:51:19 -0700 Subject: [PATCH 1/3] Chat: group the / command palette by Mac buckets with styled rows Enhance the inline "/" slash command palette (added in #890) so it matches Mac's grouped, styled command menu: - Group commands under Mac's Session / Model / Tools / Agents display buckets with uppercase category subheadings; the run options (verbose/reasoning/fast/trace/usage/elevated/exec/think/model) surface under "Model" instead of being truncated off a flat alphabetical list. - Mac-style rows: icon, /name (bold, default font), [args] hint (mono, muted), right-aligned description, and an accent-tinted "N options" badge. - The palette filters and ranks live as you type; relevance order is preserved within each group so the top match stays the default Enter target. - Auto-scroll the keyboard-selected row into view during arrow navigation. Bucket mapping mirrors Mac's mapCategory + CATEGORY_OVERRIDES; ordering mirrors CATEGORY_ORDER. No item cap (the popup ScrollViewer bounds height, so no bucket is silently dropped). No custom "Run options" menu (Mac has none) and no sessions.patch UI plumbing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommandCatalogPresentation.cs | 163 +++++++++++++++++- .../Chat/OpenClawComposer.cs | 157 +++++++++++++---- .../CommandCatalogPresentationTests.cs | 106 ++++++++++++ 3 files changed, 386 insertions(+), 40 deletions(-) diff --git a/src/OpenClaw.Shared/CommandCatalogPresentation.cs b/src/OpenClaw.Shared/CommandCatalogPresentation.cs index d8e3be33a..86b4ab9d1 100644 --- a/src/OpenClaw.Shared/CommandCatalogPresentation.cs +++ b/src/OpenClaw.Shared/CommandCatalogPresentation.cs @@ -161,16 +161,54 @@ 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) + 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 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(); } @@ -216,3 +254,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..4fb399a0e 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -454,16 +454,24 @@ public override Element Render() }, needsCatalog); IReadOnlyList slashResults = Array.Empty(); + IReadOnlyList slashGroups = Array.Empty(); 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) instead of + // one flat alphabetical list — so the run options (verbose/reasoning/ + // exec/…, bucketed under "Model") surface under their heading. Within + // a bucket the relevance order from Search is preserved, so the top + // match stays the default Enter target. + slashGroups = new ChatCommandCatalogView(slashCmds) + .GroupBy(CommandCategories.Bucket, slash.Query, CommandCategories.DisplayOrder); + + // Flatten in group/display order for keyboard navigation; BuildSlashPopup + // walks the same groups with a running index, so rendered rows stay + // index-aligned with this list. No item cap — the popup's ScrollViewer + // bounds the height, so no bucket is silently dropped (the Windows wire + // catalog carries no tier data to hide "power" commands the way Mac does). + slashResults = slashGroups.SelectMany(g => g.Commands).ToList(); } // Args-mode: the command (parsed from the composer text) plus its static @@ -1093,7 +1101,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = } else { - slashPopupContent = BuildSlashPopup(slashResults, slashIndex, insertSlashCommand); + slashPopupContent = BuildSlashPopup(slashGroups, slashIndex, insertSlashCommand); slashMenuVisible = true; } } @@ -1302,15 +1310,27 @@ private static Border BuildSlashHintPopup(string text) } private static Border BuildSlashPopup( - IReadOnlyList results, int selectedIndex, Action onPick) + IReadOnlyList groups, int selectedIndex, 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, primary, secondary, selectedBg, onPick)); + idx++; + } + } var scroll = new ScrollViewer { VerticalScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility.Auto, @@ -1321,6 +1341,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) @@ -1425,60 +1461,104 @@ private static Border SlashShell(UIElement child) private static Button SlashRow( GatewayCommand cmd, bool selected, 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); + 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); } + 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 +1589,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..b81f35928 100644 --- a/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs +++ b/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs @@ -258,6 +258,112 @@ 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()); + } + + [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() { From d6a2e1d49a053ac8d5e5dfd1c1c0869ee61d40ca Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 29 Jun 2026 23:15:59 -0700 Subject: [PATCH 2/3] Chat palette: match highlight, opaque flyout, global-best selection UI polish for the grouped "/" command palette plus the Codex/ClawSweeper review fix: - Highlight the matched query substring in the command name + description with a soft accent tint and white (primary) text. Uses TextHighlighter (rectangular, layout-safe); the alpha is baked into the Color because TextHighlighter ignores SolidColorBrush.Opacity. - Make the slash flyout background opaque, matching the composer textbox (TextControlBackground composited over the base surface) instead of a lighter floating surface that showed chat content through it. - Hide the flyout entirely when nothing matches (drop the "No matching commands" hint). - Preserve the GLOBAL best Search match as the default Enter/Tab target across grouped buckets. Grouping renders by Mac bucket order, but the weak first-bucket row is no longer the default selection; keyboard selection now defaults to the top-ranked match via ChatCommandCatalogView.GroupForPalette / GroupedPalette.DefaultSelectionIndex (addresses ClawSweeper review at OpenClawComposer.cs:474). Adds cross-bucket regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommandCatalogPresentation.cs | 34 +++++ .../Chat/OpenClawComposer.cs | 122 ++++++++++++++---- .../CommandCatalogPresentationTests.cs | 47 +++++++ 3 files changed, 176 insertions(+), 27 deletions(-) diff --git a/src/OpenClaw.Shared/CommandCatalogPresentation.cs b/src/OpenClaw.Shared/CommandCatalogPresentation.cs index 86b4ab9d1..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 @@ -213,6 +226,27 @@ int Rank(string category) .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(); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 4fb399a0e..00dd0f56c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -455,23 +455,22 @@ public override Element Render() 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) { // Category-grouped palette (Mac/web parity): commands render under - // their Mac display bucket (Session, Model, Tools, Agents) instead of - // one flat alphabetical list — so the run options (verbose/reasoning/ - // exec/…, bucketed under "Model") surface under their heading. Within - // a bucket the relevance order from Search is preserved, so the top - // match stays the default Enter target. - slashGroups = new ChatCommandCatalogView(slashCmds) - .GroupBy(CommandCategories.Bucket, slash.Query, CommandCategories.DisplayOrder); - - // Flatten in group/display order for keyboard navigation; BuildSlashPopup - // walks the same groups with a running index, so rendered rows stay - // index-aligned with this list. No item cap — the popup's ScrollViewer - // bounds the height, so no bucket is silently dropped (the Windows wire - // catalog carries no tier data to hide "power" commands the way Mac does). - slashResults = slashGroups.SelectMany(g => g.Commands).ToList(); + // 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 @@ -491,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). @@ -542,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 => { @@ -1096,12 +1101,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(slashGroups, slashIndex, insertSlashCommand); + slashPopupContent = BuildSlashPopup(slashGroups, slashIndex, slash.Query, insertSlashCommand); slashMenuVisible = true; } } @@ -1310,7 +1316,7 @@ private static Border BuildSlashHintPopup(string text) } private static Border BuildSlashPopup( - IReadOnlyList groups, 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"]; @@ -1327,7 +1333,7 @@ private static Border BuildSlashPopup( list.Children.Add(SlashCategoryHeader(CommandCategories.Label(group.Category), headerBrush)); foreach (var cmd in group.Commands) { - list.Children.Add(SlashRow(cmd, idx == selectedIndex, primary, secondary, selectedBg, onPick)); + list.Children.Add(SlashRow(cmd, idx == selectedIndex, query, primary, secondary, selectedBg, onPick)); idx++; } } @@ -1441,13 +1447,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), @@ -1458,8 +1483,49 @@ 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) { // Row layout mirrors Mac's .slash-menu-item: icon · /name · [args] on the // left; description + "N options" badge pushed to the right edge (the @@ -1495,6 +1561,7 @@ private static Button SlashRow( }; Microsoft.UI.Xaml.Controls.Grid.SetColumn(name, 1); grid.Children.Add(name); + ApplyQueryHighlight(name, query); var args = cmd.ArgTemplate(); if (!string.IsNullOrWhiteSpace(args)) @@ -1527,6 +1594,7 @@ private static Button SlashRow( }; Microsoft.UI.Xaml.Controls.Grid.SetColumn(desc, 3); grid.Children.Add(desc); + ApplyQueryHighlight(desc, query); } var opts = cmd.OptionCount(); diff --git a/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs b/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs index b81f35928..7d7309927 100644 --- a/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs +++ b/tests/OpenClaw.Shared.Tests/CommandCatalogPresentationTests.cs @@ -329,6 +329,53 @@ public void GroupBy_PreservesSearchRelevanceWithinGroup_NotAlphabetical() 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")] From 824774caea97e6f32b7db878926a688a396080e9 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Tue, 30 Jun 2026 10:39:23 -0700 Subject: [PATCH 3/3] Fix hidden slash palette key handling Only trap composer keys while the slash command catalog is still loading and the loading popup is visible. Once the catalog is loaded with no matching command, let no-match slash input behave like ordinary text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Chat/OpenClawComposer.cs | 12 +++++------- .../AppRefactorContractTests.cs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 00dd0f56c..4567feeed 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -653,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) { @@ -674,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; } 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(