@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,197 @@
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 {
18
+ ChatModel, ChatRequestModel, ChatResponseContent, CodeChatResponseContent, ErrorChatResponseContent,
19
+ MarkdownChatResponseContent, TextChatResponseContent, formatProviderError
20
+ } from '@theia/ai-chat/lib/common';
21
+ import { ImageContextVariable } from '@theia/ai-chat/lib/common/image-context-variable';
22
+ import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
23
+ import { injectable } from '@theia/core/shared/inversify';
24
+ import { MarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
25
+ import { ChatFindText } from './chat-find-text';
26
+
27
+ /** Language id of code parts that are rendered as a diagram instead of as source. */
28
+ const MERMAID_LANGUAGE_ID = 'mermaid';
29
+
30
+ export interface ChatFindOptions {
31
+ matchCase: boolean;
32
+ wholeWord: boolean;
33
+ useRegex: boolean;
34
+ }
35
+
36
+ export interface ChatFindTextRange {
37
+ start: number;
38
+ end: number;
39
+ }
40
+
41
+ /**
42
+ * A match of the find query inside a chat session, located by the tree node that renders it.
43
+ */
44
+ export interface ChatFindMatch extends ChatFindTextRange {
45
+ /** Id of the tree node rendering the matched text: the request id or the response id. */
46
+ nodeId: string;
47
+ /** The `kind` of the matched response part, or {@link ChatFindMatch.REQUEST_KIND} for request text. */
48
+ kind: string;
49
+ /** Index into `response.response.content`; `undefined` for request text. */
50
+ contentIndex?: number;
51
+ /** 0-based index of this match among all matches inside the same text. */
52
+ occurrence: number;
53
+ }
54
+
55
+ export namespace ChatFindMatch {
56
+ export const REQUEST_KIND = 'request';
57
+
58
+ /** Stable identity of a match across recomputations of the match list. */
59
+ export function key(match: ChatFindMatch): string {
60
+ return `${match.nodeId}/${match.contentIndex ?? 'request'}/${match.occurrence}`;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Computes find matches over a chat session model. Only the displayed branch (`model.getRequests()`) is searched.
66
+ *
67
+ * Text is searched as it is rendered, not as it is stored: markdown is rendered the way the chat renders it and
68
+ * its text read with {@link ChatFindText}, the same way the highlighter reads the mounted DOM, so match offsets
69
+ * are valid in both. Subclass and override {@link getSearchableText} to search additional content kinds.
70
+ */
71
+ @injectable()
72
+ export class ChatFindMatcher {
73
+
74
+ /** VS Code's default `editor.wordSeparators`; whitespace separates words as well. */
75
+ protected static readonly WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?';
76
+
77
+ /** Rendered text by the object it was rendered for, reused while its source text is unchanged. */
78
+ protected readonly renderedTextCache = new WeakMap<object, { source: string, text: string }>();
79
+
80
+ /**
81
+ * Builds the regular expression shared by model matching and DOM highlighting.
82
+ * @returns `undefined` for an empty query or an invalid regular expression.
83
+ */
84
+ createRegExp(query: string, options: ChatFindOptions): RegExp | undefined {
85
+ if (!query) {
86
+ return undefined;
87
+ }
88
+ let source = options.useRegex ? query : escapeRegExpCharacters(query);
89
+ if (options.wholeWord) {
90
+ source = this.wrapWholeWord(source);
91
+ }
92
+ try {
93
+ return new RegExp(source, options.matchCase ? 'g' : 'gi');
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /** All matches in tree order: request row, then response row, content parts in order, offsets ascending. */
100
+ findMatches(model: ChatModel, regexp: RegExp): ChatFindMatch[] {
101
+ const matches: ChatFindMatch[] = [];
102
+ for (const request of model.getRequests()) {
103
+ this.collect(matches, regexp, this.getRequestText(request), request.id, ChatFindMatch.REQUEST_KIND, undefined);
104
+ request.response.response.content.forEach((content, contentIndex) => {
105
+ const text = this.getSearchableText(content);
106
+ if (text) {
107
+ this.collect(matches, regexp, text, request.response.id, content.kind, contentIndex);
108
+ }
109
+ });
110
+ }
111
+ return matches;
112
+ }
113
+
114
+ /** All non-empty occurrences of `regexp` in `text`, in order. */
115
+ findInText(text: string, regexp: RegExp): ChatFindTextRange[] {
116
+ const ranges: ChatFindTextRange[] = [];
117
+ regexp.lastIndex = 0;
118
+ let result = regexp.exec(text);
119
+ while (result) {
120
+ if (result[0].length === 0) {
121
+ regexp.lastIndex++;
122
+ } else {
123
+ ranges.push({ start: result.index, end: result.index + result[0].length });
124
+ }
125
+ result = regexp.exec(text);
126
+ }
127
+ return ranges;
128
+ }
129
+
130
+ /**
131
+ * Restricts `source` to whole words the way VS Code does: each end of a match must be the end of the text, next
132
+ * to a word separator, or itself a word separator. Unlike `\b`, this also works for queries such as `C++` or `foo()`.
133
+ */
134
+ protected wrapWholeWord(source: string): string {
135
+ const separator = `[\\s${escapeRegExpCharacters(ChatFindMatcher.WORD_SEPARATORS)}]`;
136
+ return `(?:(?<=^|${separator})|(?=${separator}))(?:${source})(?:(?=${separator}|$)|(?<=${separator}))`;
137
+ }
138
+
139
+ protected collect(
140
+ matches: ChatFindMatch[], regexp: RegExp, text: string, nodeId: string, kind: string, contentIndex: number | undefined
141
+ ): void {
142
+ this.findInText(text, regexp).forEach((range, occurrence) => {
143
+ matches.push({ nodeId, kind, contentIndex, occurrence, start: range.start, end: range.end });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * The request text as rendered by `ChatRequestRender`: agent, variable and function mentions as labels,
149
+ * text parts as inline markdown, inline image parts skipped.
150
+ */
151
+ protected getRequestText(request: ChatRequestModel): string {
152
+ const parts = request.message.parts;
153
+ const inlineImages = ImageContextVariable.extractInlineImagesWithIndices(parts);
154
+ return parts
155
+ .filter((_, index) => !inlineImages.has(index))
156
+ .map(part => part.kind === 'agent' || part.kind === 'var' || part.kind === 'function'
157
+ ? ChatFindText.normalize(part.text)
158
+ : this.getRenderedMarkdownText(part, MarkdownRendering.prepareRequestText(part.text), true, false))
159
+ .join('');
160
+ }
161
+
162
+ /**
163
+ * The searchable text of a response part as it is rendered, or `undefined` for kinds that are not searched
164
+ * (tool calls, reasoning, progress, informational content, which the chat does not render, ...).
165
+ */
166
+ protected getSearchableText(content: ChatResponseContent): string | undefined {
167
+ if (TextChatResponseContent.is(content)) {
168
+ return ChatFindText.normalize(content.asString?.() ?? content.content);
169
+ }
170
+ if (MarkdownChatResponseContent.is(content)) {
171
+ return this.getRenderedMarkdownText(content, content.content.value, false, true);
172
+ }
173
+ if (CodeChatResponseContent.is(content)) {
174
+ // A mermaid block is rendered as a diagram rather than as its source (see MermaidPartRenderer), so its
175
+ // source text is nowhere on screen: matches in it could be counted but never highlighted or revealed.
176
+ // Other code parts are highlighted in their editor, by offsets into the code.
177
+ return content.language?.toLowerCase() === MERMAID_LANGUAGE_ID ? undefined : content.code;
178
+ }
179
+ if (ErrorChatResponseContent.is(content)) {
180
+ // Only the message of the headline; the highlighter scopes an error part to the same element.
181
+ return ChatFindText.normalize(formatProviderError(content.error.message).message);
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ /** The text of `markdown` rendered the way `useMarkdownRendering` renders it, cached per `owner`. */
187
+ protected getRenderedMarkdownText(owner: object, markdown: string, skipSurroundingParagraph: boolean, blockExternalResourceLoading: boolean): string {
188
+ const cached = this.renderedTextCache.get(owner);
189
+ if (cached?.source === markdown) {
190
+ return cached.text;
191
+ }
192
+ const fragment = MarkdownRendering.renderToFragment(markdown, skipSurroundingParagraph, blockExternalResourceLoading);
193
+ const text = ChatFindText.collect(fragment).text;
194
+ this.renderedTextCache.set(owner, { source: markdown, text });
195
+ return text;
196
+ }
197
+ }
@@ -0,0 +1,65 @@
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 { BLOCKED_RESOURCE_CLASS } from '../chat-response-renderer/block-external-resources';
18
+
19
+ export interface ChatFindTextSegment {
20
+ node: Text;
21
+ /** Offset of the node's first character in {@link ChatFindTextContent.text}. */
22
+ start: number;
23
+ }
24
+
25
+ export interface ChatFindTextContent {
26
+ text: string;
27
+ segments: ChatFindTextSegment[];
28
+ }
29
+
30
+ /**
31
+ * Reads the searchable text of rendered chat content. The matcher reads it from a detached render and the
32
+ * highlighter from the mounted DOM, so match offsets computed on the one are valid in the other.
33
+ */
34
+ export namespace ChatFindText {
35
+
36
+ /** The concatenated text nodes under `root`, skipping subtrees that do not hold visible text. */
37
+ export function collect(root: Node): ChatFindTextContent {
38
+ const segments: ChatFindTextSegment[] = [];
39
+ let text = '';
40
+ const walker = (root.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
41
+ acceptNode: node => node.nodeType === Node.ELEMENT_NODE
42
+ ? (isSkipped(node as Element) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP)
43
+ : NodeFilter.FILTER_ACCEPT
44
+ });
45
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
46
+ segments.push({ node: node as Text, start: text.length });
47
+ text += node.textContent ?? '';
48
+ }
49
+ return { text: normalize(text), segments };
50
+ }
51
+
52
+ /** Non-breaking spaces (e.g. from `&nbsp;` in markdown) are searched as plain spaces; the length is unchanged. */
53
+ export function normalize(text: string): string {
54
+ return text.replace(/ /g, ' ');
55
+ }
56
+
57
+ /**
58
+ * Code rather than visible text (e.g. the stylesheet of a rendered mermaid diagram), and blocked-resource
59
+ * placeholders, whose label disappears once the user allows the resource.
60
+ */
61
+ function isSkipped(element: Element): boolean {
62
+ const name = element.nodeName.toLowerCase();
63
+ return name === 'style' || name === 'script' || element.classList.contains(BLOCKED_RESOURCE_CLASS);
64
+ }
65
+ }
@@ -0,0 +1,261 @@
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 { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
20
+ FrontendApplicationConfigProvider.set({});
21
+ import 'reflect-metadata';
22
+ import { ChatChangeEvent, ChatModel, ChatRequestModel, ChatResponseContent, TextChatResponseContentImpl } from '@theia/ai-chat/lib/common';
23
+ import { Emitter } from '@theia/core';
24
+ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
25
+ import { expect } from 'chai';
26
+ import { ChatFindMatch, ChatFindMatcher } from './chat-find-matcher';
27
+ import { ChatFindState, ChatFindWidget } from './chat-find-widget';
28
+ disableJSDOM();
29
+
30
+ interface FakeRequest {
31
+ request: ChatRequestModel;
32
+ responseChanged: Emitter<void>;
33
+ content: ChatResponseContent[];
34
+ }
35
+
36
+ function fakeRequest(id: string, text: string, ...responseTexts: string[]): FakeRequest {
37
+ const responseChanged = new Emitter<void>();
38
+ const content: ChatResponseContent[] = responseTexts.map(t => new TextChatResponseContentImpl(t));
39
+ const request = {
40
+ id,
41
+ message: { parts: [{ kind: 'text', text }] },
42
+ response: { id: `${id}-response`, isComplete: false, onDidChange: responseChanged.event, response: { content } }
43
+ } as unknown as ChatRequestModel;
44
+ return { request, responseChanged, content };
45
+ }
46
+
47
+ function fakeModel(requests: FakeRequest[]): { model: ChatModel; changed: Emitter<ChatChangeEvent> } {
48
+ const changed = new Emitter<ChatChangeEvent>();
49
+ const model = {
50
+ onDidChange: changed.event,
51
+ getRequests: () => requests.map(r => r.request),
52
+ isEmpty: () => requests.length === 0
53
+ } as unknown as ChatModel;
54
+ return { model, changed };
55
+ }
56
+
57
+ class TestChatFindWidget extends ChatFindWidget {
58
+ visible = false;
59
+ constructor() {
60
+ super();
61
+ this.matcher = new ChatFindMatcher();
62
+ this.contextKeyService = {
63
+ createKey: () => ({
64
+ set: (value: boolean) => { this.visible = value; },
65
+ get: () => this.visible,
66
+ reset: () => { this.visible = false; }
67
+ })
68
+ } as unknown as ContextKeyService;
69
+ this.init();
70
+ }
71
+ }
72
+
73
+ describe('ChatFindWidget', () => {
74
+ let widget: TestChatFindWidget;
75
+ let states: ChatFindState[];
76
+ let reveals: ChatFindMatch[];
77
+
78
+ before(() => disableJSDOM = enableJSDOM());
79
+ after(() => disableJSDOM());
80
+
81
+ beforeEach(() => {
82
+ widget = new TestChatFindWidget();
83
+ widget.fallbackFocusTarget = document.body;
84
+ states = [];
85
+ reveals = [];
86
+ widget.onDidChangeState(state => states.push(state));
87
+ widget.onDidRequestReveal(match => reveals.push(match));
88
+ });
89
+
90
+ afterEach(() => widget.dispose());
91
+
92
+ it('computes matches for the query and reveals the first one', () => {
93
+ const { model } = fakeModel([fakeRequest('r1', 'alpha', 'alpha beta alpha')]);
94
+ widget.setChatModel(model);
95
+ widget.open();
96
+ widget.setQuery('alpha');
97
+ expect(widget.state.matches).to.have.length(3);
98
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r1', occurrence: 0 });
99
+ expect(reveals).to.have.length(1);
100
+ });
101
+
102
+ it('cycles with next and previous, wrapping around', () => {
103
+ const { model } = fakeModel([fakeRequest('r1', 'x', 'x', 'x')]);
104
+ widget.setChatModel(model);
105
+ widget.open();
106
+ widget.setQuery('x');
107
+ widget.next();
108
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r1-response', contentIndex: 0 });
109
+ widget.next();
110
+ widget.next();
111
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r1' });
112
+ widget.previous();
113
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r1-response', contentIndex: 1 });
114
+ expect(reveals).to.have.length(5);
115
+ });
116
+
117
+ it('keeps the current match while a response streams', async () => {
118
+ const first = fakeRequest('r1', 'needle', 'needle');
119
+ const second = fakeRequest('r2', '', 'nee');
120
+ const { model } = fakeModel([first, second]);
121
+ widget.setChatModel(model);
122
+ widget.open();
123
+ widget.setQuery('needle');
124
+ widget.next(); // current: r1-response
125
+ second.content[0] = new TextChatResponseContentImpl('needle needle');
126
+ second.responseChanged.fire();
127
+ await new Promise(resolve => setTimeout(resolve, 150));
128
+ expect(widget.state.matches).to.have.length(4);
129
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r1-response', occurrence: 0 });
130
+ expect(reveals).to.have.length(2); // a recompute never reveals
131
+ });
132
+
133
+ it('moves to the nearest following match when the current one disappears', async () => {
134
+ const first = fakeRequest('r1', 'a', 'a');
135
+ const second = fakeRequest('r2', 'a', 'a');
136
+ const requests = [first, second];
137
+ const { model, changed } = fakeModel(requests);
138
+ widget.setChatModel(model);
139
+ widget.open();
140
+ widget.setQuery('a');
141
+ widget.next(); // r1-response
142
+ requests.splice(0, 1);
143
+ changed.fire({ kind: 'removeRequest', requestId: 'r1' } as unknown as ChatChangeEvent);
144
+ await new Promise(resolve => setTimeout(resolve, 150));
145
+ expect(widget.state.matches).to.have.length(2);
146
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r2' });
147
+ });
148
+
149
+ it('does not search while closed, and catches up on open', async () => {
150
+ const streaming = fakeRequest('r1', '', 'nee');
151
+ const { model } = fakeModel([streaming]);
152
+ let searches = 0;
153
+ const findMatches = widget['matcher'].findMatches.bind(widget['matcher']);
154
+ widget['matcher'].findMatches = (...args) => { searches++; return findMatches(...args); };
155
+ widget.setChatModel(model);
156
+ widget.open();
157
+ widget.setQuery('needle');
158
+ widget.dismiss();
159
+ searches = 0;
160
+ streaming.content[0] = new TextChatResponseContentImpl('needle');
161
+ streaming.responseChanged.fire();
162
+ await new Promise(resolve => setTimeout(resolve, 150));
163
+ expect(searches).to.equal(0);
164
+ widget.open();
165
+ expect(widget.state.matches).to.have.length(1);
166
+ });
167
+
168
+ it('reports no matches for an invalid regular expression', () => {
169
+ const { model } = fakeModel([fakeRequest('r1', '(', '(')]);
170
+ widget.setChatModel(model);
171
+ widget.toggleOption('useRegex');
172
+ widget.setQuery('(');
173
+ expect(widget.state.regexp).to.be.undefined;
174
+ expect(widget.state.matches).to.deep.equal([]);
175
+ expect(widget.currentMatch).to.be.undefined;
176
+ });
177
+
178
+ it('sets the context key and clears highlights on open and close', () => {
179
+ const { model } = fakeModel([fakeRequest('r1', 'q', 'q')]);
180
+ widget.setChatModel(model);
181
+ widget.setQuery('q');
182
+ widget.open();
183
+ expect(widget.isOpen).to.be.true;
184
+ expect(widget.visible).to.be.true;
185
+ widget.dismiss();
186
+ expect(widget.isOpen).to.be.false;
187
+ expect(widget.visible).to.be.false;
188
+ const last = states[states.length - 1];
189
+ expect(last.regexp).to.be.undefined;
190
+ expect(last.matches).to.deep.equal([]);
191
+ widget.open();
192
+ expect(widget.state.matches).to.have.length(2);
193
+ expect(states[states.length - 1].matches).to.have.length(2);
194
+ });
195
+
196
+ it('does not open on a session without requests, where the tree shows the session list', () => {
197
+ const { model } = fakeModel([]);
198
+ widget.setChatModel(model);
199
+ widget.open();
200
+ expect(widget.isOpen).to.be.false;
201
+ expect(widget.canFind).to.be.false;
202
+ });
203
+
204
+ it('closes when the tracked session is replaced by one without requests', () => {
205
+ const { model: withRequests } = fakeModel([fakeRequest('r1', 'q', 'q')]);
206
+ const { model: empty } = fakeModel([]);
207
+ widget.setChatModel(withRequests);
208
+ widget.setQuery('q');
209
+ widget.open();
210
+ expect(widget.isOpen).to.be.true;
211
+ widget.setChatModel(empty);
212
+ expect(widget.isOpen).to.be.false;
213
+ });
214
+
215
+ it('closes on a session switch, keeping query and options for the next open', () => {
216
+ const { model: a } = fakeModel([fakeRequest('r1', 'k k', 'k')]);
217
+ const { model: b } = fakeModel([fakeRequest('r9', 'K', 'k')]);
218
+ widget.setChatModel(a);
219
+ widget.open();
220
+ widget.setQuery('k');
221
+ widget.next();
222
+ widget.next();
223
+ widget.setChatModel(b);
224
+ expect(widget.isOpen).to.be.false;
225
+ expect(widget.visible).to.be.false;
226
+ expect(states[states.length - 1].matches).to.deep.equal([]);
227
+ widget.open();
228
+ expect(widget.state.matches).to.have.length(2);
229
+ expect(widget.currentMatch).to.deep.include({ nodeId: 'r9', occurrence: 0 });
230
+ });
231
+
232
+ it('does not move focus when it closes on a session switch', () => {
233
+ const before = document.createElement('button');
234
+ const picked = document.createElement('button');
235
+ document.body.append(before, picked);
236
+ const { model: a } = fakeModel([fakeRequest('r1', 'k', 'k')]);
237
+ widget.setChatModel(a);
238
+ before.focus();
239
+ widget.open();
240
+ picked.focus(); // e.g. the entry the user clicked in the session history
241
+ widget.setChatModel(fakeModel([fakeRequest('r2', 'k', 'k')]).model);
242
+ expect(document.activeElement).to.equal(picked);
243
+ before.remove();
244
+ picked.remove();
245
+ });
246
+
247
+ it('stays open when the same session is tracked again', () => {
248
+ const { model } = fakeModel([fakeRequest('r1', 'k', 'k')]);
249
+ widget.setChatModel(model);
250
+ widget.open();
251
+ widget.setChatModel(model);
252
+ expect(widget.isOpen).to.be.true;
253
+ });
254
+
255
+ it('resets the context key when disposed while open', () => {
256
+ widget.setChatModel(fakeModel([fakeRequest('r1', 'k', 'k')]).model);
257
+ widget.open();
258
+ widget.dispose();
259
+ expect(widget.visible).to.be.false;
260
+ });
261
+ });