@jupyterlite/ai 0.12.0 → 0.13.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 (49) hide show
  1. package/lib/agent.d.ts +24 -2
  2. package/lib/agent.js +161 -24
  3. package/lib/{chat-model-registry.d.ts → chat-model-handler.d.ts} +12 -11
  4. package/lib/{chat-model-registry.js → chat-model-handler.js} +6 -40
  5. package/lib/chat-model.d.ts +8 -0
  6. package/lib/chat-model.js +156 -8
  7. package/lib/completion/completion-provider.d.ts +1 -1
  8. package/lib/completion/completion-provider.js +14 -2
  9. package/lib/components/model-select.js +4 -4
  10. package/lib/components/tool-select.d.ts +11 -2
  11. package/lib/components/tool-select.js +77 -18
  12. package/lib/index.d.ts +3 -3
  13. package/lib/index.js +128 -66
  14. package/lib/models/settings-model.d.ts +2 -0
  15. package/lib/models/settings-model.js +2 -0
  16. package/lib/providers/built-in-providers.js +7 -0
  17. package/lib/providers/provider-tools.d.ts +36 -0
  18. package/lib/providers/provider-tools.js +93 -0
  19. package/lib/rendered-message-outputarea.d.ts +24 -0
  20. package/lib/rendered-message-outputarea.js +48 -0
  21. package/lib/tokens.d.ts +44 -7
  22. package/lib/tokens.js +1 -1
  23. package/lib/tools/commands.js +4 -2
  24. package/lib/tools/web.d.ts +8 -0
  25. package/lib/tools/web.js +196 -0
  26. package/lib/widgets/ai-settings.d.ts +1 -1
  27. package/lib/widgets/ai-settings.js +125 -38
  28. package/lib/widgets/main-area-chat.d.ts +6 -0
  29. package/lib/widgets/main-area-chat.js +28 -0
  30. package/lib/widgets/provider-config-dialog.js +207 -4
  31. package/package.json +10 -4
  32. package/schema/settings-model.json +89 -1
  33. package/src/agent.ts +220 -42
  34. package/src/{chat-model-registry.ts → chat-model-handler.ts} +16 -51
  35. package/src/chat-model.ts +223 -14
  36. package/src/completion/completion-provider.ts +26 -12
  37. package/src/components/model-select.tsx +4 -5
  38. package/src/components/tool-select.tsx +110 -7
  39. package/src/index.ts +153 -82
  40. package/src/models/settings-model.ts +6 -0
  41. package/src/providers/built-in-providers.ts +7 -0
  42. package/src/providers/provider-tools.ts +179 -0
  43. package/src/rendered-message-outputarea.ts +62 -0
  44. package/src/tokens.ts +53 -9
  45. package/src/tools/commands.ts +4 -2
  46. package/src/tools/web.ts +238 -0
  47. package/src/widgets/ai-settings.tsx +282 -77
  48. package/src/widgets/main-area-chat.ts +34 -1
  49. package/src/widgets/provider-config-dialog.tsx +496 -3
package/src/chat-model.ts CHANGED
@@ -3,19 +3,26 @@ import {
3
3
  IActiveCellManager,
4
4
  IAttachment,
5
5
  IChatContext,
6
+ IMessage,
6
7
  IMessageContent,
7
8
  INewMessage,
8
9
  IUser
9
10
  } from '@jupyter/chat';
10
11
 
12
+ import { YNotebook } from '@jupyter/ydoc';
13
+
11
14
  import { PathExt } from '@jupyterlab/coreutils';
12
15
 
13
16
  import { IDocumentManager } from '@jupyterlab/docmanager';
14
17
 
15
18
  import { IDocumentWidget } from '@jupyterlab/docregistry';
16
19
 
20
+ import * as nbformat from '@jupyterlab/nbformat';
21
+
17
22
  import { INotebookModel, Notebook } from '@jupyterlab/notebook';
18
23
 
24
+ import { IRenderMime } from '@jupyterlab/rendermime';
25
+
19
26
  import { TranslationBundle } from '@jupyterlab/translation';
20
27
 
21
28
  import { UUID } from '@lumino/coreutils';
@@ -30,10 +37,6 @@ import { AISettingsModel } from './models/settings-model';
30
37
 
