@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.
- package/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- 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;
|