Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Services;
3
4namespace CascadeIDE.Features.Chat;
5
6public sealed record ChatSlashSuggestion(
7 string InsertText,
8 string SlashPath,
9 string Help,
10 string? Group = null,
11 string? StepSegment = null)
12{
13 /// <summary>Строка в popup: только следующий сегмент (домен / объект / intent).</summary>
14 public string ListTitle => StepSegment ?? "";
15
16 /// <summary>Вторичная строка: полная команда и описание.</summary>
17 public string ListSubtitle =>
18 string.Equals(InsertText.TrimEnd(), SlashPath, StringComparison.OrdinalIgnoreCase)
19 ? Help
20 : $"{SlashPath} — {Help}";
21}
22
23/// <summary>Контекст иерархии slash для шапки popup (путь + следующий шаг).</summary>
24public sealed record ChatSlashHierarchyContext(string PathPrefix, string NextStepLabel, string Breadcrumb)
25{
26 public bool HasHeader => !string.IsNullOrEmpty(PathPrefix) || !string.IsNullOrEmpty(NextStepLabel);
27}
28
29/// <summary>Иерархические подсказки для <c>/</c> в ChatInput (ADR 0119 §6, 0125 dynamic).</summary>
30public static class ChatSlashAutocomplete
31{
32 public const int DefaultWorkspaceFileSuggestionLimit = 30;
33 public const int DefaultSessionTopicSuggestionLimit = 20;
34
35 public static IReadOnlyList<ChatSlashSuggestion> GetSuggestions(
36 string? rawInput,
37 IWorkspaceFileSlashCompletionProvider? workspaceFiles = null,
38 ISessionTopicSlashCompletionProvider? sessionTopics = null,
39 IMessageAnchorSlashCompletionProvider? messageAnchors = null,
40 int workspaceFileLimit = DefaultWorkspaceFileSuggestionLimit,
41 int sessionTopicLimit = DefaultSessionTopicSuggestionLimit,
42 int messageAnchorLimit = MessageAnchorSlashCompletionProvider.DefaultLimit,
43 int? caretIndex = null)
44 {
45 if (string.IsNullOrEmpty(rawInput))
46 return [];
47
48 if (!TryGetSlashTokenBeforeCaret(rawInput, caretIndex ?? rawInput.Length, out var body))
49 return [];
50
51 body = SlashPathAliases.NormalizeCompletionBody(body);
52 if (IntercomAnchorSlash.IsAnchorPeekHexIdEntryBody(body))
53 return [];
54
55 if (body.Length == 0)
56 return SlashSemanticCatalogIndex.GetSegmentSuggestions([], endsWithSpace: false, body);
57
58 if (TryGetDynamicSuggestions(
59 body,
60 SlashCompletionKind.WorkspaceFiles,
61 workspaceFiles,
62 null,
63 null,
64 workspaceFileLimit,
65 sessionTopicLimit,
66 messageAnchorLimit,
67 out var fileSuggestions))
68 return fileSuggestions;
69
70 if (TryGetDynamicSuggestions(
71 body,
72 SlashCompletionKind.SessionTopics,
73 null,
74 sessionTopics,
75 null,
76 workspaceFileLimit,
77 sessionTopicLimit,
78 messageAnchorLimit,
79 out var topicSuggestions))
80 return topicSuggestions;
81
82 if (TryGetDynamicSuggestions(
83 body,
84 SlashCompletionKind.MessageAnchors,
85 null,
86 null,
87 messageAnchors,
88 workspaceFileLimit,
89 sessionTopicLimit,
90 messageAnchorLimit,
91 out var anchorSuggestions))
92 return anchorSuggestions;
93
94 ParseTypedBody(body, out var tokens, out var endsWithSpace);
95 return SlashSemanticCatalogIndex.GetSegmentSuggestions(tokens, endsWithSpace, body);
96 }
97
98 /// <summary>Путь и подпись шага для шапки popup (домен → объект → действие → аргумент).</summary>
99 public static ChatSlashHierarchyContext? GetHierarchyContext(string? rawInput, int? caretIndex = null)
100 {
101 if (string.IsNullOrEmpty(rawInput))
102 return null;
103
104 if (!TryGetSlashTokenBeforeCaret(rawInput, caretIndex ?? rawInput.Length, out var body))
105 return null;
106
107 ParseTypedBody(body, out var tokens, out var endsWithSpace);
108 var pathPrefix = body.Length == 0 ? "/" : "/" + body.TrimEnd();
109 SlashSemanticFields fields = default;
110 var matchedPath = "";
111 var hasSemantics = SlashSemanticCatalogIndex.TryResolveHierarchy(
112 tokens,
113 endsWithSpace,
114 out fields,
115 out matchedPath);
116
117 var nextStep = !string.IsNullOrEmpty(fields.Domain)
118 ? SlashRouteSemantics.GetNextStepLabel(tokens, endsWithSpace, fields, matchedPath)
119 : ResolveHierarchyDepth(tokens, endsWithSpace) switch
120 {
121 0 => "домен",
122 1 => "объект",
123 2 => "действие",
124 _ => "аргумент",
125 };
126
127 var breadcrumb = !string.IsNullOrEmpty(fields.Domain)
128 ? SlashRouteSemantics.BuildSemanticBreadcrumb(tokens, fields, matchedPath)
129 : BuildBreadcrumb(tokens, nextStep);
130
131 return new ChatSlashHierarchyContext(pathPrefix, nextStep, breadcrumb);
132 }
133
134 /// <summary>Заменить slash-токен на текущей строке; вернуть полный текст и каретку.</summary>
135 public static bool TryReplaceSlashLineAtCaret(
136 string rawInput,
137 int caretIndex,
138 string slashLineInsert,
139 out string newText,
140 out int newCaret)
141 {
142 newText = rawInput;
143 newCaret = caretIndex;
144 if (!TryGetSlashLineRange(rawInput, caretIndex, out var lineStart, out var lineEnd))
145 return false;
146
147 newText = rawInput[..lineStart] + slashLineInsert + rawInput[lineEnd..];
148 newCaret = lineStart + slashLineInsert.Length;
149 return true;
150 }
151
152 public static bool IsRunnableSlashLineAtCaret(string? rawInput, int caretIndex) =>
153 SlashLineResolver.TryResolveLine(rawInput, caretIndex, out var line) && line.IsRunnable;
154
155 internal static void ParseTypedBodyForResolver(string body, out List<string> tokens, out bool endsWithSpace) =>
156 ParseTypedBody(body, out tokens, out endsWithSpace);
157
158 private static void ParseTypedBody(string body, out List<string> tokens, out bool endsWithSpace)
159 {
160 endsWithSpace = body.Length > 0 && body[^1] == ' ';
161 var trimmed = endsWithSpace ? body.TrimEnd() : body;
162 tokens = trimmed.Length == 0
163 ? []
164 : trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
165 }
166
167 private static bool TryGetDynamicSuggestions(
168 string body,
169 SlashCompletionKind completionKind,
170 IWorkspaceFileSlashCompletionProvider? workspaceFiles,
171 ISessionTopicSlashCompletionProvider? sessionTopics,
172 IMessageAnchorSlashCompletionProvider? messageAnchors,
173 int workspaceFileLimit,
174 int sessionTopicLimit,
175 int messageAnchorLimit,
176 out IReadOnlyList<ChatSlashSuggestion> suggestions)
177 {
178 suggestions = [];
179 if (!TryResolveCompletionRoute(body, completionKind, out var route, out var argPrefix))
180 return false;
181
182 var group = ChatSlashCommandCatalog.GroupFor(route.SlashPath);
183 if (completionKind == SlashCompletionKind.WorkspaceFiles)
184 {
185 if (workspaceFiles is null)
186 return false;
187
188 var matches = workspaceFiles.GetMatches(argPrefix, workspaceFileLimit);
189 if (matches.Count == 0 && argPrefix.Length > 0)
190 return false;
191
192 suggestions = matches
193 .Select(m => new ChatSlashSuggestion(
194 $"{route.SlashPath} {m.InsertPath}",
195 route.SlashPath,
196 m.Help,
197 group,
198 FormatDynamicStepSegment(m.InsertPath)))
199 .ToList();
200 return true;
201 }
202
203 if (completionKind == SlashCompletionKind.SessionTopics)
204 {
205 if (sessionTopics is null)
206 return false;
207
208 var matches = sessionTopics.GetMatches(argPrefix, sessionTopicLimit);
209 if (matches.Count == 0 && argPrefix.Length > 0)
210 return false;
211
212 suggestions = matches
213 .Select(m => new ChatSlashSuggestion(
214 $"{route.SlashPath} {m.InsertArg}",
215 m.Label,
216 m.Help,
217 group,
218 FormatDynamicStepSegment(m.InsertArg)))
219 .ToList();
220 return true;
221 }
222
223 if (completionKind == SlashCompletionKind.MessageAnchors)
224 {
225 if (messageAnchors is null)
226 return false;
227
228 var matches = messageAnchors.GetMatches(argPrefix, messageAnchorLimit);
229 if (matches.Count == 0)
230 return false;
231
232 suggestions = matches
233 .Select(m => new ChatSlashSuggestion(
234 $"{route.SlashPath} {m.InsertArg}",
235 m.Label,
236 m.Help,
237 group,
238 FormatDynamicStepSegment(m.InsertArg)))
239 .ToList();
240 return true;
241 }
242
243 return false;
244 }
245
246 internal static string NormalizeSlashCompletionBody(string body) =>
247 SlashPathAliases.NormalizeCompletionBody(body);
248
249 private static bool TryResolveCompletionRoute(
250 string body,
251 SlashCompletionKind completionKind,
252 out SlashRouteEntry route,
253 out string argPrefix)
254 {
255 route = default;
256 argPrefix = "";
257
258 SlashRouteEntry? best = null;
259 var bestLen = -1;
260
261 foreach (var candidate in IntentSlashCatalog.SlashRoutes.Values)
262 {
263 if (candidate.Completion != completionKind)
264 continue;
265
266 var pathBody = candidate.SlashPath.Length >= 2 ? candidate.SlashPath[1..] : "";
267 if (body.Equals(pathBody, StringComparison.OrdinalIgnoreCase))
268 {
269 best = candidate;
270 bestLen = pathBody.Length;
271 argPrefix = "";
272 break;
273 }
274
275 if (!body.StartsWith(pathBody, StringComparison.OrdinalIgnoreCase))
276 continue;
277
278 if (body.Length == pathBody.Length)
279 continue;
280
281 if (body[pathBody.Length] != ' ')
282 continue;
283
284 if (pathBody.Length <= bestLen)
285 continue;
286
287 best = candidate;
288 bestLen = pathBody.Length;
289 argPrefix = body[(pathBody.Length + 1)..];
290 }
291
292 if (best is null)
293 return false;
294
295 route = best.Value;
296 return true;
297 }
298
299 /// <summary>Строка до каретки на текущей линии должна начинаться с <c>/</c> (после пробелов).</summary>
300 internal static bool TryGetSlashTokenBeforeCaret(string rawInput, int caretIndex, out string body)
301 {
302 body = "";
303 if (!TryGetSlashLineRange(rawInput, caretIndex, out var lineStart, out _))
304 return false;
305
306 var linePrefix = rawInput[lineStart..caretIndex];
307 if (linePrefix.Contains('\r'))
308 return false;
309
310 var trimmed = linePrefix.TrimStart();
311 if (trimmed.Length == 0 || trimmed[0] != '/')
312 return false;
313
314 body = trimmed[1..];
315 return true;
316 }
317
318 /// <summary>Полная slash-строка на линии каретки (для preview/валидации в composer).</summary>
319 public static bool TryGetSlashLineAtCaret(string? rawInput, int caretIndex, out string slashLine)
320 {
321 slashLine = "";
322 if (string.IsNullOrEmpty(rawInput))
323 return false;
324
325 if (!TryGetSlashLineRange(rawInput, caretIndex, out var lineStart, out var lineEnd))
326 return false;
327
328 slashLine = rawInput[lineStart..lineEnd].TrimEnd();
329 return slashLine.Length > 0;
330 }
331
332 internal static bool TryGetSlashLineRange(string rawInput, int caretIndex, out int lineStart, out int lineEnd)
333 {
334 lineStart = 0;
335 lineEnd = rawInput.Length;
336 caretIndex = Math.Clamp(caretIndex, 0, rawInput.Length);
337 lineStart = rawInput.LastIndexOf('\n', Math.Max(0, caretIndex - 1)) + 1;
338 lineEnd = rawInput.IndexOf('\n', caretIndex);
339 if (lineEnd < 0)
340 lineEnd = rawInput.Length;
341
342 var linePrefix = rawInput[lineStart..caretIndex];
343 if (linePrefix.Contains('\r'))
344 return false;
345
346 var trimmed = linePrefix.TrimStart();
347 if (trimmed.Length == 0 || trimmed[0] != '/')
348 return false;
349
350 lineStart += linePrefix.Length - trimmed.Length;
351 return true;
352 }
353
354 private static int ResolveHierarchyDepth(IReadOnlyList<string> tokens, bool endsWithSpace)
355 {
356 if (tokens.Count == 0)
357 return 0;
358
359 return endsWithSpace ? tokens.Count : Math.Max(0, tokens.Count - 1);
360 }
361
362 private static string BuildBreadcrumb(IReadOnlyList<string> tokens, string nextStepLabel)
363 {
364 if (tokens.Count == 0)
365 return $"/ → {nextStepLabel}";
366
367 var parts = new List<string>(tokens.Count + 2) { "/" };
368 foreach (var token in tokens)
369 parts.Add(token);
370
371 parts.Add("…");
372 return string.Join(" › ", parts);
373 }
374
375 private static string FormatDynamicStepSegment(string insertTail)
376 {
377 if (string.IsNullOrWhiteSpace(insertTail))
378 return insertTail;
379
380 var trimmed = insertTail.Trim();
381 var lastSlash = trimmed.LastIndexOf('/');
382 var leaf = lastSlash >= 0 ? trimmed[(lastSlash + 1)..] : trimmed;
383 var space = leaf.IndexOf(' ');
384 return space >= 0 ? leaf[..space] : leaf;
385 }
386}
387
View only · write via MCP/CIDE