Forge
csharpdeeb25a2
1#nullable enable
2using System.Text;
3using CascadeIDE.Services;
4
5namespace CascadeIDE.Features.Chat;
6
7/// <summary>Общая презентация тем (текстовые отчёты, picker, autocomplete).</summary>
8public static class ChatThreadPresentation
9{
10 public const string EmptyTopicsHint =
11 "Тем пока нет. Отправь сообщение или /intercom topic create <название>.";
12
13 public sealed record PickerRow(Guid ThreadId, string Title, string Meta, int Depth);
14
15 public static Dictionary<Guid, int> MessageCountsByThread(ChatSurfaceSnapshot snapshot)
16 {
17 var counts = snapshot.State.Messages
18 .GroupBy(m => m.ThreadId)
19 .ToDictionary(g => g.Key, g => g.Count());
20 foreach (var lane in snapshot.Layout.Lanes)
21 {
22 var n = lane.Entries.Count(e => e.Kind == ChatSurfaceEntryKind.Message);
23 if (!counts.TryGetValue(lane.Thread.ThreadId, out var existing) || n > existing)
24 counts[lane.Thread.ThreadId] = n;
25 }
26
27 foreach (var thread in snapshot.State.Threads)
28 counts.TryAdd(thread.ThreadId, 0);
29
30 return counts;
31 }
32
33 public static string FormatFlags(ChatThreadNode thread)
34 {
35 var flags = new List<string>();
36 if (thread.IsMainThread)
37 flags.Add("main");
38 if (thread.IsActive)
39 flags.Add("active");
40 return flags.Count == 0 ? "" : string.Join(", ", flags);
41 }
42
43 public static string FormatMeta(ChatThreadNode thread, IReadOnlyDictionary<Guid, int> messageCounts)
44 {
45 messageCounts.TryGetValue(thread.ThreadId, out var count);
46 var flags = FormatFlags(thread);
47 var shortId = thread.ThreadId.ToString("N")[..8];
48 return string.IsNullOrEmpty(flags)
49 ? $"{count} · {shortId}"
50 : $"{flags} · {count} · {shortId}";
51 }
52
53 public static string FormatListLine(ChatThreadNode thread, int messageCount, int depth)
54 {
55 var indent = new string(' ', depth * 2);
56 var flags = FormatFlags(thread);
57 var flagText = flags.Length == 0 ? "" : " [" + flags + "]";
58 var shortId = thread.ThreadId.ToString("N")[..8];
59 return $"{indent}• {thread.Title}{flagText} — {messageCount} сообщ. — {shortId}";
60 }
61
62 public static string FormatTopicList(ChatSurfaceSnapshot snapshot)
63 {
64 var threads = snapshot.State.Threads;
65 if (threads.Count == 0)
66 return EmptyTopicsHint;
67
68 var msgCounts = MessageCountsByThread(snapshot);
69 var lines = new List<string> { $"Темы сессии ({threads.Count}):" };
70 foreach (var thread in threads.OrderBy(t => t.Order))
71 {
72 msgCounts.TryGetValue(thread.ThreadId, out var count);
73 lines.Add(FormatListLine(thread, count, thread.Depth));
74 }
75
76 return string.Join(Environment.NewLine, lines);
77 }
78
79 public static string FormatTopicTree(ChatSurfaceSnapshot snapshot)
80 {
81 var threads = snapshot.State.Threads;
82 if (threads.Count == 0)
83 return EmptyTopicsHint;
84
85 var byId = threads.ToDictionary(t => t.ThreadId);
86 var msgCounts = MessageCountsByThread(snapshot);
87 var roots = threads
88 .Where(t => t.ParentThreadId is null || !byId.ContainsKey(t.ParentThreadId.Value))
89 .OrderBy(t => t.Order)
90 .ToList();
91
92 var sb = new StringBuilder();
93 sb.AppendLine($"Дерево тем ({threads.Count}):");
94 for (var i = 0; i < roots.Count; i++)
95 AppendTreeNode(sb, roots[i], byId, msgCounts, prefix: "", isLast: i == roots.Count - 1);
96
97 return sb.ToString().TrimEnd();
98 }
99
100 /// <summary>Строки Topic Navigator (дерево + опциональный фильтр по заголовку, ADR 0127-E).</summary>
101 public static IReadOnlyList<PickerRow> BuildNavigatorRows(
102 IReadOnlyList<ChatThreadNode> threads,
103 IReadOnlyDictionary<Guid, int> messageCounts,
104 string? searchQuery = null)
105 {
106 var rows = BuildPickerRows(TopicPickerPresentation.Tree, threads, messageCounts);
107 return FilterNavigatorRows(rows, searchQuery);
108 }
109
110 public static IReadOnlyList<PickerRow> FilterNavigatorRows(
111 IReadOnlyList<PickerRow> rows,
112 string? searchQuery)
113 {
114 var q = searchQuery?.Trim() ?? "";
115 if (q.Length == 0)
116 return rows;
117
118 return rows
119 .Where(r => r.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
120 || r.Meta.Contains(q, StringComparison.OrdinalIgnoreCase))
121 .ToList();
122 }
123
124 public static IReadOnlyList<PickerRow> BuildPickerRows(
125 TopicPickerPresentation mode,
126 IReadOnlyList<ChatThreadNode> threads,
127 IReadOnlyDictionary<Guid, int> messageCounts)
128 {
129 if (threads.Count == 0)
130 return [];
131
132 if (mode == TopicPickerPresentation.List)
133 {
134 return threads
135 .OrderBy(t => t.Order)
136 .Select(t => new PickerRow(t.ThreadId, t.Title, FormatMeta(t, messageCounts), t.Depth))
137 .ToList();
138 }
139
140 var byId = threads.ToDictionary(t => t.ThreadId);
141 var roots = threads
142 .Where(t => t.ParentThreadId is null || !byId.ContainsKey(t.ParentThreadId.Value))
143 .OrderBy(t => t.Order)
144 .ToList();
145
146 var rows = new List<PickerRow>();
147 foreach (var root in roots)
148 AppendPickerTreeNode(rows, root, byId, messageCounts, depth: 0, isLast: true, prefix: "");
149 return rows;
150 }
151
152 public static IEnumerable<ChatThreadNode> RankThreadsForCompletion(
153 IReadOnlyList<ChatThreadNode> threads,
154 string prefix)
155 {
156 if (threads.Count == 0)
157 return [];
158
159 if (prefix.Length == 0)
160 {
161 return threads
162 .OrderBy(t => t.IsActive ? 0 : 1)
163 .ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase);
164 }
165
166 return threads
167 .Select(t => (t, RankPrefix(prefix, t)))
168 .Where(x => x.Item2 < int.MaxValue)
169 .OrderBy(x => x.Item2)
170 .ThenBy(x => x.t.Title, StringComparer.OrdinalIgnoreCase)
171 .Select(x => x.t);
172 }
173
174 private static int RankPrefix(string prefix, ChatThreadNode thread)
175 {
176 var id = thread.ThreadId.ToString("N");
177 if (id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
178 return 0;
179 if (thread.Title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
180 return 1;
181 if (thread.Title.Contains(prefix, StringComparison.OrdinalIgnoreCase))
182 return 2;
183 return int.MaxValue;
184 }
185
186 private static void AppendTreeNode(
187 StringBuilder sb,
188 ChatThreadNode node,
189 IReadOnlyDictionary<Guid, ChatThreadNode> byId,
190 IReadOnlyDictionary<Guid, int> msgCounts,
191 string prefix,
192 bool isLast)
193 {
194 msgCounts.TryGetValue(node.ThreadId, out var count);
195 var branch = isLast ? "└─ " : "├─ ";
196 var flags = FormatFlags(node);
197 var flagText = flags.Length == 0 ? "" : " [" + flags + "]";
198 sb.AppendLine($"{prefix}{branch}{node.Title}{flagText} ({count} сообщ.)");
199
200 var childPrefix = prefix + (isLast ? " " : "│ ");
201 var children = byId.Values
202 .Where(t => t.ParentThreadId == node.ThreadId)
203 .OrderBy(t => t.Order)
204 .ToList();
205 for (var i = 0; i < children.Count; i++)
206 AppendTreeNode(sb, children[i], byId, msgCounts, childPrefix, i == children.Count - 1);
207 }
208
209 private static void AppendPickerTreeNode(
210 List<PickerRow> rows,
211 ChatThreadNode node,
212 IReadOnlyDictionary<Guid, ChatThreadNode> byId,
213 IReadOnlyDictionary<Guid, int> messageCounts,
214 int depth,
215 bool isLast,
216 string prefix)
217 {
218 rows.Add(new PickerRow(node.ThreadId, prefix + node.Title, FormatMeta(node, messageCounts), depth));
219 var children = byId.Values
220 .Where(t => t.ParentThreadId == node.ThreadId)
221 .OrderBy(t => t.Order)
222 .ToList();
223 for (var i = 0; i < children.Count; i++)
224 {
225 var childPrefix = prefix + (isLast ? " " : "│ ");
226 AppendPickerTreeNode(rows, children[i], byId, messageCounts, depth + 1, i == children.Count - 1, childPrefix);
227 }
228 }
229}
230
View only · write via MCP/CIDE