@plmbr/notebook-intelligence 5.0.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 (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
package/src/index.ts ADDED
@@ -0,0 +1,2583 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import {
4
+ JupyterFrontEnd,
5
+ JupyterFrontEndPlugin,
6
+ JupyterLab
7
+ } from '@jupyterlab/application';
8
+
9
+ import { IDocumentManager } from '@jupyterlab/docmanager';
10
+ import { DocumentWidget, IDocumentWidget } from '@jupyterlab/docregistry';
11
+
12
+ import {
13
+ Dialog,
14
+ ICommandPalette,
15
+ MainAreaWidget,
16
+ Notification
17
+ } from '@jupyterlab/apputils';
18
+ import { dispatchShowTour } from './tour/tour-events';
19
+ import { resetTour } from './tour/tour-state';
20
+ import { TOUR_DEFAULTS } from './tour/tour-steps';
21
+ import { commandLabel } from './tour/tour-config';
22
+ import { IMainMenu } from '@jupyterlab/mainmenu';
23
+
24
+ import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
25
+ import { Extension, StateEffect, StateField } from '@codemirror/state';
26
+ import {
27
+ Decoration,
28
+ DecorationSet,
29
+ EditorView,
30
+ WidgetType
31
+ } from '@codemirror/view';
32
+
33
+ import { CodeCell } from '@jupyterlab/cells';
34
+ import { ISharedNotebook } from '@jupyter/ydoc';
35
+
36
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
37
+
38
+ import {
39
+ CompletionHandler,
40
+ ICompletionProviderManager,
41
+ IInlineCompletionContext,
42
+ IInlineCompletionItem,
43
+ IInlineCompletionList,
44
+ IInlineCompletionProvider
45
+ } from '@jupyterlab/completer';
46
+
47
+ import { NotebookActions, NotebookPanel } from '@jupyterlab/notebook';
48
+ import { CodeEditor } from '@jupyterlab/codeeditor';
49
+ import { FileEditorWidget } from '@jupyterlab/fileeditor';
50
+
51
+ import { FileBrowserModel, IDefaultFileBrowser } from '@jupyterlab/filebrowser';
52
+
53
+ import { ContentsManager, KernelSpecManager } from '@jupyterlab/services';
54
+
55
+ import { LabIcon, terminalIcon } from '@jupyterlab/ui-components';
56
+
57
+ import { Menu, Panel, Widget } from '@lumino/widgets';
58
+ import { CommandRegistry } from '@lumino/commands';
59
+ import { IStatusBar } from '@jupyterlab/statusbar';
60
+ import { ILauncher } from '@jupyterlab/launcher';
61
+ import { IDisposable } from '@lumino/disposable';
62
+ import React from 'react';
63
+ import { ReactWidget } from '@jupyterlab/apputils';
64
+ import { LauncherPicker } from './components/launcher-picker';
65
+ import stripAnsi from 'strip-ansi';
66
+ import {
67
+ ChatSidebar,
68
+ FormInputDialogBody,
69
+ GitHubCopilotLoginDialogBody,
70
+ IInlinePromptWidgetOptions,
71
+ InlinePopoverComponent,
72
+ GitHubCopilotStatusBarItem,
73
+ RunChatCompletionType
74
+ } from './chat-sidebar';
75
+ import {
76
+ CellOutputActionFlag,
77
+ NBIAPI,
78
+ GitHubCopilotLoginStatus,
79
+ IClaudeSessionInfo
80
+ } from './api';
81
+ import { CellOutputHoverToolbar } from './cell-output-toolbar';
82
+ import { attachOpenFileRefreshWatcher } from './open-file-refresh-watcher';
83
+ import { buildRefreshWatcherEnv } from './open-file-refresh-watcher-env';
84
+ import {
85
+ BackendMessageType,
86
+ GITHUB_COPILOT_PROVIDER_ID,
87
+ IActiveDocumentInfo,
88
+ ICellContents,
89
+ INotebookIntelligence,
90
+ ITelemetryEmitter,
91
+ ITelemetryEvent,
92
+ ITelemetryListener,
93
+ RequestDataType,
94
+ TelemetryEventType
95
+ } from './tokens';
96
+ import sparklesSvgstr from '../style/icons/sparkles.svg';
97
+ import copilotSvgstr from '../style/icons/copilot.svg';
98
+ import sparklesWarningSvgstr from '../style/icons/sparkles-warning.svg';
99
+ import claudeSvgstr from '../style/icons/claude.svg';
100
+ import openaiSvgstr from '../style/icons/openai.svg';
101
+ import opencodeSvgstr from '../style/icons/opencode.svg';
102
+
103
+ import {
104
+ applyCodeToSelectionInEditor,
105
+ cellOutputAsText,
106
+ cellOutputHasError,
107
+ chooseWorkspaceDirectory,
108
+ compareSelections,
109
+ extractLLMGeneratedCode,
110
+ getSelectionInEditor,
111
+ getTokenCount,
112
+ getWholeNotebookContent,
113
+ isSelectionEmpty,
114
+ markdownToComment,
115
+ waitForDuration
116
+ } from './utils';
117
+ import { cellOutputAsContextBundle } from './cell-output-bundle';
118
+ import { UUID } from '@lumino/coreutils';
119
+
120
+ import * as path from 'path';
121
+ import { createRoot, Root } from 'react-dom/client';
122
+ import { SettingsPanel } from './components/settings-panel';
123
+ import { ITerminalConnection } from '@jupyterlab/services/lib/terminal/terminal';
124
+ import { ITerminalTracker } from '@jupyterlab/terminal';
125
+ import { Token } from '@lumino/coreutils';
126
+ import { NotebookGenerationToolbarExtension } from './notebook-generation-toolbar';
127
+ import { attachTerminalDragDrop } from './terminal-drag';
128
+
129
+ import { CommandIDs } from './command-ids';
130
+
131
+ const addInlinePromptEffect = StateEffect.define<{
132
+ pos: number;
133
+ widget: InlinePromptBlockWidget;
134
+ }>();
135
+ const removeInlinePromptEffect = StateEffect.define<void>();
136
+
137
+ const inlinePromptField = StateField.define<DecorationSet>({
138
+ create() {
139
+ return Decoration.none;
140
+ },
141
+ update(decorations, transaction) {
142
+ decorations = decorations.map(transaction.changes);
143
+
144
+ for (const effect of transaction.effects) {
145
+ if (effect.is(removeInlinePromptEffect)) {
146
+ decorations = Decoration.none;
147
+ } else if (effect.is(addInlinePromptEffect)) {
148
+ decorations = Decoration.set([
149
+ Decoration.widget({
150
+ block: true,
151
+ side: 1,
152
+ widget: effect.value.widget
153
+ }).range(effect.value.pos)
154
+ ]);
155
+ }
156
+ }
157
+
158
+ return decorations;
159
+ },
160
+ provide: field => EditorView.decorations.from(field)
161
+ });
162
+
163
+ class InlinePromptBlockWidget extends WidgetType {
164
+ constructor(
165
+ private readonly _content: React.ReactElement,
166
+ private readonly _onNodeCreated: (node: HTMLElement) => void,
167
+ private readonly _onDismissRequested: () => void
168
+ ) {
169
+ super();
170
+ }
171
+
172
+ eq(other: WidgetType): boolean {
173
+ return other === this;
174
+ }
175
+
176
+ toDOM(): HTMLElement {
177
+ const host = document.createElement('div');
178
+ host.className = 'nbi-inline-prompt-block';
179
+ host.contentEditable = 'false';
180
+
181
+ const node = document.createElement('div');
182
+ node.className = 'inline-prompt-widget inline-prompt-widget-inline';
183
+ node.style.height = '48px';
184
+ host.appendChild(node);
185
+
186
+ // Bubble-phase stopPropagation so JupyterLab's document-level
187
+ // keybindings (Ctrl+S, etc.) don't intercept keys typed in the
188
+ // textarea. CM's own handling is already opted out via ignoreEvent;
189
+ // this is purely about JL keymap.
190
+ const stopEditorEvent = (event: Event) => event.stopPropagation();
191
+ for (const eventName of [
192
+ 'keydown',
193
+ 'keypress',
194
+ 'keyup',
195
+ 'mousedown',
196
+ 'mouseup',
197
+ 'click',
198
+ 'pointerdown',
199
+ 'pointerup',
200
+ 'input',
201
+ 'beforeinput'
202
+ ]) {
203
+ host.addEventListener(eventName, stopEditorEvent);
204
+ }
205
+
206
+ // While the popover is mounted, suppress JupyterLab's notebook-panel
207
+ // _evtFocusIn handler for focus events inside the owning cell. Without
208
+ // this, JL's _ensureFocus reacts to every focusin on the textarea by
209
+ // refocusing .cm-content, which then triggers our own refocus, which
210
+ // re-fires JL — an infinite loop until the stack overflows.
211
+ //
212
+ // Capture-phase listener on document fires before JL's bubble-phase
213
+ // handler on the notebook panel; stopPropagation here is sufficient.
214
+ // The owning .cm-content / .jp-Cell are resolved inside the handler
215
+ // because at toDOM() time the host is created but not yet inserted.
216
+ this._focusinGuard = (event: FocusEvent) => {
217
+ if (!host.isConnected) {
218
+ return;
219
+ }
220
+ const ownCmContent = host.closest('.cm-content');
221
+ if (!ownCmContent) {
222
+ return;
223
+ }
224
+ const ownCell = ownCmContent.closest('.jp-Cell');
225
+ const target = event.target as HTMLElement | null;
226
+ if (!ownCell || !target || !ownCell.contains(target)) {
227
+ return;
228
+ }
229
+
230
+ event.stopPropagation();
231
+
232
+ if (target === ownCmContent) {
233
+ const ta = node.querySelector('textarea');
234
+ if (ta && document.activeElement !== ta) {
235
+ ta.focus({ preventScroll: true });
236
+ }
237
+ }
238
+ };
239
+ document.addEventListener('focusin', this._focusinGuard, true);
240
+
241
+ // Outside-click / focus-leave dismissal. The previous floating-widget
242
+ // path relied on InlinePromptWidget's own focusout listener; the
243
+ // block-widget path mounts the React tree directly so we replicate
244
+ // it here. Focus moving within the popover (textarea -> Accept /
245
+ // Cancel) is ignored.
246
+ host.addEventListener('focusout', (event: FocusEvent) => {
247
+ const next = event.relatedTarget as Node | null;
248
+ if (next && host.contains(next)) {
249
+ return;
250
+ }
251
+ this._onDismissRequested();
252
+ });
253
+
254
+ this._root = createRoot(node);
255
+ this._root.render(this._content);
256
+ this._onNodeCreated(node);
257
+ return host;
258
+ }
259
+
260
+ ignoreEvent(): boolean {
261
+ return true;
262
+ }
263
+
264
+ destroy(): void {
265
+ if (this._focusinGuard) {
266
+ document.removeEventListener('focusin', this._focusinGuard, true);
267
+ this._focusinGuard = null;
268
+ }
269
+ this._root?.unmount();
270
+ this._root = null;
271
+ }
272
+
273
+ private _root: Root | null = null;
274
+ private _focusinGuard: ((event: FocusEvent) => void) | null = null;
275
+ }
276
+
277
+ function getCodeMirrorView(editor: CodeEditor.IEditor): EditorView | null {
278
+ const codeMirrorEditor = editor as CodeEditor.IEditor & {
279
+ editor?: EditorView;
280
+ };
281
+ return codeMirrorEditor.editor ?? null;
282
+ }
283
+
284
+ function ensureInlinePromptExtension(view: EditorView): void {
285
+ if (view.state.field(inlinePromptField, false)) {
286
+ return;
287
+ }
288
+ view.dispatch({
289
+ effects: StateEffect.appendConfig.of([inlinePromptField] as Extension[])
290
+ });
291
+ }
292
+
293
+ function getLineEndOffset(editor: CodeEditor.IEditor, offset: number): number {
294
+ const position = editor.getPositionAt(offset);
295
+ return editor.getOffsetAt({
296
+ line: position.line,
297
+ column: editor.getLine(position.line).length
298
+ });
299
+ }
300
+
301
+ const DOCUMENT_WATCH_INTERVAL = 1000;
302
+ const MAX_TOKENS = 4096;
303
+ const githubCopilotIcon = new LabIcon({
304
+ name: 'notebook-intelligence:github-copilot-icon',
305
+ svgstr: copilotSvgstr
306
+ });
307
+
308
+ const sparkleIcon = new LabIcon({
309
+ name: 'notebook-intelligence:sparkles-icon',
310
+ svgstr: sparklesSvgstr
311
+ });
312
+
313
+ const claudeIcon = new LabIcon({
314
+ name: 'notebook-intelligence:claude-icon',
315
+ svgstr: claudeSvgstr
316
+ });
317
+
318
+ const openaiIcon = new LabIcon({
319
+ name: 'notebook-intelligence:openai-icon',
320
+ svgstr: openaiSvgstr
321
+ });
322
+
323
+ const opencodeIcon = new LabIcon({
324
+ name: 'notebook-intelligence:opencode-icon',
325
+ svgstr: opencodeSvgstr
326
+ });
327
+
328
+ const sparkleWarningIcon = new LabIcon({
329
+ name: 'notebook-intelligence:sparkles-warning-icon',
330
+ svgstr: sparklesWarningSvgstr
331
+ });
332
+ const emptyNotebookContent: any = {
333
+ cells: [],
334
+ metadata: {},
335
+ nbformat: 4,
336
+ nbformat_minor: 5
337
+ };
338
+
339
+ const BACKEND_TELEMETRY_LISTENER_NAME = 'backend-telemetry-listener';
340
+
341
+ class ActiveDocumentWatcher {
342
+ static initialize(
343
+ app: JupyterLab,
344
+ languageRegistry: IEditorLanguageRegistry,
345
+ fileBrowser: IDefaultFileBrowser
346
+ ) {
347
+ ActiveDocumentWatcher._languageRegistry = languageRegistry;
348
+
349
+ app.shell.currentChanged?.connect((_sender, args) => {
350
+ ActiveDocumentWatcher.watchDocument(args.newValue);
351
+ });
352
+
353
+ ActiveDocumentWatcher.activeDocumentInfo.activeWidget =
354
+ app.shell.currentWidget;
355
+ ActiveDocumentWatcher.handleWatchDocument();
356
+
357
+ if (fileBrowser) {
358
+ const onPathChanged = (model: FileBrowserModel) => {
359
+ ActiveDocumentWatcher.currentDirectory = model.path;
360
+ };
361
+ fileBrowser.model.pathChanged.connect(onPathChanged);
362
+ }
363
+ }
364
+
365
+ static watchDocument(widget: Widget) {
366
+ if (ActiveDocumentWatcher.activeDocumentInfo.activeWidget === widget) {
367
+ return;
368
+ }
369
+ clearInterval(ActiveDocumentWatcher._watchTimer);
370
+ ActiveDocumentWatcher.activeDocumentInfo.activeWidget = widget;
371
+
372
+ ActiveDocumentWatcher._watchTimer = setInterval(() => {
373
+ ActiveDocumentWatcher.handleWatchDocument();
374
+ }, DOCUMENT_WATCH_INTERVAL);
375
+
376
+ ActiveDocumentWatcher.handleWatchDocument();
377
+ }
378
+
379
+ static handleWatchDocument() {
380
+ const activeDocumentInfo = ActiveDocumentWatcher.activeDocumentInfo;
381
+ const previousDocumentInfo = {
382
+ ...activeDocumentInfo,
383
+ ...{ activeWidget: null }
384
+ };
385
+
386
+ const activeWidget = activeDocumentInfo.activeWidget;
387
+ if (activeWidget instanceof NotebookPanel) {
388
+ const np = activeWidget as NotebookPanel;
389
+ activeDocumentInfo.filename = np.sessionContext.name;
390
+ activeDocumentInfo.filePath = np.sessionContext.path;
391
+ activeDocumentInfo.language =
392
+ (np.model?.sharedModel?.metadata?.kernelspec?.language as string) ||
393
+ 'python';
394
+ const { activeCellIndex, activeCell } = np.content;
395
+ activeDocumentInfo.activeCellIndex = activeCellIndex;
396
+ activeDocumentInfo.selection = activeCell?.editor?.getSelection();
397
+ } else if (activeWidget) {
398
+ const dw = activeWidget as DocumentWidget;
399
+ const contentsModel = dw.context?.contentsModel;
400
+ if (contentsModel?.format === 'text') {
401
+ const fileName = contentsModel.name;
402
+ const filePath = contentsModel.path;
403
+ const language =
404
+ ActiveDocumentWatcher._languageRegistry.findByMIME(
405
+ contentsModel.mimetype
406
+ ) || ActiveDocumentWatcher._languageRegistry.findByFileName(fileName);
407
+ activeDocumentInfo.language = language?.name || 'unknown';
408
+ activeDocumentInfo.filename = fileName;
409
+ activeDocumentInfo.filePath = filePath;
410
+ if (activeWidget instanceof FileEditorWidget) {
411
+ const fe = activeWidget as FileEditorWidget;
412
+ activeDocumentInfo.selection = fe.content.editor?.getSelection();
413
+ } else {
414
+ activeDocumentInfo.selection = undefined;
415
+ }
416
+ } else {
417
+ activeDocumentInfo.filename = '';
418
+ activeDocumentInfo.filePath = '';
419
+ activeDocumentInfo.language = '';
420
+ }
421
+ }
422
+
423
+ if (
424
+ ActiveDocumentWatcher.documentInfoChanged(
425
+ previousDocumentInfo,
426
+ activeDocumentInfo
427
+ )
428
+ ) {
429
+ ActiveDocumentWatcher.fireActiveDocumentChangedEvent();
430
+ }
431
+ }
432
+
433
+ private static documentInfoChanged(
434
+ lhs: IActiveDocumentInfo,
435
+ rhs: IActiveDocumentInfo
436
+ ): boolean {
437
+ if (!lhs || !rhs) {
438
+ return true;
439
+ }
440
+
441
+ return (
442
+ lhs.filename !== rhs.filename ||
443
+ lhs.filePath !== rhs.filePath ||
444
+ lhs.language !== rhs.language ||
445
+ lhs.activeCellIndex !== rhs.activeCellIndex ||
446
+ !compareSelections(lhs.selection, rhs.selection)
447
+ );
448
+ }
449
+
450
+ static getActiveSelectionContent(): string {
451
+ const activeDocumentInfo = ActiveDocumentWatcher.activeDocumentInfo;
452
+ const activeWidget = activeDocumentInfo.activeWidget;
453
+
454
+ if (activeWidget instanceof NotebookPanel) {
455
+ const np = activeWidget as NotebookPanel;
456
+ const editor = np.content.activeCell.editor;
457
+ if (isSelectionEmpty(editor.getSelection())) {
458
+ return getWholeNotebookContent(np);
459
+ } else {
460
+ return getSelectionInEditor(editor);
461
+ }
462
+ } else if (activeWidget instanceof FileEditorWidget) {
463
+ const fe = activeWidget as FileEditorWidget;
464
+ const editor = fe.content.editor;
465
+ if (isSelectionEmpty(editor.getSelection())) {
466
+ return editor.model.sharedModel.getSource();
467
+ } else {
468
+ return getSelectionInEditor(editor);
469
+ }
470
+ } else {
471
+ const dw = activeWidget as DocumentWidget;
472
+ const content = dw?.context?.model?.toString();
473
+ const maxContext = 0.5 * MAX_TOKENS;
474
+ return content.substring(0, maxContext);
475
+ }
476
+ }
477
+
478
+ static getCurrentCellContents(): ICellContents {
479
+ const activeDocumentInfo = ActiveDocumentWatcher.activeDocumentInfo;
480
+ const activeWidget = activeDocumentInfo.activeWidget;
481
+
482
+ if (activeWidget instanceof NotebookPanel) {
483
+ const np = activeWidget as NotebookPanel;
484
+ const activeCell = np.content.activeCell;
485
+ const input = activeCell.model.sharedModel.source.trim();
486
+ let output = '';
487
+ if (activeCell instanceof CodeCell) {
488
+ output = cellOutputAsText(np.content.activeCell as CodeCell);
489
+ }
490
+
491
+ return { input, output };
492
+ }
493
+
494
+ return null;
495
+ }
496
+
497
+ static fireActiveDocumentChangedEvent() {
498
+ document.dispatchEvent(
499
+ new CustomEvent('copilotSidebar:activeDocumentChanged', {
500
+ detail: {
501
+ activeDocumentInfo: ActiveDocumentWatcher.activeDocumentInfo
502
+ }
503
+ })
504
+ );
505
+ }
506
+
507
+ static currentDirectory: string = '';
508
+
509
+ static activeDocumentInfo: IActiveDocumentInfo = {
510
+ language: 'python',
511
+ filename: 'nb-doesnt-exist.ipynb',
512
+ filePath: 'nb-doesnt-exist.ipynb',
513
+ activeWidget: null,
514
+ activeCellIndex: -1,
515
+ selection: null
516
+ };
517
+ private static _watchTimer: any;
518
+ private static _languageRegistry: IEditorLanguageRegistry;
519
+ }
520
+
521
+ class NBIInlineCompletionProvider
522
+ implements IInlineCompletionProvider<IInlineCompletionItem>
523
+ {
524
+ constructor(telemetryEmitter: TelemetryEmitter) {
525
+ this._telemetryEmitter = telemetryEmitter;
526
+ }
527
+
528
+ get schema(): ISettingRegistry.IProperty {
529
+ return {
530
+ default: {
531
+ debouncerDelay: NBIAPI.config.inlineCompletionDebouncerDelay,
532
+ timeout: 15000
533
+ }
534
+ };
535
+ }
536
+
537
+ fetch(
538
+ request: CompletionHandler.IRequest,
539
+ context: IInlineCompletionContext
540
+ ): Promise<IInlineCompletionList<IInlineCompletionItem>> {
541
+ let preContent = '';
542
+ let postContent = '';
543
+ const preCursor = request.text.substring(0, request.offset);
544
+ const postCursor = request.text.substring(request.offset);
545
+ let language = ActiveDocumentWatcher.activeDocumentInfo.language;
546
+
547
+ let editorType = 'file-editor';
548
+
549
+ if (context.widget instanceof NotebookPanel) {
550
+ editorType = 'notebook';
551
+ const activeCell = context.widget.content.activeCell;
552
+ if (activeCell.model.sharedModel.cell_type === 'markdown') {
553
+ language = 'markdown';
554
+ }
555
+ let activeCellReached = false;
556
+
557
+ for (const cell of context.widget.content.widgets) {
558
+ const cellModel = cell.model.sharedModel;
559
+ if (cell === activeCell) {
560
+ activeCellReached = true;
561
+ } else if (!activeCellReached) {
562
+ if (cellModel.cell_type === 'code') {
563
+ preContent += cellModel.source + '\n';
564
+ } else if (cellModel.cell_type === 'markdown') {
565
+ preContent += markdownToComment(cellModel.source) + '\n';
566
+ }
567
+ } else {
568
+ if (cellModel.cell_type === 'code') {
569
+ postContent += cellModel.source + '\n';
570
+ } else if (cellModel.cell_type === 'markdown') {
571
+ postContent += markdownToComment(cellModel.source) + '\n';
572
+ }
573
+ }
574
+ }
575
+ }
576
+
577
+ const nbiConfig = NBIAPI.config;
578
+ const inlineCompletionsEnabled =
579
+ nbiConfig.isInClaudeCodeMode ||
580
+ (nbiConfig.inlineCompletionModel.provider === GITHUB_COPILOT_PROVIDER_ID
581
+ ? NBIAPI.getLoginStatus() === GitHubCopilotLoginStatus.LoggedIn
582
+ : nbiConfig.inlineCompletionModel.provider !== 'none');
583
+
584
+ this._telemetryEmitter.emitTelemetryEvent({
585
+ type: TelemetryEventType.InlineCompletionRequest,
586
+ data: {
587
+ inlineCompletionModel: {
588
+ provider: NBIAPI.config.inlineCompletionModel.provider,
589
+ model: NBIAPI.config.inlineCompletionModel.model
590
+ },
591
+ editorType
592
+ }
593
+ });
594
+
595
+ return new Promise((resolve, reject) => {
596
+ const items: IInlineCompletionItem[] = [];
597
+
598
+ if (!inlineCompletionsEnabled) {
599
+ resolve({ items });
600
+ return;
601
+ }
602
+
603
+ if (this._lastRequestInfo) {
604
+ NBIAPI.sendWebSocketMessage(
605
+ this._lastRequestInfo.messageId,
606
+ RequestDataType.CancelInlineCompletionRequest,
607
+ { chatId: this._lastRequestInfo.chatId }
608
+ );
609
+ }
610
+
611
+ const messageId = UUID.uuid4();
612
+ const chatId = UUID.uuid4();
613
+ this._lastRequestInfo = { chatId, messageId, requestTime: new Date() };
614
+
615
+ NBIAPI.inlineCompletionsRequest(
616
+ chatId,
617
+ messageId,
618
+ preContent + preCursor,
619
+ postCursor + postContent,
620
+ language,
621
+ ActiveDocumentWatcher.activeDocumentInfo.filename,
622
+ {
623
+ emit: (response: any) => {
624
+ if (
625
+ response.type === BackendMessageType.StreamMessage &&
626
+ response.id === this._lastRequestInfo.messageId
627
+ ) {
628
+ items.push({
629
+ insertText: response.data.completions
630
+ });
631
+
632
+ const timeElapsed =
633
+ (new Date().getTime() -
634
+ this._lastRequestInfo.requestTime.getTime()) /
635
+ 1000;
636
+ this._telemetryEmitter.emitTelemetryEvent({
637
+ type: TelemetryEventType.InlineCompletionResponse,
638
+ data: {
639
+ inlineCompletionModel: {
640
+ provider: NBIAPI.config.inlineCompletionModel.provider,
641
+ model: NBIAPI.config.inlineCompletionModel.model
642
+ },
643
+ timeElapsed
644
+ }
645
+ });
646
+
647
+ resolve({ items });
648
+ } else {
649
+ reject();
650
+ }
651
+ }
652
+ }
653
+ );
654
+ });
655
+ }
656
+
657
+ get name(): string {
658
+ return 'Notebook Intelligence';
659
+ }
660
+
661
+ get identifier(): string {
662
+ return '@plmbr/notebook-intelligence';
663
+ }
664
+
665
+ get icon(): LabIcon.ILabIcon {
666
+ const isClaudeModel =
667
+ NBIAPI.config.isInClaudeCodeMode &&
668
+ NBIAPI.config.claudeSettings.inline_completion_model !== 'none' &&
669
+ NBIAPI.config.claudeSettings.inline_completion_model !== 'inherit';
670
+ return isClaudeModel
671
+ ? claudeIcon
672
+ : NBIAPI.config.usingGitHubCopilotModel
673
+ ? githubCopilotIcon
674
+ : sparkleIcon;
675
+ }
676
+
677
+ private _lastRequestInfo: {
678
+ chatId: string;
679
+ messageId: string;
680
+ requestTime: Date;
681
+ } = null;
682
+ private _telemetryEmitter: TelemetryEmitter;
683
+ }
684
+
685
+ class TelemetryEmitter implements ITelemetryEmitter {
686
+ registerTelemetryListener(listener: ITelemetryListener) {
687
+ const listenerName = listener.name;
688
+
689
+ if (listenerName !== BACKEND_TELEMETRY_LISTENER_NAME) {
690
+ console.warn(
691
+ `Notebook Intelligence telemetry listener '${listenerName}' registered. Make sure it is from a trusted source.`
692
+ );
693
+ }
694
+
695
+ let listenerAlreadyExists = false;
696
+ this._listeners.forEach(existingListener => {
697
+ if (existingListener.name === listenerName) {
698
+ listenerAlreadyExists = true;
699
+ }
700
+ });
701
+
702
+ if (listenerAlreadyExists) {
703
+ console.error(
704
+ `Notebook Intelligence telemetry listener '${listenerName}' already exists!`
705
+ );
706
+ return;
707
+ }
708
+
709
+ this._listeners.add(listener);
710
+ }
711
+
712
+ unregisterTelemetryListener(listener: ITelemetryListener) {
713
+ this._listeners.delete(listener);
714
+ }
715
+
716
+ emitTelemetryEvent(event: ITelemetryEvent) {
717
+ this._listeners.forEach(listener => {
718
+ listener.onTelemetryEvent(event);
719
+ });
720
+ }
721
+
722
+ private _listeners: Set<ITelemetryListener> = new Set<ITelemetryListener>();
723
+ }
724
+
725
+ class MCPConfigEditor {
726
+ constructor(docManager: IDocumentManager) {
727
+ this._docManager = docManager;
728
+ }
729
+
730
+ async open() {
731
+ const contents = new ContentsManager();
732
+ const newJSONFile = await contents.newUntitled({
733
+ ext: '.json'
734
+ });
735
+ const mcpConfig = await NBIAPI.getMCPConfigFile();
736
+
737
+ try {
738
+ await contents.delete(this._tmpMCPConfigFilename);
739
+ } catch (error) {
740
+ // ignore
741
+ }
742
+
743
+ await contents.save(newJSONFile.path, {
744
+ content: JSON.stringify(mcpConfig, null, 2),
745
+ format: 'text',
746
+ type: 'file'
747
+ });
748
+ await contents.rename(newJSONFile.path, this._tmpMCPConfigFilename);
749
+ this._docWidget = this._docManager.openOrReveal(
750
+ this._tmpMCPConfigFilename,
751
+ 'Editor'
752
+ );
753
+ this._addListeners();
754
+ // tab closed
755
+ this._docWidget.disposed.connect((_, args) => {
756
+ this._removeListeners();
757
+ contents.delete(this._tmpMCPConfigFilename);
758
+ });
759
+ this._isOpen = true;
760
+ }
761
+
762
+ close() {
763
+ if (!this._isOpen) {
764
+ return;
765
+ }
766
+ this._isOpen = false;
767
+ this._docWidget.dispose();
768
+ this._docWidget = null;
769
+ }
770
+
771
+ get isOpen(): boolean {
772
+ return this._isOpen;
773
+ }
774
+
775
+ private _addListeners() {
776
+ this._docWidget.context.model.stateChanged.connect(
777
+ this._onStateChanged,
778
+ this
779
+ );
780
+ }
781
+
782
+ private _removeListeners() {
783
+ this._docWidget.context.model.stateChanged.disconnect(
784
+ this._onStateChanged,
785
+ this
786
+ );
787
+ }
788
+
789
+ private _onStateChanged(model: any, args: any) {
790
+ if (args.name === 'dirty' && args.newValue === false) {
791
+ this._onSave();
792
+ }
793
+ }
794
+
795
+ private async _onSave() {
796
+ const mcpConfig = this._docWidget.context.model.toJSON();
797
+ try {
798
+ await NBIAPI.setMCPConfigFile(mcpConfig);
799
+ } catch (reason: any) {
800
+ // Surface server-side validation rejections (400 from the
801
+ // shape validator, 500 from a downstream save / reconcile
802
+ // failure) to the user. Without this, the document model
803
+ // goes clean on save and the user has no signal that their
804
+ // edit did not actually persist. ServerConnection.ResponseError
805
+ // carries the handler's JSON `message` field on reason.message.
806
+ Notification.error(
807
+ `Failed to save MCP config: ${reason?.message ?? reason}`,
808
+ { autoClose: 5000 }
809
+ );
810
+ return;
811
+ }
812
+ await NBIAPI.fetchCapabilities();
813
+ }
814
+
815
+ private _docManager: IDocumentManager;
816
+ private _docWidget: IDocumentWidget = null;
817
+ private _tmpMCPConfigFilename = 'nbi.mcp.temp.json';
818
+ private _isOpen = false;
819
+ }
820
+
821
+ /**
822
+ * Initialization data for the @plmbr/notebook-intelligence extension.
823
+ */
824
+ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
825
+ id: '@plmbr/notebook-intelligence:plugin',
826
+ description: 'Notebook Intelligence',
827
+ autoStart: true,
828
+ requires: [
829
+ ICompletionProviderManager,
830
+ IDocumentManager,
831
+ IDefaultFileBrowser,
832
+ IEditorLanguageRegistry,
833
+ ICommandPalette,
834
+ IMainMenu
835
+ ],
836
+ // @jupyterlab/terminal nests its own @lumino/coreutils copy, so its
837
+ // Token class is structurally identical but nominally distinct from
838
+ // ours. Cast through the top-level Token type to keep the plugin
839
+ // declaration well-typed for the other optionals.
840
+ optional: [
841
+ IStatusBar,
842
+ ILauncher,
843
+ ITerminalTracker as unknown as Token<unknown>
844
+ ],
845
+ provides: INotebookIntelligence,
846
+ activate: async (
847
+ app: JupyterFrontEnd,
848
+ completionManager: ICompletionProviderManager,
849
+ docManager: IDocumentManager,
850
+ defaultBrowser: IDefaultFileBrowser,
851
+ languageRegistry: IEditorLanguageRegistry,
852
+ palette: ICommandPalette,
853
+ mainMenu: IMainMenu,
854
+ statusBar: IStatusBar | null,
855
+ launcher: ILauncher | null,
856
+ terminalTracker: ITerminalTracker | null
857
+ ) => {
858
+ console.log(
859
+ 'JupyterLab extension @plmbr/notebook-intelligence is activated!'
860
+ );
861
+
862
+ const telemetryEmitter = new TelemetryEmitter();
863
+
864
+ telemetryEmitter.registerTelemetryListener({
865
+ name: BACKEND_TELEMETRY_LISTENER_NAME,
866
+ onTelemetryEvent: event => {
867
+ NBIAPI.emitTelemetryEvent(event);
868
+ }
869
+ });
870
+
871
+ const extensionService: INotebookIntelligence = {
872
+ registerTelemetryListener: (listener: ITelemetryListener) => {
873
+ telemetryEmitter.registerTelemetryListener(listener);
874
+ },
875
+ unregisterTelemetryListener: (listener: ITelemetryListener) => {
876
+ telemetryEmitter.unregisterTelemetryListener(listener);
877
+ }
878
+ };
879
+
880
+ await NBIAPI.initialize();
881
+
882
+ if (terminalTracker) {
883
+ attachTerminalDragDrop({
884
+ tracker: terminalTracker,
885
+ // dragover fires at ~60Hz, so we read straight off the
886
+ // capabilities object (cheap property lookup) instead of going
887
+ // through the `featurePolicies` getter (which rebuilds an
888
+ // 11-key object every call). Re-evaluated per event so a future
889
+ // capabilities reload (policy flipped server-side) takes effect
890
+ // without tearing down listeners.
891
+ isEnabled: () => {
892
+ const policy =
893
+ NBIAPI.config.capabilities?.feature_policies?.terminal_drag_drop;
894
+ // Default-enabled when the key is absent: an older backend or
895
+ // a future capability schema bump shouldn't silently disable
896
+ // the feature.
897
+ if (!policy) {
898
+ return true;
899
+ }
900
+ return policy.enabled !== false;
901
+ }
902
+ });
903
+ }
904
+
905
+ let closeOpenPopover: (() => void) | null = null;
906
+ let mcpConfigEditor: MCPConfigEditor | null = null;
907
+
908
+ completionManager.registerInlineProvider(
909
+ new NBIInlineCompletionProvider(telemetryEmitter)
910
+ );
911
+
912
+ // JL plugins have no deactivate hook, so the watcher runs for the
913
+ // lifetime of the Lab session and the returned teardown is intentionally
914
+ // discarded (it exists for test ergonomics).
915
+ attachOpenFileRefreshWatcher({
916
+ env: buildRefreshWatcherEnv(app, app.serviceManager.contents),
917
+ isEnabled: () =>
918
+ NBIAPI.config.featurePolicies.refresh_open_files_on_disk_change.enabled
919
+ });
920
+
921
+ const waitForFileToBeActive = async (
922
+ filePath: string
923
+ ): Promise<boolean> => {
924
+ const isNotebook = filePath.endsWith('.ipynb');
925
+
926
+ return new Promise<boolean>((resolve, reject) => {
927
+ const checkIfActive = () => {
928
+ const activeFilePath =
929
+ ActiveDocumentWatcher.activeDocumentInfo.filePath;
930
+ const filePathToCheck = filePath;
931
+ const currentWidget = app.shell.currentWidget;
932
+ if (
933
+ activeFilePath === filePathToCheck &&
934
+ ((isNotebook &&
935
+ currentWidget instanceof NotebookPanel &&
936
+ currentWidget.content.activeCell &&
937
+ currentWidget.content.activeCell.node.contains(
938
+ document.activeElement
939
+ )) ||
940
+ (!isNotebook &&
941
+ currentWidget instanceof FileEditorWidget &&
942
+ currentWidget.content.editor.hasFocus()))
943
+ ) {
944
+ resolve(true);
945
+ } else {
946
+ setTimeout(checkIfActive, 200);
947
+ }
948
+ };
949
+ checkIfActive();
950
+
951
+ waitForDuration(10000).then(() => {
952
+ resolve(false);
953
+ });
954
+ });
955
+ };
956
+
957
+ const panel = new Panel();
958
+ panel.id = 'notebook-intelligence-tab';
959
+ panel.title.caption = 'Notebook Intelligence';
960
+ const sidebarIcon = new LabIcon({
961
+ name: 'notebook-intelligence:sidebar-icon',
962
+ svgstr: sparklesSvgstr
963
+ });
964
+ panel.title.icon = sidebarIcon;
965
+ const sidebar = new ChatSidebar({
966
+ getCurrentDirectory: (): string => {
967
+ return ActiveDocumentWatcher.currentDirectory;
968
+ },
969
+ getActiveDocumentInfo: (): IActiveDocumentInfo => {
970
+ return ActiveDocumentWatcher.activeDocumentInfo;
971
+ },
972
+ getActiveSelectionContent: (): string => {
973
+ return ActiveDocumentWatcher.getActiveSelectionContent();
974
+ },
975
+ getCurrentCellContents: (): ICellContents => {
976
+ return ActiveDocumentWatcher.getCurrentCellContents();
977
+ },
978
+ openFile: (path: string) => {
979
+ docManager.openOrReveal(path);
980
+ },
981
+ getApp(): JupyterFrontEnd {
982
+ return app;
983
+ },
984
+ getTelemetryEmitter(): ITelemetryEmitter {
985
+ return telemetryEmitter;
986
+ }
987
+ });
988
+ panel.addWidget(sidebar);
989
+ app.shell.add(panel, 'left', { rank: 1000 });
990
+ app.shell.activateById(panel.id);
991
+
992
+ // Global focus shortcut. Activates the NBI sidebar (revealing it if
993
+ // collapsed) and lands focus on the prompt textarea, so keyboard-
994
+ // first users can start typing without mousing through panel tabs.
995
+ // Ctrl/Cmd+Shift+L mirrors common "focus search / focus input"
996
+ // bindings used by other editors and doesn't collide with any
997
+ // built-in JupyterLab shortcut.
998
+ //
999
+ // Implementation: dispatch a CustomEvent the React sidebar listens
1000
+ // for. The sidebar owns `promptInputRef` and can focus the
1001
+ // textarea reliably regardless of whether the panel was collapsed
1002
+ // (the event fires after the React tree has been mounted by the
1003
+ // activate path), so we don't have to race the Lumino layout with
1004
+ // a DOM-id `querySelector`. Matches the existing
1005
+ // `copilotSidebar:*` event pattern used elsewhere in this file.
1006
+ app.commands.addCommand(CommandIDs.focusChatInput, {
1007
+ label: 'Focus Notebook Intelligence chat input',
1008
+ caption: 'Open the NBI sidebar and move focus to the prompt textarea',
1009
+ execute: () => {
1010
+ app.shell.activateById(panel.id);
1011
+ document.dispatchEvent(new CustomEvent('copilotSidebar:focusPrompt'));
1012
+ }
1013
+ });
1014
+ app.commands.addKeyBinding({
1015
+ command: CommandIDs.focusChatInput,
1016
+ keys: ['Accel Shift L'],
1017
+ selector: 'body'
1018
+ });
1019
+
1020
+ app.docRegistry.addWidgetExtension(
1021
+ 'Notebook',
1022
+ new NotebookGenerationToolbarExtension({
1023
+ app,
1024
+ icon: sparkleIcon,
1025
+ chatSidebarId: panel.id
1026
+ })
1027
+ );
1028
+
1029
+ const updateSidebarIcon = () => {
1030
+ if (NBIAPI.getChatEnabled()) {
1031
+ panel.title.icon = sidebarIcon;
1032
+ } else {
1033
+ panel.title.icon = sparkleWarningIcon;
1034
+ }
1035
+ };
1036
+
1037
+ NBIAPI.githubLoginStatusChanged.connect((_, args) => {
1038
+ updateSidebarIcon();
1039
+ });
1040
+
1041
+ NBIAPI.configChanged.connect((_, args) => {
1042
+ updateSidebarIcon();
1043
+ });
1044
+
1045
+ setTimeout(() => {
1046
+ updateSidebarIcon();
1047
+ }, 2000);
1048
+
1049
+ app.commands.addCommand(CommandIDs.chatuserInput, {
1050
+ execute: args => {
1051
+ NBIAPI.sendChatUserInput(args.id as string, args.data);
1052
+ }
1053
+ });
1054
+
1055
+ app.commands.addCommand(CommandIDs.insertAtCursor, {
1056
+ execute: args => {
1057
+ const currentWidget = app.shell.currentWidget;
1058
+ if (currentWidget instanceof NotebookPanel) {
1059
+ const activeCell = currentWidget.content.activeCell;
1060
+ if (activeCell) {
1061
+ applyCodeToSelectionInEditor(
1062
+ activeCell.editor,
1063
+ args.code as string
1064
+ );
1065
+ return;
1066
+ }
1067
+ } else if (currentWidget instanceof FileEditorWidget) {
1068
+ applyCodeToSelectionInEditor(
1069
+ currentWidget.content.editor,
1070
+ args.code as string
1071
+ );
1072
+ return;
1073
+ }
1074
+
1075
+ app.commands.execute('apputils:notify', {
1076
+ message:
1077
+ 'Failed to insert at cursor. Open a notebook or file to insert the code.',
1078
+ type: 'error',
1079
+ options: { autoClose: true }
1080
+ });
1081
+ }
1082
+ });
1083
+
1084
+ app.commands.addCommand(CommandIDs.addCodeAsNewCell, {
1085
+ execute: args => {
1086
+ const currentWidget = app.shell.currentWidget;
1087
+ if (currentWidget instanceof NotebookPanel) {
1088
+ let activeCellIndex = currentWidget.content.activeCellIndex;
1089
+ activeCellIndex =
1090
+ activeCellIndex === -1
1091
+ ? currentWidget.content.widgets.length
1092
+ : activeCellIndex + 1;
1093
+
1094
+ currentWidget.model?.sharedModel.insertCell(activeCellIndex, {
1095
+ cell_type: 'code',
1096
+ metadata: { trusted: true },
1097
+ source: args.code as string
1098
+ });
1099
+ currentWidget.content.activeCellIndex = activeCellIndex;
1100
+ } else {
1101
+ app.commands.execute('apputils:notify', {
1102
+ message: 'Open a notebook to insert the code as new cell',
1103
+ type: 'error',
1104
+ options: { autoClose: true }
1105
+ });
1106
+ }
1107
+ }
1108
+ });
1109
+
1110
+ app.commands.addCommand(CommandIDs.createNewFile, {
1111
+ execute: async args => {
1112
+ const contents = new ContentsManager();
1113
+ const newPyFile = await contents.newUntitled({
1114
+ ext: '.py',
1115
+ path: defaultBrowser?.model.path
1116
+ });
1117
+ contents.save(newPyFile.path, {
1118
+ content: extractLLMGeneratedCode(args.code as string),
1119
+ format: 'text',
1120
+ type: 'file'
1121
+ });
1122
+ docManager.openOrReveal(newPyFile.path);
1123
+
1124
+ await waitForFileToBeActive(newPyFile.path);
1125
+
1126
+ return newPyFile;
1127
+ }
1128
+ });
1129
+
1130
+ app.commands.addCommand(CommandIDs.showTour, {
1131
+ label: () =>
1132
+ commandLabel(
1133
+ NBIAPI.config.tourOverrides,
1134
+ TOUR_DEFAULTS.command?.label ?? 'Show NBI tour'
1135
+ ),
1136
+ caption:
1137
+ 'Replay the first-run tour highlighting the chat sidebar affordances',
1138
+ execute: async () => {
1139
+ // Make sure the sidebar is open before the tour fires; the
1140
+ // overlay anchors to elements that only exist when the
1141
+ // sidebar widget is mounted. Activate the left-rail panel
1142
+ // directly (the same id used in app.shell.add above).
1143
+ app.shell.activateById(panel.id);
1144
+ // Reset persistence so re-runs always fire.
1145
+ resetTour();
1146
+ // activateById is fire-and-forget: the panel can still be
1147
+ // mid-layout when this returns, and dispatching synchronously
1148
+ // would race the anchor measurement. Wait for the panel to
1149
+ // actually be visible before firing, then dispatch on a frame
1150
+ // boundary so the sidebar's React tree has committed.
1151
+ const fire = () => {
1152
+ requestAnimationFrame(() => dispatchShowTour());
1153
+ };
1154
+ if (panel.isVisible) {
1155
+ fire();
1156
+ } else {
1157
+ // Use a bounded rAF poll waiting for the panel to become
1158
+ // visible. activateById is asynchronous and we can't
1159
+ // synchronously observe completion from outside the widget.
1160
+ let attempts = 0;
1161
+ const tick = () => {
1162
+ attempts += 1;
1163
+ if (panel.isVisible || attempts >= 30) {
1164
+ fire();
1165
+ return;
1166
+ }
1167
+ requestAnimationFrame(tick);
1168
+ };
1169
+ requestAnimationFrame(tick);
1170
+ }
1171
+ }
1172
+ });
1173
+ palette.addItem({
1174
+ command: CommandIDs.showTour,
1175
+ category: 'Notebook Intelligence'
1176
+ });
1177
+ // The label thunk above reads from NBIAPI.config.tourOverrides,
1178
+ // which arrives asynchronously over the capabilities call. The
1179
+ // command palette caches labels until told otherwise, so tell it.
1180
+ NBIAPI.configChanged.connect(() => {
1181
+ app.commands.notifyCommandChanged(CommandIDs.showTour);
1182
+ });
1183
+
1184
+ app.commands.addCommand(CommandIDs.showFormInputDialog, {
1185
+ execute: async args => {
1186
+ const title = args.title as string;
1187
+ const fields = args.fields;
1188
+
1189
+ return new Promise<any>((resolve, reject) => {
1190
+ let dialog: Dialog<unknown> | null = null;
1191
+ const dialogBody = new FormInputDialogBody({
1192
+ fields: fields,
1193
+ onDone: (formData: any) => {
1194
+ dialog.dispose();
1195
+ resolve(formData);
1196
+ }
1197
+ });
1198
+ dialog = new Dialog({
1199
+ title: title,
1200
+ hasClose: true,
1201
+ body: dialogBody,
1202
+ buttons: []
1203
+ });
1204
+
1205
+ dialog
1206
+ .launch()
1207
+ .then((result: any) => {
1208
+ reject();
1209
+ })
1210
+ .catch(() => {
1211
+ reject(new Error('Failed to show form input dialog'));
1212
+ });
1213
+ });
1214
+ }
1215
+ });
1216
+
1217
+ app.commands.addCommand(CommandIDs.createNewNotebookFromPython, {
1218
+ execute: async args => {
1219
+ let pythonKernelSpec = null;
1220
+ const contents = new ContentsManager();
1221
+ const kernels = new KernelSpecManager();
1222
+ await kernels.ready;
1223
+ const kernelspecs = kernels.specs?.kernelspecs;
1224
+ if (kernelspecs) {
1225
+ for (const key in kernelspecs) {
1226
+ const kernelspec = kernelspecs[key];
1227
+ if (kernelspec?.language === 'python') {
1228
+ pythonKernelSpec = kernelspec;
1229
+ break;
1230
+ }
1231
+ }
1232
+ }
1233
+
1234
+ const newNBFile = await contents.newUntitled({
1235
+ ext: '.ipynb',
1236
+ path: defaultBrowser?.model.path
1237
+ });
1238
+ const nbFileContent = structuredClone(emptyNotebookContent);
1239
+ if (pythonKernelSpec) {
1240
+ nbFileContent.metadata = {
1241
+ kernelspec: {
1242
+ language: 'python',
1243
+ name: pythonKernelSpec.name,
1244
+ display_name: pythonKernelSpec.display_name
1245
+ }
1246
+ };
1247
+ }
1248
+
1249
+ if (args.code) {
1250
+ nbFileContent.cells.push({
1251
+ cell_type: 'code',
1252
+ metadata: { trusted: true },
1253
+ source: [args.code as string],
1254
+ outputs: []
1255
+ });
1256
+ }
1257
+
1258
+ contents.save(newNBFile.path, {
1259
+ content: nbFileContent,
1260
+ format: 'json',
1261
+ type: 'notebook'
1262
+ });
1263
+ docManager.openOrReveal(newNBFile.path);
1264
+
1265
+ await waitForFileToBeActive(newNBFile.path);
1266
+
1267
+ return newNBFile;
1268
+ }
1269
+ });
1270
+
1271
+ app.commands.addCommand(CommandIDs.renameNotebook, {
1272
+ execute: async args => {
1273
+ const activeWidget = app.shell.currentWidget;
1274
+ if (activeWidget instanceof NotebookPanel) {
1275
+ const oldPath = activeWidget.context.path;
1276
+ const oldParentPath = path.dirname(oldPath);
1277
+ let newPath = path.join(oldParentPath, args.newName as string);
1278
+ if (path.extname(newPath) !== '.ipynb') {
1279
+ newPath += '.ipynb';
1280
+ }
1281
+
1282
+ if (path.dirname(newPath) !== oldParentPath) {
1283
+ return 'Failed to rename notebook. New path is outside the old parent directory';
1284
+ }
1285
+
1286
+ try {
1287
+ await app.serviceManager.contents.rename(oldPath, newPath);
1288
+ return 'Successfully renamed notebook';
1289
+ } catch (error) {
1290
+ return `Failed to rename notebook: ${error}`;
1291
+ }
1292
+ } else {
1293
+ return 'Cannot rename non notebook files';
1294
+ }
1295
+ }
1296
+ });
1297
+
1298
+ app.commands.addCommand(CommandIDs.runCommandInTerminal, {
1299
+ execute: async args => {
1300
+ const command = args.command as string;
1301
+ const terminal = await app.commands.execute('terminal:create-new', {
1302
+ cwd: (args.cwd as string) || ActiveDocumentWatcher.currentDirectory
1303
+ });
1304
+
1305
+ const session: ITerminalConnection = terminal?.content?.session;
1306
+
1307
+ if (!session) {
1308
+ return 'Failed to execute command in Jupyter terminal';
1309
+ }
1310
+
1311
+ return new Promise<string>((resolve, reject) => {
1312
+ let lastMessageReceivedTime = Date.now();
1313
+ let lastMessageCheckInterval: NodeJS.Timeout | null = null;
1314
+ const messageCheckTimeout = 5000;
1315
+ const messageCheckInterval = 1000;
1316
+ let output = '';
1317
+ const messageReceivedHandler = (sender: any, message: any) => {
1318
+ const content = stripAnsi(message.content.join(''));
1319
+ output += content;
1320
+ lastMessageReceivedTime = Date.now();
1321
+ };
1322
+ session.messageReceived.connect(messageReceivedHandler);
1323
+
1324
+ session.send({
1325
+ type: 'stdin',
1326
+ content: [command + '\n'] // Add newline to execute the command
1327
+ });
1328
+
1329
+ // wait for the messageCheckInterval and if no message received, return the output.
1330
+ // otherwise wait for the next message.
1331
+ lastMessageCheckInterval = setInterval(() => {
1332
+ if (Date.now() - lastMessageReceivedTime > messageCheckTimeout) {
1333
+ clearInterval(lastMessageCheckInterval);
1334
+ session.messageReceived.disconnect(messageReceivedHandler);
1335
+ resolve(
1336
+ `Command executed in Jupyter terminal, output: ${output}`
1337
+ );
1338
+ }
1339
+ }, messageCheckInterval);
1340
+ });
1341
+ }
1342
+ });
1343
+
1344
+ // Claude Code launcher tile: shows a session picker backed by history.jsonl
1345
+ // (all projects), then opens a terminal at the session's project directory.
1346
+
1347
+ // Waits for bash's first prompt before sending, avoiding the race condition
1348
+ // where the command is sent before the shell has started.
1349
+ const launchCliInTerminal = async (
1350
+ command: string,
1351
+ cwd?: string
1352
+ ): Promise<void> => {
1353
+ const mgr = app.serviceManager.terminals;
1354
+ const before = new Set([...mgr.running()].map((s: any) => s.name));
1355
+ try {
1356
+ await app.commands.execute('terminal:create-new', cwd ? { cwd } : {});
1357
+ } catch {
1358
+ return;
1359
+ }
1360
+ const newModel = [...mgr.running()].find((s: any) => !before.has(s.name));
1361
+ if (!newModel) {
1362
+ return;
1363
+ }
1364
+ const conn: any = mgr.connectTo({ model: newModel });
1365
+ let sent = false;
1366
+ const sendCommand = () => {
1367
+ if (sent) {
1368
+ return;
1369
+ }
1370
+ sent = true;
1371
+ conn.messageReceived.disconnect(handler);
1372
+ conn.send({ type: 'stdin', content: [command + '\r'] });
1373
+ };
1374
+ const handler = (_: any, msg: any) => {
1375
+ if (msg.type === 'stdout') {
1376
+ sendCommand();
1377
+ }
1378
+ };
1379
+ conn.messageReceived.connect(handler);
1380
+ setTimeout(sendCommand, 3000);
1381
+ };
1382
+
1383
+ app.commands.addCommand(CommandIDs.openClaudeCodeLauncher, {
1384
+ label: 'Claude Code',
1385
+ caption: 'Resume or start a Claude Code session',
1386
+ icon: claudeIcon,
1387
+ // The launcher tile opens a Jupyter terminal that runs the
1388
+ // `claude` CLI directly — it doesn't depend on NBI being in
1389
+ // Claude Code chat mode (which gates the chat-sidebar SDK
1390
+ // backend). CLI presence on PATH is the only real prerequisite
1391
+ // (issue #230). Honor the admin policy on the same gate as the
1392
+ // four CLI-launcher tiles so an admin force-off blocks the
1393
+ // command palette too, not just the launcher tile.
1394
+ isVisible: () =>
1395
+ NBIAPI.config.isClaudeCliAvailable &&
1396
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy('claude-code'),
1397
+ execute: async () => {
1398
+ class PickerWidget extends ReactWidget {
1399
+ getValue(): void {
1400
+ return;
1401
+ }
1402
+ render() {
1403
+ return React.createElement(LauncherPicker, {
1404
+ onSessionSelected: (session: IClaudeSessionInfo) => {
1405
+ dialog.close();
1406
+ const cmd = session.cwd
1407
+ ? `cd ${session.cwd} && claude --resume ${session.session_id}`
1408
+ : `claude --resume ${session.session_id}`;
1409
+ launchCliInTerminal(cmd);
1410
+ }
1411
+ });
1412
+ }
1413
+ }
1414
+
1415
+ const picker = new PickerWidget();
1416
+ picker.addClass('nbi-claude-code-picker');
1417
+ const dialog = new Dialog({
1418
+ title: 'Claude Code terminal session',
1419
+ body: picker,
1420
+ buttons: [
1421
+ Dialog.cancelButton({ label: 'Cancel' }),
1422
+ Dialog.okButton({ label: '+ New Session' })
1423
+ ],
1424
+ hasClose: true
1425
+ });
1426
+ const result = await dialog.launch();
1427
+ if (result.button.accept) {
1428
+ // New Session: let the user choose a start directory (defaulting
1429
+ // to the file browser's current path). If they cancel the folder
1430
+ // picker, abort rather than silently opening the terminal in an
1431
+ // unexpected location.
1432
+ const cwd = await chooseWorkspaceDirectory(
1433
+ docManager,
1434
+ 'Choose start directory for Claude Code',
1435
+ defaultBrowser?.model.path
1436
+ );
1437
+ if (cwd === undefined) {
1438
+ return;
1439
+ }
1440
+ launchCliInTerminal('claude', cwd);
1441
+ }
1442
+ }
1443
+ });
1444
+
1445
+ // Add or dispose a launcher entry based on a live availability check.
1446
+ // The launcher renders every item in its model regardless of the
1447
+ // backing command's `isVisible`, so gating tile visibility requires
1448
+ // adding only when available and disposing when not. Re-evaluates on
1449
+ // every NBIAPI.configChanged so a late capabilities load (or a
1450
+ // future hot-reload of the CLI on PATH) takes effect without a
1451
+ // browser refresh.
1452
+ const syncLauncherEntry = (
1453
+ commandId: string,
1454
+ itemOptions: Omit<ILauncher.IItemOptions, 'command'>,
1455
+ isAvailable: () => boolean
1456
+ ) => {
1457
+ if (!launcher) {
1458
+ return;
1459
+ }
1460
+ let entry: IDisposable | null = null;
1461
+ const sync = () => {
1462
+ const available = isAvailable();
1463
+ if (available && !entry) {
1464
+ entry = launcher.add({ command: commandId, ...itemOptions });
1465
+ } else if (!available && entry) {
1466
+ entry.dispose();
1467
+ entry = null;
1468
+ }
1469
+ };
1470
+ sync();
1471
+ NBIAPI.configChanged.connect(sync);
1472
+ };
1473
+
1474
+ syncLauncherEntry(
1475
+ CommandIDs.openClaudeCodeLauncher,
1476
+ { category: 'Coding Agent', rank: -1 },
1477
+ () =>
1478
+ NBIAPI.config.isClaudeCliAvailable &&
1479
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy('claude-code')
1480
+ );
1481
+
1482
+ // Additional coding-agent CLIs (issue #260). First-phase scope: detect
1483
+ // the binary on PATH (backend exposes `<agent>_cli_available`), show a
1484
+ // tile when present, click opens a terminal in the file-browser's
1485
+ // current directory and types the CLI command. No session picker.
1486
+ const registerAgentCliLauncher = (config: {
1487
+ commandId: string;
1488
+ label: string;
1489
+ caption: string;
1490
+ icon: LabIcon;
1491
+ cliCommand: string;
1492
+ isAvailable: () => boolean;
1493
+ }) => {
1494
+ app.commands.addCommand(config.commandId, {
1495
+ label: config.label,
1496
+ caption: config.caption,
1497
+ icon: config.icon,
1498
+ isVisible: () => config.isAvailable(),
1499
+ execute: async () => {
1500
+ const cwd = await chooseWorkspaceDirectory(
1501
+ docManager,
1502
+ `Choose start directory for ${config.label}`,
1503
+ defaultBrowser?.model.path
1504
+ );
1505
+ if (cwd === undefined) {
1506
+ return;
1507
+ }
1508
+ launchCliInTerminal(config.cliCommand, cwd);
1509
+ }
1510
+ });
1511
+ syncLauncherEntry(
1512
+ config.commandId,
1513
+ { category: 'Coding Agent' },
1514
+ config.isAvailable
1515
+ );
1516
+ NBIAPI.configChanged.connect(() => {
1517
+ app.commands.notifyCommandChanged(config.commandId);
1518
+ });
1519
+ };
1520
+
1521
+ registerAgentCliLauncher({
1522
+ commandId: CommandIDs.openOpenCodeLauncher,
1523
+ label: 'opencode',
1524
+ caption: 'Start an opencode session in a Jupyter terminal',
1525
+ icon: opencodeIcon,
1526
+ cliCommand: 'opencode',
1527
+ isAvailable: () =>
1528
+ NBIAPI.config.isOpenCodeCliAvailable &&
1529
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy('opencode')
1530
+ });
1531
+
1532
+ registerAgentCliLauncher({
1533
+ commandId: CommandIDs.openPiLauncher,
1534
+ label: 'Pi',
1535
+ caption: 'Start a Pi session in a Jupyter terminal',
1536
+ icon: terminalIcon,
1537
+ cliCommand: 'pi',
1538
+ isAvailable: () =>
1539
+ NBIAPI.config.isPiCliAvailable &&
1540
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy('pi')
1541
+ });
1542
+
1543
+ registerAgentCliLauncher({
1544
+ commandId: CommandIDs.openGitHubCopilotCliLauncher,
1545
+ label: 'GitHub Copilot CLI',
1546
+ caption: 'Start a GitHub Copilot CLI session in a Jupyter terminal',
1547
+ icon: githubCopilotIcon,
1548
+ cliCommand: 'copilot',
1549
+ isAvailable: () =>
1550
+ NBIAPI.config.isGitHubCopilotCliAvailable &&
1551
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy(
1552
+ 'github-copilot-cli'
1553
+ )
1554
+ });
1555
+
1556
+ registerAgentCliLauncher({
1557
+ commandId: CommandIDs.openCodexLauncher,
1558
+ label: 'Codex',
1559
+ caption: 'Start an OpenAI Codex CLI session in a Jupyter terminal',
1560
+ icon: openaiIcon,
1561
+ cliCommand: 'codex',
1562
+ isAvailable: () =>
1563
+ NBIAPI.config.isCodexCliAvailable &&
1564
+ !NBIAPI.config.isCodingAgentLauncherDisabledByPolicy('codex')
1565
+ });
1566
+
1567
+ // Refresh the Claude Code command's palette-visibility state when the
1568
+ // user installs/uninstalls the CLI. The launcher tile is already gated
1569
+ // via syncLauncherEntry; this is for the command palette only.
1570
+ NBIAPI.configChanged.connect(() => {
1571
+ app.commands.notifyCommandChanged(CommandIDs.openClaudeCodeLauncher);
1572
+ });
1573
+
1574
+ const isNewEmptyNotebook = (model: ISharedNotebook) => {
1575
+ return (
1576
+ model.cells.length === 1 &&
1577
+ model.cells[0].cell_type === 'code' &&
1578
+ model.cells[0].source === ''
1579
+ );
1580
+ };
1581
+
1582
+ const githubLoginRequired = () => {
1583
+ return (
1584
+ NBIAPI.config.usingGitHubCopilotModel &&
1585
+ NBIAPI.getLoginStatus() === GitHubCopilotLoginStatus.NotLoggedIn
1586
+ );
1587
+ };
1588
+
1589
+ const isChatEnabled = (): boolean => {
1590
+ return (
1591
+ NBIAPI.config.isInClaudeCodeMode ||
1592
+ (NBIAPI.config.chatModel.provider === GITHUB_COPILOT_PROVIDER_ID
1593
+ ? !githubLoginRequired()
1594
+ : NBIAPI.config.chatModel.provider !== 'none')
1595
+ );
1596
+ };
1597
+
1598
+ const isActiveCellCodeCell = (): boolean => {
1599
+ if (!(app.shell.currentWidget instanceof NotebookPanel)) {
1600
+ return false;
1601
+ }
1602
+ const np = app.shell.currentWidget as NotebookPanel;
1603
+ const activeCell = np.content.activeCell;
1604
+ return activeCell instanceof CodeCell;
1605
+ };
1606
+
1607
+ const isCurrentWidgetFileEditor = (): boolean => {
1608
+ return app.shell.currentWidget instanceof FileEditorWidget;
1609
+ };
1610
+
1611
+ const addCellToNotebook = (
1612
+ filePath: string,
1613
+ cellType: 'code' | 'markdown',
1614
+ source: string
1615
+ ): boolean => {
1616
+ const widget = docManager.findWidget(filePath);
1617
+ const notebook =
1618
+ widget instanceof NotebookPanel && widget.model ? widget : null;
1619
+ if (!notebook) {
1620
+ app.commands.execute('apputils:notify', {
1621
+ message: `Failed to access the notebook: ${filePath}`,
1622
+ type: 'error',
1623
+ options: { autoClose: true }
1624
+ });
1625
+ return false;
1626
+ }
1627
+
1628
+ const model = notebook.model.sharedModel;
1629
+
1630
+ const newCellIndex = isNewEmptyNotebook(model)
1631
+ ? 0
1632
+ : model.cells.length - 1;
1633
+ model.insertCell(newCellIndex, {
1634
+ cell_type: cellType,
1635
+ metadata: { trusted: true },
1636
+ source
1637
+ });
1638
+
1639
+ return true;
1640
+ };
1641
+
1642
+ app.commands.addCommand(CommandIDs.addCodeCellToNotebook, {
1643
+ execute: args => {
1644
+ return addCellToNotebook(
1645
+ args.path as string,
1646
+ 'code',
1647
+ args.code as string
1648
+ );
1649
+ }
1650
+ });
1651
+
1652
+ app.commands.addCommand(CommandIDs.addMarkdownCellToNotebook, {
1653
+ execute: args => {
1654
+ return addCellToNotebook(
1655
+ args.path as string,
1656
+ 'markdown',
1657
+ args.markdown as string
1658
+ );
1659
+ }
1660
+ });
1661
+
1662
+ // Resolve the notebook a cell-targeting command should operate on.
1663
+ //
1664
+ // When the chat sidebar dispatches a RunUICommand on behalf of the
1665
+ // agent it injects `notebookPath` — the notebook that was active at
1666
+ // chat-request time. We look that one up via the doc manager so the
1667
+ // command keeps targeting the right notebook even after the user
1668
+ // switches tabs mid-task. If a `notebookPath` was supplied but no
1669
+ // longer resolves (target tab was closed, or the notebook was
1670
+ // renamed), we *do not* silently fall through to `currentWidget` —
1671
+ // that would let the agent mutate a different notebook than it
1672
+ // believed it was operating on. Surface the error and bail.
1673
+ //
1674
+ // For manually-invoked commands (palette, menu, toolbar) there's no
1675
+ // `notebookPath`; the current widget is the intended target and the
1676
+ // fallback applies.
1677
+ const resolveTargetNotebook = (
1678
+ args: { notebookPath?: string } | null | undefined
1679
+ ): NotebookPanel | null => {
1680
+ const requestedPath = args?.notebookPath;
1681
+ if (requestedPath) {
1682
+ const widget = docManager.findWidget(requestedPath);
1683
+ if (widget instanceof NotebookPanel && widget.model) {
1684
+ return widget;
1685
+ }
1686
+ app.commands.execute('apputils:notify', {
1687
+ message: `Failed to find notebook: ${requestedPath}`,
1688
+ type: 'error',
1689
+ options: { autoClose: true }
1690
+ });
1691
+ return null;
1692
+ }
1693
+ const currentWidget = app.shell.currentWidget;
1694
+ if (currentWidget instanceof NotebookPanel && currentWidget.model) {
1695
+ return currentWidget;
1696
+ }
1697
+ app.commands.execute('apputils:notify', {
1698
+ message: 'Failed to find active notebook',
1699
+ type: 'error',
1700
+ options: { autoClose: true }
1701
+ });
1702
+ return null;
1703
+ };
1704
+
1705
+ const ensureAFileEditorIsActive = (): boolean => {
1706
+ const currentWidget = app.shell.currentWidget;
1707
+ const textFileOpen = currentWidget instanceof FileEditorWidget;
1708
+ if (!textFileOpen) {
1709
+ app.commands.execute('apputils:notify', {
1710
+ message: 'Failed to find active file',
1711
+ type: 'error',
1712
+ options: { autoClose: true }
1713
+ });
1714
+ return false;
1715
+ }
1716
+
1717
+ return true;
1718
+ };
1719
+
1720
+ app.commands.addCommand(CommandIDs.addMarkdownCellToActiveNotebook, {
1721
+ execute: args => {
1722
+ const np = resolveTargetNotebook(args);
1723
+ if (!np) {
1724
+ return false;
1725
+ }
1726
+ const model = np.model.sharedModel;
1727
+
1728
+ const newCellIndex = isNewEmptyNotebook(model)
1729
+ ? 0
1730
+ : model.cells.length - 1;
1731
+ model.insertCell(newCellIndex, {
1732
+ cell_type: 'markdown',
1733
+ metadata: { trusted: true },
1734
+ source: args.source as string
1735
+ });
1736
+
1737
+ return true;
1738
+ }
1739
+ });
1740
+
1741
+ app.commands.addCommand(CommandIDs.addCodeCellToActiveNotebook, {
1742
+ execute: args => {
1743
+ const np = resolveTargetNotebook(args);
1744
+ if (!np) {
1745
+ return false;
1746
+ }
1747
+ const model = np.model.sharedModel;
1748
+
1749
+ const newCellIndex = isNewEmptyNotebook(model)
1750
+ ? 0
1751
+ : model.cells.length - 1;
1752
+ model.insertCell(newCellIndex, {
1753
+ cell_type: 'code',
1754
+ metadata: { trusted: true },
1755
+ source: args.source as string
1756
+ });
1757
+
1758
+ return true;
1759
+ }
1760
+ });
1761
+
1762
+ app.commands.addCommand(CommandIDs.getCellTypeAndSource, {
1763
+ execute: args => {
1764
+ const np = resolveTargetNotebook(args);
1765
+ if (!np) {
1766
+ return false;
1767
+ }
1768
+ const model = np.model.sharedModel;
1769
+
1770
+ return {
1771
+ type: model.cells[args.cellIndex as number].cell_type,
1772
+ source: model.cells[args.cellIndex as number].source
1773
+ };
1774
+ }
1775
+ });
1776
+
1777
+ app.commands.addCommand(CommandIDs.setCellTypeAndSource, {
1778
+ execute: args => {
1779
+ const np = resolveTargetNotebook(args);
1780
+ if (!np) {
1781
+ return false;
1782
+ }
1783
+ const model = np.model.sharedModel;
1784
+
1785
+ const cellIndex = args.cellIndex as number;
1786
+ const cellType = args.cellType as 'code' | 'markdown';
1787
+ const cell = model.getCell(cellIndex);
1788
+
1789
+ model.deleteCell(cellIndex);
1790
+ model.insertCell(cellIndex, {
1791
+ cell_type: cellType,
1792
+ metadata: cell.metadata,
1793
+ source: args.source as string
1794
+ });
1795
+
1796
+ return true;
1797
+ }
1798
+ });
1799
+
1800
+ app.commands.addCommand(CommandIDs.getNumberOfCells, {
1801
+ execute: args => {
1802
+ const np = resolveTargetNotebook(args);
1803
+ if (!np) {
1804
+ return false;
1805
+ }
1806
+ const model = np.model.sharedModel;
1807
+
1808
+ return model.cells.length;
1809
+ }
1810
+ });
1811
+
1812
+ app.commands.addCommand(CommandIDs.getCellOutput, {
1813
+ execute: args => {
1814
+ const np = resolveTargetNotebook(args);
1815
+ if (!np) {
1816
+ return false;
1817
+ }
1818
+ const cellIndex = args.cellIndex as number;
1819
+
1820
+ const cell = np.content.widgets[cellIndex];
1821
+
1822
+ if (!(cell instanceof CodeCell)) {
1823
+ return '';
1824
+ }
1825
+
1826
+ const content = cellOutputAsText(cell as CodeCell);
1827
+
1828
+ return content;
1829
+ }
1830
+ });
1831
+
1832
+ app.commands.addCommand(CommandIDs.insertCellAtIndex, {
1833
+ execute: args => {
1834
+ const np = resolveTargetNotebook(args);
1835
+ if (!np) {
1836
+ return false;
1837
+ }
1838
+ const model = np.model.sharedModel;
1839
+ const cellIndex = args.cellIndex as number;
1840
+ const cellType = args.cellType as 'code' | 'markdown';
1841
+
1842
+ model.insertCell(cellIndex, {
1843
+ cell_type: cellType,
1844
+ metadata: { trusted: true },
1845
+ source: args.source as string
1846
+ });
1847
+
1848
+ return true;
1849
+ }
1850
+ });
1851
+
1852
+ app.commands.addCommand(CommandIDs.deleteCellAtIndex, {
1853
+ execute: args => {
1854
+ const np = resolveTargetNotebook(args);
1855
+ if (!np) {
1856
+ return false;
1857
+ }
1858
+ const model = np.model.sharedModel;
1859
+ const cellIndex = args.cellIndex as number;
1860
+
1861
+ model.deleteCell(cellIndex);
1862
+
1863
+ return true;
1864
+ }
1865
+ });
1866
+
1867
+ app.commands.addCommand(CommandIDs.runCellAtIndex, {
1868
+ execute: async args => {
1869
+ const np = resolveTargetNotebook(args);
1870
+ if (!np) {
1871
+ return false;
1872
+ }
1873
+ np.content.activeCellIndex = args.cellIndex as number;
1874
+
1875
+ // Drive the cell run via NotebookActions directly rather than the
1876
+ // app-level `notebook:run-cell` command. The command operates on the
1877
+ // currently-focused notebook; calling NotebookActions.run with our
1878
+ // resolved target avoids stealing focus from whichever tab the user
1879
+ // is on while the agent is running.
1880
+ await NotebookActions.run(np.content, np.sessionContext);
1881
+ }
1882
+ });
1883
+
1884
+ app.commands.addCommand(CommandIDs.getCurrentFileContent, {
1885
+ execute: async args => {
1886
+ if (!ensureAFileEditorIsActive()) {
1887
+ return false;
1888
+ }
1889
+
1890
+ const currentWidget = app.shell.currentWidget as FileEditorWidget;
1891
+ const editor = currentWidget.content.editor;
1892
+ return editor.model.sharedModel.getSource();
1893
+ }
1894
+ });
1895
+
1896
+ app.commands.addCommand(CommandIDs.setCurrentFileContent, {
1897
+ execute: async args => {
1898
+ if (!ensureAFileEditorIsActive()) {
1899
+ return false;
1900
+ }
1901
+
1902
+ const currentWidget = app.shell.currentWidget as FileEditorWidget;
1903
+ const editor = currentWidget.content.editor;
1904
+ editor.model.sharedModel.setSource(args.content as string);
1905
+ return editor.model.sharedModel.getSource();
1906
+ }
1907
+ });
1908
+
1909
+ app.commands.addCommand(CommandIDs.openGitHubCopilotLoginDialog, {
1910
+ execute: args => {
1911
+ let dialog: Dialog<unknown> | null = null;
1912
+ const dialogBody = new GitHubCopilotLoginDialogBody({
1913
+ onLoggedIn: () => dialog?.dispose()
1914
+ });
1915
+ dialog = new Dialog({
1916
+ title: 'GitHub Copilot Status',
1917
+ hasClose: true,
1918
+ body: dialogBody,
1919
+ buttons: []
1920
+ });
1921
+
1922
+ dialog.launch();
1923
+ }
1924
+ });
1925
+
1926
+ const createNewSettingsWidget = () => {
1927
+ const settingsPanel = new SettingsPanel({
1928
+ onSave: () => {
1929
+ NBIAPI.fetchCapabilities();
1930
+ },
1931
+ onEditMCPConfigClicked: () => {
1932
+ app.commands.execute('notebook-intelligence:open-mcp-config-editor');
1933
+ }
1934
+ });
1935
+
1936
+ const widget = new MainAreaWidget({ content: settingsPanel });
1937
+ widget.id = 'nbi-settings';
1938
+ widget.title.label = 'NBI Settings';
1939
+ widget.title.closable = true;
1940
+
1941
+ return widget;
1942
+ };
1943
+
1944
+ let settingsWidget = createNewSettingsWidget();
1945
+
1946
+ app.commands.addCommand(CommandIDs.openConfigurationDialog, {
1947
+ label: 'Notebook Intelligence Settings',
1948
+ execute: args => {
1949
+ if (settingsWidget.isDisposed) {
1950
+ settingsWidget = createNewSettingsWidget();
1951
+ }
1952
+ if (!settingsWidget.isAttached) {
1953
+ app.shell.add(settingsWidget, 'main');
1954
+ }
1955
+ app.shell.activateById(settingsWidget.id);
1956
+ }
1957
+ });
1958
+
1959
+ app.commands.addCommand(CommandIDs.openMCPConfigEditor, {
1960
+ label: 'Open MCP Config Editor',
1961
+ execute: args => {
1962
+ if (mcpConfigEditor && mcpConfigEditor.isOpen) {
1963
+ mcpConfigEditor.close();
1964
+ }
1965
+ mcpConfigEditor = new MCPConfigEditor(docManager);
1966
+ mcpConfigEditor.open();
1967
+ }
1968
+ });
1969
+
1970
+ palette.addItem({
1971
+ command: CommandIDs.openConfigurationDialog,
1972
+ category: 'Notebook Intelligence'
1973
+ });
1974
+ palette.addItem({
1975
+ command: CommandIDs.focusChatInput,
1976
+ category: 'Notebook Intelligence'
1977
+ });
1978
+
1979
+ mainMenu.settingsMenu.addGroup([
1980
+ {
1981
+ command: CommandIDs.openConfigurationDialog
1982
+ }
1983
+ ]);
1984
+
1985
+ const getPrefixAndSuffixForActiveCell = (): {
1986
+ prefix: string;
1987
+ suffix: string;
1988
+ } => {
1989
+ let prefix = '';
1990
+ let suffix = '';
1991
+ const currentWidget = app.shell.currentWidget;
1992
+ if (
1993
+ !(
1994
+ currentWidget instanceof NotebookPanel &&
1995
+ currentWidget.content.activeCell
1996
+ )
1997
+ ) {
1998
+ return { prefix, suffix };
1999
+ }
2000
+
2001
+ const activeCellIndex = currentWidget.content.activeCellIndex;
2002
+ const numCells = currentWidget.content.widgets.length;
2003
+ const maxContext = 0.7 * MAX_TOKENS;
2004
+
2005
+ for (let d = 1; d < numCells; ++d) {
2006
+ const above = activeCellIndex - d;
2007
+ const below = activeCellIndex + d;
2008
+ if (
2009
+ (above < 0 && below >= numCells) ||
2010
+ getTokenCount(`${prefix} ${suffix}`) >= maxContext
2011
+ ) {
2012
+ break;
2013
+ }
2014
+
2015
+ if (above >= 0) {
2016
+ const aboveCell = currentWidget.content.widgets[above];
2017
+ const cellModel = aboveCell.model.sharedModel;
2018
+
2019
+ if (cellModel.cell_type === 'code') {
2020
+ prefix = cellModel.source + '\n' + prefix;
2021
+ } else if (cellModel.cell_type === 'markdown') {
2022
+ prefix = markdownToComment(cellModel.source) + '\n' + prefix;
2023
+ }
2024
+ }
2025
+
2026
+ if (below < numCells) {
2027
+ const belowCell = currentWidget.content.widgets[below];
2028
+ const cellModel = belowCell.model.sharedModel;
2029
+
2030
+ if (cellModel.cell_type === 'code') {
2031
+ suffix += cellModel.source + '\n';
2032
+ } else if (cellModel.cell_type === 'markdown') {
2033
+ suffix += markdownToComment(cellModel.source) + '\n';
2034
+ }
2035
+ }
2036
+ }
2037
+
2038
+ return { prefix, suffix };
2039
+ };
2040
+
2041
+ const getPrefixAndSuffixForFileEditor = (): {
2042
+ prefix: string;
2043
+ suffix: string;
2044
+ } => {
2045
+ let prefix = '';
2046
+ let suffix = '';
2047
+ const currentWidget = app.shell.currentWidget;
2048
+ if (!(currentWidget instanceof FileEditorWidget)) {
2049
+ return { prefix, suffix };
2050
+ }
2051
+
2052
+ const fe = currentWidget as FileEditorWidget;
2053
+
2054
+ const cursor = fe.content.editor.getCursorPosition();
2055
+ const offset = fe.content.editor.getOffsetAt(cursor);
2056
+ const source = fe.content.editor.model.sharedModel.getSource();
2057
+ prefix = source.substring(0, offset);
2058
+ suffix = source.substring(offset);
2059
+
2060
+ return { prefix, suffix };
2061
+ };
2062
+
2063
+ const generateCodeForCellOrFileEditor = () => {
2064
+ const isCodeCell = isActiveCellCodeCell();
2065
+ const currentWidget = app.shell.currentWidget;
2066
+ let editor: CodeEditor.IEditor;
2067
+ let codeInput: HTMLElement | null = null;
2068
+ if (isCodeCell) {
2069
+ const np = currentWidget as NotebookPanel;
2070
+ const activeCell = np.content.activeCell;
2071
+ codeInput = activeCell.node.querySelector('.jp-InputArea-editor');
2072
+ if (!codeInput) {
2073
+ return;
2074
+ }
2075
+ editor = activeCell.editor;
2076
+ } else {
2077
+ const fe = currentWidget as FileEditorWidget;
2078
+ editor = fe.content.editor;
2079
+ }
2080
+
2081
+ const editorView = getCodeMirrorView(editor);
2082
+ if (!editorView) {
2083
+ return;
2084
+ }
2085
+
2086
+ let blockPromptView: EditorView | null = null;
2087
+ let removed = false;
2088
+ const removePopover = () => {
2089
+ // Cleared outside the `removed` guard so the auto-insert path's
2090
+ // second call still removes the class added between calls.
2091
+ if (isCodeCell) {
2092
+ codeInput?.classList.remove('generating');
2093
+ }
2094
+
2095
+ if (removed) {
2096
+ return;
2097
+ }
2098
+ removed = true;
2099
+ closeOpenPopover = null;
2100
+
2101
+ if (blockPromptView) {
2102
+ blockPromptView.dispatch({
2103
+ effects: removeInlinePromptEffect.of()
2104
+ });
2105
+ blockPromptView = null;
2106
+ }
2107
+ };
2108
+
2109
+ let userPrompt = '';
2110
+ let existingCode = '';
2111
+ let generatedContent = '';
2112
+
2113
+ let prefix = '',
2114
+ suffix = '';
2115
+ if (isCodeCell) {
2116
+ const ps = getPrefixAndSuffixForActiveCell();
2117
+ prefix = ps.prefix;
2118
+ suffix = ps.suffix;
2119
+ } else {
2120
+ const ps = getPrefixAndSuffixForFileEditor();
2121
+ prefix = ps.prefix;
2122
+ suffix = ps.suffix;
2123
+ }
2124
+ const selection = editor.getSelection();
2125
+
2126
+ const startOffset = editor.getOffsetAt(selection.start);
2127
+ const endOffset = editor.getOffsetAt(selection.end);
2128
+ const source = editor.model.sharedModel.getSource();
2129
+
2130
+ if (isCodeCell) {
2131
+ prefix += '\n' + source.substring(0, startOffset);
2132
+ existingCode = source.substring(startOffset, endOffset);
2133
+ suffix = source.substring(endOffset) + '\n' + suffix;
2134
+ } else {
2135
+ existingCode = source.substring(startOffset, endOffset);
2136
+ }
2137
+
2138
+ const applyGeneratedCode = () => {
2139
+ generatedContent = extractLLMGeneratedCode(generatedContent);
2140
+ // extractLLMGeneratedCode preserves the newline that sits before
2141
+ // the closing ``` in fenced LLM output. If the user's selection
2142
+ // didn't already end with a newline (or there's no selection at
2143
+ // all in the auto-insert path), inserting that trailing \n leaves
2144
+ // an extra blank line below the generated code. Strip one
2145
+ // trailing newline in those cases so the result matches the
2146
+ // selection's original line-break state.
2147
+ if (!existingCode.endsWith('\n') && generatedContent.endsWith('\n')) {
2148
+ generatedContent = generatedContent.slice(0, -1);
2149
+ }
2150
+ applyCodeToSelectionInEditor(editor, generatedContent);
2151
+ generatedContent = '';
2152
+ removePopover();
2153
+ };
2154
+
2155
+ closeOpenPopover?.();
2156
+
2157
+ const promptOptions: IInlinePromptWidgetOptions = {
2158
+ prompt: userPrompt,
2159
+ existingCode,
2160
+ prefix: prefix,
2161
+ suffix: suffix,
2162
+ language: ActiveDocumentWatcher.activeDocumentInfo.language,
2163
+ filename: ActiveDocumentWatcher.activeDocumentInfo.filePath,
2164
+ onRequestSubmitted: (prompt: string) => {
2165
+ userPrompt = prompt;
2166
+ generatedContent = '';
2167
+ if (existingCode !== '') {
2168
+ return;
2169
+ }
2170
+ removePopover();
2171
+ if (isCodeCell) {
2172
+ codeInput?.classList.add('generating');
2173
+ }
2174
+ },
2175
+ onRequestCancelled: () => {
2176
+ removePopover();
2177
+ editor.focus();
2178
+ },
2179
+ onContentStream: (content: string) => {
2180
+ if (existingCode !== '') {
2181
+ return;
2182
+ }
2183
+ generatedContent += content;
2184
+ },
2185
+ onContentStreamEnd: (streamError?: string | null) => {
2186
+ if (existingCode !== '') {
2187
+ return;
2188
+ }
2189
+ if (streamError) {
2190
+ // The backend tagged this stream as interrupted. Discard the
2191
+ // partial buffer rather than auto-inserting truncated code (or
2192
+ // worse, the [Stream interrupted] marker text itself) into the
2193
+ // user's cell, and surface the failure as a toast.
2194
+ generatedContent = '';
2195
+ removePopover();
2196
+ app.commands.execute('apputils:notify', {
2197
+ message: `Inline chat failed: ${streamError}`,
2198
+ type: 'error',
2199
+ options: { autoClose: true }
2200
+ });
2201
+ editor.focus();
2202
+ return;
2203
+ }
2204
+ applyGeneratedCode();
2205
+ editor.focus();
2206
+ },
2207
+ onUpdatedCodeChange: (content: string) => {
2208
+ generatedContent = content;
2209
+ },
2210
+ onUpdatedCodeAccepted: () => {
2211
+ applyGeneratedCode();
2212
+ editor.focus();
2213
+ },
2214
+ telemetryEmitter: telemetryEmitter
2215
+ };
2216
+
2217
+ let requestTime: Date | null = null;
2218
+ let streamError: string | null = null;
2219
+ let blockPromptNode: HTMLElement | null = null;
2220
+ const onRequestSubmitted = (prompt: string) => {
2221
+ if (blockPromptNode && existingCode !== '') {
2222
+ blockPromptNode.style.height = '300px';
2223
+ }
2224
+ promptOptions.prompt = prompt;
2225
+ promptOptions.onRequestSubmitted(prompt);
2226
+ requestTime = new Date();
2227
+ telemetryEmitter.emitTelemetryEvent({
2228
+ type: TelemetryEventType.InlineChatRequest,
2229
+ data: {
2230
+ chatModel: {
2231
+ provider: NBIAPI.config.chatModel.provider,
2232
+ model: NBIAPI.config.chatModel.model
2233
+ },
2234
+ prompt: prompt
2235
+ }
2236
+ });
2237
+ };
2238
+ const onResponseEmit = (response: any) => {
2239
+ if (response.type === BackendMessageType.StreamMessage) {
2240
+ if (typeof response.data?.nbi_stream_error === 'string') {
2241
+ streamError = response.data.nbi_stream_error;
2242
+ }
2243
+ const responseMessage =
2244
+ response.data['choices']?.[0]?.['delta']?.['content'];
2245
+ if (responseMessage) {
2246
+ promptOptions.onContentStream(responseMessage);
2247
+ }
2248
+ } else if (response.type === BackendMessageType.StreamEnd) {
2249
+ promptOptions.onContentStreamEnd(streamError);
2250
+ streamError = null;
2251
+ const timeElapsed =
2252
+ requestTime === null
2253
+ ? 0
2254
+ : (new Date().getTime() - requestTime.getTime()) / 1000;
2255
+ telemetryEmitter.emitTelemetryEvent({
2256
+ type: TelemetryEventType.InlineChatResponse,
2257
+ data: {
2258
+ chatModel: {
2259
+ provider: NBIAPI.config.chatModel.provider,
2260
+ model: NBIAPI.config.chatModel.model
2261
+ },
2262
+ timeElapsed
2263
+ }
2264
+ });
2265
+ }
2266
+ };
2267
+ // Handed up by InlinePopoverComponent on mount so this scope can
2268
+ // cancel the in-flight WS request from non-React dismissal paths
2269
+ // (focus-leave) without going through onRequestCancelled (which
2270
+ // would also yank focus back to the editor).
2271
+ let cancelInflightRequest: (() => void) | null = null;
2272
+ const widget = new InlinePromptBlockWidget(
2273
+ React.createElement(InlinePopoverComponent, {
2274
+ prompt: promptOptions.prompt,
2275
+ existingCode: promptOptions.existingCode,
2276
+ onRequestSubmitted,
2277
+ onRequestCancelled: promptOptions.onRequestCancelled,
2278
+ onResponseEmit,
2279
+ prefix: promptOptions.prefix,
2280
+ suffix: promptOptions.suffix,
2281
+ language: promptOptions.language,
2282
+ filename: promptOptions.filename,
2283
+ onUpdatedCodeChange: promptOptions.onUpdatedCodeChange,
2284
+ onUpdatedCodeAccepted: promptOptions.onUpdatedCodeAccepted,
2285
+ registerCancel: (fn: (() => void) | null) => {
2286
+ cancelInflightRequest = fn;
2287
+ }
2288
+ }),
2289
+ node => {
2290
+ blockPromptNode = node;
2291
+ },
2292
+ // Focus-leave dismissal: cancel the request so the backend stops
2293
+ // streaming, but don't call onRequestCancelled — that would steal
2294
+ // focus back from whatever the user clicked.
2295
+ () => {
2296
+ cancelInflightRequest?.();
2297
+ removePopover();
2298
+ }
2299
+ );
2300
+ const anchorOffset = getLineEndOffset(
2301
+ editor,
2302
+ Math.max(startOffset, endOffset)
2303
+ );
2304
+ ensureInlinePromptExtension(editorView);
2305
+ blockPromptView = editorView;
2306
+ // Replace-on-reopen path: when a second Ctrl+G fires while this
2307
+ // popover is still here, cancel our in-flight request before the
2308
+ // widget is torn down so the backend stops streaming for the
2309
+ // discarded prompt.
2310
+ closeOpenPopover = () => {
2311
+ cancelInflightRequest?.();
2312
+ removePopover();
2313
+ };
2314
+ editorView.dispatch({
2315
+ effects: [
2316
+ addInlinePromptEffect.of({
2317
+ pos: anchorOffset,
2318
+ widget
2319
+ }),
2320
+ EditorView.scrollIntoView(anchorOffset, { y: 'center' })
2321
+ ]
2322
+ });
2323
+
2324
+ telemetryEmitter.emitTelemetryEvent({
2325
+ type: TelemetryEventType.GenerateCodeRequest,
2326
+ data: {
2327
+ chatModel: {
2328
+ provider: NBIAPI.config.chatModel.provider,
2329
+ model: NBIAPI.config.chatModel.model
2330
+ },
2331
+ editorType: isCodeCell ? 'notebook' : 'file-editor'
2332
+ }
2333
+ });
2334
+ };
2335
+
2336
+ const generateCellCodeCommand: CommandRegistry.ICommandOptions = {
2337
+ execute: args => {
2338
+ generateCodeForCellOrFileEditor();
2339
+ },
2340
+ label: 'Generate code',
2341
+ isEnabled: () =>
2342
+ isChatEnabled() &&
2343
+ (isActiveCellCodeCell() || isCurrentWidgetFileEditor())
2344
+ };
2345
+ app.commands.addCommand(
2346
+ CommandIDs.editorGenerateCode,
2347
+ generateCellCodeCommand
2348
+ );
2349
+
2350
+ const copilotMenuCommands = new CommandRegistry();
2351
+ copilotMenuCommands.addCommand(
2352
+ CommandIDs.editorGenerateCode,
2353
+ generateCellCodeCommand
2354
+ );
2355
+ copilotMenuCommands.addCommand(CommandIDs.editorExplainThisCode, {
2356
+ execute: () => {
2357
+ const np = app.shell.currentWidget as NotebookPanel;
2358
+ const activeCell = np.content.activeCell;
2359
+ const content = activeCell?.model.sharedModel.source || '';
2360
+ document.dispatchEvent(
2361
+ new CustomEvent('copilotSidebar:runPrompt', {
2362
+ detail: {
2363
+ type: RunChatCompletionType.ExplainThis,
2364
+ content,
2365
+ language: ActiveDocumentWatcher.activeDocumentInfo.language,
2366
+ filename: ActiveDocumentWatcher.activeDocumentInfo.filename
2367
+ }
2368
+ })
2369
+ );
2370
+
2371
+ app.commands.execute('tabsmenu:activate-by-id', { id: panel.id });
2372
+
2373
+ telemetryEmitter.emitTelemetryEvent({
2374
+ type: TelemetryEventType.ExplainThisRequest,
2375
+ data: {
2376
+ chatModel: {
2377
+ provider: NBIAPI.config.chatModel.provider,
2378
+ model: NBIAPI.config.chatModel.model
2379
+ }
2380
+ }
2381
+ });
2382
+ },
2383
+ label: 'Explain code',
2384
+ isEnabled: () => isChatEnabled() && isActiveCellCodeCell()
2385
+ });
2386
+ copilotMenuCommands.addCommand(CommandIDs.editorFixThisCode, {
2387
+ execute: () => {
2388
+ const np = app.shell.currentWidget as NotebookPanel;
2389
+ const activeCell = np.content.activeCell;
2390
+ const content = activeCell?.model.sharedModel.source || '';
2391
+ document.dispatchEvent(
2392
+ new CustomEvent('copilotSidebar:runPrompt', {
2393
+ detail: {
2394
+ type: RunChatCompletionType.FixThis,
2395
+ content,
2396
+ language: ActiveDocumentWatcher.activeDocumentInfo.language,
2397
+ filename: ActiveDocumentWatcher.activeDocumentInfo.filename
2398
+ }
2399
+ })
2400
+ );
2401
+
2402
+ app.commands.execute('tabsmenu:activate-by-id', { id: panel.id });
2403
+
2404
+ telemetryEmitter.emitTelemetryEvent({
2405
+ type: TelemetryEventType.FixThisCodeRequest,
2406
+ data: {
2407
+ chatModel: {
2408
+ provider: NBIAPI.config.chatModel.provider,
2409
+ model: NBIAPI.config.chatModel.model
2410
+ }
2411
+ }
2412
+ });
2413
+ },
2414
+ label: 'Fix code',
2415
+ isEnabled: () => isChatEnabled() && isActiveCellCodeCell()
2416
+ });
2417
+ const registerOutputContextCommand = (opts: {
2418
+ commandId: string;
2419
+ label: string;
2420
+ telemetryType: TelemetryEventType;
2421
+ autoSubmitPrompt?: string;
2422
+ featureFlag?: CellOutputActionFlag;
2423
+ requireError?: boolean;
2424
+ }) => {
2425
+ const isFlagOn = () =>
2426
+ !opts.featureFlag ||
2427
+ NBIAPI.config.cellOutputFeatures[opts.featureFlag].enabled;
2428
+
2429
+ copilotMenuCommands.addCommand(opts.commandId, {
2430
+ execute: () => {
2431
+ const np = app.shell.currentWidget as NotebookPanel;
2432
+ const activeCell = np.content.activeCell;
2433
+ if (!(activeCell instanceof CodeCell)) {
2434
+ return;
2435
+ }
2436
+ const outputContext = cellOutputAsContextBundle(
2437
+ activeCell as CodeCell,
2438
+ { supportsVision: NBIAPI.config.chatModelSupportsVision }
2439
+ );
2440
+ document.dispatchEvent(
2441
+ new CustomEvent('copilotSidebar:addOutputContext', {
2442
+ detail: {
2443
+ outputContext,
2444
+ cellIndex: np.content.activeCellIndex,
2445
+ notebookFilename: np.sessionContext.name,
2446
+ cellId: activeCell.model.id,
2447
+ autoSubmitPrompt: opts.autoSubmitPrompt
2448
+ }
2449
+ })
2450
+ );
2451
+ app.commands.execute('tabsmenu:activate-by-id', { id: panel.id });
2452
+ telemetryEmitter.emitTelemetryEvent({
2453
+ type: opts.telemetryType,
2454
+ data: {
2455
+ chatModel: {
2456
+ provider: NBIAPI.config.chatModel.provider,
2457
+ model: NBIAPI.config.chatModel.model
2458
+ }
2459
+ }
2460
+ });
2461
+ },
2462
+ label: opts.label,
2463
+ isEnabled: () => {
2464
+ if (
2465
+ !(
2466
+ isChatEnabled() &&
2467
+ app.shell.currentWidget instanceof NotebookPanel
2468
+ )
2469
+ ) {
2470
+ return false;
2471
+ }
2472
+ if (!isFlagOn()) {
2473
+ return false;
2474
+ }
2475
+ const np = app.shell.currentWidget as NotebookPanel;
2476
+ const activeCell = np.content.activeCell;
2477
+ if (!(activeCell instanceof CodeCell)) {
2478
+ return false;
2479
+ }
2480
+ if (activeCell.outputArea.model.length === 0) {
2481
+ return false;
2482
+ }
2483
+ if (opts.requireError) {
2484
+ return cellOutputHasError(activeCell);
2485
+ }
2486
+ return true;
2487
+ },
2488
+ isVisible: opts.featureFlag ? isFlagOn : undefined
2489
+ });
2490
+ };
2491
+
2492
+ registerOutputContextCommand({
2493
+ commandId: CommandIDs.editorExplainThisOutput,
2494
+ label: 'Explain output',
2495
+ telemetryType: TelemetryEventType.ExplainThisOutputRequest,
2496
+ autoSubmitPrompt: "Explain this cell's output.",
2497
+ featureFlag: 'output_followup'
2498
+ });
2499
+ registerOutputContextCommand({
2500
+ commandId: CommandIDs.editorAskAboutThisOutput,
2501
+ label: 'Ask about this output',
2502
+ telemetryType: TelemetryEventType.OutputFollowUpRequest,
2503
+ featureFlag: 'output_followup'
2504
+ });
2505
+ registerOutputContextCommand({
2506
+ commandId: CommandIDs.editorTroubleshootThisOutput,
2507
+ label: 'Troubleshoot errors in output',
2508
+ telemetryType: TelemetryEventType.TroubleshootThisOutputRequest,
2509
+ autoSubmitPrompt: "Troubleshoot the error in this cell's output.",
2510
+ featureFlag: 'explain_error',
2511
+ requireError: true
2512
+ });
2513
+
2514
+ const copilotContextMenu = new Menu({ commands: copilotMenuCommands });
2515
+ copilotContextMenu.id = 'notebook-intelligence:editor-context-menu';
2516
+ copilotContextMenu.title.label = 'Notebook Intelligence';
2517
+ copilotContextMenu.title.icon = sidebarIcon;
2518
+ copilotContextMenu.addItem({ command: CommandIDs.editorGenerateCode });
2519
+ copilotContextMenu.addItem({ command: CommandIDs.editorExplainThisCode });
2520
+ copilotContextMenu.addItem({ command: CommandIDs.editorFixThisCode });
2521
+ copilotContextMenu.addItem({ command: CommandIDs.editorExplainThisOutput });
2522
+ copilotContextMenu.addItem({
2523
+ command: CommandIDs.editorAskAboutThisOutput
2524
+ });
2525
+ copilotContextMenu.addItem({
2526
+ command: CommandIDs.editorTroubleshootThisOutput
2527
+ });
2528
+
2529
+ app.contextMenu.addItem({
2530
+ type: 'submenu',
2531
+ submenu: copilotContextMenu,
2532
+ selector: '.jp-Editor',
2533
+ rank: 1
2534
+ });
2535
+
2536
+ app.contextMenu.addItem({
2537
+ type: 'submenu',
2538
+ submenu: copilotContextMenu,
2539
+ selector: '.jp-OutputArea-child',
2540
+ rank: 1
2541
+ });
2542
+
2543
+ new CellOutputHoverToolbar(app, copilotMenuCommands);
2544
+
2545
+ if (statusBar) {
2546
+ const githubCopilotStatusBarItem = new GitHubCopilotStatusBarItem({
2547
+ getApp: () => app
2548
+ });
2549
+
2550
+ statusBar.registerStatusItem(
2551
+ 'notebook-intelligence:github-copilot-status',
2552
+ {
2553
+ item: githubCopilotStatusBarItem,
2554
+ align: 'right',
2555
+ rank: 100,
2556
+ isActive: () =>
2557
+ !NBIAPI.config.isInClaudeCodeMode &&
2558
+ NBIAPI.config.usingGitHubCopilotModel
2559
+ }
2560
+ );
2561
+
2562
+ NBIAPI.configChanged.connect(() => {
2563
+ if (
2564
+ !NBIAPI.config.isInClaudeCodeMode &&
2565
+ NBIAPI.config.usingGitHubCopilotModel
2566
+ ) {
2567
+ githubCopilotStatusBarItem.show();
2568
+ } else {
2569
+ githubCopilotStatusBarItem.hide();
2570
+ }
2571
+ });
2572
+ }
2573
+
2574
+ const jlabApp = app as JupyterLab;
2575
+ ActiveDocumentWatcher.initialize(jlabApp, languageRegistry, defaultBrowser);
2576
+
2577
+ return extensionService;
2578
+ }
2579
+ };
2580
+
2581
+ export * from './tokens';
2582
+
2583
+ export default plugin;