@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
@@ -22,6 +22,7 @@ export * from './markdown-part-renderer';
22
22
  export * from './mcp-app-frame';
23
23
  export * from './mermaid-part-renderer';
24
24
  export * from './mermaid-rendering';
25
+ export * from './spinner';
25
26
  export * from './text-part-renderer';
26
27
  export * from './toolcall-part-renderer';
27
28
  export * from './server-toolcall-part-renderer';
@@ -77,6 +77,39 @@ export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownCh
77
77
  }
78
78
  }
79
79
 
80
+ export namespace MarkdownRendering {
81
+ /**
82
+ * Renders markdown into a detached fragment, exactly as {@link useMarkdownRendering} does before mounting it.
83
+ * Also used to read the rendered text of markdown that is not mounted, e.g. to search a chat session.
84
+ */
85
+ export function renderToFragment(markdown: string, skipSurroundingParagraph: boolean = false, blockExternalResourceLoading: boolean = true): DocumentFragment {
86
+ const markdownIt = markdownit({ html: true }).use(markdownitemoji.full);
87
+ const template = document.createElement('template');
88
+
89
+ // markdownIt always puts the content in a paragraph element, so we remove it if we don't want that
90
+ const html = skipSurroundingParagraph ? markdownIt.render(markdown).replace(/^<p>|<\/p>|<p><\/p>$/g, '') : markdownIt.render(markdown);
91
+
92
+ template.innerHTML = DOMPurify.sanitize(html, {
93
+ // DOMPurify usually strips non http(s) links from hrefs
94
+ // but we want to allow them (see handleClick via OpenerService in useMarkdownRendering)
95
+ ALLOW_UNKNOWN_PROTOCOLS: true,
96
+ ADD_TAGS: ['iframe', 'frame'],
97
+ ADD_ATTR: ['src', 'srcset', 'srcdoc', 'poster', 'href', 'xlink:href', 'data']
98
+ });
99
+ // Active embedded content is always blocked; trusted content (blockExternalResourceLoading=false)
100
+ // still renders its external URL resources directly.
101
+ blockExternalResources(template.content, blockExternalResourceLoading);
102
+ return template.content;
103
+ }
104
+
105
+ /** Prepares the text of a chat request text part for inline rendering. */
106
+ export function prepareRequestText(text: string): string {
107
+ return text
108
+ .replace(/^[\r\n]+|[\r\n]+$/g, '') // remove excessive new lines
109
+ .replace(/(^ )/g, '&nbsp;'); // enforce keeping space before
110
+ }
111
+ }
112
+
80
113
  export interface DeclaredEventsEventListenerObject extends EventListenerObject {
81
114
  handledEvents?: (keyof HTMLElementEventMap)[];
82
115
  }
