@theia/ai-chat-ui 1.56.0 → 1.57.0-next.136

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 (45) hide show
  1. package/README.md +2 -1
  2. package/lib/browser/ai-chat-ui-contribution.d.ts.map +1 -1
  3. package/lib/browser/ai-chat-ui-contribution.js +1 -1
  4. package/lib/browser/ai-chat-ui-contribution.js.map +1 -1
  5. package/lib/browser/ai-chat-ui-frontend-module.d.ts.map +1 -1
  6. package/lib/browser/ai-chat-ui-frontend-module.js +9 -1
  7. package/lib/browser/ai-chat-ui-frontend-module.js.map +1 -1
  8. package/lib/browser/chat-input-widget.d.ts +18 -5
  9. package/lib/browser/chat-input-widget.d.ts.map +1 -1
  10. package/lib/browser/chat-input-widget.js +229 -80
  11. package/lib/browser/chat-input-widget.js.map +1 -1
  12. package/lib/browser/chat-response-renderer/code-part-renderer.d.ts +30 -2
  13. package/lib/browser/chat-response-renderer/code-part-renderer.d.ts.map +1 -1
  14. package/lib/browser/chat-response-renderer/code-part-renderer.js +45 -10
  15. package/lib/browser/chat-response-renderer/code-part-renderer.js.map +1 -1
  16. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts +8 -3
  17. package/lib/browser/chat-response-renderer/markdown-part-renderer.d.ts.map +1 -1
  18. package/lib/browser/chat-response-renderer/markdown-part-renderer.js +38 -10
  19. package/lib/browser/chat-response-renderer/markdown-part-renderer.js.map +1 -1
  20. package/lib/browser/chat-response-renderer/toolcall-part-renderer.d.ts.map +1 -1
  21. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js +8 -2
  22. package/lib/browser/chat-response-renderer/toolcall-part-renderer.js.map +1 -1
  23. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts +2 -1
  24. package/lib/browser/chat-tree-view/chat-view-tree-widget.d.ts.map +1 -1
  25. package/lib/browser/chat-tree-view/chat-view-tree-widget.js +8 -4
  26. package/lib/browser/chat-tree-view/chat-view-tree-widget.js.map +1 -1
  27. package/lib/browser/chat-view-language-contribution.d.ts +2 -1
  28. package/lib/browser/chat-view-language-contribution.d.ts.map +1 -1
  29. package/lib/browser/chat-view-language-contribution.js +3 -6
  30. package/lib/browser/chat-view-language-contribution.js.map +1 -1
  31. package/lib/browser/chat-view-widget.d.ts +4 -1
  32. package/lib/browser/chat-view-widget.d.ts.map +1 -1
  33. package/lib/browser/chat-view-widget.js +14 -4
  34. package/lib/browser/chat-view-widget.js.map +1 -1
  35. package/package.json +12 -12
  36. package/src/browser/ai-chat-ui-contribution.ts +2 -2
  37. package/src/browser/ai-chat-ui-frontend-module.ts +27 -5
  38. package/src/browser/chat-input-widget.tsx +339 -100
  39. package/src/browser/chat-response-renderer/code-part-renderer.tsx +48 -9
  40. package/src/browser/chat-response-renderer/markdown-part-renderer.tsx +39 -12
  41. package/src/browser/chat-response-renderer/toolcall-part-renderer.tsx +8 -2
  42. package/src/browser/chat-tree-view/chat-view-tree-widget.tsx +10 -4
  43. package/src/browser/chat-view-language-contribution.ts +6 -8
  44. package/src/browser/chat-view-widget.tsx +19 -6
  45. package/src/browser/style/index.css +209 -28
@@ -13,27 +13,32 @@
13
13
  //
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
- import { ChatAgent, ChatAgentService, ChatModel, ChatRequestModel } from '@theia/ai-chat';
17
- import { UntitledResourceResolver } from '@theia/core';
18
- import { ContextMenuRenderer, Message, ReactWidget } from '@theia/core/lib/browser';
19
- import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
20
- import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
16
+ import { ChangeSet, ChangeSetElement, ChatChangeEvent, ChatModel, ChatRequestModel } from '@theia/ai-chat';
17
+ import { Disposable, UntitledResourceResolver } from '@theia/core';
18
+ import { ContextMenuRenderer, LabelProvider, Message, ReactWidget } from '@theia/core/lib/browser';
19
+ import { Deferred } from '@theia/core/lib/common/promise-util';
20
+ import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
21
21
  import * as React from '@theia/core/shared/react';