31
38
  import { ITokenUsage } from './tokens';
32
39
 
33
- import { YNotebook } from '@jupyter/ydoc';
34
-
35
- import * as nbformat from '@jupyterlab/nbformat';
36
-
37
40
  /**
38
41
  * Tool call status types.
39
42
  */
@@ -77,6 +80,10 @@ interface IToolExecutionContext {
77
80
  * Human-readable summary extracted from tool input for display.
78
81
  */
79
82
  summary?: string;
83
+ /**
84
+ * Whether this tool call should auto-render trusted MIME bundles on completion.
85
+ */
86
+ shouldAutoRenderMimeBundles?: boolean;
80
87
  }
81
88
 
82
89
  /**
@@ -334,8 +341,9 @@ export class AIChatModel extends AbstractChatModel {
334
341
  type: 'msg',
335
342
  raw_time: false
336
343
  };
337
- this._currentStreamingMessage = aiMessage;
338
344
  this.messageAdded(aiMessage);
345
+ this._currentStreamingMessage =
346
+ this.messages.find(message => message.id === aiMessage.id) ?? null;
339
347
  }
340
348
 
341
349
  /**
@@ -347,8 +355,7 @@ export class AIChatModel extends AbstractChatModel {
347
355
  this._currentStreamingMessage &&
348
356
  this._currentStreamingMessage.id === event.data.messageId
349
357
  ) {
350
- this._currentStreamingMessage.body = event.data.fullContent;
351
- this.messageAdded(this._currentStreamingMessage);
358
+ this._currentStreamingMessage.update({ body: event.data.fullContent });
352
359
  }
353
360
  }
354
361
 
@@ -361,8 +368,7 @@ export class AIChatModel extends AbstractChatModel {
361
368
  this._currentStreamingMessage &&
362
369
  this._currentStreamingMessage.id === event.data.messageId
363
370
  ) {
364
- this._currentStreamingMessage.body = event.data.content;
365
- this.messageAdded(this._currentStreamingMessage);
371
+ this._currentStreamingMessage.update({ body: event.data.content });
366
372
  this._currentStreamingMessage = null;
367
373
  }
368
374
  }
@@ -401,6 +407,21 @@ export class AIChatModel extends AbstractChatModel {
401
407
  return parsedInput.name;
402
408
  }
403
409
  break;
410
+ case 'browser_fetch':
411
+ if (parsedInput.url) {
412
+ return parsedInput.url;
413
+ }
414
+ break;
415
+ case 'web_fetch':
416
+ if (parsedInput.url) {
417
+ return parsedInput.url;
418
+ }
419
+ break;
420
+ case 'web_search':
421
+ if (parsedInput.query) {
422
+ return `query: "${parsedInput.query}"`;
423
+ }
424
+ break;
404
425
  }
405
426
  } catch {
406
427
  // If parsing fails, return empty string
@@ -408,6 +429,30 @@ export class AIChatModel extends AbstractChatModel {
408
429
  return '';
409
430
  }
410
431
 
432
+ /**
433
+ * Determine whether this tool call should auto-render trusted MIME bundles.
434
+ */
435
+ private _computeShouldAutoRenderMimeBundles(
436
+ toolName: string,
437
+ input: string
438
+ ): boolean {
439
+ if (toolName !== 'execute_command') {
440
+ return false;
441
+ }
442
+
443
+ try {
444
+ const parsedInput = JSON.parse(input);
445
+ return (
446
+ typeof parsedInput.commandId === 'string' &&
447
+ this._settingsModel.config.commandsAutoRenderMimeBundles.includes(
448
+ parsedInput.commandId
449
+ )
450
+ );
451
+ } catch {
452
+ return false;
453
+ }
454
+ }
455
+
411
456
  /**
412
457
  * Handles the start of a tool call execution.
413
458
  * @param event Event containing the tool call start data
@@ -420,13 +465,19 @@ export class AIChatModel extends AbstractChatModel {
420
465
  event.data.toolName,
421
466
  event.data.input
422
467
  );
468
+ const shouldAutoRenderMimeBundles =
469
+ this._computeShouldAutoRenderMimeBundles(
470
+ event.data.toolName,
471
+ event.data.input
472
+ );
423
473
  const context: IToolExecutionContext = {
424
474
  toolCallId: event.data.callId,
425
475
  messageId,
426
476
  toolName: event.data.toolName,
427
477
  input: event.data.input,
428
478
  status: 'pending',
429
- summary
479
+ summary,
480
+ shouldAutoRenderMimeBundles
430
481
  };
431
482
 
432
483
  this._toolContexts.set(event.data.callId, context);
@@ -455,11 +506,53 @@ export class AIChatModel extends AbstractChatModel {
455
506
  private _handleToolCallCompleteEvent(
456
507
  event: IAgentEvent<'tool_call_complete'>
457
508
  ): void {
509
+ const context = this._toolContexts.get(event.data.callId);
458
510
  const status = event.data.isError ? 'error' : 'completed';
459
- this._updateToolCallUI(event.data.callId, status, event.data.output);
511
+ this._updateToolCallUI(
512
+ event.data.callId,
513
+ status,
514
+ Private.formatToolOutput(event.data.outputData)
515
+ );
516
+
517
+ if (!event.data.isError && this._shouldAutoRenderMimeBundles(context)) {
518
+ // Tool results are arbitrary command payloads (often wrapped in
519
+ // { success, result, outputs, ... }), so extract display outputs
520
+ // defensively instead of assuming a raw kernel message shape.
521
+ const mimeBundles = Private.extractMimeBundlesFromUnknown(
522
+ event.data.outputData,
523
+ {
524
+ trustedMimeTypes:
525
+ this._settingsModel.config.trustedMimeTypesForAutoRender
526
+ }
527
+ );
528
+ for (const bundle of mimeBundles) {
529
+ this.messageAdded({
530
+ body: bundle,
531
+ sender: this._getAIUser(),
532
+ id: UUID.uuid4(),
533
+ time: Date.now() / 1000,
534
+ type: 'msg',
535
+ raw_time: false
536
+ });
537
+ }
538
+ }
539
+
460
540
  this._toolContexts.delete(event.data.callId);
461
541
  }
462
542
 
543
+ /**
544
+ * Determine whether a tool call output should auto-render MIME bundles.
545
+ */
546
+ private _shouldAutoRenderMimeBundles(
547
+ context: IToolExecutionContext | undefined
548
+ ): boolean {
549
+ if (!context) {
550
+ return false;
551
+ }
552
+
553
+ return !!context.shouldAutoRenderMimeBundles;
554
+ }
555
+
463
556
  /**
464
557
  * Handles error events from the AI agent.
465
558
  */
