Skip to content

Commit a3c5e0b

Browse files
bkudiessCopilotCopilot
authored
Chat: inline /-triggered gateway command catalog menu (Mac parity) (#890)
* Add /-triggered gateway command catalog menu in chat composer Inline slash-command autocomplete: typing / opens a floating opaque popup over the composer listing gateway commands.list results with single-line rows (name, description, source/args badges), keyboard nav, and caret-preserving insertion. Catalog cached and revalidated at deliver time; clear unsupported/disconnected states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Polish slash command menu: arg-choice picker, Mac-aligned visuals, review fixes - Add an argument-choice picker: selecting a command with static first-arg choices transitions the same popup into a choice list (Mac slashMenuMode parity), filtered as you type, filling /name value on select. - Mac-align rows: name-mapped Fluent icons, one-line layout, inline arg template, 'N options' badge; elevated opaque Tertiary surface. - Rubber-duck fixes: gate the menu on CommandsSupported so it is inert on gateways without commands.list; swallow Enter while loading so raw /text can't race the fetch; relevance-ordered results so Enter inserts the top match; exit args mode on whitespace; move catalog fetch to a deps-keyed UseEffect to avoid render-time churn and unbounded retries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle commands.list failures without trapping slash input When commands.list throws on a connected gateway, publish an unsupported empty catalog fallback instead of leaving AvailableCommands null. This moves the composer out of the loading state so slash-leading text keeps normal send behavior and avoids immediate retry loops until reconnect. Add focused provider coverage for the exception fallback and cached no-retry behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Limit chat slash catalog to text-invokable commands Request commands.list with CommandCatalogQuery Scope=text for the chat composer so native-only commands never surface in text autocomplete. Add focused provider coverage with a mixed native/text/both catalog to prove native-only commands are excluded while text and both commands are kept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7463a34 commit a3c5e0b

10 files changed

Lines changed: 1535 additions & 11 deletions

File tree

src/OpenClaw.Chat/ChatModels.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ public record ChatDataSnapshot(
192192
string? ConnectionStatus,
193193
string[] AvailableModels,
194194
ChatComposeTarget ComposeTarget,
195-
IReadOnlyList<ChatModelChoice>? ModelChoices = null);
195+
IReadOnlyList<ChatModelChoice>? ModelChoices = null,
196+
IReadOnlyList<OpenClaw.Shared.GatewayCommand>? AvailableCommands = null,
197+
bool CommandsSupported = true);
196198

197199
/// <summary>
198200
/// Describes where the UI may send the next chat message. Distinct from
@@ -275,4 +277,11 @@ Task RespondToPermissionAsync(string threadId, string requestId, bool allow, Can
275277
requestId,
276278
allow ? ChatPermissionActionKeys.AllowOnce : ChatPermissionActionKeys.Deny,
277279
cancellationToken);
280+
281+
/// <summary>
282+
/// Requests a refresh of the gateway command catalog surfaced via
283+
/// <see cref="ChatDataSnapshot.AvailableCommands"/>. Providers that have no
284+
/// command catalog (e.g. previews/fakes) may treat this as a no-op.
285+
/// </summary>
286+
Task EnsureCommandCatalogAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
278287
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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. "&lt;message&gt; [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+
}

src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ public interface IChatGatewayBridge : IDisposable
3636

3737
Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
3838
Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
39+
/// <summary>
40+
/// Fetches the gateway command catalog (<c>commands.list</c>) via the typed
41+
/// protocol API. Returns a <see cref="CommandCatalog"/> whose
42+
/// <see cref="CommandCatalog.IsSupported"/> is <c>false</c> when the gateway
43+
/// does not implement the method. Request/response — no event subscription.
44+
/// </summary>
45+
Task<CommandCatalog> ListCommandsAsync(CommandCatalogQuery? query = null);
3946
Task PatchSessionModelAsync(string sessionKey, string model);
4047
/// <summary>
4148
/// Clears the session's model override (tri-state <c>sessions.patch</c> with
@@ -175,6 +182,9 @@ public Task ClearSessionModelAsync(string sessionKey) =>
175182
public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) =>
176183
_client.PatchSessionAsync(sessionKey, new SessionPatch { ThinkingLevel = thinkingLevel });
177184

185+
public Task<CommandCatalog> ListCommandsAsync(CommandCatalogQuery? query = null) =>
186+
_client.ListCommandsAsync(query);
187+
178188
public Task<ChatHistoryInfo> RequestChatHistoryAsync(string? sessionKey) =>
179189
_client.RequestChatHistoryAsync(sessionKey);
180190

0 commit comments

Comments
 (0)