@theia/ai-chat-ui 1.76.0-next.7 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/lib/browser/ai-chat-ui-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-chat-ui-frontend-module.js +6 -0
  3. package/lib/browser/ai-chat-ui-frontend-module.js.map +1 -1
  4. package/lib/browser/chat-find/chat-find-contribution.d.ts +18 -0
  5. package/lib/browser/chat-find/chat-find-contribution.d.ts.map +1 -0
  6. package/lib/browser/chat-find/chat-find-contribution.js +76 -0
  7. package/lib/browser/chat-find/chat-find-contribution.js.map +1 -0
  8. package/lib/browser/chat-find/chat-find-highlighter.d.ts +114 -0
  9. package/lib/browser/chat-find/chat-find-highlighter.d.ts.map +1 -0
  10. package/lib/browser/chat-find/chat-find-highlighter.js +353 -0
  11. package/lib/browser/chat-find/chat-find-highlighter.js.map +1 -0
  12. package/lib/browser/chat-find/chat-find-highlighter.spec.d.ts +2 -0
  13. package/lib/browser/chat-find/chat-find-highlighter.spec.d.ts.map +1 -0
  14. package/lib/browser/chat-find/chat-find-highlighter.spec.js +283 -0
  15. package/lib/browser/chat-find/chat-find-highlighter.spec.js.map +1 -0
  16. package/lib/browser/chat-find/chat-find-matcher.d.ts +72 -0
  17. package/lib/browser/chat-find/chat-find-matcher.d.ts.map +1 -0
  18. package/lib/browser/chat-find/chat-find-matcher.js +168 -0
  19. package/lib/browser/chat-find/chat-find-matcher.js.map +1 -0
  20. package/lib/browser/chat-find/chat-find-matcher.spec.d.ts +2 -0
  21. package/lib/browser/chat-find/chat-find-matcher.spec.d.ts.map +1 -0
  22. package/lib/browser/chat-find/chat-find-matcher.spec.js +145 -0
  23. package/lib/browser/chat-find/chat-find-matcher.spec.js.map +1 -0
  24. package/lib/browser/chat-find/chat-find-text.d.ts +20 -0
  25. package/lib/browser/chat-find/chat-find-text.d.ts.map +1 -0
  26. package/lib/browser/chat-find/chat-find-text.js +56 -0
  27. package/lib/browser/chat-find/chat-find-text.js.map +1 -0
  28. package/lib/browser/chat-find/chat-find-widget.d.ts +96 -0
  29. package/lib/browser/chat-find/chat-find-widget.d.ts.map +1 -0
  30. package/lib/browser/chat-find/chat-find-widget.js +332 -0
  31. package/lib/browser/chat-find/chat-find-widget.js.map +1 -0
  32. package/lib/browser/chat-find/chat-find-widget.spec.d.ts +2 -0
  33. package/lib/browser/chat-find/chat-find-widget.spec.d.ts.map +1 -0
  34. package/lib/browser/chat-find/chat-find-widget.spec.js +236 -0
  35. package/lib/browser/chat-find/chat-find-widget.spec.js.map +1 -0
  36. package/lib/browser/chat-input-scroll-util.d.ts +7 -0
  37. package/lib/browser/chat-input-scroll-util.d.ts.map +1 -0
  38. package/lib/browser/chat-input-scroll-util.js +33 -0
  39. package/lib/browser/chat-input-scroll-util.js.map +1 -0
  40. package/lib/browser/chat-input-scroll-util.spec.d.ts +2 -0
  41. package/lib/browser/chat-input-scroll-util.spec.d.ts.map +1 -0
  42. package/lib/browser/chat-input-scroll-util.spec.js +38 -0
  43. package/lib/browser/chat-input-scroll-util.spec.js.map +1 -0
  44. package/lib/browser/chat-input-widget.d.ts +21 -3
  45. package/lib/browser/chat-input-widget.d.ts.map +1 -1
  46. package/lib/browser/chat-input-widget.js +109 -50
  47. package/lib/browser/chat-input-widget.js.map +1 -1
  48. package/lib/browser/chat-input-widget.spec.js +21 -0
  49. package/lib/browser/chat-input-widget.spec.js.map +1 -1
  50. package/lib/browser/chat-response-renderer/code-part-renderer.d.ts.map +1 -1
  51. package/lib/browser/chat-response-renderer/code-part-renderer.js +9 -1
  52. package/lib/browser/chat-response-renderer/code-part-renderer.js.map +1 -1
  53. package/lib/browser/chat-response-renderer/code-wrapper-editors.d.ts +13 -0
  54. package/lib/browser/chat-response-renderer/code-wrapper-editors.d.ts.map +1 -0
  55. package/lib/browser/chat-response-renderer/code-wrapper-editors.js +41 -0
  56. package/lib/browser/chat-response-renderer/code-wrapper-editors.js.map +1 -0
  57. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js +74 -43
  58. package/lib/browser/chat-response-renderer/delegation-tool-renderer.js.map +1 -1
  59. package/lib/browser/chat-response-renderer/index.d.ts +1 -0
  60. package/lib/browser/chat-response-renderer/index.d.ts.map +1 -1
  61. package/lib/browser/chat-response-renderer/index.js +1 -0
  62. package/lib/browser/chat-response-renderer/index.js.map +1 -1
  63. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts +9 -0
  64. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts.map +1 -1
  65. package/lib/browser/chat-response-renderer/markdown-part-renderer.js +34 -16
  66. package/lib/browser/chat-response-renderer/markdown-part-renderer.js.map +1 -1
  67. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.d.ts.map +1 -1
  68. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.js +2 -2
  69. package/lib/browser/chat-response-renderer/server-toolcall-part-renderer.js.map +1 -1
  70. package/lib/browser/chat-response-renderer/spinner.d.ts +3 -0
  71. package/lib/browser/chat-response-renderer/spinner.d.ts.map +1 -0
  72. package/lib/browser/chat-response-renderer/spinner.js +24 -0
  73. package/lib/browser/chat-response-renderer/spinner.js.map +1 -0
  74. package/lib/browser/chat-response-renderer/thinking-part-renderer.d.ts +9 -1
  75. package/lib/browser/chat-response-renderer/thinking-part-renderer.d.ts.map +1 -1
  76. package/lib/browser/chat-response-renderer/thinking-part-renderer.js +93 -2
  77. package/lib/browser/chat-response-renderer/thinking-part-renderer.js.map +1 -1
  78. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.d.ts +2 -0
  79. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.d.ts.map +1 -0
  80. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.js +84 -0
  81. package/lib/browser/chat-response-renderer/thinking-part-renderer.spec.js.map +1 -0
  82. package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts.map +1 -1
  83. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js +2 -2
  84. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js.map +1 -1
  85. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js +16 -0
  86. package/lib/browser/chat-response-renderer/toolcall-part-renderer.spec.js.map +1 -1
  87. package/lib/browser/chat-response-renderer/toolcall-utils.d.ts.map +1 -1
  88. package/lib/browser/chat-response-renderer/toolcall-utils.js +7 -2
  89. package/lib/browser/chat-response-renderer/toolcall-utils.js.map +1 -1
  90. package/lib/browser/chat-tree-view/chat-view-tree-container.d.ts.map +1 -1
  91. package/lib/browser/chat-tree-view/chat-view-tree-container.js +4 -0
  92. package/lib/browser/chat-tree-view/chat-view-tree-container.js.map +1 -1
  93. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts +20 -1
  94. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts.map +1 -1
  95. package/lib/browser/chat-tree-view/chat-view-tree-widget.js +88 -7
  96. package/lib/browser/chat-tree-view/chat-view-tree-widget.js.map +1 -1
  97. package/lib/common/toolcall-utils.d.ts +11 -1
  98. package/lib/common/toolcall-utils.d.ts.map +1 -1
  99. package/lib/common/toolcall-utils.js +191 -9
  100. package/lib/common/toolcall-utils.js.map +1 -1
  101. package/lib/common/toolcall-utils.spec.js +92 -4
  102. package/lib/common/toolcall-utils.spec.js.map +1 -1
  103. package/package.json +13 -13
  104. package/src/browser/ai-chat-ui-frontend-module.ts +7 -0
  105. package/src/browser/chat-find/chat-find-contribution.ts +74 -0
  106. package/src/browser/chat-find/chat-find-highlighter.spec.ts +313 -0
  107. package/src/browser/chat-find/chat-find-highlighter.ts +385 -0
  108. package/src/browser/chat-find/chat-find-matcher.spec.ts +173 -0
  109. package/src/browser/chat-find/chat-find-matcher.ts +197 -0
  110. package/src/browser/chat-find/chat-find-text.ts +65 -0
  111. package/src/browser/chat-find/chat-find-widget.spec.tsx +261 -0
  112. package/src/browser/chat-find/chat-find-widget.tsx +418 -0
  113. package/src/browser/chat-input-scroll-util.spec.ts +42 -0
  114. package/src/browser/chat-input-scroll-util.ts +30 -0
  115. package/src/browser/chat-input-widget.spec.ts +29 -0
  116. package/src/browser/chat-input-widget.tsx +118 -56
  117. package/src/browser/chat-response-renderer/code-part-renderer.tsx +9 -1
  118. package/src/browser/chat-response-renderer/code-wrapper-editors.ts +41 -0
  119. package/src/browser/chat-response-renderer/delegation-tool-renderer.tsx +88 -62
  120. package/src/browser/chat-response-renderer/index.ts +1 -0
  121. package/src/browser/chat-response-renderer/markdown-part-renderer.tsx +34 -17
  122. package/src/browser/chat-response-renderer/server-toolcall-part-renderer.tsx +2 -5
  123. package/src/browser/chat-response-renderer/spinner.tsx +22 -0
  124. package/src/browser/chat-response-renderer/thinking-part-renderer.spec.tsx +104 -0
  125. package/src/browser/chat-response-renderer/thinking-part-renderer.tsx +129 -9
  126. package/src/browser/chat-response-renderer/toolcall-part-renderer.spec.ts +16 -0
  127. package/src/browser/chat-response-renderer/toolcall-part-renderer.tsx +1 -4
  128. package/src/browser/chat-response-renderer/toolcall-utils.ts +5 -0
  129. package/src/browser/chat-tree-view/chat-view-tree-container.ts +4 -0
  130. package/src/browser/chat-tree-view/chat-view-tree-widget.tsx +99 -6
  131. package/src/browser/style/chat-find.css +107 -0
  132. package/src/browser/style/index.css +75 -8
  133. package/src/common/toolcall-utils.spec.ts +113 -4
  134. package/src/common/toolcall-utils.ts +207 -9