@@ -827,12 +920,119 @@ export class AIChatModel extends AbstractChatModel {
827
920
  private _user: IUser;
828
921
  private _toolContexts: Map<string, IToolExecutionContext> = new Map();
829
922
  private _agentManager: AgentManager;
830
- private _currentStreamingMessage: IMessageContent | null = null;
923
+ private _currentStreamingMessage: IMessage | null = null;
831
924
  private _nameChanged = new Signal<AIChatModel, string>(this);
832
925
  private _trans: TranslationBundle;
833
926
  }
834
927
 
835
928
  namespace Private {
929
+ type IMimeBody = Partial<IRenderMime.IMimeModel> &
930
+ Pick<IRenderMime.IMimeModel, 'data'>;
931
+ type IDisplayOutput =
932
+ | nbformat.IDisplayData
933
+ | nbformat.IDisplayUpdate
934
+ | nbformat.IExecuteResult;
935
+
936
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
937
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
938
+ };
939
+
940
+ const isDisplayOutput = (value: unknown): value is IDisplayOutput => {
941
+ if (!isPlainObject(value)) {
942
+ return false;
943
+ }
944
+
945
+ const output = value as nbformat.IOutput;
946
+ return (
947
+ nbformat.isDisplayData(output) ||
948
+ nbformat.isDisplayUpdate(output) ||
949
+ nbformat.isExecuteResult(output)
950
+ );
951
+ };
952
+
953
+ const toMimeBundle = (
954
+ value: IDisplayOutput,
955
+ trustedMimeTypes: ReadonlySet<string>
956
+ ): IMimeBody | null => {
957
+ const data = value.data;
958
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
959
+ return null;
960
+ }
961
+
962
+ return {
963
+ data: data as IRenderMime.IMimeModel['data'],
964
+ ...(isPlainObject(value.metadata)
965
+ ? { metadata: value.metadata as IRenderMime.IMimeModel['metadata'] }
966
+ : {}),
967
+ // MIME auto-rendering only runs for explicitly configured command IDs.
968
+ // Trust handling is configurable to keep risky MIME execution opt-in.
969
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
970
+ ? { trusted: true }
971
+ : {})
972
+ };
973
+ };
974
+
975
+ /**
976
+ * Normalize arbitrary tool payloads into canonical display outputs.
977
+ *
978
+ * Tool outputs are not guaranteed to be raw Jupyter IOPub messages; they are
979
+ * often wrapped objects (for example `{ success, result: { outputs: [...] } }`).
980
+ */
981
+ const toDisplayOutputs = (value: unknown): IDisplayOutput[] => {
982
+ if (isDisplayOutput(value)) {
983
+ return [value];
984
+ }
985
+
986
+ if (Array.isArray(value)) {
987
+ return value.filter(isDisplayOutput);
988
+ }
989
+
990
+ if (!isPlainObject(value)) {
991
+ return [];
992
+ }
993
+
994
+ if (Array.isArray(value.outputs)) {
995
+ return value.outputs.filter(isDisplayOutput);
996
+ }
997
+
998
+ if ('result' in value) {
999
+ return toDisplayOutputs(value.result);
1000
+ }
1001
+
1002
+ return [];
1003
+ };
1004
+
1005
+ /**
1006
+ * Extract rendermime-ready mime bundles from arbitrary tool results.
1007
+ */
1008
+ export function extractMimeBundlesFromUnknown(
1009
+ content: unknown,
1010
+ options: { trustedMimeTypes?: ReadonlyArray<string> } = {}
1011
+ ): IMimeBody[] {
1012
+ const bundles: IMimeBody[] = [];
1013
+ const outputs = toDisplayOutputs(content);
1014
+ const trustedMimeTypes = new Set(options.trustedMimeTypes ?? []);
1015
+ for (const output of outputs) {
1016
+ const bundle = toMimeBundle(output, trustedMimeTypes);
1017
+ if (bundle) {
1018
+ bundles.push(bundle);
1019
+ }
1020
+ }
1021
+ return bundles;
1022
+ }
1023
+
1024
+ export function formatToolOutput(outputData: unknown): string {
1025
+ if (typeof outputData === 'string') {
1026
+ return outputData;
1027
+ }
1028
+
1029
+ try {
1030
+ return JSON.stringify(outputData, null, 2);
1031
+ } catch {
1032
+ return '[Complex object - cannot serialize]';
1033
+ }
1034
+ }
1035
+
836
1036
  export function escapeHtml(value: string): string {
837
1037
  // Prefer the same native escaping approach used in JupyterLab itself
838
1038
  // (e.g. `@jupyterlab/completer`).
@@ -927,7 +1127,9 @@ namespace Private {
927
1127
  /**
928
1128
  * Builds HTML for a tool call display.
929
1129
  */
930
- export function buildToolCallHtml(options: IToolCallHtmlOptions): string {
1130
+ export function buildToolCallHtml(
1131
+ options: IToolCallHtmlOptions
1132
+ ): Partial<IRenderMime.IMimeModel> & Pick<IRenderMime.IMimeModel, 'data'> {
931
1133
  const { toolName, input, status, summary, output, approvalId, trans } =
932
1134
  options;
933
1135
  const config = STATUS_CONFIG[status];
@@ -965,7 +1167,7 @@ namespace Private {
965
1167
  </div>`;
966
1168
  }
967
1169
 
968
- return `<details class="jp-ai-tool-call ${config.cssClass}"${openAttr}>
1170
+ const HTMLContent = `<details class="jp-ai-tool-call ${config.cssClass}"${openAttr}>
969
1171
  <summary class="jp-ai-tool-header">
970
1172
  <div class="jp-ai-tool-icon">⚡</div>
971
1173
  <div class="jp-ai-tool-title">${escapedToolName}${summaryHtml}</div>
@@ -974,6 +1176,13 @@ namespace Private {
974
1176
  <div class="jp-ai-tool-body">${bodyContent}
975
1177
  </div>
976
1178
  </details>`;
1179
+
1180
+ return {
1181
+ trusted: true,
1182
+ data: {
1183
+ 'text/html': HTMLContent
1184
+ }
1185
+ };
977
1186
  }
978
1187
  }
979
1188
 
@@ -53,6 +53,11 @@ export class AICompletionProvider implements IInlineCompletionProvider {
53
53
  this._updateModel();
54
54
  });
55
55
  this._updateModel();
56
+
57
+ // Disable the secrets manager if the token is empty.
58
+ if (!options.token) {
59
+ this._secretsManager = undefined;
60
+ }
56
61
  }
57
62
 
58
63
  /**
@@ -163,14 +168,23 @@ export class AICompletionProvider implements IInlineCompletionProvider {
163
168
 
164
169
  let apiKey: string;
165
170
  if (this._secretsManager && this._settingsModel.config.useSecretsManager) {
166
- apiKey =
167
- (
168
- await this._secretsManager.get(
169
- Private.getToken(),
170
- SECRETS_NAMESPACE,
171
- `${provider}:apiKey`
172
- )
173
- )?.value ?? '';
171
+ const token = Private.getToken();
172
+ if (!token) {
173
+ // This should never happen, the secrets manager should be disabled.
174
+ console.error(
175
+ '@jupyterlite/ai::AICompletionProvider error: the settings manager token is not set.\nYou should disable the secrets manager from the AI settings.'
176
+ );
177
+ apiKey = '';
178
+ } else {
179
+ apiKey =
180
+ (
181
+ await this._secretsManager.get(
182
+ token,
183
+ SECRETS_NAMESPACE,
184
+ `${provider}:apiKey`
185
+ )
186
+ )?.value ?? '';
187
+ }
174
188
  } else {
175
189
  apiKey = this._settingsModel.getApiKey(activeProvider.id);
176
190
  }
@@ -316,7 +330,7 @@ export namespace AICompletionProvider {
316
330
  /**
317
331
  * The token used to request the secrets manager.
318
332
  */
319
- token: symbol;
333
+ token: symbol | null;
320
334
  }
