| 1 | #nullable enable |
| 2 | using CascadeIDE.Services; |
| 3 | |
| 4 | namespace CascadeIDE.Features.Chat; |
| 5 | |
| 6 | public 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> |
| 24 | public 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> |
| 30 | public 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 | |