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(