@@ -113,24 +146,8 @@ export const useMarkdownRendering = (
113
146
  const ref = useRef<HTMLDivElement | null>(null);
114
147
  const markdownString = typeof markdown === 'string' ? markdown : markdown.value;
115
148
  useEffect(() => {
116
- const markdownIt = markdownit({ html: true }).use(markdownitemoji.full);
117
149
  const host = document.createElement('div');
118
- const template = document.createElement('template');
119
-
120
- // markdownIt always puts the content in a paragraph element, so we remove it if we don't want that
121
- const html = skipSurroundingParagraph ? markdownIt.render(markdownString).replace(/^<p>|<\/p>|<p><\/p>$/g, '') : markdownIt.render(markdownString);
122
-
123
- template.innerHTML = DOMPurify.sanitize(html, {
124
- // DOMPurify usually strips non http(s) links from hrefs
125
- // but we want to allow them (see handleClick via OpenerService below)
126
- ALLOW_UNKNOWN_PROTOCOLS: true,
127
- ADD_TAGS: ['iframe', 'frame'],
128
- ADD_ATTR: ['src', 'srcset', 'srcdoc', 'poster', 'href', 'xlink:href', 'data']
129
- });
130
- // Active embedded content is always blocked; trusted content (blockExternalResourceLoading=false)
131
- // still renders its external URL resources directly.
132
- blockExternalResources(template.content, blockExternalResourceLoading);
133
- host.appendChild(template.content);
150
+ host.appendChild(MarkdownRendering.renderToFragment(markdownString, skipSurroundingParagraph, blockExternalResourceLoading));
134
151
  while (ref?.current?.firstChild) {
135
152
  ref.current.removeChild(ref.current.firstChild);
136
153
  }
@@ -20,9 +20,10 @@ import { ChatResponseContent, ServerToolCallChatResponseContent } from '@theia/a
20
20
  import * as React from '@theia/core/shared/react';
21
21
  import { ReactNode } from '@theia/core/shared/react';
22
22
  import { nls } from '@theia/core/lib/common/nls';
23
- import { codicon, HoverService, OpenerService } from '@theia/core/lib/browser';
23
+ import { HoverService, OpenerService } from '@theia/core/lib/browser';
24
24
  import { renderToolCallResult } from './toolcall-result';
25
25
  import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
26
+ import { Spinner } from './spinner';
26
27
 
27
28
  /**
28
29
  * Renders a {@link ServerToolCallChatResponseContent}: a tool the provider executed on its own
@@ -90,10 +91,6 @@ export class ServerToolCallPartRenderer implements ChatResponsePartRenderer<Serv
90
91
  }
91
92
  }
92
93
 
93
- const Spinner = () => (
94
- <span className={`${codicon('loading')} theia-animation-spin`}></span>
95
- );
96
-
97
94
  interface ServerToolCallContentProps {
98
95
  response: ServerToolCallChatResponseContent;
99
96
  getArgumentsLabel: (args: string | undefined) => string;
@@ -0,0 +1,22 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
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 { codicon } from '@theia/core/lib/browser';
18
+
19
+ /** Spinning indicator shared by the chat response part renderers to signal ongoing activity. */
20
+ export const Spinner = () => (
21
+ <span className={`${codicon('loading')} theia-animation-spin`}></span>
22
+ );
@@ -0,0 +1,104 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
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
+
19
+ let disableJSDOM = enableJSDOM();
20
+
21
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
22
+ FrontendApplicationConfigProvider.set({});
23
+
24
+ import { ChatResponseContent, ThinkingChatResponseContent } from '@theia/ai-chat';
25
+ import { expect } from 'chai';
26
+ import * as React from '@theia/core/shared/react';
27
+ import { ResponseNode } from '../chat-tree-view';
28
+ import { ThinkingPartRenderer } from './thinking-part-renderer';
29
+
30
+ const thinking = (content: string): ThinkingChatResponseContent => ({
31
+ kind: 'thinking',
32
+ content,
33
+ signature: 'signature',
34
+ asString: () => content,
35
+ asDisplayString: () => content,
36
+ merge: () => true
37
+ } as unknown as ThinkingChatResponseContent);
38
+
39
+ const responseNode = (
40
+ content: ChatResponseContent[],
41
+ state: { isComplete?: boolean, isCanceled?: boolean, isError?: boolean } = {}
42
+ ): ResponseNode => ({
43
+ id: 'response-1',
44
+ sessionId: 'session-1',
45
+ parent: undefined,
46
+ response: {
47
+ isComplete: false,
48
+ isCanceled: false,
49
+ isError: false,
50
+ ...state,
51
+ response: { content }
52
+ }
53
+ } as unknown as ResponseNode);
54
+
55
+ /** The rendered element is a `ThinkingContent`; its `active` prop is what drives expand/collapse. */
56
+ const isActive = (node: React.ReactNode): boolean => (node as React.ReactElement<{ active: boolean }>).props.active;
57
+
58
+ disableJSDOM();
59
+
60
+ describe('ThinkingPartRenderer', () => {
61
+
62
+ before(() => disableJSDOM = enableJSDOM());
63
+ after(() => disableJSDOM());
64
+
65
+ it('only handles thinking parts', () => {
66
+ const renderer = new ThinkingPartRenderer();
67
+ expect(renderer.canHandle(thinking('...'))).to.be.greaterThan(0);
68
+ expect(renderer.canHandle({ kind: 'text' } as ChatResponseContent)).to.be.lessThan(0);
69
+ });
70
+
71
+ it('renders the thinking content', () => {
72
+ const renderer = new ThinkingPartRenderer();
73
+ const part = thinking('Let me check the imports first.');
74
+ const node = renderer.render(part, responseNode([part]));
75
+ expect(JSON.stringify(node)).to.contain('Let me check the imports first.');
76
+ });
77
+
78
+ it('is active while it is the last part of a running response', () => {
79
+ const renderer = new ThinkingPartRenderer();
80
+ const part = thinking('...');
81
+ expect(isActive(renderer.render(part, responseNode([part])))).to.be.true;
82
+ });
83
+
84
+ it('is inactive once a later part has arrived', () => {
85
+ const renderer = new ThinkingPartRenderer();
86
+ const part = thinking('...');
87
+ const node = responseNode([part, { kind: 'text' } as ChatResponseContent]);
88
+ expect(isActive(renderer.render(part, node))).to.be.false;
89
+ });
90
+
91
+ it('is inactive for a completed, canceled or failed response', () => {
92
+ const renderer = new ThinkingPartRenderer();
93
+ const part = thinking('...');
94
+ expect(isActive(renderer.render(part, responseNode([part], { isComplete: true })))).to.be.false;
95
+ expect(isActive(renderer.render(part, responseNode([part], { isCanceled: true })))).to.be.false;
96
+ expect(isActive(renderer.render(part, responseNode([part], { isError: true })))).to.be.false;
97
+ });
98
+
99
+ it('is inactive without a parent node', () => {
100
+ const renderer = new ThinkingPartRenderer();
101
+ expect(isActive(renderer.render(thinking('...')))).to.be.false;
102
+ });
103
+
104
+ });
@@ -19,6 +19,19 @@ import { injectable } from '@theia/core/shared/inversify';
19
19
  import { ChatResponseContent, ThinkingChatResponseContent } from '@theia/ai-chat/lib/common';
20
20
  import { ReactNode } from '@theia/core/shared/react';
21
21
  import { nls } from '@theia/core/lib/common/nls';
22
+ import * as React from '@theia/core/shared/react';
23
+ import { ResponseNode } from '../chat-tree-view';
24
+ import { Spinner } from './spinner';
25
+
26
+ /**
27
+ * Number of trailing characters of the reasoning the live preview keeps at least. Far more than its
28
+ * few visible lines, so that the dropped text is always well above them, while the browser never has
29
+ * to lay out the whole reasoning.
30
+ */
31
+ const PREVIEW_LENGTH = 2000;
32
+
33
+ /** Duration in milliseconds of the slide that moves the live preview up when a new line starts. */
34
+ const PREVIEW_SCROLL_DURATION = 600;
22
35
 
23
36
  @injectable()
24
37
  export class ThinkingPartRenderer implements ChatResponsePartRenderer<ThinkingChatResponseContent> {
@@ -30,14 +43,121 @@ export class ThinkingPartRenderer implements ChatResponsePartRenderer<ThinkingCh
30
43
  return -1;
31
44
  }
32
45
 
33
- render(response: ThinkingChatResponseContent): ReactNode {
34
- return (
35
- <div className='theia-thinking'>
36
- <details>
37
- <summary>{nls.localizeByDefault('Thinking')}</summary>
38
- <pre>{response.content}</pre>
39
- </details>
40
- </div>
41
- );
46
+ render(response: ThinkingChatResponseContent, parentNode?: ResponseNode): ReactNode {
47
+ return <ThinkingContent response={response} active={this.isThinking(response, parentNode)} />;
48
+ }
49
+
50
+ /**
51
+ * A thinking part is still being written iff it is the last content part of a response that is
52
+ * still running: as soon as the answer text or a tool call arrives, a new part becomes the last
53
+ * one. `ThinkingChatResponseContent` carries no completion flag of its own, and chunks are
54
+ * merged into the existing part, so its position in the response is the reliable signal.
55
+ */
56
+ protected isThinking(response: ThinkingChatResponseContent, parentNode?: ResponseNode): boolean {
57
+ const model = parentNode?.response;
58
+ if (!model || model.isComplete || model.isCanceled || model.isError) {
59
+ return false;
60
+ }
61
+ const contents = model.response.content;
62
+ return contents[contents.length - 1] === response;
42
63
  }
43
64
  }
65
+
66
+ /**
67
+ * Renders the reasoning of an agent. While the agent is still thinking, a spinner and a preview of
68
+ * the last few lines of the streaming reasoning show that it is working, without the reasoning
69
+ * taking over the response. Once the thinking is done, the full reasoning becomes available in a
70
+ * collapsed block the user can expand.
71
+ */
72
+ const ThinkingContent: React.FC<{ response: ThinkingChatResponseContent, active: boolean }> = ({ response, active }) => active
73
+ ? <div className='theia-thinking active'>
74
+ <div className='theia-thinking-status'>
75
+ <Spinner /> {nls.localizeByDefault('Thinking')}
76
+ </div>
77
+ <ThinkingPreview content={response.content} />
78
+ </div>
79
+ : <div className='theia-thinking'>
80
+ <details>
81
+ <summary>{nls.localizeByDefault('Thinking')}</summary>
82
+ <pre>{response.content}</pre>
83
+ </details>
84
+ </div>;
85
+
86
+ interface PreviewParagraph {
87
+ /** Offset of the paragraph in the reasoning; stays the same while the reasoning grows. */
88
+ start: number;
89
+ text: string;
90
+ }
91
+
92
+ /**
93
+ * Splits the tail of the reasoning into its non-empty paragraphs. Only whole paragraphs are dropped
94
+ * from the start, never parts of one, so that the visible lines never re-wrap as the reasoning grows.
95
+ */
96
+ const previewParagraphs = (content: string): PreviewParagraph[] => {
97
+ const cut = content.length - PREVIEW_LENGTH;
98
+ let start = cut > 0 ? content.lastIndexOf('\n', cut) + 1 : 0;
99
+ const paragraphs: PreviewParagraph[] = [];
100
+ for (const text of content.slice(start).split('\n')) {
101
+ if (text.trim()) {
102
+ paragraphs.push({ start, text });
103
+ }
104
+ start += text.length + 1;
105
+ }
106
+ return paragraphs;
107
+ };
108
+
109
+ /**
110
+ * The last few lines of the reasoning as it streams in, hidden from assistive technology because the
111
+ * streaming text carries no information on its own. The text is anchored to the bottom, so the newest
112
+ * reasoning always stays visible, while the oldest line dissolves at the top.
113
+ *
114
+ * New text within a line simply appears. When a new line starts, the lines slide up instead of jumping,
115
+ * and a line starting mid-slide continues from the current position rather than restarting.
116
+ */
117
+ const ThinkingPreview: React.FC<{ content: string }> = ({ content }) => {
118
+ const boxRef = React.useRef<HTMLDivElement | undefined>(undefined);
119
+ const textRef = React.useRef<HTMLDivElement | undefined>(undefined);
120
+ // The last paragraph of the previous render and its position; how far it has moved up since is
121
+ // how far the lines have to slide.
122
+ const anchorRef = React.useRef<{ start: number, top: number } | undefined>(undefined);
123
+ const [overflowing, setOverflowing] = React.useState(false);
124
+ const paragraphs = React.useMemo(() => previewParagraphs(content), [content]);
125
+
126
+ React.useLayoutEffect(() => {
127
+ const box = boxRef.current;
128
+ const text = textRef.current;
129
+ const last = text?.lastElementChild;
130
+ if (!box || !text || !(last instanceof HTMLElement)) {
131
+ return;
132
+ }
133
+ setOverflowing(text.offsetHeight > box.clientHeight);
134
+ const previous = anchorRef.current;
135
+ anchorRef.current = { start: Number(last.dataset.start), top: last.offsetTop };
136
+ const anchor = previous && text.querySelector<HTMLElement>(`[data-start="${previous.start}"]`);
137
+ if (!previous || !anchor || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
138
+ return;
139
+ }
140
+ const shift = Math.min(previous.top - anchor.offsetTop, box.clientHeight);
141
+ if (shift <= 0) {
142
+ return;
143
+ }
144
+ // Where a running slide currently is, so that the next one picks up from there.
145
+ const transform = getComputedStyle(text).transform;
146
+ const currentOffset = transform === 'none' ? 0 : new DOMMatrixReadOnly(transform).m42;
147
+ text.getAnimations().forEach(animation => animation.cancel());
148
+ text.animate(
149
+ [{ transform: `translateY(${currentOffset + shift}px)` }, { transform: 'none' }],
150
+ { duration: PREVIEW_SCROLL_DURATION, easing: 'ease-out' }
151
+ );
152
+ }, [paragraphs]);
153
+
154
+ return <div
155
+ className={`theia-thinking-preview${overflowing ? ' overflowing' : ''}`}
156
+ aria-hidden={true}
157
+ ref={(element: HTMLDivElement | null) => { boxRef.current = element ?? undefined; }}
158
+ >
159
+ <div className='theia-thinking-preview-text' ref={(element: HTMLDivElement | null) => { textRef.current = element ?? undefined; }}>
160
+ {paragraphs.map(paragraph => <div key={paragraph.start} data-start={paragraph.start}>{paragraph.text}</div>)}
161
+ </div>
162
+ </div>;
163
+ };
@@ -40,6 +40,22 @@ describe('condenseArguments', () => {
40
40
  expect(condenseArguments('{invalid}')).to.equal('{invalid}');
41
41
  });
42
42
 
43
+ it('returns the raw string without attempting JSON.parse while an arguments object is still streaming', () => {
44
+ const originalParse = JSON.parse;
45
+ let parseCalls = 0;
46
+ JSON.parse = ((text: string, reviver?: Parameters<typeof originalParse>[1]) => {
47
+ parseCalls++;
48
+ return originalParse(text, reviver);
49
+ }) as typeof JSON.parse;
50
+ try {
51
+ const streaming = '{"path": "src/index.ts", "content": "partial';
52
+ expect(condenseArguments(streaming)).to.equal(streaming);
53
+ expect(parseCalls).to.equal(0);
54
+ } finally {
55
+ JSON.parse = originalParse;
56
+ }
57
+ });
58
+
43
59
  it('condenses single string parameter as value only', () => {
44
60
  const result = condenseArguments('{"query": "search term"}');
45
61
  expect(result).to.equal('search term');
@@ -33,6 +33,7 @@ import {
33
33
  DENY_LATEST_TOOL_CONFIRMATION_COMMAND
34
34
  } from '../tool-confirmation-keybinding-contribution';
35
35
  import { condenseArguments, formatArgsForTooltip } from './toolcall-utils';
36
+ import { Spinner } from './spinner';
36
37
 
37
38
  @injectable()
38
39
  export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@@ -165,10 +166,6 @@ export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallCh
165
166
  }
166
167
  }
167
168
 
168
- const Spinner = () => (
169
- <span className={`${codicon('loading')} theia-animation-spin`}></span>
170
- );
171
-
172
169
  interface ToolCallContentProps {
173
170
  response: ToolCallChatResponseContent;
174
171
  confirmationMode: ToolConfirmationMode;
@@ -15,6 +15,7 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
18
+ import { mayBeCompleteJson } from '../../common/toolcall-utils';
18
19
 
19
20
  export { extractJsonStringField } from '../../common/toolcall-utils';
20
21
 
@@ -168,6 +169,10 @@ export function condenseArguments(args: string): string | undefined {
168
169
  if (!args || !args.trim() || args.trim() === '{}') {
169
170
  return undefined;
170
171
  }
172
+ if (!mayBeCompleteJson(args)) {
173
+ // still streaming: parsing could only fail, so show the raw text right away
174
+ return truncateString(args, MAX_CONDENSED_LENGTH);
175
+ }
171
176
  let parsed: unknown;
172
177
  try {
173
178
  parsed = JSON.parse(args);
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { createTreeContainer, TreeProps } from '@theia/core/lib/browser';
18
18
  import { interfaces } from '@theia/core/shared/inversify';
19
+ import { ChatFindHighlighter } from '../chat-find/chat-find-highlighter';
20
+ import { ChatFindWidget } from '../chat-find/chat-find-widget';
19
21
  import { ChatViewTreeWidget } from './chat-view-tree-widget';
20
22
 
21
23
  const CHAT_VIEW_TREE_PROPS = {
@@ -28,5 +30,7 @@ export function createChatViewTreeWidget(parent: interfaces.Container): ChatView
28
30
  props: CHAT_VIEW_TREE_PROPS,
29
31
  widget: ChatViewTreeWidget,
30
32
  });
33
+ child.bind(ChatFindWidget).toSelf().inSingletonScope();
34
+ child.bind(ChatFindHighlighter).toSelf().inSingletonScope();
31
35
  return child.get(ChatViewTreeWidget);
32
36
  }
@@ -38,16 +38,21 @@ import {
38
38
  HoverService,
39
39
  Key,
40
40
  KeyCode,
41
+ Message,
41
42
  NodeProps,
42
43
  OpenerService,
43
44
  TreeModel,
44
45
  TreeNode,
45
46
  TreeProps,
46
47
  TreeWidget,
48
+ UnsafeWidgetUtilities,
47
49
  Widget,
48
50
  type ReactWidget
49
51
  } from '@theia/core/lib/browser';
50
52
  import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
53
+ import { ChatFindHighlighter } from '../chat-find/chat-find-highlighter';
54
+ import { ChatFindMatch } from '../chat-find/chat-find-matcher';
55
+ import { ChatFindWidget } from '../chat-find/chat-find-widget';
51
56
  import { nls } from '@theia/core/lib/common/nls';
52
57
  import {
53
58
  inject,
@@ -61,7 +66,7 @@ import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering';
61
66
  import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
62
67
  import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
63
68
  import { formatTokenCount } from '../chat-token-usage-indicator-util';
64
- import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
69
+ import { MarkdownRendering, useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
65
70
  import { ProgressMessage } from '../chat-progress-message';
66
71
  import { AIChatTreeInputFactory, type AIChatTreeInputWidget } from './chat-view-tree-input-widget';
67
72
  import { PromptVariantBadge } from './prompt-variant-badge';
@@ -150,6 +155,12 @@ export class ChatViewTreeWidget extends TreeWidget {
150
155
 
151
156
  protected chatResponseFocusKey: ContextKey<boolean>;
152
157
 
158
+ @inject(ChatFindWidget)
159
+ protected readonly findWidget: ChatFindWidget;
160
+
161
+ @inject(ChatFindHighlighter)
162
+ protected readonly findHighlighter: ChatFindHighlighter;
163
+
153
164
  protected readonly onDidSubmitEditEmitter = new Emitter<ChatRequest>();
154
165
  onDidSubmitEdit = this.onDidSubmitEditEmitter.event;
155
166
 
@@ -208,8 +219,14 @@ export class ChatViewTreeWidget extends TreeWidget {
208
219
  this.chatResponseFocusKey = this.contextKeyService.createKey<boolean>('chatResponseFocus', false);
209
220
  this.node.setAttribute('tabindex', '0');
210
221
  this.node.setAttribute('aria-label', nls.localize('theia/ai/chat-ui/chatResponses', 'Chat responses'));
211
- this.addEventListener(this.node, 'focusin', () => this.chatResponseFocusKey.set(true));
212
- this.addEventListener(this.node, 'focusout', () => this.chatResponseFocusKey.set(false));
222
+
223
+ this.findWidget.fallbackFocusTarget = this.node;
224
+ this.toDispose.pushAll([
225
+ this.findWidget,
226
+ this.findHighlighter,
227
+ this.findWidget.onDidChangeState(state => this.findHighlighter.update(this.node, state.regexp, state.matches, state.current)),
228
+ this.findWidget.onDidRequestReveal(match => this.revealFindMatch(match))
229
+ ]);
213
230
 
214
231
  this.toDispose.pushAll([
215
232
  this.toDisposeOnChatModelChange,
@@ -245,9 +262,79 @@ export class ChatViewTreeWidget extends TreeWidget {
245
262
 
246
263
  public setEnabled(enabled: boolean): void {
247
264
  this.isEnabled = enabled;
265
+ if (!enabled) {
266
+ this.findWidget.dismiss();
267
+ }
248
268
  this.update();
249
269
  }
250
270
 
271
+ /** Opens the find bar over the response tree (or refocuses it when already open). */
272
+ showFind(): void {
273
+ this.findWidget.open();
274
+ }
275
+
276
+ hideFind(): void {
277
+ this.findWidget.dismiss();
278
+ }
279
+
280
+ get isFindVisible(): boolean {
281
+ return this.findWidget.isOpen;
282
+ }
283
+
284
+ /** Whether the tracked session has content to search; false while the welcome/session-list screen shows. */
285
+ get canFind(): boolean {
286
+ return this.findWidget.canFind;
287
+ }
288
+
289
+ /**
290
+ * Reveals a match: mounts the owning row via virtuoso when it is not rendered yet, then re-applies the highlights
291
+ * and scrolls the matched text itself into view (rows can be taller than the viewport).
292
+ */
293
+ protected revealFindMatch(match: ChatFindMatch): void {
294
+ const row = this.rows.get(match.nodeId);
295
+ if (row === undefined) {
296
+ return;
297
+ }
298
+ const settle = (): void => {
299
+ this.findHighlighter.refresh();
300
+ this.findHighlighter.scrollCurrentIntoView(this.node);
301
+ };
302
+ if (this.findHighlighter.findRow(this.node, match.nodeId)) {
303
+ requestAnimationFrame(settle);
304
+ return;
305
+ }
306
+ this.view?.list?.scrollIntoView({
307
+ index: row.index,
308
+ align: 'center',
309
+ done: settle
310
+ });
311
+ }
312
+
313
+ protected override onAfterAttach(msg: Message): void {
314
+ super.onAfterAttach(msg);
315
+ // Registered per attach, not in `init()`: `addEventListener` disposes on detach, so listeners registered
316
+ // once would be lost the first time the view is moved (e.g. to the main area), leaving `chatResponseFocus`
317
+ // stuck and every keybinding scoped to it dead until the page is reloaded.
318
+ this.addEventListener(this.node, 'focusin', () => this.chatResponseFocusKey.set(true));
319
+ this.addEventListener(this.node, 'focusout', () => this.chatResponseFocusKey.set(false));
320
+ // The tree node is a React root, so the find bar is attached as a sibling right before it,
321
+ // the same way the core TreeWidget hosts its SearchBox.
322
+ if (this.findWidget.isAttached) {
323
+ Widget.detach(this.findWidget);
324
+ }
325
+ if (this.node.parentElement) {
326
+ UnsafeWidgetUtilities.attach(this.findWidget, this.node.parentElement, this.node);
327
+ }
328
+ }
329
+
330
+ protected override onBeforeDetach(msg: Message): void {
331
+ this.chatResponseFocusKey.set(false);
332
+ if (this.findWidget.isAttached) {
333
+ Widget.detach(this.findWidget);
334
+ }
335
+ super.onBeforeDetach(msg);
336
+ }
337
+
251
338
  /** Toggles auto-scroll and the scroll-to-bottom button based on whether the viewport includes the bottom of the list. */
252
339
  protected handleAtBottomStateChange(isAtBottom: boolean): void {
253
340
  if (isAtBottom !== this.atBottom) {
@@ -390,6 +477,7 @@ export class ChatViewTreeWidget extends TreeWidget {
390
477
  public trackChatModel(chatModel: ChatModel): void {
391
478
  this.toDisposeOnChatModelChange.dispose();
392
479
  this.recreateModelTree(chatModel);
480
+ this.findWidget.setChatModel(chatModel);
393
481
 
394
482
  chatModel.getRequests().forEach(request => {
395
483
  if (!request.response.isComplete) {
@@ -439,6 +527,12 @@ export class ChatViewTreeWidget extends TreeWidget {
439
527
  }
440
528
  }
441
529
 
530
+ protected override doUpdateRows(): void {
531
+ super.doUpdateRows();
532
+ // Follow new requests and responses to the end of the chat, unless auto-scroll is locked.
533
+ this.scheduleUpdateScrollToRow();
534
+ }
535
+
442
536
  protected override getScrollToRow(): number | undefined {
443
537
  // Only scroll to end if auto-scroll is enabled (not locked)
444
538
  if (this.shouldScrollToEnd) {
@@ -478,6 +572,7 @@ export class ChatViewTreeWidget extends TreeWidget {
478
572
  return <React.Fragment key={node.id}>
479
573
  <div
480
574
  className='theia-ChatNode'
575
+ data-node-id={node.id}
481
576
  role='article'
482
577
  aria-label={ariaLabel}
483
578
  onContextMenu={e => this.handleContextMenu(node, e)}
@@ -880,9 +975,7 @@ export const ChatRequestRender = (
880
975
  );
881
976
  } else {
882
977
  const ref = useMarkdownRendering(
883
- part.text
884
- .replace(/^[\r\n]+|[\r\n]+$/g, '') // remove excessive new lines
885
- .replace(/(^ )/g, '&nbsp;'), // enforce keeping space before
978
+ MarkdownRendering.prepareRequestText(part.text),
886
979
  openerService,
887
980
  true,
888
981
  undefined,