22
+ import { IMouseEvent } from '@theia/monaco-editor-core';
23
+ import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
22
24
  import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
23
25
  import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution';
24
- import { IMouseEvent } from '@theia/monaco-editor-core';
25
26
 
26
27
  type Query = (query: string) => Promise<void>;
27
28
  type Cancel = (requestModel: ChatRequestModel) => void;
29
+ type DeleteChangeSet = (requestModel: ChatRequestModel) => void;
30
+ type DeleteChangeSetElement = (requestModel: ChatRequestModel, index: number) => void;
31
+
32
+ export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration');
33
+ export interface AIChatInputConfiguration {
34
+ showContext?: boolean;
35
+ }
28
36
 
29
37
  @injectable()
30
38
  export class AIChatInputWidget extends ReactWidget {
31
39
  public static ID = 'chat-input-widget';
32
40
  static readonly CONTEXT_MENU = ['chat-input-context-menu'];
33
41
 
34
- @inject(ChatAgentService)
35
- protected readonly agentService: ChatAgentService;
36
-
37
42
  @inject(MonacoEditorProvider)
38
43
  protected readonly editorProvider: MonacoEditorProvider;
39
44
 
@@ -43,6 +48,15 @@ export class AIChatInputWidget extends ReactWidget {
43
48
  @inject(ContextMenuRenderer)
44
49
  protected readonly contextMenuRenderer: ContextMenuRenderer;
45
50
 
51
+ @inject(AIChatInputConfiguration) @optional()
52
+ protected readonly configuration: AIChatInputConfiguration | undefined;
53
+
54
+ @inject(LabelProvider)
55
+ protected readonly labelProvider: LabelProvider;
56
+
57
+ protected editorRef: MonacoEditor | undefined = undefined;
58
+ private editorReady = new Deferred<void>();
59
+
46
60
  protected isEnabled = false;
47
61
 
48
62
  private _onQuery: Query;
@@ -53,6 +67,14 @@ export class AIChatInputWidget extends ReactWidget {
53
67
  set onCancel(cancel: Cancel) {
54
68
  this._onCancel = cancel;
55
69
  }
70
+ private _onDeleteChangeSet: DeleteChangeSet;
71
+ set onDeleteChangeSet(deleteChangeSet: DeleteChangeSet) {
72
+ this._onDeleteChangeSet = deleteChangeSet;
73
+ }
74
+ private _onDeleteChangeSetElement: DeleteChangeSetElement;
75
+ set onDeleteChangeSetElement(deleteChangeSetElement: DeleteChangeSetElement) {
76
+ this._onDeleteChangeSetElement = deleteChangeSetElement;
77
+ }
56
78
  private _chatModel: ChatModel;
57
79
  set chatModel(chatModel: ChatModel) {
58
80
  this._chatModel = chatModel;
@@ -65,13 +87,14 @@ export class AIChatInputWidget extends ReactWidget {
65
87
  this.title.closable = false;
66
88
  this.update();
67
89
  }
90
+
68
91
  protected override onActivateRequest(msg: Message): void {
69
92
  super.onActivateRequest(msg);
70
- this.node.focus({ preventScroll: true });
71
- }
72
-
73
- protected getChatAgents(): ChatAgent[] {
74
- return this.agentService.getAgents();
93
+ this.editorReady.promise.then(() => {
94
+ if (this.editorRef) {
95
+ this.editorRef.focus();
96
+ }
97
+ });
75
98
  }
76
99
 
77
100
  protected render(): React.ReactNode {
@@ -79,12 +102,19 @@ export class AIChatInputWidget extends ReactWidget {
79
102
  <ChatInput
80
103
  onQuery={this._onQuery.bind(this)}
81
104
  onCancel={this._onCancel.bind(this)}
105
+ onDeleteChangeSet={this._onDeleteChangeSet.bind(this)}
106
+ onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
82
107
  chatModel={this._chatModel}
83
- getChatAgents={this.getChatAgents.bind(this)}
84
108
  editorProvider={this.editorProvider}
85
109
  untitledResourceResolver={this.untitledResourceResolver}
86
110
  contextMenuCallback={this.handleContextMenu.bind(this)}
87
111
  isEnabled={this.isEnabled}
112
+ setEditorRef={editor => {
113
+ this.editorRef = editor;
114
+ this.editorReady.resolve();
115
+ }}
116
+ showContext={this.configuration?.showContext}
117
+ labelProvider={this.labelProvider}
88
118
  />
89
119
  );
90
120
  }
@@ -107,105 +137,145 @@ export class AIChatInputWidget extends ReactWidget {
107
137
  interface ChatInputProperties {
108
138
  onCancel: (requestModel: ChatRequestModel) => void;
109
139
  onQuery: (query: string) => void;
140
+ onDeleteChangeSet: (sessionId: string) => void;
141
+ onDeleteChangeSetElement: (sessionId: string, index: number) => void;
110
142
  isEnabled?: boolean;
111
143
  chatModel: ChatModel;
112
- getChatAgents: () => ChatAgent[];
113
144
  editorProvider: MonacoEditorProvider;
114
145
  untitledResourceResolver: UntitledResourceResolver;
115
146
  contextMenuCallback: (event: IMouseEvent) => void;
147
+ setEditorRef: (editor: MonacoEditor | undefined) => void;
148
+ showContext?: boolean;
149
+ labelProvider: LabelProvider;
116
150
  }
151
+
117
152
  const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInputProperties) => {
153
+ const onDeleteChangeSet = () => props.onDeleteChangeSet(props.chatModel.id);
154
+ const onDeleteChangeSetElement = (index: number) => props.onDeleteChangeSetElement(props.chatModel.id, index);
118
155
 
119
156
  const [inProgress, setInProgress] = React.useState(false);
157
+ const [isInputEmpty, setIsInputEmpty] = React.useState(true);
158
+ const [changeSetUI, setChangeSetUI] = React.useState(
159
+ () => props.chatModel.changeSet ? buildChangeSetUI(props.chatModel.changeSet, props.labelProvider, onDeleteChangeSet, onDeleteChangeSetElement) : undefined
160
+ );
161
+
120
162
  // eslint-disable-next-line no-null/no-null
121
163
  const editorContainerRef = React.useRef<HTMLDivElement | null>(null);
122
164
  // eslint-disable-next-line no-null/no-null
123
165
  const placeholderRef = React.useRef<HTMLDivElement | null>(null);
124
166
  const editorRef = React.useRef<MonacoEditor | undefined>(undefined);
125
- const allRequests = props.chatModel.getRequests();
126
- const lastRequest = allRequests.length === 0 ? undefined : allRequests[allRequests.length - 1];
127
-
128
- const createInputElement = async () => {
129
- const paddingTop = 8;
130
- const lineHeight = 20;
131
- const maxHeight = 240;
132
- const resource = await props.untitledResourceResolver.createUntitledResource('', CHAT_VIEW_LANGUAGE_EXTENSION);
133
- const editor = await props.editorProvider.createInline(resource.uri, editorContainerRef.current!, {
134
- language: CHAT_VIEW_LANGUAGE_EXTENSION,
135
- // Disable code lens, inlay hints and hover support to avoid console errors from other contributions
136
- codeLens: false,
137
- inlayHints: { enabled: 'off' },
138
- hover: { enabled: false },
139
- autoSizing: false, // we handle the sizing ourselves
140
- scrollBeyondLastLine: false,
141
- scrollBeyondLastColumn: 0,
142
- minHeight: 1,
143
- fontFamily: 'var(--theia-ui-font-family)',
144
- fontSize: 13,
145
- cursorWidth: 1,
146
- maxHeight: -1,
147
- scrollbar: { horizontal: 'hidden' },
148
- automaticLayout: true,
149
- lineNumbers: 'off',
150
- lineHeight,
151
- padding: { top: paddingTop },
152
- suggest: {
153
- showIcons: true,
154
- showSnippets: false,
155
- showWords: false,
156
- showStatusBar: false,
157
- insertMode: 'replace',
158
- },
159
- bracketPairColorization: { enabled: false },
160
- wrappingStrategy: 'advanced',
161
- stickyScroll: { enabled: false },
162
- });
163
167
 
164
- if (editorContainerRef.current) {
165
- editorContainerRef.current.style.height = (lineHeight + (2 * paddingTop)) + 'px';
166
- }
168
+ React.useEffect(() => {
169
+ const createInputElement = async () => {
170
+ const paddingTop = 6;
171
+ const lineHeight = 20;
172
+ const maxHeight = 240;
173
+ const resource = await props.untitledResourceResolver.createUntitledResource('', CHAT_VIEW_LANGUAGE_EXTENSION);
174
+ const editor = await props.editorProvider.createInline(resource.uri, editorContainerRef.current!, {
175
+ language: CHAT_VIEW_LANGUAGE_EXTENSION,
176
+ // Disable code lens, inlay hints and hover support to avoid console errors from other contributions
177
+ codeLens: false,
178
+ inlayHints: { enabled: 'off' },
179
+ hover: { enabled: false },
180
+ autoSizing: false, // we handle the sizing ourselves
181
+ scrollBeyondLastLine: false,
182
+ scrollBeyondLastColumn: 0,
183
+ minHeight: 1,
184
+ fontFamily: 'var(--theia-ui-font-family)',
185
+ fontSize: 13,
186
+ cursorWidth: 1,
187
+ maxHeight: -1,
188
+ scrollbar: { horizontal: 'hidden' },
189
+ automaticLayout: true,
190
+ lineNumbers: 'off',
191
+ lineHeight,
192
+ padding: { top: paddingTop },
193
+ suggest: {
194
+ showIcons: true,
195
+ showSnippets: false,
196
+ showWords: false,
197
+ showStatusBar: false,
198
+ insertMode: 'replace',
199
+ },
200
+ bracketPairColorization: { enabled: false },
201
+ wrappingStrategy: 'advanced',
202
+ stickyScroll: { enabled: false },
203
+ });
167
204
 
168
- const updateEditorHeight = () => {
169
205
  if (editorContainerRef.current) {
170
- const contentHeight = editor.getControl().getContentHeight() + paddingTop;
171
- editorContainerRef.current.style.height = `${Math.min(contentHeight, maxHeight)}px`;
206
+ editorContainerRef.current.style.height = (lineHeight + (2 * paddingTop)) + 'px';
172
207
  }
173
- };
174
- editor.getControl().onDidChangeModelContent(updateEditorHeight);
175
- const resizeObserver = new ResizeObserver(updateEditorHeight);
176
- if (editorContainerRef.current) {
177
- resizeObserver.observe(editorContainerRef.current);
178
- }
179
- editor.getControl().onDidDispose(() => {
180
- resizeObserver.disconnect();
181
- });
182
208
 
183
- editor.getControl().onContextMenu(e =>
184
- props.contextMenuCallback(e.event)
185
- );
209
+ const updateEditorHeight = () => {
210
+ if (editorContainerRef.current) {
211
+ const contentHeight = editor.getControl().getContentHeight() + paddingTop;
212
+ editorContainerRef.current.style.height = `${Math.min(contentHeight, maxHeight)}px`;
213
+ }
214
+ };
215
+ editor.getControl().onDidChangeModelContent(() => {
216
+ const value = editor.getControl().getValue();
217
+ setIsInputEmpty(!value || value.length === 0);
218
+ updateEditorHeight();
219
+ handleOnChange();
220
+ });
221
+ const resizeObserver = new ResizeObserver(updateEditorHeight);
222
+ if (editorContainerRef.current) {
223
+ resizeObserver.observe(editorContainerRef.current);
224
+ }
225
+ editor.getControl().onDidDispose(() => {
226
+ resizeObserver.disconnect();
227
+ });
186
228
 
187
- editorRef.current = editor;
188
- };
229
+ editor.getControl().onContextMenu(e =>
230
+ props.contextMenuCallback(e.event)
231
+ );
189
232
 
190
- React.useEffect(() => {
233
+ editorRef.current = editor;
234
+ props.setEditorRef(editor);
235
+ };
191
236
  createInputElement();
192
237
  return () => {
238
+ props.setEditorRef(undefined);
193
239
  if (editorRef.current) {
194
240
  editorRef.current.dispose();
195
241
  }
196
242
  };
197
243
  }, []);
198
244
 
245
+ const responseListenerRef = React.useRef<Disposable>();
246
+ // track chat model updates to keep our UI in sync
247
+ // - keep "inProgress" in sync with the request state
248
+ // - keep "changeSetUI" in sync with the change set
199
249
  React.useEffect(() => {
200
- const listener = lastRequest?.response.onDidChange(() => {
201
- if (lastRequest.response.isCanceled || lastRequest.response.isComplete || lastRequest.response.isError) {
202
- setInProgress(false);
250
+ const listener = props.chatModel.onDidChange(event => {
251
+ if (event.kind === 'addRequest') {
252
+ if (event.request) {
253
+ setInProgress(ChatRequestModel.isInProgress(event.request));
254
+ }
255
+ responseListenerRef.current?.dispose();
256
+ responseListenerRef.current = event.request.response.onDidChange(() =>
257
+ setInProgress(ChatRequestModel.isInProgress(event.request))
258
+ );
259
+ } else if (ChatChangeEvent.isChangeSetEvent(event)) {
260
+ if (event.changeSet) {
261
+ setChangeSetUI(buildChangeSetUI(event.changeSet, props.labelProvider, onDeleteChangeSet, onDeleteChangeSetElement));
262
+ } else {
263
+ setChangeSetUI(undefined);
264
+ }
203
265
  }
204
266
  });
205
- return () => listener?.dispose();
206
- }, [lastRequest]);
267
+ setChangeSetUI(props.chatModel.changeSet ? buildChangeSetUI(props.chatModel.changeSet, props.labelProvider, onDeleteChangeSet, onDeleteChangeSetElement) : undefined);
268
+ return () => {
269
+ listener?.dispose();
270
+ responseListenerRef.current?.dispose();
271
+ responseListenerRef.current = undefined;
272
+ };
273
+ }, [props.chatModel]);
207
274
 
208
275
  function submit(value: string): void {
276
+ if (!value || value.trim().length === 0) {
277
+ return;
278
+ }
209
279
  setInProgress(true);
210
280
  props.onQuery(value);
211
281
  if (editorRef.current) {
@@ -224,39 +294,208 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
224
294
  }, [props.isEnabled]);
225
295
 
226
296
  const handleInputFocus = () => {
227
- placeholderRef.current?.classList.add('hidden');
297
+ hidePlaceholderIfEditorFilled();
298
+ };
299
+
300
+ const handleOnChange = () => {
301
+ showPlaceholderIfEditorEmpty();
302
+ hidePlaceholderIfEditorFilled();
228
303
  };
229
304
 
230
305
  const handleInputBlur = () => {
306
+ showPlaceholderIfEditorEmpty();
307
+ };
308
+
309
+ const showPlaceholderIfEditorEmpty = () => {
231
310
  if (!editorRef.current?.getControl().getValue()) {
232
311
  placeholderRef.current?.classList.remove('hidden');
233
312
  }
234
313
  };
235
314
 
315
+ const hidePlaceholderIfEditorFilled = () => {
316
+ const value = editorRef.current?.getControl().getValue();
317
+ if (value && value.length > 0) {
318
+ placeholderRef.current?.classList.add('hidden');
319
+ }
320
+ };
321
+
322
+ const leftOptions = props.showContext ? [{
323
+ title: 'Attach elements to context',
324
+ handler: () => { /* TODO */ },
325
+ className: 'codicon-add'
326
+ }] : [];
327
+
328
+ const rightOptions = inProgress
329
+ ? [{
330
+ title: 'Cancel (Esc)',
331
+ handler: () => {
332
+ const latestRequest = getLatestRequest(props.chatModel);
333
+ if (latestRequest) {
334
+ props.onCancel(latestRequest);
335
+ }
336
+ setInProgress(false);
337
+ },
338
+ className: 'codicon-stop-circle'
339
+ }]
340
+ : [{
341
+ title: 'Send (Enter)',
342
+ handler: () => {
343
+ if (props.isEnabled) {
344
+ submit(editorRef.current?.document.textEditorModel.getValue() || '');
345
+ }
346
+ },
347
+ className: 'codicon-send',
348
+ disabled: isInputEmpty || !props.isEnabled
349
+ }];
350
+
236
351
  return <div className='theia-ChatInput'>
352
+ {changeSetUI?.elements &&
353
+ <ChangeSetBox changeSet={changeSetUI} />
354
+ }
237
355
  <div className='theia-ChatInput-Editor-Box'>
238
356
  <div className='theia-ChatInput-Editor' ref={editorContainerRef} onKeyDown={onKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur}>
239
357
  <div ref={placeholderRef} className='theia-ChatInput-Editor-Placeholder'>Ask a question</div>
240
358
  </div>
241
- </div>
242
- <div className="theia-ChatInputOptions">
243
- {
244
- inProgress ? <span
245
- className="codicon codicon-stop-circle option"
246
- title="Cancel (Esc)"
247
- onClick={() => {
248
- if (lastRequest) {
249
- props.onCancel(lastRequest);
250
- }
251
- setInProgress(false);
252
- }} /> :
253
- <span
254
- className="codicon codicon-send option"
255
- title="Send (Enter)"
256
- onClick={!props.isEnabled ? undefined : () => submit(editorRef.current?.document.textEditorModel.getValue() || '')}
257
- style={{ cursor: !props.isEnabled ? 'default' : 'pointer', opacity: !props.isEnabled ? 0.5 : 1 }}
258
- />
259
- }
359
+ <ChatInputOptions leftOptions={leftOptions} rightOptions={rightOptions} />
260
360
  </div>
261
361
  </div>;
262
362
  };
363
+
364
+ const noPropagation = (handler: () => void) => (e: React.MouseEvent) => {
365
+ handler();
366
+ e.stopPropagation();
367
+ };
368
+
369
+ const buildChangeSetUI = (changeSet: ChangeSet, labelProvider: LabelProvider, onDeleteChangeSet: () => void, onDeleteChangeSetElement: (index: number) => void): ChangeSetUI => ({
370
+ title: changeSet.title,
371
+ disabled: !hasPendingElementsToAccept(changeSet),
372
+ acceptAllPendingElements: () => acceptAllPendingElements(changeSet),
373
+ delete: () => onDeleteChangeSet(),
374
+ elements: changeSet.getElements().map(element => ({
375
+ open: element?.open?.bind(element),
376
+ iconClass: element.icon ?? labelProvider.getIcon(element.uri) ?? labelProvider.fileIcon,
377
+ nameClass: `${element.type} ${element.state}`,
378
+ name: element.name ?? labelProvider.getName(element.uri),
379
+ additionalInfo: element.additionalInfo ?? labelProvider.getDetails(element.uri),
380
+ openChange: element?.openChange?.bind(element),
381
+ accept: element.state !== 'applied' ? element?.accept?.bind(element) : undefined,
382
+ discard: element.state === 'applied' ? element?.discard?.bind(element) : undefined,
383
+ delete: () => onDeleteChangeSetElement(changeSet.getElements().indexOf(element))
384
+ }))
385
+ });
386
+
387
+ interface ChangeSetUIElement {
388
+ name: string;
389
+ iconClass: string;
390
+ nameClass: string;
391
+ additionalInfo: string;
392
+ open?: () => void;
393
+ openChange?: () => void;
394
+ accept?: () => void;
395
+ discard?: () => void;
396
+ delete: () => void;
397
+ }
398
+
399
+ interface ChangeSetUI {
400
+ title: string;
401
+ disabled: boolean;
402
+ acceptAllPendingElements: () => void;
403
+ delete: () => void;
404
+ elements: ChangeSetUIElement[];
405
+ }
406
+
407
+ const ChangeSetBox: React.FunctionComponent<{ changeSet: ChangeSetUI }> = ({ changeSet }) => (
408
+ <div className='theia-ChatInput-ChangeSet-Box'>
409
+ <div className='theia-ChatInput-ChangeSet-Header'>
410
+ <h3>{changeSet.title}</h3>
411
+ <div className='theia-ChatInput-ChangeSet-Header-Actions'>
412
+ <button
413
+ className='theia-button'
414
+ disabled={changeSet.disabled}
415
+ title='Accept all pending changes'
416
+ onClick={() => changeSet.acceptAllPendingElements()}
417
+ >
418
+ Accept
419
+ </button>
420
+ <span className='codicon codicon-close action' title='Delete Change Set' onClick={() => changeSet.delete()} />
421
+ </div>
422
+ </div>
423
+ <div className='theia-ChatInput-ChangeSet-List'>
424
+ <ul>
425
+ {changeSet.elements.map((element, index) => (
426
+ <li key={index} title='Open Diff' onClick={() => element.openChange?.()}>
427
+ <div className={`theia-ChatInput-ChangeSet-Icon ${element.iconClass}`} />
428
+ <span className='theia-ChatInput-ChangeSet-labelParts'>
429
+ <span className={`theia-ChatInput-ChangeSet-title ${element.nameClass}`}>
430
+ {element.name}
431
+ </span>
432
+ <span className='theia-ChatInput-ChangeSet-additionalInfo'>
433
+ {element.additionalInfo}
434
+ </span>
435
+ </span>
436
+ <div className='theia-ChatInput-ChangeSet-Actions'>
437
+ {element.open && (<span className='codicon codicon-file action' title='Open Original File' onClick={noPropagation(() => element.open!())} />)}
438
+ {element.discard && (<span className='codicon codicon-discard action' title='Undo' onClick={noPropagation(() => element.discard!())} />)}
439
+ {element.accept && (<span className='codicon codicon-check action' title='Accept' onClick={noPropagation(() => element.accept!())} />)}
440
+ <span className='codicon codicon-close action' title='Delete' onClick={noPropagation(() => element.delete())} />
441
+ </div>
442
+ </li>
443
+ ))}
444
+ </ul>
445
+ </div>
446
+ </div>
447
+ );
448
+
449
+ interface ChatInputOptionsProps {
450
+ leftOptions: Option[];
451
+ rightOptions: Option[];
452
+ }
453
+
454
+ interface Option {
455
+ title: string;
456
+ handler: () => void;
457
+ className: string;
458
+ disabled?: boolean;
459
+ }
460
+
461
+ const ChatInputOptions: React.FunctionComponent<ChatInputOptionsProps> = ({ leftOptions, rightOptions }) => (
462
+ <div className="theia-ChatInputOptions">
463
+ <div className="theia-ChatInputOptions-left">
464
+ {leftOptions.map((option, index) => (
465
+ <span
466
+ key={index}
467
+ className={`codicon ${option.className} option ${option.disabled ? 'disabled' : ''}`}
468
+ title={option.title}
469
+ onClick={option.handler}
470
+ />
471
+ ))}
472
+ </div>
473
+ <div className="theia-ChatInputOptions-right">
474
+ {rightOptions.map((option, index) => (
475
+ <span
476
+ key={index}
477
+ className={`codicon ${option.className} option ${option.disabled ? 'disabled' : ''}`}
478
+ title={option.title}
479
+ onClick={option.handler}
480
+ />
481
+ ))}
482
+ </div>
483
+ </div>
484
+ );
485
+
486
+ function acceptAllPendingElements(changeSet: ChangeSet): void {
487
+ acceptablePendingElements(changeSet).forEach(e => e.accept!());
488
+ }
489
+
490
+ function hasPendingElementsToAccept(changeSet: ChangeSet): boolean | undefined {
491
+ return acceptablePendingElements(changeSet).length > 0;
492
+ }
493
+
494
+ function acceptablePendingElements(changeSet: ChangeSet): ChangeSetElement[] {
495
+ return changeSet.getElements().filter(e => e.accept && (e.state === undefined || e.state === 'pending'));
496
+ }
497
+
498
+ function getLatestRequest(chatModel: ChatModel): ChatRequestModel | undefined {
499
+ const requests = chatModel.getRequests();
500
+ return requests.length > 0 ? requests[requests.length - 1] : undefined;
501
+ }
@@ -13,15 +13,14 @@
13
13
  //
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
-
17
16
  import {
18
17
  ChatResponseContent,
19
18
  CodeChatResponseContent,
20
19
  } from '@theia/ai-chat/lib/common';
21
- import { UntitledResourceResolver, URI } from '@theia/core';
20
+ import { ContributionProvider, UntitledResourceResolver, URI } from '@theia/core';
22
21
  import { ContextMenuRenderer, TreeNode } from '@theia/core/lib/browser';
23
22
  import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
24
- import { inject, injectable } from '@theia/core/shared/inversify';
23
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
25
24
  import * as React from '@theia/core/shared/react';
26
25
  import { ReactNode } from '@theia/core/shared/react';
27
26
  import { Position } from '@theia/core/shared/vscode-languageserver-protocol';
@@ -33,12 +32,29 @@ import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
33
32
  import { ChatViewTreeWidget, ResponseNode } from '../chat-tree-view/chat-view-tree-widget';
34
33
  import { IMouseEvent } from '@theia/monaco-editor-core';
35
34
 
35
+ export const CodePartRendererAction = Symbol('CodePartRendererAction');
36
+ /**
37
+ * The CodePartRenderer offers to contribute arbitrary React nodes to the rendered code part.
38
+ * Technically anything can be rendered, however it is intended to be used for actions, like
39
+ * "Copy to Clipboard" or "Insert at Cursor".
40
+ */
41
+ export interface CodePartRendererAction {
42
+ render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode;
43
+ /**
44
+ * Determines if the action should be rendered for the given response.
45
+ */
46
+ canRender?(response: CodeChatResponseContent, parentNode: ResponseNode): boolean;
47
+ /**
48
+ * The priority determines the order in which the actions are rendered.
49
+ * The default priorities are 10 and 20.
50
+ */
51
+ priority: number;
52
+ }
53
+
36
54
  @injectable()
37
55
  export class CodePartRenderer
38
56
  implements ChatResponsePartRenderer<CodeChatResponseContent> {
39
57
 
40
- @inject(ClipboardService)
41
- protected readonly clipboardService: ClipboardService;
42
58
  @inject(EditorManager)
43
59
  protected readonly editorManager: EditorManager;
44
60
  @inject(UntitledResourceResolver)
@@ -49,6 +65,8 @@ export class CodePartRenderer
49
65
  protected readonly languageService: MonacoLanguages;
50
66
  @inject(ContextMenuRenderer)
51
67
  protected readonly contextMenuRenderer: ContextMenuRenderer;
68
+ @inject(ContributionProvider) @named(CodePartRendererAction)
69
+ protected readonly codePartRendererActions: ContributionProvider<CodePartRendererAction>;
52
70
 
53
71
  canHandle(response: ChatResponseContent): number {
54
72
  if (CodeChatResponseContent.is(response)) {
@@ -59,14 +77,15 @@ export class CodePartRenderer
59
77
 
60
78
  render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode {
61
79
  const language = response.language ? this.languageService.getExtension(response.language) : undefined;
62
-
63
80
  return (
64
81
  <div className="theia-CodePartRenderer-root">
65
82
  <div className="theia-CodePartRenderer-top">
66
83
  <div className="theia-CodePartRenderer-left">{this.renderTitle(response)}</div>
67
- <div className="theia-CodePartRenderer-right">
68
- <CopyToClipboardButton code={response.code} clipboardService={this.clipboardService} />
69
- <InsertCodeAtCursorButton code={response.code} editorManager={this.editorManager} />
84
+ <div className="theia-CodePartRenderer-right theia-CodePartRenderer-actions">
85
+ {this.codePartRendererActions.getContributions()
86
+ .filter(action => action.canRender ? action.canRender(response, parentNode) : true)
87
+ .sort((a, b) => a.priority - b.priority)
88
+ .map(action => action.render(response, parentNode))}
70
89
  </div>
71
90
  </div>
72
91
  <div className="theia-CodePartRenderer-separator"></div>
@@ -123,6 +142,16 @@ export class CodePartRenderer
123
142
  }
124
143
  }
125
144
 
145
+ @injectable()
146
+ export class CopyToClipboardButtonAction implements CodePartRendererAction {
147
+ @inject(ClipboardService)
148
+ protected readonly clipboardService: ClipboardService;
149
+ priority = 10;
150
+ render(response: CodeChatResponseContent): ReactNode {
151
+ return <CopyToClipboardButton key='copyToClipBoard' code={response.code} clipboardService={this.clipboardService} />;
152
+ }
153
+ }
154
+
126
155
  const CopyToClipboardButton = (props: { code: string, clipboardService: ClipboardService }) => {
127
156
  const { code, clipboardService } = props;
128
157
  const copyCodeToClipboard = React.useCallback(() => {
@@ -131,6 +160,16 @@ const CopyToClipboardButton = (props: { code: string, clipboardService: Clipboar
131
160
  return <div className='button codicon codicon-copy' title='Copy' role='button' onClick={copyCodeToClipboard}></div>;
132
161
  };
133
162
 
163
+ @injectable()
164
+ export class InsertCodeAtCursorButtonAction implements CodePartRendererAction {
165
+ @inject(EditorManager)
166
+ protected readonly editorManager: EditorManager;
167
+ priority = 20;
168
+ render(response: CodeChatResponseContent): ReactNode {
169
+ return <InsertCodeAtCursorButton key='insertCodeAtCursor' code={response.code} editorManager={this.editorManager} />;
170
+ }
171
+ }
172
+
134
173
  const InsertCodeAtCursorButton = (props: { code: string, editorManager: EditorManager }) => {
135
174
  const { code, editorManager } = props;
136
175
  const insertCode = React.useCallback(() => {