@plmbr/notebook-intelligence 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { JupyterFrontEnd } from '@jupyterlab/application';
3
+ import { IActiveDocumentInfo } from './tokens';
4
+ type MarkdownRendererProps = {
5
+ children: string;
6
+ getApp: () => JupyterFrontEnd;
7
+ getActiveDocumentInfo(): IActiveDocumentInfo;
8
+ };
9
+ export declare function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }: MarkdownRendererProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,64 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React from 'react';
3
+ import Markdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
5
+ import { Prism as SyntaxHighlighterBase } from 'react-syntax-highlighter';
6
+ const SyntaxHighlighter = SyntaxHighlighterBase;
7
+ import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
8
+ import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
9
+ import { isDarkTheme, writeTextToClipboard } from './utils';
10
+ export function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }) {
11
+ const app = getApp();
12
+ const activeDocumentInfo = getActiveDocumentInfo();
13
+ const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
14
+ return (React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
15
+ code({ node, inline, className, children, getApp, ...props }) {
16
+ const match = /language-(\w+)/.exec(className || '');
17
+ const codeString = String(children).replace(/\n$/, '');
18
+ const language = match ? match[1] : 'text';
19
+ const handleCopyClick = () => {
20
+ void writeTextToClipboard(codeString);
21
+ };
22
+ const handleInsertAtCursorClick = () => {
23
+ app.commands.execute('notebook-intelligence:insert-at-cursor', {
24
+ language,
25
+ code: codeString
26
+ });
27
+ };
28
+ const handleAddCodeAsNewCell = () => {
29
+ app.commands.execute('notebook-intelligence:add-code-as-new-cell', {
30
+ language,
31
+ code: codeString
32
+ });
33
+ };
34
+ const handleCreateNewFileClick = () => {
35
+ app.commands.execute('notebook-intelligence:create-new-file', {
36
+ language,
37
+ code: codeString
38
+ });
39
+ };
40
+ const handleCreateNewNotebookClick = () => {
41
+ app.commands.execute('notebook-intelligence:create-new-notebook-from-py', { language, code: codeString });
42
+ };
43
+ if (inline || !match) {
44
+ return (React.createElement("code", { className: className, ...props }, children));
45
+ }
46
+ return (React.createElement("div", null,
47
+ React.createElement("div", { className: "code-block-header" },
48
+ React.createElement("div", { className: "code-block-header-language" },
49
+ React.createElement("span", null, language)),
50
+ React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCopyClick(), "aria-label": "Copy code to clipboard" },
51
+ React.createElement(VscCopy, { size: 16, "aria-hidden": "true" }),
52
+ React.createElement("span", null, "Copy")),
53
+ React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleInsertAtCursorClick(), "aria-label": "Insert code at cursor", title: "Insert at cursor" },
54
+ React.createElement(VscInsert, { size: 16, "aria-hidden": "true" })),
55
+ isNotebook && (React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleAddCodeAsNewCell(), "aria-label": "Add code as new cell", title: "Add as new cell" },
56
+ React.createElement(VscAdd, { size: 16, "aria-hidden": "true" }))),
57
+ React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCreateNewFileClick(), "aria-label": "Create new file from code", title: "New file" },
58
+ React.createElement(VscNewFile, { size: 16, "aria-hidden": "true" })),
59
+ language === 'python' && (React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCreateNewNotebookClick(), "aria-label": "Create new notebook from code", title: "New notebook" },
60
+ React.createElement(VscNotebook, { size: 16, "aria-hidden": "true" })))),
61
+ React.createElement(SyntaxHighlighter, { style: isDarkTheme() ? oneDark : oneLight, PreTag: "div", language: language, ...props }, codeString)));
62
+ }
63
+ } }, markdown));
64
+ }
@@ -0,0 +1,16 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { DocumentRegistry } from '@jupyterlab/docregistry';
3
+ import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook';
4
+ import { LabIcon } from '@jupyterlab/ui-components';
5
+ import { IDisposable } from '@lumino/disposable';
6
+ interface INotebookGenerationToolbarOptions {
7
+ app: JupyterFrontEnd;
8
+ icon: LabIcon;
9
+ chatSidebarId: string;
10
+ }
11
+ export declare class NotebookGenerationToolbarExtension implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel> {
12
+ constructor(options: INotebookGenerationToolbarOptions);
13
+ createNew(panel: NotebookPanel, _context: DocumentRegistry.IContext<INotebookModel>): IDisposable;
14
+ private _options;
15
+ }
16
+ export {};
@@ -0,0 +1,197 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import { ReactWidget } from '@jupyterlab/apputils';
3
+ import { ToolbarButton } from '@jupyterlab/ui-components';
4
+ import { DisposableDelegate } from '@lumino/disposable';
5
+ import { Widget } from '@lumino/widgets';
6
+ import { UUID } from '@lumino/coreutils';
7
+ import React from 'react';
8
+ import { RunChatCompletionType } from './chat-sidebar';
9
+ import { buildNotebookGenerationPrompt, NOTEBOOK_GENERATION_PROGRESS_EVENT } from './notebook-generation';
10
+ import { NotebookGenerationPopover } from './components/notebook-generation-popover';
11
+ const TOOLBAR_BUTTON_NAME = 'nbi-generate-notebook';
12
+ const TOOLBAR_STATUS_NAME = 'nbi-generate-notebook-status';
13
+ class NotebookGenerationPopoverWidget extends ReactWidget {
14
+ constructor(options) {
15
+ super();
16
+ this._onDocumentMouseDown = (event) => {
17
+ const target = event.target;
18
+ if (target && this.node.contains(target)) {
19
+ return;
20
+ }
21
+ this._options.onClose();
22
+ };
23
+ this.addClass('nbi-notebook-generation-popover-host');
24
+ this._options = options;
25
+ }
26
+ onAfterAttach() {
27
+ document.addEventListener('mousedown', this._onDocumentMouseDown, true);
28
+ }
29
+ onBeforeDetach() {
30
+ document.removeEventListener('mousedown', this._onDocumentMouseDown, true);
31
+ }
32
+ positionAt(rect) {
33
+ const popoverWidth = 360;
34
+ const margin = 8;
35
+ let left = rect.left;
36
+ if (left + popoverWidth + margin > window.innerWidth) {
37
+ left = Math.max(margin, window.innerWidth - popoverWidth - margin);
38
+ }
39
+ const top = rect.bottom + 4;
40
+ this.node.style.position = 'fixed';
41
+ this.node.style.left = `${left}px`;
42
+ this.node.style.top = `${top}px`;
43
+ this.node.style.width = `${popoverWidth}px`;
44
+ this.node.style.zIndex = '10000';
45
+ }
46
+ render() {
47
+ return (React.createElement(NotebookGenerationPopover, { initialShowInChat: this._options.initialShowInChat, onSubmit: this._options.onSubmit, onClose: this._options.onClose }));
48
+ }
49
+ }
50
+ class NotebookGenerationToolbarController {
51
+ constructor(options, panel) {
52
+ this._onProgress = (event) => {
53
+ const detail = event
54
+ .detail;
55
+ if (!detail || detail.requestId !== this._activeProgressRequestId) {
56
+ return;
57
+ }
58
+ if (!detail.inProgress) {
59
+ document.removeEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
60
+ this._activeProgressRequestId = null;
61
+ if (detail.error) {
62
+ this._setStatus(`Generation failed: ${detail.error}`);
63
+ this._scheduleStatusHide(4000);
64
+ }
65
+ else {
66
+ this._setStatus('Notebook generation complete');
67
+ this._scheduleStatusHide(2500);
68
+ }
69
+ }
70
+ };
71
+ this._popover = null;
72
+ this._activeProgressRequestId = null;
73
+ this._statusWidget = null;
74
+ this._statusHideTimer = null;
75
+ this._app = options.app;
76
+ this._chatSidebarId = options.chatSidebarId;
77
+ this._panel = panel;
78
+ }
79
+ openPopover(button) {
80
+ if (this._popover) {
81
+ this.closePopover();
82
+ return;
83
+ }
84
+ const buttonRect = button.node.getBoundingClientRect();
85
+ this._popover = new NotebookGenerationPopoverWidget({
86
+ initialShowInChat: NotebookGenerationToolbarController._showInChat,
87
+ onSubmit: (prompt, showInChat) => {
88
+ NotebookGenerationToolbarController._showInChat = showInChat;
89
+ this._submitPrompt(prompt, showInChat);
90
+ this.closePopover();
91
+ },
92
+ onClose: () => this.closePopover()
93
+ });
94
+ Widget.attach(this._popover, document.body);
95
+ // ReactWidget renders on update-request; Widget.attach doesn't queue one.
96
+ this._popover.update();
97
+ this._popover.positionAt(buttonRect);
98
+ }
99
+ closePopover() {
100
+ if (!this._popover) {
101
+ return;
102
+ }
103
+ this._popover.dispose();
104
+ this._popover = null;
105
+ }
106
+ dispose() {
107
+ this.closePopover();
108
+ if (this._activeProgressRequestId) {
109
+ document.removeEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
110
+ this._activeProgressRequestId = null;
111
+ }
112
+ if (this._statusHideTimer !== null) {
113
+ clearTimeout(this._statusHideTimer);
114
+ this._statusHideTimer = null;
115
+ }
116
+ this._setStatus(null);
117
+ }
118
+ _submitPrompt(rawPrompt, showInChat) {
119
+ const prefixedPrompt = buildNotebookGenerationPrompt(rawPrompt);
120
+ const externalRequestId = UUID.uuid4();
121
+ // chatMode and toolSelections are forced by the chat-sidebar handler
122
+ // (NotebookGeneration always needs agent mode + the notebook-edit
123
+ // toolset). Leaving them off the request keeps the toolbar
124
+ // independent of the sidebar's current configuration.
125
+ const request = {
126
+ type: RunChatCompletionType.NotebookGeneration,
127
+ content: prefixedPrompt,
128
+ externalRequestId,
129
+ hideInChat: !showInChat
130
+ };
131
+ if (!showInChat) {
132
+ this._setStatus('Generating notebook…');
133
+ this._activeProgressRequestId = externalRequestId;
134
+ document.addEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
135
+ }
136
+ document.dispatchEvent(new CustomEvent('copilotSidebar:runPrompt', { detail: request }));
137
+ if (showInChat) {
138
+ this._app.commands.execute('tabsmenu:activate-by-id', {
139
+ id: this._chatSidebarId
140
+ });
141
+ }
142
+ }
143
+ _scheduleStatusHide(delayMs) {
144
+ if (this._statusHideTimer !== null) {
145
+ clearTimeout(this._statusHideTimer);
146
+ }
147
+ this._statusHideTimer = setTimeout(() => {
148
+ this._statusHideTimer = null;
149
+ this._setStatus(null);
150
+ }, delayMs);
151
+ }
152
+ _setStatus(message) {
153
+ if (this._panel.isDisposed) {
154
+ return;
155
+ }
156
+ if (!message) {
157
+ if (this._statusWidget) {
158
+ this._statusWidget.dispose();
159
+ this._statusWidget = null;
160
+ }
161
+ return;
162
+ }
163
+ if (!this._statusWidget) {
164
+ const widget = new Widget();
165
+ widget.addClass('nbi-notebook-generation-status');
166
+ this._panel.toolbar.insertAfter(TOOLBAR_BUTTON_NAME, TOOLBAR_STATUS_NAME, widget);
167
+ this._statusWidget = widget;
168
+ }
169
+ this._statusWidget.node.textContent = message;
170
+ }
171
+ }
172
+ // Defaults ON; remembers the user's last choice for the rest of the tab.
173
+ NotebookGenerationToolbarController._showInChat = true;
174
+ export class NotebookGenerationToolbarExtension {
175
+ constructor(options) {
176
+ this._options = options;
177
+ }
178
+ createNew(panel, _context) {
179
+ const controller = new NotebookGenerationToolbarController(this._options, panel);
180
+ const button = new ToolbarButton({
181
+ icon: this._options.icon,
182
+ onClick: () => controller.openPopover(button),
183
+ // Notebook generation works in any chat mode — the chat-sidebar
184
+ // handler forces agent mode and the notebook-edit toolset when
185
+ // routing this request (issue #229). The button used to be gated
186
+ // on isInClaudeCodeMode here, but that gate undermined the
187
+ // sidebar-side fix.
188
+ tooltip: 'Update active notebook with AI'
189
+ });
190
+ button.addClass('nbi-notebook-generation-toolbar-button');
191
+ panel.toolbar.insertAfter('cellType', TOOLBAR_BUTTON_NAME, button);
192
+ return new DisposableDelegate(() => {
193
+ controller.dispose();
194
+ button.dispose();
195
+ });
196
+ }
197
+ }
@@ -0,0 +1,8 @@
1
+ export declare const NOTEBOOK_GENERATION_PROMPT_PREFIX = "Update active notebook based on the user request. Don't create a new notebook, always update the active one. User request: ";
2
+ export declare const NOTEBOOK_GENERATION_PROGRESS_EVENT = "copilotSidebar:notebookGenerationProgress";
3
+ export interface INotebookGenerationProgressDetail {
4
+ requestId: string;
5
+ inProgress: boolean;
6
+ error?: string;
7
+ }
8
+ export declare function buildNotebookGenerationPrompt(rawPrompt: string): string;
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ //
3
+ // Pure helpers and event constants used by the notebook-generation toolbar
4
+ // button. Keeping these in their own module (free of JupyterLab/React
5
+ // dependencies) makes them easy to unit-test under jsdom without bringing
6
+ // in the chat sidebar's heavyweight ESM imports.
7
+ export const NOTEBOOK_GENERATION_PROMPT_PREFIX = "Update active notebook based on the user request. Don't create a new notebook, always update the active one. User request: ";
8
+ export const NOTEBOOK_GENERATION_PROGRESS_EVENT = 'copilotSidebar:notebookGenerationProgress';
9
+ export function buildNotebookGenerationPrompt(rawPrompt) {
10
+ const trimmed = (rawPrompt || '').trim();
11
+ return `${NOTEBOOK_GENERATION_PROMPT_PREFIX}${trimmed}`;
12
+ }
@@ -0,0 +1,4 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { Contents } from '@jupyterlab/services';
3
+ import { IRefreshWatcherEnv } from './open-file-refresh-watcher';
4
+ export declare function buildRefreshWatcherEnv(app: JupyterFrontEnd, contents: Contents.IManager): IRefreshWatcherEnv;
@@ -0,0 +1,33 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import { DocumentWidget } from '@jupyterlab/docregistry';
3
+ import { WATCHED_SHELL_AREAS } from './open-file-refresh-watcher';
4
+ export function buildRefreshWatcherEnv(app, contents) {
5
+ return {
6
+ iterDocumentWidgets: function* () {
7
+ for (const area of WATCHED_SHELL_AREAS) {
8
+ // Defensive try/catch: LabShell.widgets() throws for areas it
9
+ // doesn't implement. We've audited the current set against
10
+ // the runtime, but a future JL bump could rename or remove
11
+ // an area; surfacing it as a console warning rather than an
12
+ // unhandled rejection keeps the watcher running for the
13
+ // areas that DO work.
14
+ let widgets;
15
+ try {
16
+ widgets = app.shell.widgets(area);
17
+ }
18
+ catch (err) {
19
+ console.warn(`[NBI] open-file-refresh-watcher: skipping shell area "${area}":`, err);
20
+ continue;
21
+ }
22
+ for (const widget of widgets) {
23
+ if (widget instanceof DocumentWidget) {
24
+ yield widget;
25
+ }
26
+ }
27
+ }
28
+ },
29
+ fetchDiskModel: path => contents.get(path, { content: false }),
30
+ setInterval: (handler, ms) => window.setInterval(handler, ms),
31
+ clearInterval: handle => window.clearInterval(handle)
32
+ };
33
+ }
@@ -0,0 +1,97 @@
1
+ import type { DocumentRegistry } from '@jupyterlab/docregistry';
2
+ import type { Contents } from '@jupyterlab/services';
3
+ export interface IRefreshWatcherWidget {
4
+ readonly context: DocumentRegistry.Context | null | undefined;
5
+ }
6
+ export declare const DEFAULT_REFRESH_POLL_INTERVAL_MS = 3000;
7
+ export declare const DEFAULT_TICK_CONCURRENCY = 4;
8
+ export declare const WATCHED_SHELL_AREAS: readonly ["main", "left", "right"];
9
+ /**
10
+ * Inputs the revert decision depends on. Keeping this pure (no
11
+ * JupyterLab types) lets the unit test pin the policy without
12
+ * instantiating a real Context.
13
+ */
14
+ export interface IRevertDecisionInputs {
15
+ diskLastModified: string | null | undefined;
16
+ contextLastModified: string | null | undefined;
17
+ isDirty: boolean;
18
+ isReady: boolean;
19
+ isDisposed: boolean;
20
+ }
21
+ /**
22
+ * Whether the open document's in-memory model should be reverted to
23
+ * match the on-disk version. The rules, in order:
24
+ *
25
+ * 1. Skip if the context is gone (disposed) or not yet populated
26
+ * (`isReady` false). Calling `revert()` against an unready
27
+ * context races the initial load.
28
+ * 2. Skip if the user has unsaved local edits (`isDirty`). Silently
29
+ * clobbering their work would be hostile; the standard
30
+ * JupyterLab "newer on disk" prompt will surface on save.
31
+ * 3. Skip if we can't parse either timestamp (either side missing
32
+ * or unparseable).
33
+ * 4. Revert iff disk's `last_modified` parses to a strictly greater
34
+ * epoch ms than the context's last-known value. Equal means
35
+ * already current (a save we initiated, or a no-op re-read).
36
+ *
37
+ * The comparison is numeric, not lexicographic, because jupyter_server
38
+ * serializes `last_modified` via Python's `datetime.isoformat()` which
39
+ * omits the fractional component when `microsecond == 0`. That means
40
+ * the same instant can arrive as `"...:56Z"` or `"...:56.000000Z"`,
41
+ * and `"...:56.000000Z" < "...:56Z"` lexicographically (the `.` at
42
+ * 0x2E sorts below the `Z` at 0x5A at position 19). A string compare
43
+ * would fire a spurious revert when the two sides happen to land on
44
+ * different sides of the fractional/non-fractional boundary for the
45
+ * same mtime. Parsing both through `new Date(s).getTime()` collapses
46
+ * them to the same epoch ms.
47
+ *
48
+ * JupyterLab's own newer-on-disk check (`docregistry/lib/context.js`,
49
+ * see `lastModifiedCheckMargin`) applies a 500ms tolerance to absorb
50
+ * filesystem mtime jitter, but that comparison drives a user-facing
51
+ * "newer on disk, save anyway?" prompt where false-positive is just a
52
+ * dialog. Our comparison drives a silent revert that could clobber an
53
+ * agent's edit that landed within the margin of the user's own save;
54
+ * we deliberately stay strict.
55
+ */
56
+ export declare function shouldRevertContext({ diskLastModified, contextLastModified, isDirty, isReady, isDisposed }: IRevertDecisionInputs): boolean;
57
+ /**
58
+ * Side-effect surface the watcher reaches into. Extracted so tests
59
+ * can pass a thin fake without standing up a real JupyterFrontEnd or
60
+ * Contents singleton.
61
+ */
62
+ export interface IRefreshWatcherEnv {
63
+ /** Yield every currently-open document widget the watcher should consider. */
64
+ iterDocumentWidgets: () => Iterable<IRefreshWatcherWidget>;
65
+ /** Fetch on-disk metadata without the body (cheap stat-shaped call). */
66
+ fetchDiskModel: (path: string) => Promise<Contents.IModel>;
67
+ /** Set/clear the polling interval. Pulled out for fake timers in tests. */
68
+ setInterval: (handler: () => void, ms: number) => unknown;
69
+ clearInterval: (handle: unknown) => void;
70
+ }
71
+ export interface IRefreshWatcherOptions {
72
+ env: IRefreshWatcherEnv;
73
+ intervalMs?: number;
74
+ /** Cap on concurrent Contents.get calls per tick. Defaults to DEFAULT_TICK_CONCURRENCY. */
75
+ tickConcurrency?: number;
76
+ /** Re-checked on every tick so a settings toggle takes effect without restart. */
77
+ isEnabled: () => boolean;
78
+ /** Hook for tests / telemetry — fired once per revert (the heavy outcome). */
79
+ onRevert?: (path: string) => void;
80
+ /** Hook for tests / diagnostics — fired when a check throws. */
81
+ onError?: (path: string, error: unknown) => void;
82
+ }
83
+ /**
84
+ * Polls every open document widget on a fixed cadence, comparing the
85
+ * file's on-disk `last_modified` against the context's last-known
86
+ * value, and calls `context.revert()` when the disk is newer.
87
+ *
88
+ * Why polling at all (when the Contents API exposes a `fileChanged`
89
+ * signal): agents like Claude write directly to the filesystem,
90
+ * bypassing the API. The signal fires for Lab-routed writes only.
91
+ * Polling catches both paths uniformly and keeps the watcher
92
+ * single-purpose.
93
+ *
94
+ * Returns a teardown function the caller invokes on plugin
95
+ * deactivation to stop the timer.
96
+ */
97
+ export declare function attachOpenFileRefreshWatcher(options: IRefreshWatcherOptions): () => void;
@@ -0,0 +1,190 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ export const DEFAULT_REFRESH_POLL_INTERVAL_MS = 3000;
3
+ // Max in-flight Contents.get calls per tick. The Jupyter server runs
4
+ // each request through its own handler thread; with 15 open tabs and
5
+ // unthrottled fan-out we'd pin 15 sockets every poll. Cap at 4 so a
6
+ // heavy-tab user still finishes a tick well under the 3s interval
7
+ // without hammering the server.
8
+ export const DEFAULT_TICK_CONCURRENCY = 4;
9
+ // Shell areas the live binding walks looking for open DocumentWidgets.
10
+ // 'main' is the primary editor area (including split panes managed by
11
+ // the underlying DockPanel); 'left' and 'right' catch the rare case
12
+ // where a user drags a notebook tab into a sidebar in JL4.
13
+ //
14
+ // 'down' is intentionally absent. It's listed in JupyterLab's
15
+ // TypeScript Area union (application/lib/shell.d.ts) but NOT
16
+ // implemented in LabShell.widgets()'s runtime switch
17
+ // (application/lib/shell.js) — asking for it throws
18
+ // `Invalid area: down`. Reported on PR #330 review; do not re-add
19
+ // without confirming the runtime impl in the installed JL version.
20
+ //
21
+ // The constant lives here (rather than in the env binding) so the
22
+ // test suite can pin its contents without importing
23
+ // @jupyterlab/docregistry, which ships ESM that ts-jest's default
24
+ // transform can't parse.
25
+ export const WATCHED_SHELL_AREAS = ['main', 'left', 'right'];
26
+ /**
27
+ * Whether the open document's in-memory model should be reverted to
28
+ * match the on-disk version. The rules, in order:
29
+ *
30
+ * 1. Skip if the context is gone (disposed) or not yet populated
31
+ * (`isReady` false). Calling `revert()` against an unready
32
+ * context races the initial load.
33
+ * 2. Skip if the user has unsaved local edits (`isDirty`). Silently
34
+ * clobbering their work would be hostile; the standard
35
+ * JupyterLab "newer on disk" prompt will surface on save.
36
+ * 3. Skip if we can't parse either timestamp (either side missing
37
+ * or unparseable).
38
+ * 4. Revert iff disk's `last_modified` parses to a strictly greater
39
+ * epoch ms than the context's last-known value. Equal means
40
+ * already current (a save we initiated, or a no-op re-read).
41
+ *
42
+ * The comparison is numeric, not lexicographic, because jupyter_server
43
+ * serializes `last_modified` via Python's `datetime.isoformat()` which
44
+ * omits the fractional component when `microsecond == 0`. That means
45
+ * the same instant can arrive as `"...:56Z"` or `"...:56.000000Z"`,
46
+ * and `"...:56.000000Z" < "...:56Z"` lexicographically (the `.` at
47
+ * 0x2E sorts below the `Z` at 0x5A at position 19). A string compare
48
+ * would fire a spurious revert when the two sides happen to land on
49
+ * different sides of the fractional/non-fractional boundary for the
50
+ * same mtime. Parsing both through `new Date(s).getTime()` collapses
51
+ * them to the same epoch ms.
52
+ *
53
+ * JupyterLab's own newer-on-disk check (`docregistry/lib/context.js`,
54
+ * see `lastModifiedCheckMargin`) applies a 500ms tolerance to absorb
55
+ * filesystem mtime jitter, but that comparison drives a user-facing
56
+ * "newer on disk, save anyway?" prompt where false-positive is just a
57
+ * dialog. Our comparison drives a silent revert that could clobber an
58
+ * agent's edit that landed within the margin of the user's own save;
59
+ * we deliberately stay strict.
60
+ */
61
+ export function shouldRevertContext({ diskLastModified, contextLastModified, isDirty, isReady, isDisposed }) {
62
+ if (isDisposed || !isReady) {
63
+ return false;
64
+ }
65
+ if (isDirty) {
66
+ return false;
67
+ }
68
+ if (!diskLastModified || !contextLastModified) {
69
+ return false;
70
+ }
71
+ // Match JL's own idiom (docregistry/lib/context.js:625-630) and the
72
+ // five other `new Date(...).getTime()` call sites in this codebase.
73
+ // Equivalent to `Date.parse(s)` but consistent with house style.
74
+ const diskMs = new Date(diskLastModified).getTime();
75
+ const contextMs = new Date(contextLastModified).getTime();
76
+ // NaN on either side (unparseable string) makes the comparison
77
+ // false, so a malformed timestamp degrades safely to "don't revert."
78
+ return diskMs > contextMs;
79
+ }
80
+ /**
81
+ * Polls every open document widget on a fixed cadence, comparing the
82
+ * file's on-disk `last_modified` against the context's last-known
83
+ * value, and calls `context.revert()` when the disk is newer.
84
+ *
85
+ * Why polling at all (when the Contents API exposes a `fileChanged`
86
+ * signal): agents like Claude write directly to the filesystem,
87
+ * bypassing the API. The signal fires for Lab-routed writes only.
88
+ * Polling catches both paths uniformly and keeps the watcher
89
+ * single-purpose.
90
+ *
91
+ * Returns a teardown function the caller invokes on plugin
92
+ * deactivation to stop the timer.
93
+ */
94
+ export function attachOpenFileRefreshWatcher(options) {
95
+ var _a;
96
+ const intervalMs = (_a = options.intervalMs) !== null && _a !== void 0 ? _a : DEFAULT_REFRESH_POLL_INTERVAL_MS;
97
+ let inFlight = false;
98
+ let stopped = false;
99
+ const tick = async () => {
100
+ var _a;
101
+ // Defense against the browser firing a stale interval handler
102
+ // after we've called clearInterval, and against tests that invoke
103
+ // the captured handler directly post-teardown.
104
+ if (stopped) {
105
+ return;
106
+ }
107
+ if (!options.isEnabled()) {
108
+ return;
109
+ }
110
+ // Re-entrancy guard: a slow Contents.get on one widget shouldn't
111
+ // pile up additional ticks while it resolves. Skip rather than
112
+ // queue so a transient server slowdown doesn't snowball.
113
+ if (inFlight) {
114
+ return;
115
+ }
116
+ inFlight = true;
117
+ try {
118
+ const seen = new Set();
119
+ const targets = [];
120
+ for (const widget of options.env.iterDocumentWidgets()) {
121
+ const context = widget.context;
122
+ if (!context || !context.path) {
123
+ continue;
124
+ }
125
+ // Dedupe across widgets sharing the same context (split-view,
126
+ // notebook + editor view, etc.); reverting once per path is
127
+ // enough since the context is the shared mutable state.
128
+ if (seen.has(context.path)) {
129
+ continue;
130
+ }
131
+ seen.add(context.path);
132
+ targets.push(context);
133
+ }
134
+ // Chunked fan-out: parallelism within a batch (so 4 tabs finish
135
+ // in one RTT instead of four), serialized across batches (so a
136
+ // 20-tab user doesn't pin 20 sockets at once). Per-context
137
+ // errors stay inside checkOneContext via its try/catch, so a
138
+ // Promise.all on the batch can't reject and tear the loop.
139
+ const concurrency = Math.max(1, (_a = options.tickConcurrency) !== null && _a !== void 0 ? _a : DEFAULT_TICK_CONCURRENCY);
140
+ for (let i = 0; i < targets.length; i += concurrency) {
141
+ const batch = targets.slice(i, i + concurrency);
142
+ await Promise.all(batch.map(ctx => checkOneContext(ctx, options)));
143
+ }
144
+ }
145
+ finally {
146
+ inFlight = false;
147
+ }
148
+ };
149
+ const handle = options.env.setInterval(() => {
150
+ // Swallow tick-level errors so a single bad path can't kill the
151
+ // poller; per-context errors are reported via onError above.
152
+ void tick();
153
+ }, intervalMs);
154
+ return () => {
155
+ stopped = true;
156
+ options.env.clearInterval(handle);
157
+ };
158
+ }
159
+ async function checkOneContext(context, options) {
160
+ var _a, _b, _c;
161
+ try {
162
+ const diskModel = await options.env.fetchDiskModel(context.path);
163
+ const decision = shouldRevertContext({
164
+ diskLastModified: diskModel.last_modified,
165
+ contextLastModified: (_a = context.contentsModel) === null || _a === void 0 ? void 0 : _a.last_modified,
166
+ isDirty: context.model.dirty,
167
+ isReady: context.isReady,
168
+ isDisposed: context.isDisposed
169
+ });
170
+ if (!decision) {
171
+ return;
172
+ }
173
+ // Defense in depth: re-read dirty/disposed immediately before the
174
+ // revert call. Today this is strictly belt-and-suspenders — no
175
+ // microtask boundary exists between the dirty read inside
176
+ // shouldRevertContext above and the await on revert() below, so a
177
+ // keystroke cannot land in that window. The re-check survives a
178
+ // future refactor that inserts an await (telemetry, an instrument
179
+ // hook, etc.) between the decision and the revert call without
180
+ // anyone having to re-derive the safety argument.
181
+ if (context.model.dirty || context.isDisposed) {
182
+ return;
183
+ }
184
+ await context.revert();
185
+ (_b = options.onRevert) === null || _b === void 0 ? void 0 : _b.call(options, context.path);
186
+ }
187
+ catch (error) {
188
+ (_c = options.onError) === null || _c === void 0 ? void 0 : _c.call(options, context.path, error);
189
+ }
190
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * POSIX-shell single-quote escape: every embedded single quote is closed,
3
+ * emitted as an escaped literal, and the quote re-opened. The result is
4
+ * safe to splice into a shell command without further sanitization.
5
+ */
6
+ export declare function shellSingleQuote(value: string): string;
@@ -0,0 +1,9 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ /**
3
+ * POSIX-shell single-quote escape: every embedded single quote is closed,
4
+ * emitted as an escaped literal, and the quote re-opened. The result is
5
+ * safe to splice into a shell command without further sanitization.
6
+ */
7
+ export function shellSingleQuote(value) {
8
+ return "'" + value.replace(/'/g, "'\\''") + "'";
9
+ }
@@ -0,0 +1,2 @@
1
+ export declare const NOTEBOOK_TARGETED_COMMAND_IDS: ReadonlySet<string>;
2
+ export declare function injectTaskTargetNotebook(commandId: string, args: Record<string, unknown> | undefined, taskTargetPath: string | null): Record<string, unknown> | undefined;