|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.Linq; |
| 4 | + |
| 5 | +namespace OpenClaw.Shared; |
| 6 | + |
| 7 | +// ── Command catalog presentation helpers ── |
| 8 | +// |
| 9 | +// The wire DTOs (GatewayCommand / GatewayCommandArg / CommandCatalog / |
| 10 | +// CommandCatalogQuery) and the gateway request API (ListCommandsAsync) live in |
| 11 | +// GatewayProtocolModels.cs + OpenClawGatewayClient.Protocol.cs. This file adds |
| 12 | +// only UI-facing presentation logic on top of those DTOs: |
| 13 | +// • display / insertion helpers for a single GatewayCommand |
| 14 | +// • ranked search + category grouping that the chat command palette needs |
| 15 | +// (CommandCatalogQuery does boolean filtering only — no ranking or |
| 16 | +// grouped output). |
| 17 | +// Nothing here duplicates a protocol DTO. |
| 18 | + |
| 19 | +/// <summary>Source/display/insertion presentation helpers for gateway commands.</summary> |
| 20 | +public static class GatewayCommandPresentation |
| 21 | +{ |
| 22 | + /// <summary> |
| 23 | + /// Best slash/native string to show as the command's primary label. Prefers |
| 24 | + /// the native name, then the first text alias, then a slash-prefixed name. |
| 25 | + /// </summary> |
| 26 | + public static string DisplayName(this GatewayCommand command) |
| 27 | + { |
| 28 | + if (command is null) return ""; |
| 29 | + if (!string.IsNullOrWhiteSpace(command.NativeName)) return Normalize(command.NativeName!); |
| 30 | + var alias = command.TextAliases?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)); |
| 31 | + if (!string.IsNullOrWhiteSpace(alias)) return Normalize(alias!); |
| 32 | + return Normalize(command.Name); |
| 33 | + } |
| 34 | + |
| 35 | + /// <summary>Short, capitalized label for the command source ("native"→"Native").</summary> |
| 36 | + public static string SourceLabel(this GatewayCommand command) |
| 37 | + { |
| 38 | + var s = command?.Source; |
| 39 | + if (string.IsNullOrWhiteSpace(s)) return ""; |
| 40 | + var t = s!.Trim(); |
| 41 | + return char.ToUpperInvariant(t[0]) + (t.Length > 1 ? t[1..] : ""); |
| 42 | + } |
| 43 | + |
| 44 | + /// <summary>True when the command needs argument input before it can run.</summary> |
| 45 | + public static bool RequiresArgs(this GatewayCommand command) |
| 46 | + { |
| 47 | + if (command is null) return false; |
| 48 | + return command.AcceptsArgs || (command.Args?.Any(a => a.Required) ?? false); |
| 49 | + } |
| 50 | + |
| 51 | + /// <summary> |
| 52 | + /// Text to insert into the composer when the command is chosen. Commands |
| 53 | + /// that take arguments get a trailing space so the user can immediately type |
| 54 | + /// the value (we never inject placeholder text, which would be sent verbatim). |
| 55 | + /// </summary> |
| 56 | + public static string BuildInsertionText(this GatewayCommand command) |
| 57 | + { |
| 58 | + var token = command.DisplayName(); |
| 59 | + return command.RequiresArgs() ? token + " " : token; |
| 60 | + } |
| 61 | + |
| 62 | + /// <summary>Inline argument template (e.g. "<message> [level]"), or "" when none.</summary> |
| 63 | + public static string ArgTemplate(this GatewayCommand command) |
| 64 | + { |
| 65 | + if (command?.Args is null || command.Args.Count == 0) return ""; |
| 66 | + return string.Join(" ", command.Args |
| 67 | + .Where(a => !string.IsNullOrWhiteSpace(a.Name)) |
| 68 | + .Select(a => a.Required ? $"<{a.Name}>" : $"[{a.Name}]")); |
| 69 | + } |
| 70 | + |
| 71 | + /// <summary>Static choice count on the first arg (for the "N options" badge); 0 when dynamic/none.</summary> |
| 72 | + public static int OptionCount(this GatewayCommand command) |
| 73 | + { |
| 74 | + var first = command?.Args?.FirstOrDefault(); |
| 75 | + return first is { IsDynamic: false } ? first.Choices.Count : 0; |
| 76 | + } |
| 77 | + |
| 78 | + /// <summary>Static choices on the first declared arg (empty when dynamic or none).</summary> |
| 79 | + public static IReadOnlyList<GatewayCommandArgChoice> FirstArgChoices(this GatewayCommand command) |
| 80 | + { |
| 81 | + var first = command?.Args?.FirstOrDefault(); |
| 82 | + return first is { IsDynamic: false } ? first.Choices : Array.Empty<GatewayCommandArgChoice>(); |
| 83 | + } |
| 84 | + |
| 85 | + /// <summary>Composer text for a chosen arg value: "/name value".</summary> |
| 86 | + public static string BuildArgInsertionText(this GatewayCommand command, string value) => |
| 87 | + command.DisplayName() + " " + (value ?? "").Trim(); |
| 88 | + |
| 89 | + /// <summary>True when <paramref name="name"/> (slash-stripped) matches this command's name/native/alias.</summary> |
| 90 | + public static bool MatchesName(this GatewayCommand command, string name) |
| 91 | + { |
| 92 | + if (command is null || string.IsNullOrWhiteSpace(name)) return false; |
| 93 | + var n = name.Trim().TrimStart('/'); |
| 94 | + bool Eq(string? a) => !string.IsNullOrWhiteSpace(a) && |
| 95 | + string.Equals(a!.Trim().TrimStart('/'), n, StringComparison.OrdinalIgnoreCase); |
| 96 | + if (Eq(command.NativeName) || Eq(command.Name)) return true; |
| 97 | + return command.TextAliases?.Any(Eq) ?? false; |
| 98 | + } |
| 99 | + |
| 100 | + private static string Normalize(string value) |
| 101 | + { |
| 102 | + var v = (value ?? "").Trim(); |
| 103 | + if (v.Length == 0) return v; |
| 104 | + // Slash-style commands are the convention; only prefix bare identifiers |
| 105 | + // (don't double a leading slash, and leave already-prefixed values alone). |
| 106 | + return v[0] == '/' ? v : "/" + v; |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +/// <summary>A named group of commands sharing a category, in display order.</summary> |
| 111 | +public sealed record CommandCategoryGroup(string Category, IReadOnlyList<GatewayCommand> Commands); |
| 112 | + |
| 113 | +/// <summary> |
| 114 | +/// Ranked search + category grouping over a set of gateway commands for the chat |
| 115 | +/// command palette. Distinct from <see cref="CommandCatalogQuery"/> (a boolean |
| 116 | +/// filter mirroring the gateway's server-side filtering) — this adds relevance |
| 117 | +/// ranking and grouped output the UI needs. UI-only; lives in OpenClaw.Shared so |
| 118 | +/// it can be unit-tested directly. |
| 119 | +/// </summary> |
| 120 | +public sealed class ChatCommandCatalogView |
| 121 | +{ |
| 122 | + private readonly List<GatewayCommand> _commands; |
| 123 | + |
| 124 | + public ChatCommandCatalogView(IEnumerable<GatewayCommand>? commands) |
| 125 | + { |
| 126 | + _commands = (commands ?? Enumerable.Empty<GatewayCommand>()) |
| 127 | + .Where(c => c is not null) |
| 128 | + .ToList(); |
| 129 | + } |
| 130 | + |
| 131 | + public IReadOnlyList<GatewayCommand> Commands => _commands; |
| 132 | + public int Count => _commands.Count; |
| 133 | + |
| 134 | + /// <summary> |
| 135 | + /// Case-insensitive ranked search across display name, native name, text |
| 136 | + /// aliases, canonical name, description and category. A leading slash in the |
| 137 | + /// query is ignored so "/cl" and "cl" behave identically. An empty query |
| 138 | + /// returns the full catalog in display order. |
| 139 | + /// </summary> |
| 140 | + public IReadOnlyList<GatewayCommand> Search(string? query) |
| 141 | + { |
| 142 | + var q = (query ?? "").Trim(); |
| 143 | + if (q.StartsWith("/", StringComparison.Ordinal)) q = q.TrimStart('/'); |
| 144 | + q = q.Trim(); |
| 145 | + |
| 146 | + if (q.Length == 0) |
| 147 | + return Ordered(_commands).ToList(); |
| 148 | + |
| 149 | + var scored = new List<(GatewayCommand Cmd, int Score)>(); |
| 150 | + foreach (var cmd in _commands) |
| 151 | + { |
| 152 | + var score = ScoreMatch(cmd, q); |
| 153 | + if (score > 0) scored.Add((cmd, score)); |
| 154 | + } |
| 155 | + |
| 156 | + return scored |
| 157 | + .OrderByDescending(t => t.Score) |
| 158 | + .ThenBy(t => t.Cmd.DisplayName(), StringComparer.OrdinalIgnoreCase) |
| 159 | + .Select(t => t.Cmd) |
| 160 | + .ToList(); |
| 161 | + } |
| 162 | + |
| 163 | + /// <summary> |
| 164 | + /// Groups commands by category (falling back to source label, then "Other"), |
| 165 | + /// optionally filtered by the same search used in <see cref="Search"/>. |
| 166 | + /// Groups and their members are returned in a stable, display-friendly order. |
| 167 | + /// </summary> |
| 168 | + public IReadOnlyList<CommandCategoryGroup> GroupByCategory(string? query = null) |
| 169 | + { |
| 170 | + var matched = Search(query); |
| 171 | + return matched |
| 172 | + .GroupBy(CategoryKey, StringComparer.OrdinalIgnoreCase) |
| 173 | + .Select(g => new CommandCategoryGroup(g.Key, Ordered(g).ToList())) |
| 174 | + .OrderBy(g => g.Category, StringComparer.OrdinalIgnoreCase) |
| 175 | + .ToList(); |
| 176 | + } |
| 177 | + |
| 178 | + private static string CategoryKey(GatewayCommand cmd) |
| 179 | + { |
| 180 | + if (!string.IsNullOrWhiteSpace(cmd.Category)) return cmd.Category!.Trim(); |
| 181 | + var src = cmd.SourceLabel(); |
| 182 | + if (!string.IsNullOrWhiteSpace(src)) return src; |
| 183 | + return "Other"; |
| 184 | + } |
| 185 | + |
| 186 | + private static IEnumerable<GatewayCommand> Ordered(IEnumerable<GatewayCommand> source) => |
| 187 | + source.OrderBy(c => c.DisplayName(), StringComparer.OrdinalIgnoreCase); |
| 188 | + |
| 189 | + private static int ScoreMatch(GatewayCommand cmd, string q) |
| 190 | + { |
| 191 | + int best = 0; |
| 192 | + |
| 193 | + void Consider(string? token, int exact, int prefix, int contains) |
| 194 | + { |
| 195 | + if (string.IsNullOrWhiteSpace(token)) return; |
| 196 | + var t = token!.TrimStart('/'); |
| 197 | + if (t.Equals(q, StringComparison.OrdinalIgnoreCase)) best = Math.Max(best, exact); |
| 198 | + else if (t.StartsWith(q, StringComparison.OrdinalIgnoreCase)) best = Math.Max(best, prefix); |
| 199 | + else if (t.Contains(q, StringComparison.OrdinalIgnoreCase)) best = Math.Max(best, contains); |
| 200 | + } |
| 201 | + |
| 202 | + Consider(cmd.DisplayName(), 100, 80, 50); |
| 203 | + Consider(cmd.NativeName, 100, 80, 50); |
| 204 | + Consider(cmd.Name, 90, 70, 45); |
| 205 | + foreach (var alias in cmd.TextAliases ?? Array.Empty<string>()) |
| 206 | + Consider(alias, 90, 70, 45); |
| 207 | + |
| 208 | + if (best == 0 && !string.IsNullOrWhiteSpace(cmd.Description) && |
| 209 | + cmd.Description!.Contains(q, StringComparison.OrdinalIgnoreCase)) |
| 210 | + best = 20; |
| 211 | + |
| 212 | + if (best == 0 && !string.IsNullOrWhiteSpace(cmd.Category) && |
| 213 | + cmd.Category!.Contains(q, StringComparison.OrdinalIgnoreCase)) |
| 214 | + best = 15; |
| 215 | + |
| 216 | + return best; |
| 217 | + } |
| 218 | +} |
0 commit comments