@@ -0,0 +1,385 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Disposable } from '@theia/core';
18
+ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
19
+ import { Range as EditorRange } from '@theia/monaco-editor-core/esm/vs/editor/common/core/range';
20
+ import { IEditorDecorationsCollection } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon';
21
+ import { IModelDecorationOptions, TrackedRangeStickiness } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
22
+ import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor';
23
+ import { CodeWrapperEditors } from '../chat-response-renderer/code-wrapper-editors';
24
+ import { ChatFindMatch, ChatFindMatcher } from './chat-find-matcher';
25
+ import { ChatFindText, ChatFindTextSegment } from './chat-find-text';
26
+
27
+ /** Minimal abstraction over `CSS.highlights` so the highlighter can be tested and degrades when unsupported. */
28
+ export interface ChatFindHighlightRegistry {
29
+ set(name: string, ranges: Range[]): void;
30
+ delete(name: string): void;
31
+ }
32
+
33
+ export namespace ChatFindHighlightRegistry {
34
+ /** The CSS Custom Highlight API registry, or `undefined` when the browser does not support it. */
35
+ export function fromCssHighlights(): ChatFindHighlightRegistry | undefined {
36
+ if (typeof CSS === 'undefined' || !('highlights' in CSS) || typeof Highlight === 'undefined') {
37
+ return undefined;
38
+ }
39
+ return {
40
+ set: (name, ranges) => { CSS.highlights.set(name, new Highlight(...ranges)); },
41
+ delete: name => { CSS.highlights.delete(name); }
42
+ };
43
+ }
44
+ }
45
+
46
+ /** Where the current match is shown: a DOM range, or a range in the editor of a code part. */
47
+ type ChatFindCurrentTarget =
48
+ | { kind: 'dom', range: Range }
49
+ | { kind: 'code', editor: SimpleMonacoEditor, range: EditorRange };
50
+
51
+ interface ChatFindCodeDecorations {
52
+ collection: IEditorDecorationsCollection;
53
+ /** What the collection currently shows, so unchanged decorations are not set again. */
54
+ signature: string;
55
+ }
56
+
57
+ /**
58
+ * Highlights find matches in the mounted rows of the chat tree: text with the CSS Custom Highlight API, code parts
59
+ * with decorations in their editor.
60
+ *
61
+ * Only the content the matcher actually searched is highlighted: the elements to scan are derived from the
62
+ * match list, so content the matcher skips (tool calls, reasoning, delegated sub-chats) is never highlighted
63
+ * even though it renders inside the same row. While active, DOM mutations under the root (virtuoso mounting
64
+ * rows, streamed content) re-apply the highlights on the next animation frame.
65
+ */
66
+ @injectable()
67
+ export class ChatFindHighlighter implements Disposable {
68
+
69
+ static readonly MATCH_HIGHLIGHT = 'theia-chat-find-match';
70
+ static readonly CURRENT_HIGHLIGHT = 'theia-chat-find-current';
71
+
72
+ /** Monaco's own find decorations, so code parts look the same as a match of the editor find widget. */
73
+ protected static readonly CODE_MATCH_DECORATION: IModelDecorationOptions = {
74
+ description: 'chat-find-match',
75
+ className: 'findMatch',
76
+ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
77
+ zIndex: 13
78
+ };
79
+ protected static readonly CODE_CURRENT_DECORATION: IModelDecorationOptions = {
80
+ description: 'chat-find-current',
81
+ className: 'currentFindMatch',
82
+ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
83
+ zIndex: 13
84
+ };
85
+
86
+ @inject(ChatFindMatcher)
87
+ protected readonly matcher: ChatFindMatcher;
88
+
89
+ protected registry: ChatFindHighlightRegistry | undefined;
90
+ protected root: HTMLElement | undefined;
91
+ protected regexp: RegExp | undefined;
92
+ protected matches: readonly ChatFindMatch[] = [];
93
+ protected current: ChatFindMatch | undefined;
94
+ protected currentTarget: ChatFindCurrentTarget | undefined;
95
+ protected readonly codeDecorations = new Map<SimpleMonacoEditor, ChatFindCodeDecorations>();
96
+ protected observer: MutationObserver | undefined;
97
+ protected scheduled = false;
98
+
99
+ @postConstruct()
100
+ protected init(): void {
101
+ this.registry = this.createRegistry();
102
+ }
103
+
104
+ protected createRegistry(): ChatFindHighlightRegistry | undefined {
105
+ return ChatFindHighlightRegistry.fromCssHighlights();
106
+ }
107
+
108
+ /** Highlights the given matches under `root` and marks `current`; an undefined `regexp` clears. */
109
+ update(root: HTMLElement, regexp: RegExp | undefined, matches: readonly ChatFindMatch[], current: ChatFindMatch | undefined): void {
110
+ this.regexp = regexp;
111
+ this.matches = matches;
112
+ this.current = current;
113
+ if (!regexp) {
114
+ this.clear();
115
+ return;
116
+ }
117
+ this.observe(root);
118
+ this.apply();
119
+ }
120
+
121
+ /** Re-applies the current highlights, e.g. after a scroll mounted new rows. */
122
+ refresh(): void {
123
+ if (this.regexp && this.root) {
124
+ this.apply();
125
+ }
126
+ }
127
+
128
+ /** The mounted row rendering the tree node `nodeId`, if any. */
129
+ findRow(root: HTMLElement, nodeId: string): HTMLElement | undefined {
130
+ return root.querySelector<HTMLElement>(`[data-node-id="${nodeId.replace(/["\\]/g, '\\$&')}"]`) ?? undefined;
131
+ }
132
+
133
+ /**
134
+ * Scrolls the current match into view within the chat tree's own scroll container.
135
+ *
136
+ * Deliberately does not use `Element.scrollIntoView()`: that scrolls every scrollable ancestor up to the
137
+ * viewport, which shifts the whole application shell. Only the first scroll container between the match
138
+ * and `root` is moved, and only when the match is not already visible.
139
+ */
140
+ scrollCurrentIntoView(root: HTMLElement): void {
141
+ const target = this.getCurrentBounds();
142
+ if (!target) {
143
+ return;
144
+ }
145
+ const scroller = this.findScrollContainer(target.element, root);
146
+ if (!scroller) {
147
+ return;
148
+ }
149
+ const scrollerRect = scroller.getBoundingClientRect();
150
+ if (target.top >= scrollerRect.top && target.top + target.height <= scrollerRect.top + scroller.clientHeight) {
151
+ return;
152
+ }
153
+ const centered = scroller.scrollTop + (target.top - scrollerRect.top) - (scroller.clientHeight - target.height) / 2;
154
+ scroller.scrollTop = Math.max(0, Math.min(centered, scroller.scrollHeight - scroller.clientHeight));
155
+ }
156
+
157
+ clear(): void {
158
+ this.observer?.disconnect();
159
+ this.observer = undefined;
160
+ this.root = undefined;
161
+ this.currentTarget = undefined;
162
+ this.registry?.delete(ChatFindHighlighter.MATCH_HIGHLIGHT);
163
+ this.registry?.delete(ChatFindHighlighter.CURRENT_HIGHLIGHT);
164
+ this.codeDecorations.forEach(decorations => decorations.collection.clear());
165
+ this.codeDecorations.clear();
166
+ }
167
+
168
+ dispose(): void {
169
+ this.clear();
170
+ }
171
+
172
+ /** The element showing the current match and its vertical extent in viewport coordinates, if it is mounted. */
173
+ protected getCurrentBounds(): { element: Element, top: number, height: number } | undefined {
174
+ const target = this.currentTarget;
175
+ if (target?.kind === 'code') {
176
+ const control = target.editor.getControl();
177
+ const editorNode = control.getDomNode();
178
+ // The editor is sized to its content, so this only scrolls long lines horizontally.
179
+ control.revealRangeInCenterIfOutsideViewport(target.range);
180
+ const position = control.getScrolledVisiblePosition(target.range.getStartPosition());
181
+ if (!editorNode?.isConnected || !position) {
182
+ return undefined;
183
+ }
184
+ return { element: editorNode, top: editorNode.getBoundingClientRect().top + position.top, height: position.height };
185
+ }
186
+ const container = target?.range.startContainer;
187
+ const element = container instanceof Element ? container : container?.parentElement;
188
+ if (!element?.isConnected) {
189
+ return undefined;
190
+ }
191
+ const rect = element.getBoundingClientRect();
192
+ return { element, top: rect.top, height: rect.height };
193
+ }
194
+
195
+ /** The first scroll container between `from` and `root`, inclusive; never anything outside `root`. */
196
+ protected findScrollContainer(from: Element, root: HTMLElement): HTMLElement | undefined {
197
+ let element: HTMLElement | undefined = from instanceof HTMLElement ? from : from.parentElement ?? undefined;
198
+ while (element && (root === element || root.contains(element))) {
199
+ const overflowY = element.ownerDocument.defaultView?.getComputedStyle(element).overflowY;
200
+ if ((overflowY === 'auto' || overflowY === 'scroll') && element.scrollHeight > element.clientHeight) {
201
+ return element;
202
+ }
203
+ element = element.parentElement ?? undefined;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ protected observe(root: HTMLElement): void {
209
+ if (this.root === root && this.observer) {
210
+ return;
211
+ }
212
+ this.observer?.disconnect();
213
+ this.root = root;
214
+ this.observer = new MutationObserver(() => this.scheduleApply());
215
+ this.observer.observe(root, { childList: true, subtree: true, characterData: true });
216
+ }
217
+
218
+ protected scheduleApply(): void {
219
+ if (this.scheduled) {
220
+ return;
221
+ }
222
+ this.scheduled = true;
223
+ const run = (): void => {
224
+ this.scheduled = false;
225
+ if (this.root && this.regexp) {
226
+ this.apply();
227
+ }
228
+ };
229
+ if (typeof requestAnimationFrame === 'function') {
230
+ requestAnimationFrame(run);
231
+ } else {
232
+ setTimeout(run, 16);
233
+ }
234
+ }
235
+
236
+ protected apply(): void {
237
+ const { root, regexp } = this;
238
+ if (!root || !regexp) {
239
+ return;
240
+ }
241
+ const all: Range[] = [];
242
+ const decorated = new Set<SimpleMonacoEditor>();
243
+ let currentTarget: ChatFindCurrentTarget | undefined;
244
+ for (const matches of this.groupByContent().values()) {
245
+ const element = this.findContentElement(root, matches[0]);
246
+ if (!element) {
247
+ continue; // the row is not mounted
248
+ }
249
+ const current = this.findCurrentIn(matches);
250
+ if (matches[0].kind === 'code') {
251
+ const editor = this.findCodeEditor(element);
252
+ if (editor) {
253
+ decorated.add(editor);
254
+ currentTarget = this.decorateCode(editor, matches, current) ?? currentTarget;
255
+ }
256
+ continue;
257
+ }
258
+ const ranges = this.collectRanges(this.findTextRoot(element, matches[0]), regexp);
259
+ all.push(...ranges.map(({ range }) => range));
260
+ if (current) {
261
+ // The DOM text is the text the matcher searched, so the current match is found by its offsets. Only
262
+ // while the DOM lags behind the model (e.g. mid-stream) does it fall back to the occurrence index.
263
+ const range = ranges.find(({ start, end }) => start === current.start && end === current.end) ?? ranges[current.occurrence];
264
+ currentTarget = range ? { kind: 'dom', range: range.range } : currentTarget;
265
+ }
266
+ }
267
+ this.clearCodeDecorations(decorated);
268
+ this.currentTarget = currentTarget;
269
+ this.registry?.set(ChatFindHighlighter.MATCH_HIGHLIGHT, all);
270
+ this.registry?.set(ChatFindHighlighter.CURRENT_HIGHLIGHT, currentTarget?.kind === 'dom' ? [currentTarget.range] : []);
271
+ }
272
+
273
+ protected findCurrentIn(matches: readonly ChatFindMatch[]): ChatFindMatch | undefined {
274
+ const current = this.current;
275
+ if (!current) {
276
+ return undefined;
277
+ }
278
+ const key = ChatFindMatch.key(current);
279
+ return matches.find(match => match === current || ChatFindMatch.key(match) === key);
280
+ }
281
+
282
+ /** Matches grouped by the element that renders them, so each element is scanned once. */
283
+ protected groupByContent(): Map<string, ChatFindMatch[]> {
284
+ const groups = new Map<string, ChatFindMatch[]>();
285
+ for (const match of this.matches) {
286
+ const key = `${match.nodeId}/${match.contentIndex ?? ChatFindMatch.REQUEST_KIND}`;
287
+ const group = groups.get(key);
288
+ if (group) {
289
+ group.push(match);
290
+ } else {
291
+ groups.set(key, [match]);
292
+ }
293
+ }
294
+ return groups;
295
+ }
296
+
297
+ /** The element rendering the text a match was found in, or `undefined` when its row is not mounted. */
298
+ protected findContentElement(root: HTMLElement, match: ChatFindMatch): Element | undefined {
299
+ const row = this.findRow(root, match.nodeId);
300
+ if (!row) {
301
+ return undefined;
302
+ }
303
+ // Only the row's own nodes: a delegated sub-chat renders another response node inside a content element.
304
+ if (match.contentIndex === undefined) {
305
+ return row.querySelector(':scope > .theia-RequestNode > p') ?? undefined;
306
+ }
307
+ return row.querySelectorAll(':scope > .theia-ResponseNode > .theia-ResponseNode-Content')[match.contentIndex] ?? undefined;
308
+ }
309
+
310
+ /** The element holding exactly the text the matcher searched for a part: an error part searches only its message. */
311
+ protected findTextRoot(contentElement: Element, match: ChatFindMatch): Element {
312
+ if (match.kind === 'error') {
313
+ return contentElement.querySelector('.theia-ChatPart-Error-message') ?? contentElement;
314
+ }
315
+ return contentElement;
316
+ }
317
+
318
+ /** The editor of a code part, once it has been created. */
319
+ protected findCodeEditor(contentElement: Element): SimpleMonacoEditor | undefined {
320
+ const wrapper = contentElement.querySelector(`.${CodeWrapperEditors.CLASS}`);
321
+ return wrapper ? CodeWrapperEditors.get(wrapper) : undefined;
322
+ }
323
+
324
+ /**
325
+ * Decorates the matches of a code part in its editor; match offsets are offsets into the code the editor shows.
326
+ * @returns the current match's target when it is one of `matches`.
327
+ */
328
+ protected decorateCode(editor: SimpleMonacoEditor, matches: readonly ChatFindMatch[], current: ChatFindMatch | undefined): ChatFindCurrentTarget | undefined {
329
+ const model = editor.document.textEditorModel;
330
+ const toRange = (match: ChatFindMatch): EditorRange => EditorRange.fromPositions(model.getPositionAt(match.start), model.getPositionAt(match.end));
331
+ const decorations = matches.map(match => ({
332
+ range: toRange(match),
333
+ options: match === current ? ChatFindHighlighter.CODE_CURRENT_DECORATION : ChatFindHighlighter.CODE_MATCH_DECORATION
334
+ }));
335
+ // Setting the value of the model drops its decorations, so the version is part of what is shown.
336
+ const signature = `${model.getVersionId()}:${matches.map(match => `${match.start}-${match.end}${match === current ? '*' : ''}`).join(',')}`;
337
+ const existing = this.codeDecorations.get(editor);
338
+ if (existing?.signature !== signature) {
339
+ // Only set when changed: decorating re-renders the editor, which would otherwise re-trigger `apply()`.
340
+ const collection = existing?.collection ?? editor.getControl().createDecorationsCollection();
341
+ collection.set(decorations);
342
+ this.codeDecorations.set(editor, { collection, signature });
343
+ }
344
+ return current ? { kind: 'code', editor, range: toRange(current) } : undefined;
345
+ }
346
+
347
+ /** Removes the decorations of editors that no longer show a match, e.g. because their row was unmounted. */
348
+ protected clearCodeDecorations(keep: Set<SimpleMonacoEditor>): void {
349
+ for (const [editor, decorations] of this.codeDecorations) {
350
+ if (!keep.has(editor)) {
351
+ decorations.collection.clear();
352
+ this.codeDecorations.delete(editor);
353
+ }
354
+ }
355
+ }
356
+
357
+ /** One `Range` per occurrence of `regexp` in the text of `element`, with its offsets in that text. */
358
+ protected collectRanges(element: Element, regexp: RegExp): { start: number, end: number, range: Range }[] {
359
+ const { text, segments } = ChatFindText.collect(element);
360
+ if (!text) {
361
+ return [];
362
+ }
363
+ return this.matcher.findInText(text, regexp).map(({ start, end }) => {
364
+ const range = element.ownerDocument.createRange();
365
+ const from = this.locate(segments, start);
366
+ const to = this.locate(segments, end);
367
+ range.setStart(from.node, from.offset);
368
+ range.setEnd(to.node, to.offset);
369
+ return { start, end, range };
370
+ });
371
+ }
372
+
373
+ /** Map an offset in the concatenated text to a text node and offset inside it (end offsets stay in the preceding node). */
374
+ protected locate(segments: ChatFindTextSegment[], offset: number): { node: Text; offset: number } {
375
+ let segment = segments[0];
376
+ for (const candidate of segments) {
377
+ if (candidate.start < offset) {
378
+ segment = candidate;
379
+ } else {
380
+ break;
381
+ }
382
+ }
383
+ return { node: segment.node, offset: offset - segment.start };
384
+ }
385
+ }
@@ -0,0 +1,173 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+ import {
20
+ ChatModel, ChatRequestModel, ChatResponseContent, CodeChatResponseContentImpl, ErrorChatResponseContentImpl,
21
+ InformationalChatResponseContentImpl, MarkdownChatResponseContentImpl, TextChatResponseContentImpl,
22
+ ThinkingChatResponseContentImpl, ToolCallChatResponseContentImpl
23
+ } from '@theia/ai-chat/lib/common';
24
+ import { expect } from 'chai';
25
+ import { ChatFindMatcher, ChatFindOptions } from './chat-find-matcher';
26
+ disableJSDOM();
27
+
28
+ const plain: ChatFindOptions = { matchCase: false, wholeWord: false, useRegex: false };
29
+
30
+ function request(id: string, text: string, content: ChatResponseContent[]): ChatRequestModel {
31
+ return {
32
+ id,
33
+ message: { parts: [{ kind: 'text', text }] },
34
+ response: { id: `${id}-response`, response: { content } }
35
+ } as unknown as ChatRequestModel;
36
+ }
37
+
38
+ function model(...requests: ChatRequestModel[]): ChatModel {
39
+ return { getRequests: () => requests } as unknown as ChatModel;
40
+ }
41
+
42
+ describe('ChatFindMatcher', () => {
43
+ const matcher = new ChatFindMatcher();
44
+ const count = (chat: ChatModel, query: string, options: ChatFindOptions = plain): number =>
45
+ matcher.findMatches(chat, matcher.createRegExp(query, options)!).length;
46
+
47
+ before(() => disableJSDOM = enableJSDOM());
48
+ after(() => disableJSDOM());
49
+
50
+ describe('createRegExp', () => {
51
+ it('returns undefined for an empty query', () => {
52
+ expect(matcher.createRegExp('', plain)).to.be.undefined;
53
+ });
54
+
55
+ it('escapes regex characters unless useRegex is set', () => {
56
+ expect(matcher.createRegExp('a.b', plain)!.test('axb')).to.be.false;
57
+ expect(matcher.createRegExp('a.b', { ...plain, useRegex: true })!.test('axb')).to.be.true;
58
+ });
59
+
60
+ it('is case-insensitive unless matchCase is set', () => {
61
+ expect(matcher.createRegExp('abc', plain)!.test('ABC')).to.be.true;
62
+ expect(matcher.createRegExp('abc', { ...plain, matchCase: true })!.test('ABC')).to.be.false;
63
+ });
64
+
65
+ it('matches whole words only for wholeWord', () => {
66
+ const whole = { ...plain, wholeWord: true };
67
+ const find = (query: string, text: string) => matcher.findInText(text, matcher.createRegExp(query, whole)!);
68
+ expect(find('cat', 'a cat sat')).to.deep.equal([{ start: 2, end: 5 }]);
69
+ expect(find('cat', 'concatenate')).to.deep.equal([]);
70
+ expect(find('cat', 'cat')).to.deep.equal([{ start: 0, end: 3 }]);
71
+ expect(find('cat', 'cat.')).to.deep.equal([{ start: 0, end: 3 }]);
72
+ });
73
+
74
+ it('matches whole words that start or end with a separator, unlike \\b', () => {
75
+ const whole = { ...plain, wholeWord: true };
76
+ const find = (query: string, text: string) => matcher.findInText(text, matcher.createRegExp(query, whole)!);
77
+ expect(find('C++', 'I like C++ a lot')).to.deep.equal([{ start: 7, end: 10 }]);
78
+ expect(find('foo()', 'call foo() now')).to.deep.equal([{ start: 5, end: 10 }]);
79
+ expect(find('C++', 'xC++')).to.deep.equal([]);
80
+ });
81
+
82
+ it('returns undefined for an invalid regular expression', () => {
83
+ expect(matcher.createRegExp('(', { ...plain, useRegex: true })).to.be.undefined;
84
+ });
85
+ });
86
+
87
+ describe('findInText', () => {
88
+ it('returns all occurrences in order', () => {
89
+ const ranges = matcher.findInText('foo bar foo', matcher.createRegExp('foo', plain)!);
90
+ expect(ranges).to.deep.equal([{ start: 0, end: 3 }, { start: 8, end: 11 }]);
91
+ });
92
+
93
+ it('skips zero-length matches without looping forever', () => {
94
+ const ranges = matcher.findInText('aab', matcher.createRegExp('a*', { ...plain, useRegex: true })!);
95
+ expect(ranges).to.deep.equal([{ start: 0, end: 2 }]);
96
+ });
97
+ });
98
+
99
+ describe('findMatches', () => {
100
+ it('searches request text and response parts in tree order', () => {
101
+ const chat = model(
102
+ request('r1', 'find me', [new TextChatResponseContentImpl('nothing'), new MarkdownChatResponseContentImpl('find **me** and find me')]),
103
+ request('r2', 'other', [new CodeChatResponseContentImpl('const findMe = 1;', 'ts')])
104
+ );
105
+ const matches = matcher.findMatches(chat, matcher.createRegExp('find', plain)!);
106
+ // Offsets are into the rendered text: the markdown renders as 'find me and find me'.
107
+ expect(matches).to.deep.equal([
108
+ { nodeId: 'r1', kind: 'request', contentIndex: undefined, occurrence: 0, start: 0, end: 4 },
109
+ { nodeId: 'r1-response', kind: 'markdownContent', contentIndex: 1, occurrence: 0, start: 0, end: 4 },
110
+ { nodeId: 'r1-response', kind: 'markdownContent', contentIndex: 1, occurrence: 1, start: 12, end: 16 },
111
+ { nodeId: 'r2-response', kind: 'code', contentIndex: 0, occurrence: 0, start: 6, end: 10 }
112
+ ]);
113
+ });
114
+
115
+ it('searches markdown as rendered: link targets and markup are not searched, text across markup is', () => {
116
+ const chat = model(request('r1', '', [
117
+ new MarkdownChatResponseContentImpl('See the [docs](https://example.org/docs/page) for **important note** details.')
118
+ ]));
119
+ expect(count(chat, 'docs')).to.equal(1);
120
+ expect(count(chat, 'for important')).to.equal(1);
121
+ expect(count(chat, '**')).to.equal(0);
122
+ });
123
+
124
+ it('follows streamed markdown', () => {
125
+ const content = new MarkdownChatResponseContentImpl('first needle');
126
+ const chat = model(request('r1', '', [content]));
127
+ expect(count(chat, 'needle')).to.equal(1);
128
+ content.merge(new MarkdownChatResponseContentImpl(' and a second needle'));
129
+ expect(count(chat, 'needle')).to.equal(2);
130
+ });
131
+
132
+ it('searches request text as rendered markdown', () => {
133
+ expect(count(model(request('r1', 'use `npm` **now**', [])), 'npm now')).to.equal(1);
134
+ });
135
+
136
+ it('searches the visible error headline only and skips informational parts, which are not rendered', () => {
137
+ const error = new ErrorChatResponseContentImpl(new Error('401 {"error":{"message":"needle in headline","type":"needle_type"}}'));
138
+ const chat = model(request('r1', '', [new InformationalChatResponseContentImpl('needle info'), error]));
139
+ const matches = matcher.findMatches(chat, matcher.createRegExp('needle', plain)!);
140
+ expect(matches.map(m => [m.contentIndex, m.kind, m.occurrence])).to.deep.equal([[1, 'error', 0]]);
141
+ });
142
+
143
+ it('ignores a mermaid block, which is rendered as a diagram rather than as its source', () => {
144
+ const chart = new CodeChatResponseContentImpl('flowchart TD\n A[needle] --> B[other]', 'mermaid');
145
+ const chat = model(request('r1', '', [chart, new TextChatResponseContentImpl('needle in text')]));
146
+ const matches = matcher.findMatches(chat, matcher.createRegExp('needle', plain)!);
147
+ expect(matches.map(m => m.contentIndex)).to.deep.equal([1]);
148
+ });
149
+
150
+ it('still searches a plain code block', () => {
151
+ const chat = model(request('r1', '', [new CodeChatResponseContentImpl('const needle = 1;', 'ts')]));
152
+ expect(matcher.findMatches(chat, matcher.createRegExp('needle', plain)!)).to.have.length(1);
153
+ });
154
+
155
+ it('ignores tool calls and reasoning', () => {
156
+ const chat = model(request('r1', '', [
157
+ new ToolCallChatResponseContentImpl('id', 'needle_tool', '{"q":"needle"}', true),
158
+ new ThinkingChatResponseContentImpl('thinking about needle', 'sig')
159
+ ]));
160
+ expect(matcher.findMatches(chat, matcher.createRegExp('needle', plain)!)).to.deep.equal([]);
161
+ });
162
+
163
+ it('joins request parts, e.g. agent mentions, into one text', () => {
164
+ const req = {
165
+ id: 'r1',
166
+ message: { parts: [{ kind: 'agent', text: '@Coder' }, { kind: 'text', text: ' please fix' }] },
167
+ response: { id: 'r1-response', response: { content: [] } }
168
+ } as unknown as ChatRequestModel;
169
+ const matches = matcher.findMatches(model(req), matcher.createRegExp('coder please', plain)!);
170
+ expect(matches).to.deep.equal([{ nodeId: 'r1', kind: 'request', contentIndex: undefined, occurrence: 0, start: 1, end: 13 }]);
171
+ });
172
+ });
173
+ });