321
335
  }
322
336
 
@@ -324,11 +338,11 @@ namespace Private {
324
338
  /**
325
339
  * The token to use with the secrets manager, setter and getter.
326
340
  */
327
- let secretsToken: symbol;
328
- export function setToken(value: symbol): void {
341
+ let secretsToken: symbol | null;
342
+ export function setToken(value: symbol | null): void {
329
343
  secretsToken = value;
330
344
  }
331
- export function getToken(): symbol {
345
+ export function getToken(): symbol | null {
332
346
  return secretsToken;
333
347
  }
334
348
  }
@@ -5,7 +5,6 @@ import { Menu, MenuItem, Typography } from '@mui/material';
5
5
  import React, { useCallback, useEffect, useState } from 'react';
6
6
  import { AIChatModel } from '../chat-model';
7
7
  import { AISettingsModel } from '../models/settings-model';
8
-
9
8
  /**
10
9
  * Properties for the model select component.
11
10
  */
@@ -199,11 +198,11 @@ export function ModelSelect(props: IModelSelectProps): JSX.Element {
199
198
  }}
200
199
  sx={{
201
200
  backgroundColor: isSelected
202
- ? 'var(--jp-brand-color3, rgba(33, 150, 243, 0.1))'
201
+ ? 'var(--mui-palette-primary-main)'
203
202
  : 'transparent',
204
203
  '&:hover': {
205
204
  backgroundColor: isSelected
206
- ? 'var(--jp-brand-color3, rgba(33, 150, 243, 0.15))'
205
+ ? 'var(--mui-palette-primary-main)'
207
206
  : 'var(--jp-layout-color1)'
208
207
  },
209
208
  display: 'flex',
@@ -214,7 +213,7 @@ export function ModelSelect(props: IModelSelectProps): JSX.Element {
214
213
  {isSelected ? (
215
214
  <CheckIcon
216
215
  sx={{
217
- color: 'var(--jp-brand-color1, #2196F3)',
216
+ color: 'var(--jp-ui-inverse-font-color1)',
218
217
  fontSize: 16
219
218
  }}
220
219
  />
@@ -227,7 +226,7 @@ export function ModelSelect(props: IModelSelectProps): JSX.Element {
227
226
  sx={{
228
227
  fontWeight: isSelected ? 600 : 400,
229
228
  color: isSelected
230
- ? 'var(--jp-brand-color1, #2196F3)'
229
+ ? 'var(--jp-ui-inverse-font-color1)'
231
230
  : 'inherit'
232
231
  }}
233
232
  >
@@ -6,12 +6,14 @@ import BuildIcon from '@mui/icons-material/Build';
6
6
 
7
7
  import CheckIcon from '@mui/icons-material/Check';
8
8
 
9
- import { Menu, MenuItem, Tooltip, Typography } from '@mui/material';
9
+ import { Divider, Menu, MenuItem, Tooltip, Typography } from '@mui/material';
10
10
 
11
11
  import React, { useCallback, useEffect, useState } from 'react';
12
12
 
13
- import { INamedTool, IToolRegistry } from '../tokens';
13
+ import { INamedTool, IProviderRegistry, IToolRegistry } from '../tokens';
14
14
  import { AIChatModel } from '../chat-model';
15
+ import { AISettingsModel } from '../models/settings-model';
16
+ import { createProviderTools } from '../providers/provider-tools';
15
17
 
16
18
  const SELECT_ITEM_CLASS = 'jp-AIToolSelect-item';
17
19
 
@@ -35,6 +37,16 @@ export interface IToolSelectProps
35
37
  */
36
38
  onToolSelectionChange: (selectedToolNames: string[]) => void;
37
39
 
40
+ /**
41
+ * The settings model to compute provider-level web tools.
42
+ */
43
+ settingsModel: AISettingsModel;
44
+
45
+ /**
46
+ * Registry for provider metadata used to resolve provider tool capabilities.
47
+ */
48
+ providerRegistry: IProviderRegistry;
49
+
38
50
  /**
39
51
  * The application language translator.
40
52
  */
@@ -49,13 +61,19 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
49
61
  toolRegistry,
50
62
  onToolSelectionChange,
51
63
  toolsEnabled,
64
+ settingsModel,
65
+ providerRegistry,
66
+ model,
52
67
  translator: trans
53
68
  } = props;
69
+ const chatContext = model.chatContext as AIChatModel.IAIChatContext;
70
+ const agentManager = chatContext.agentManager;
54
71
 
55
72
  const [selectedToolNames, setSelectedToolNames] = useState<string[]>([]);
56
73
  const [tools, setTools] = useState<INamedTool[]>(
57
74
  toolRegistry?.namedTools || []
58
75
  );
76
+ const [providerToolNames, setProviderToolNames] = useState<string[]>([]);
59
77
  const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
60
78
  const [menuOpen, setMenuOpen] = useState(false);
61
79
 
@@ -103,6 +121,48 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
103
121
  }
104
122
  }, [toolRegistry]);
105
123
 
124
+ // Track provider-level tools (e.g. web_search/web_fetch).
125
+ useEffect(() => {
126
+ if (!agentManager || !toolsEnabled) {
127
+ setProviderToolNames([]);
128
+ return;
129
+ }
130
+
131
+ const updateProviderTools = () => {
132
+ const activeProviderId = agentManager.activeProvider;
133
+ const providerConfig = settingsModel.getProvider(activeProviderId);
134
+ if (!providerConfig) {
135
+ setProviderToolNames([]);
136
+ return;
137
+ }
138
+
139
+ const providerInfo = providerRegistry.getProviderInfo(
140
+ providerConfig.provider
141
+ );
142
+ const providerTools = createProviderTools({
143
+ providerInfo,
144
+ customSettings: providerConfig.customSettings,
145
+ hasFunctionTools: selectedToolNames.length > 0
146
+ });
147
+ setProviderToolNames(Object.keys(providerTools));
148
+ };
149
+
150
+ updateProviderTools();
151
+ settingsModel.stateChanged.connect(updateProviderTools);
152
+ agentManager.activeProviderChanged.connect(updateProviderTools);
153
+
154
+ return () => {
155
+ settingsModel.stateChanged.disconnect(updateProviderTools);
156
+ agentManager.activeProviderChanged.disconnect(updateProviderTools);
157
+ };
158
+ }, [
159
+ settingsModel,
160
+ providerRegistry,
161
+ agentManager,
162
+ selectedToolNames.length,
163
+ toolsEnabled
164
+ ]);
165
+
106
166
  // Initialize selected tools to all tools by default
107
167
  useEffect(() => {
108
168
  if (tools.length > 0 && selectedToolNames.length === 0) {
@@ -113,10 +173,13 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
113
173
  }, [tools, selectedToolNames.length, onToolSelectionChange]);
114
174
 
115
175
  // Don't render if tools are disabled or no tools available
116
- if (!toolsEnabled || tools.length === 0) {
176
+ if (!toolsEnabled || (tools.length === 0 && providerToolNames.length === 0)) {
117
177
  return <></>;
118
178
  }
119
179
 
180
+ const selectedCount = selectedToolNames.length + providerToolNames.length;
181
+ const totalCount = tools.length + providerToolNames.length;
182
+
120
183
  return (
121
184
  <>
122
185
  <TooltippedButton
@@ -125,11 +188,11 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
125
188
  }}
126
189
  tooltip={trans.__(
127
190
  'Tools (%1/%2 selected)',
128
- selectedToolNames.length.toString(),
129
- tools.length.toString()
191
+ selectedCount.toString(),
192
+ totalCount.toString()
130
193
  )}
131
194
  buttonProps={{
132
- ...(selectedToolNames.length === 0 && {
195
+ ...(selectedCount === 0 && {
133
196
  variant: 'outlined'
134
197
  }),
135
198
  title: trans.__('Select AI Tools'),
@@ -143,7 +206,7 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
143
206
  }
144
207
  }}
145
208
  sx={
146
- selectedToolNames.length === 0
209
+ selectedCount === 0
147
210
  ? { backgroundColor: 'var(--jp-layout-color3)' }
148
211
  : {}
149
212
  }
@@ -198,6 +261,42 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
198
261
  </MenuItem>
199
262
  </Tooltip>
200
263
  ))}
264
+
265
+ {providerToolNames.length > 0 && tools.length > 0 && <Divider />}
266
+
267
+ {providerToolNames.length > 0 && (
268
+ <MenuItem disabled>
269
+ <Typography variant="caption">
270
+ {trans.__('Provider Tools')}
271
+ </Typography>
272
+ </MenuItem>
273
+ )}
274
+
275
+ {providerToolNames.map(toolName => {
276
+ return (
277
+ <Tooltip
278
+ key={toolName}
279
+ title={trans.__('Enabled via provider settings.')}
280
+ placement="left"
281
+ >
282
+ <MenuItem
283
+ className={SELECT_ITEM_CLASS}
284
+ onClick={e => {
285
+ // Keep provider-managed tools read-only from this menu.
286
+ e.stopPropagation();
287
+ }}
288
+ >
289
+ <CheckIcon
290
+ sx={{
291
+ marginRight: '8px',
292
+ color: 'text.disabled'
293
+ }}
294
+ />
295
+ <Typography variant="body2">{toolName}</Typography>
296
+ </MenuItem>
297
+ </Tooltip>
298
+ );
299
+ })}
201
300
  </Menu>
202
301
  </>
203
302
  );
@@ -208,6 +307,8 @@ export function ToolSelect(props: IToolSelectProps): JSX.Element {
208
307
  */
209
308
  export function createToolSelectItem(
210
309
  toolRegistry: IToolRegistry,
310
+ settingsModel: AISettingsModel,
311
+ providerRegistry: IProviderRegistry,
211
312
  toolsEnabled: boolean = true,
212
313
  translator: TranslationBundle
213
314
  ): InputToolbarRegistry.IToolbarItem {
@@ -225,6 +326,8 @@ export function createToolSelectItem(
225
326
  const toolSelectProps: IToolSelectProps = {
226
327
  ...props,
227
328
  toolRegistry,
329
+ settingsModel,
330
+ providerRegistry,
228
331
  onToolSelectionChange,
229
332
  toolsEnabled,
230
333
  translator