@plmbr/notebook-intelligence 5.0.0 → 5.1.0-a.1

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/README.md CHANGED
@@ -159,7 +159,7 @@ NBI reloads open document tabs when their files change on disk, so edits an AI a
159
159
 
160
160
  ## Configuration
161
161
 
162
- Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/03/05/support-for-any-llm-provider.html).
162
+ Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://plmbr.dev/blog/archive/support-for-any-llm-provider/).
163
163
 
164
164
  <img src="media/provider-list.png" alt="Settings dialog" width=500 />
165
165
 
@@ -383,6 +383,12 @@ jupyter lab --NotebookIntelligence.enable_chat_feedback=true
383
383
 
384
384
  The feedback fires an in-process `telemetry` event. Nothing leaves the process by default — see the [admin guide](docs/admin-guide.md#chat-feedback-event-hook) for how to wire it into your observability stack.
385
385
 
386
+ The thumbs buttons reveal on hover by default. To keep them always visible, enable:
387
+
388
+ ```python
389
+ c.NotebookIntelligence.enable_chat_feedback_always_visible = True
390
+ ```
391
+
386
392
  <img src="media/chat-feedback.png" alt="Chat feedback" width=500 />
387
393
 
388
394
  ## Documentation
@@ -398,10 +404,10 @@ The feedback fires an in-process `telemetry` event. Nothing leaves the process b
398
404
 
399
405
  ## Further reading
400
406
 
401
- - [Introducing Notebook Intelligence!](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/01/08/introducing-notebook-intelligence.html)
402
- - [Building AI Extensions for JupyterLab](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/02/05/building-ai-extensions-for-jupyterlab.html)
403
- - [Building AI Agents for JupyterLab](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/02/09/building-ai-agents-for-jupyterlab.html)
404
- - [Notebook Intelligence now supports any LLM Provider and AI Model!](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/03/05/support-for-any-llm-provider.html)
407
+ - [Introducing Notebook Intelligence!](https://plmbr.dev/blog/archive/introducing-notebook-intelligence/)
408
+ - [Building AI Extensions for JupyterLab](https://plmbr.dev/blog/archive/building-ai-extensions-for-jupyterlab/)
409
+ - [Building AI Agents for JupyterLab](https://plmbr.dev/blog/archive/building-ai-agents-for-jupyterlab/)
410
+ - [Notebook Intelligence now supports any LLM Provider and AI Model!](https://plmbr.dev/blog/archive/support-for-any-llm-provider/)
405
411
 
406
412
  ## Roadmap
407
413
 
package/lib/api.d.ts CHANGED
@@ -174,6 +174,10 @@ export declare class NBIConfig {
174
174
  getMCPServerPrompt(serverId: string, promptName: string): any;
175
175
  get mcpServerSettings(): any;
176
176
  get claudeSettings(): any;
177
+ get spinnerVerbs(): {
178
+ mode: string;
179
+ verbs: string[];
180
+ } | null;
177
181
  get claudeModels(): IClaudeModelInfo[];
178
182
  get isInClaudeCodeMode(): boolean;
179
183
  get isClaudeCliAvailable(): boolean;
@@ -183,6 +187,7 @@ export declare class NBIConfig {
183
187
  get isCodexCliAvailable(): boolean;
184
188
  isCodingAgentLauncherDisabledByPolicy(launcherId: string): boolean;
185
189
  get chatFeedbackEnabled(): boolean;
190
+ get chatFeedbackAlwaysVisible(): boolean;
186
191
  get tourOverrides(): Record<string, any>;
187
192
  get allowGithubSkillImport(): boolean;
188
193
  get additionalSkippedWorkspaceDirectories(): string[];
package/lib/api.js CHANGED
@@ -142,6 +142,10 @@ export class NBIConfig {
142
142
  get claudeSettings() {
143
143
  return this.capabilities.claude_settings;
144
144
  }
145
+ get spinnerVerbs() {
146
+ var _b;
147
+ return (_b = this.capabilities.spinner_verbs) !== null && _b !== void 0 ? _b : null;
148
+ }
145
149
  get claudeModels() {
146
150
  var _b;
147
151
  return ((_b = this.capabilities.claude_models) !== null && _b !== void 0 ? _b : []).map(claudeModelFromWire);
@@ -181,6 +185,9 @@ export class NBIConfig {
181
185
  get chatFeedbackEnabled() {
182
186
  return this.capabilities.chat_feedback_enabled === true;
183
187
  }
188
+ get chatFeedbackAlwaysVisible() {
189
+ return this.capabilities.chat_feedback_always_visible === true;
190
+ }
184
191
  // Admin-supplied tour-copy overrides, served from the capabilities
185
192
  // response after server-side validation. Returns the raw dict; the
186
193
  // tour module decides how to apply it. Defaults to a shared frozen
@@ -13,12 +13,15 @@ import copySvgstr from '../style/icons/copy.svg';
13
13
  import copilotSvgstr from '../style/icons/copilot.svg';
14
14
  import copilotWarningSvgstr from '../style/icons/copilot-warning.svg';
15
15
  import { VscSend, VscStopCircle, VscEye, VscEyeClosed, VscAdd, VscClose, VscHistory, VscTriangleRight, VscTriangleDown, VscSettingsGear, VscPassFilled, VscTools, VscTrash, VscThumbsup, VscThumbsdown, VscThumbsupFilled, VscThumbsdownFilled, VscCloudUpload, VscFile, VscRefresh } from './icons';
16
- import { extractLLMGeneratedCode, isDarkTheme, safeAnchorUri, writeTextToClipboard } from './utils';
16
+ import { extractLLMGeneratedCode, isDarkTheme, writeTextToClipboard } from './utils';
17
17
  import { CheckBoxItem } from './components/checkbox';
18
+ import { SafeAnchor } from './components/safe-anchor';
18
19
  import { mcpServerSettingsToEnabledState } from './components/mcp-util';
19
20
  import claudeSvgStr from '../style/icons/claude.svg';
20
21
  import { AskUserQuestion } from './components/ask-user-question';
21
22
  import { ClaudeSessionPicker } from './components/claude-session-picker';
23
+ import { ToolCallGroup } from './components/tool-call-group';
24
+ import { upsertToolCallContent } from './tool-call-stream';
22
25
  import { TourOverlay } from './tour/tour-overlay';
23
26
  import { TOUR_ANCHOR } from './tour/tour-anchors';
24
27
  import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
@@ -281,8 +284,81 @@ function ChatResponseHTMLFrame(props) {
281
284
  }
282
285
  // Memoize ChatResponse for performance
283
286
  function ChatResponse(props) {
284
- var _a, _b, _c;
287
+ var _a, _b, _c, _d;
285
288
  const [renderCount, setRenderCount] = useState(0);
289
+ const shuffledOrder = useRef([]);
290
+ const shufflePos = useRef(0);
291
+ const _spinnerVerbs = NBIAPI.config.isInClaudeCodeMode
292
+ ? ((_a = NBIAPI.config.spinnerVerbs) !== null && _a !== void 0 ? _a : null)
293
+ : null;
294
+ const hasCustomVerbs = (_spinnerVerbs === null || _spinnerVerbs === void 0 ? void 0 : _spinnerVerbs.mode) === 'replace' &&
295
+ Array.isArray(_spinnerVerbs.verbs) &&
296
+ _spinnerVerbs.verbs.length > 0;
297
+ // Fisher-Yates shuffle. When `avoidFirst` is set, swap it out of
298
+ // position 0 so the new first verb is never the same as the last shown
299
+ // (prevents an identical repeat at the wrap point between passes).
300
+ const shuffleVerbs = (verbs, avoidFirst) => {
301
+ const order = verbs.map((_, i) => i);
302
+ for (let i = order.length - 1; i > 0; i--) {
303
+ const j = Math.floor(Math.random() * (i + 1));
304
+ [order[i], order[j]] = [order[j], order[i]];
305
+ }
306
+ if (avoidFirst !== undefined &&
307
+ order[0] === avoidFirst &&
308
+ order.length > 1) {
309
+ [order[0], order[1]] = [order[1], order[0]];
310
+ }
311
+ return order;
312
+ };
313
+ // Initialize the shuffle synchronously in useState so the correct verb
314
+ // is shown on the very first paint. A useEffect-based init fires after
315
+ // paint and causes a single-frame flash of verbs[0] before correcting.
316
+ const [verbIndex, setVerbIndex] = useState(() => {
317
+ var _a;
318
+ const sv = NBIAPI.config.isInClaudeCodeMode
319
+ ? ((_a = NBIAPI.config.spinnerVerbs) !== null && _a !== void 0 ? _a : null)
320
+ : null;
321
+ if (!sv ||
322
+ sv.mode !== 'replace' ||
323
+ !Array.isArray(sv.verbs) ||
324
+ sv.verbs.length === 0) {
325
+ return 0;
326
+ }
327
+ const order = shuffleVerbs(sv.verbs);
328
+ shuffledOrder.current = order;
329
+ shufflePos.current = 0;
330
+ return order[0];
331
+ });
332
+ useEffect(() => {
333
+ if (!props.showGenerating || !hasCustomVerbs) {
334
+ return;
335
+ }
336
+ const verbs = _spinnerVerbs.verbs;
337
+ // Shuffle already initialized by useState on mount. Only re-initialize
338
+ // if hasCustomVerbs just became true after a capabilities refresh
339
+ // (shuffledOrder would be empty because the lazy init found no verbs).
340
+ if (shuffledOrder.current.length === 0) {
341
+ shuffledOrder.current = shuffleVerbs(verbs);
342
+ shufflePos.current = 0;
343
+ setVerbIndex(shuffledOrder.current[0]);
344
+ }
345
+ let id;
346
+ const scheduleNext = () => {
347
+ const delay = 4000 + Math.random() * 3000;
348
+ id = setTimeout(() => {
349
+ shufflePos.current++;
350
+ if (shufflePos.current >= shuffledOrder.current.length) {
351
+ const lastShown = shuffledOrder.current[shuffledOrder.current.length - 1];
352
+ shuffledOrder.current = shuffleVerbs(verbs, lastShown);
353
+ shufflePos.current = 0;
354
+ }
355
+ setVerbIndex(shuffledOrder.current[shufflePos.current]);
356
+ scheduleNext();
357
+ }, delay);
358
+ };
359
+ scheduleNext();
360
+ return () => clearTimeout(id);
361
+ }, [props.showGenerating, hasCustomVerbs]);
286
362
  const msg = props.message;
287
363
  const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
288
364
  const openNotebook = (event) => {
@@ -376,6 +452,19 @@ function ChatResponse(props) {
376
452
  lastItem.reasoningFinished = true;
377
453
  }
378
454
  }
455
+ else if (item.type === ResponseStreamDataType.ToolCall &&
456
+ lastItemType === ResponseStreamDataType.ToolCall) {
457
+ // Bundle a run of consecutive tool calls into one group item so the
458
+ // renderer can collapse them; ToolCallGroup unwraps content.toolCalls.
459
+ const lastItem = groupedContents[groupedContents.length - 1];
460
+ lastItem.content.toolCalls.push(structuredClone(item.content));
461
+ }
462
+ else if (item.type === ResponseStreamDataType.ToolCall) {
463
+ const grouped = structuredClone(item);
464
+ grouped.content = { toolCalls: [structuredClone(item.content)] };
465
+ groupedContents.push(grouped);
466
+ lastItemType = item.type;
467
+ }
379
468
  else {
380
469
  groupedContents.push(structuredClone(item));
381
470
  lastItemType = item.type;
@@ -417,28 +506,35 @@ function ChatResponse(props) {
417
506
  ? 'Thought'
418
507
  : `Thinking (${Math.floor(item.reasoningTime)} s)`;
419
508
  };
420
- const chatParticipantId = ((_a = msg.participant) === null || _a === void 0 ? void 0 : _a.id) || 'default';
509
+ const chatParticipantId = ((_b = msg.participant) === null || _b === void 0 ? void 0 : _b.id) || 'default';
421
510
  return (React.createElement("div", { className: `chat-message chat-message-${msg.from}`, "data-render-count": renderCount },
422
511
  React.createElement("div", { className: "chat-message-header" },
423
512
  React.createElement("div", { className: "chat-message-from" },
424
- ((_b = msg.participant) === null || _b === void 0 ? void 0 : _b.iconPath) && (React.createElement("div", { className: `chat-message-from-icon chat-message-from-icon-${chatParticipantId} ${isDarkTheme() ? 'dark' : ''}` },
513
+ ((_c = msg.participant) === null || _c === void 0 ? void 0 : _c.iconPath) && (React.createElement("div", { className: `chat-message-from-icon chat-message-from-icon-${chatParticipantId} ${isDarkTheme() ? 'dark' : ''}` },
425
514
  React.createElement("img", { src: msg.participant.iconPath, alt: "" }))),
426
515
  React.createElement("div", { className: "chat-message-from-title" }, msg.from === 'user'
427
516
  ? 'User'
428
- : ((_c = msg.participant) === null || _c === void 0 ? void 0 : _c.name) || 'AI Assistant'),
517
+ : ((_d = msg.participant) === null || _d === void 0 ? void 0 : _d.name) || 'AI Assistant'),
429
518
  React.createElement("div", { className: "chat-message-from-progress", style: { display: `${props.showGenerating ? 'visible' : 'none'}` } },
430
519
  React.createElement("span", {
431
520
  // Key on the heartbeat tick so React re-mounts the dot on
432
521
  // every beat; CSS-animation restart from an attribute-only
433
522
  // change is not reliable across browsers.
434
523
  key: props.heartbeatTick, className: `generating-pulse${props.isStalled ? ' is-stalled' : ''}`, "aria-hidden": "true" }),
435
- React.createElement("div", { className: "generating-label", "aria-live": "polite", "aria-atomic": "true" },
524
+ React.createElement("div", { className: "generating-label", "aria-hidden": "true" },
436
525
  props.isStalled
437
526
  ? 'Still working, server may be slow'
438
- : 'Generating',
527
+ : hasCustomVerbs
528
+ ? _spinnerVerbs.verbs[verbIndex]
529
+ : 'Generating',
439
530
  props.showGenerating && props.elapsedSeconds > 0
440
531
  ? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
441
- : ''))),
532
+ : ''),
533
+ React.createElement("div", { className: "nbi-sr-only", "aria-live": "polite", "aria-atomic": "true" }, props.isStalled
534
+ ? 'Still working, server may be slow'
535
+ : hasCustomVerbs
536
+ ? _spinnerVerbs.verbs[verbIndex]
537
+ : 'Generating'))),
442
538
  React.createElement("div", { className: "chat-message-timestamp" }, timestamp)),
443
539
  React.createElement("div", { className: "chat-message-content" },
444
540
  groupedContents.map((item, index) => {
@@ -474,17 +570,8 @@ function ChatResponse(props) {
474
570
  React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-styled", onClick: () => runCommand(item.content.commandId, item.content.args) },
475
571
  React.createElement("div", { className: "jp-Dialog-buttonLabel" }, item.content.title))));
476
572
  case ResponseStreamDataType.Anchor: {
477
- const safeUri = safeAnchorUri(item.content.uri);
478
- if (!safeUri) {
479
- return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
480
- React.createElement("span", null,
481
- item.content.title,
482
- React.createElement("span", { className: "nbi-sr-only" }, " (link blocked)"))));
483
- }
484
573
  return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
485
- React.createElement("a", { href: safeUri, target: "_blank", rel: "noopener noreferrer" },
486
- item.content.title,
487
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)"))));
574
+ React.createElement(SafeAnchor, { href: item.content.uri }, item.content.title)));
488
575
  }
489
576
  case ResponseStreamDataType.Progress:
490
577
  // Render only the most recent progress entry, and only while
@@ -495,6 +582,12 @@ function ChatResponse(props) {
495
582
  // ✗ for error) rather than forcing a single rendering here.
496
583
  return index === groupedContents.length - 1 &&
497
584
  props.showGenerating ? (React.createElement("div", { className: "chat-response-progress", key: `key-${index}` }, item.content)) : null;
585
+ case ResponseStreamDataType.ToolCall:
586
+ // Unlike Progress, tool-call cards persist after the turn ends,
587
+ // so they render regardless of `showGenerating`. The grouping
588
+ // pass bundled this run's calls into content.toolCalls; the
589
+ // group renders a single card or a collapsible group.
590
+ return (React.createElement(ToolCallGroup, { key: `key-${index}`, toolCalls: item.content.toolCalls }));
498
591
  case ResponseStreamDataType.Confirmation:
499
592
  return answeredForms.get(item.id) ===
500
593
  'confirmed' ? null : answeredForms.get(item.id) ===
@@ -548,8 +641,8 @@ function ChatResponse(props) {
548
641
  }),
549
642
  msg.notebookLink && (React.createElement("button", { type: "button", className: "copilot-generated-notebook-link", "data-ref": msg.notebookLink, "aria-label": `Open notebook ${msg.notebookLink}`, onClick: openNotebook }, "open notebook"))),
550
643
  msg.from === 'copilot' &&
551
- !props.showGenerating &&
552
- NBIAPI.config.chatFeedbackEnabled && (React.createElement("div", { className: "chat-message-feedback" },
644
+ (NBIAPI.config.chatFeedbackAlwaysVisible || !props.showGenerating) &&
645
+ NBIAPI.config.chatFeedbackEnabled && (React.createElement("div", { className: `chat-message-feedback${NBIAPI.config.chatFeedbackAlwaysVisible ? ' always-visible' : ''}` },
553
646
  React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`, onClick: () => {
554
647
  var _a;
555
648
  props.onFeedback(msg.id, 'positive');
@@ -566,7 +659,7 @@ function ChatResponse(props) {
566
659
  }
567
660
  });
568
661
  }
569
- }, "aria-label": "Rate as helpful", "aria-pressed": msg.feedback === 'positive', title: "Helpful" }, msg.feedback === 'positive' ? (React.createElement(VscThumbsupFilled, null)) : (React.createElement(VscThumbsup, null))),
662
+ }, "aria-label": "Rate response as good", "aria-pressed": msg.feedback === 'positive', title: "Good response" }, msg.feedback === 'positive' ? (React.createElement(VscThumbsupFilled, null)) : (React.createElement(VscThumbsup, null))),
570
663
  React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'negative' ? 'selected' : ''}`, onClick: () => {
571
664
  var _a;
572
665
  props.onFeedback(msg.id, 'negative');
@@ -583,7 +676,7 @@ function ChatResponse(props) {
583
676
  }
584
677
  });
585
678
  }
586
- }, "aria-label": "Rate as unhelpful", "aria-pressed": msg.feedback === 'negative', title: "Not helpful" }, msg.feedback === 'negative' ? (React.createElement(VscThumbsdownFilled, null)) : (React.createElement(VscThumbsdown, null)))))));
679
+ }, "aria-label": "Rate response as bad", "aria-pressed": msg.feedback === 'negative', title: "Bad response" }, msg.feedback === 'negative' ? (React.createElement(VscThumbsdownFilled, null)) : (React.createElement(VscThumbsdown, null)))))));
587
680
  }
588
681
  const MemoizedChatResponse = memo(ChatResponse);
589
682
  async function submitCompletionRequest(request, responseEmitter) {
@@ -2131,21 +2224,29 @@ function SidebarComponent(props) {
2131
2224
  }
2132
2225
  if (delta['nbiContent']) {
2133
2226
  const nbiContent = delta['nbiContent'];
2134
- contents.push({
2135
- id: UUID.uuid4(),
2136
- type: nbiContent.type,
2137
- content: nbiContent.content || '',
2138
- reasoningContent: nbiContent.reasoning_content || '',
2139
- reasoningTag: nbiContent.reasoning_content
2140
- ? '<think>'
2141
- : undefined,
2142
- reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2143
- nbiContent.reasoning_content
2144
- ? true
2145
- : false,
2146
- contentDetail: nbiContent.detail,
2147
- created: new Date(response.created)
2148
- });
2227
+ if (nbiContent.type === ResponseStreamDataType.ToolCall) {
2228
+ // A tool call streams twice under one id (start, then finish);
2229
+ // merge by id so it stays one persistent card. See
2230
+ // upsertToolCallContent.
2231
+ upsertToolCallContent(contents, nbiContent.content, new Date(response.created));
2232
+ }
2233
+ else {
2234
+ contents.push({
2235
+ id: UUID.uuid4(),
2236
+ type: nbiContent.type,
2237
+ content: nbiContent.content || '',
2238
+ reasoningContent: nbiContent.reasoning_content || '',
2239
+ reasoningTag: nbiContent.reasoning_content
2240
+ ? '<think>'
2241
+ : undefined,
2242
+ reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2243
+ nbiContent.reasoning_content
2244
+ ? true
2245
+ : false,
2246
+ contentDetail: nbiContent.detail,
2247
+ created: new Date(response.created)
2248
+ });
2249
+ }
2149
2250
  }
2150
2251
  else {
2151
2252
  responseMessage =
@@ -2505,21 +2606,29 @@ function SidebarComponent(props) {
2505
2606
  }
2506
2607
  if (delta['nbiContent']) {
2507
2608
  const nbiContent = delta['nbiContent'];
2508
- contents.push({
2509
- id: UUID.uuid4(),
2510
- type: nbiContent.type,
2511
- content: nbiContent.content || '',
2512
- reasoningContent: nbiContent.reasoning_content || '',
2513
- reasoningTag: nbiContent.reasoning_content
2514
- ? '<think>'
2515
- : undefined,
2516
- reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2517
- nbiContent.reasoning_content
2518
- ? true
2519
- : false,
2520
- contentDetail: nbiContent.detail,
2521
- created: new Date(response.created)
2522
- });
2609
+ if (nbiContent.type === ResponseStreamDataType.ToolCall) {
2610
+ // A tool call streams twice under one id (start, then finish);
2611
+ // merge by id so it stays one persistent card. See
2612
+ // upsertToolCallContent.
2613
+ upsertToolCallContent(contents, nbiContent.content, new Date(response.created));
2614
+ }
2615
+ else {
2616
+ contents.push({
2617
+ id: UUID.uuid4(),
2618
+ type: nbiContent.type,
2619
+ content: nbiContent.content || '',
2620
+ reasoningContent: nbiContent.reasoning_content || '',
2621
+ reasoningTag: nbiContent.reasoning_content
2622
+ ? '<think>'
2623
+ : undefined,
2624
+ reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2625
+ nbiContent.reasoning_content
2626
+ ? true
2627
+ : false,
2628
+ contentDetail: nbiContent.detail,
2629
+ created: new Date(response.created)
2630
+ });
2631
+ }
2523
2632
  }
2524
2633
  else {
2525
2634
  const responseMessage = (_e = (_d = (_c = response.data['choices']) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d['delta']) === null || _e === void 0 ? void 0 : _e['content'];
@@ -3369,28 +3478,20 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
3369
3478
  ghLoginStatus === GitHubCopilotLoginStatus.NotLoggedIn && (React.createElement(React.Fragment, null,
3370
3479
  React.createElement("div", null, "Your code and data are directly transferred to GitHub Copilot as needed without storing any copies other than keeping in the process memory."),
3371
3480
  React.createElement("div", null,
3372
- React.createElement("a", { href: "https://github.com/features/copilot", target: "_blank", rel: "noopener noreferrer" },
3373
- "GitHub Copilot",
3374
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3481
+ React.createElement(SafeAnchor, { href: "https://github.com/features/copilot" }, "GitHub Copilot"),
3375
3482
  ' ',
3376
3483
  "requires a subscription and it has a free tier. GitHub Copilot is subject to the",
3377
3484
  ' ',
3378
- React.createElement("a", { href: "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", target: "_blank", rel: "noopener noreferrer" },
3379
- "GitHub Terms for Additional Products and Features",
3380
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3485
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features" }, "GitHub Terms for Additional Products and Features"),
3381
3486
  "."),
3382
3487
  React.createElement("div", null,
3383
3488
  React.createElement("h4", null, "Privacy and terms"),
3384
3489
  "By using Notebook Intelligence with GitHub Copilot subscription you agree to",
3385
3490
  ' ',
3386
- React.createElement("a", { href: "https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide", target: "_blank", rel: "noopener noreferrer" },
3387
- "GitHub Copilot chat terms",
3388
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3491
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide" }, "GitHub Copilot chat terms"),
3389
3492
  ". Review the terms to understand about usage, limitations and ways to improve GitHub Copilot. Please review",
3390
3493
  ' ',
3391
- React.createElement("a", { href: "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", target: "_blank", rel: "noopener noreferrer" },
3392
- "Privacy Statement",
3393
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3494
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" }, "Privacy Statement"),
3394
3495
  "."),
3395
3496
  React.createElement("div", null,
3396
3497
  React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-reject jp-mod-styled", onClick: handleLoginClick },
@@ -3415,7 +3516,7 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
3415
3516
  ' ',
3416
3517
  "and enter at",
3417
3518
  ' ',
3418
- React.createElement("a", { href: deviceActivationURL, target: "_blank", rel: "noopener noreferrer" }, deviceActivationURL),
3519
+ React.createElement(SafeAnchor, { href: deviceActivationURL }, deviceActivationURL),
3419
3520
  ' ',
3420
3521
  "to allow access to GitHub Copilot from this app. Activation could take up to a minute after you enter the code."))),
3421
3522
  ghLoginStatus === GitHubCopilotLoginStatus.ActivatingDevice && (React.createElement("div", { style: { marginTop: '10px' } },
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { JupyterFrontEnd } from '@jupyterlab/application';
3
+ type MarkdownLinkProps = {
4
+ app: JupyterFrontEnd;
5
+ baseDir: string;
6
+ href: unknown;
7
+ title?: unknown;
8
+ children?: React.ReactNode;
9
+ };
10
+ /**
11
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
12
+ * links can never replace the JupyterLab shell or pivot through the
13
+ * lab origin.
14
+ *
15
+ * Three branches:
16
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
17
+ * navigate to `about:blank#section`, and a same-tab open would scroll
18
+ * the wrong document; neither matches what the LLM meant.
19
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
20
+ * resolved against the active document's directory, re-validated,
21
+ * and routed through JupyterLab's `docmanager:open` command so a
22
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
23
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
24
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
25
+ * navigate `/lab/<path>` with session cookies attached; the hover
26
+ * preview moves to `title` so the user still sees the intended
27
+ * target.
28
+ * - Everything else: handed to `SafeAnchor`, which enforces the
29
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
30
+ * `rel="noopener noreferrer"`.
31
+ */
32
+ export declare function MarkdownLink({ app, baseDir, href, title, children }: MarkdownLinkProps): React.ReactElement;
33
+ export {};
@@ -0,0 +1,114 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React from 'react';
3
+ import { PathExt } from '@jupyterlab/coreutils';
4
+ import { SafeAnchor } from './safe-anchor';
5
+ import { hasDangerousTextCodepoints } from '../utils';
6
+ // Match an absolute URI by its scheme prefix so a workspace-relative path
7
+ // (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
8
+ // Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
9
+ // answers a different question (presence vs. allowlist).
10
+ const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
11
+ /**
12
+ * True when a freshly-joined workspace path is *not* safe to hand to
13
+ * `docmanager:open` or expose on a rendered anchor. Rejects:
14
+ *
15
+ * - leading `..` segments or absolute paths: the join didn't anchor and
16
+ * the path escapes the Jupyter root (ContentsManager rejects too, but
17
+ * we want to fail closed visually as well so the status bar/title
18
+ * never previews a traversal target),
19
+ * - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
20
+ * returns the input verbatim, so a path that looks workspace-relative
21
+ * pre-join can unmask into a `javascript:` href when `baseDir` is
22
+ * empty (any active doc at server root),
23
+ * - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
24
+ * the WHATWG URL parser strips these from the scheme during
25
+ * recognition, and they also visually impersonate the link target on
26
+ * hover / in dev-tools logs.
27
+ */
28
+ function isUnsafeWorkspacePath(path) {
29
+ // Empty / cwd-only paths reach here when react-markdown's built-in
30
+ // `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
31
+ // to an empty string before our override runs: the result joins to
32
+ // either `""` or `"."`, both of which would render as a dead
33
+ // `<a href="#">` that 404s on click. Surface them as blocked-link
34
+ // spans so the user sees why nothing happened.
35
+ if (path === '' || path === '.' || path === './') {
36
+ return true;
37
+ }
38
+ if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
39
+ return true;
40
+ }
41
+ if (SCHEME_PREFIX_RE.test(path)) {
42
+ return true;
43
+ }
44
+ if (hasDangerousTextCodepoints(path)) {
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ /**
50
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
51
+ * links can never replace the JupyterLab shell or pivot through the
52
+ * lab origin.
53
+ *
54
+ * Three branches:
55
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
56
+ * navigate to `about:blank#section`, and a same-tab open would scroll
57
+ * the wrong document; neither matches what the LLM meant.
58
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
59
+ * resolved against the active document's directory, re-validated,
60
+ * and routed through JupyterLab's `docmanager:open` command so a
61
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
62
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
63
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
64
+ * navigate `/lab/<path>` with session cookies attached; the hover
65
+ * preview moves to `title` so the user still sees the intended
66
+ * target.
67
+ * - Everything else: handed to `SafeAnchor`, which enforces the
68
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
69
+ * `rel="noopener noreferrer"`.
70
+ */
71
+ export function MarkdownLink({ app, baseDir, href, title, children }) {
72
+ if (typeof href === 'string') {
73
+ if (href.startsWith('#')) {
74
+ return React.createElement("span", null, children);
75
+ }
76
+ if (!SCHEME_PREFIX_RE.test(href) &&
77
+ !href.startsWith('/') &&
78
+ !href.startsWith('//')) {
79
+ // PathExt.join: plain concatenation + normalization. Resolve()
80
+ // would fall back to the browser process cwd when `baseDir` is
81
+ // relative, which gives nonsense like `/Users/.../notebooks/...`.
82
+ const resolvedPath = PathExt.join(baseDir, href);
83
+ // Re-validate post-join. Two attack/confusion shapes the pre-check
84
+ // alone misses: `[x](java\tscript:alert(1))` survives the scheme
85
+ // sniff because `\t` isn't a scheme char, then unmasks once the
86
+ // WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
87
+ // looks workspace-relative but escapes the workspace root.
88
+ if (isUnsafeWorkspacePath(resolvedPath)) {
89
+ return (React.createElement(SafeAnchor, { href: null, title: undefined }, children));
90
+ }
91
+ // href="#" rather than href={resolvedPath}: a modifier-click on a
92
+ // populated href bypasses the React onClick, lets the browser
93
+ // navigate the chat sidebar to /lab/<path> in a new tab, and would
94
+ // ride along the user's Jupyter session cookies. The hover preview
95
+ // moves to `title` so the user still sees the intended target.
96
+ const safeTitleFromMd = typeof title === 'string' && !hasDangerousTextCodepoints(title)
97
+ ? title
98
+ : undefined;
99
+ const hoverTitle = safeTitleFromMd !== null && safeTitleFromMd !== void 0 ? safeTitleFromMd : resolvedPath;
100
+ const onClick = (e) => {
101
+ e.preventDefault();
102
+ // ContentsManager rejects paths outside the Jupyter root with a
103
+ // promise rejection. Catch so the failure surfaces in logs instead
104
+ // of an unhandled rejection, and the user can see the rendered
105
+ // anchor was attempted even when the target doesn't exist.
106
+ Promise.resolve(app.commands.execute('docmanager:open', { path: resolvedPath })).catch(err => {
107
+ console.warn(`NBI: failed to open workspace path "${resolvedPath}":`, err);
108
+ });
109
+ };
110
+ return (React.createElement("a", { href: "#", title: hoverTitle, onClick: onClick }, children));
111
+ }
112
+ }
113
+ return (React.createElement(SafeAnchor, { href: typeof href === 'string' ? href : null, title: typeof title === 'string' ? title : undefined }, children));
114
+ }
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ type SafeAnchorProps = {
3
+ href: string | undefined | null;
4
+ children: React.ReactNode;
5
+ title?: string;
6
+ className?: string;
7
+ };
8
+ /**
9
+ * The single render path for anchor elements driven by LLM / tool output.
10
+ *
11
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
12
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
13
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
14
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
15
+ * on reject it falls through to plain text plus an SR-only "(link
16
+ * blocked)" note so screen readers can tell why the link disappeared.
17
+ *
18
+ * The `title` attribute is scrubbed for the same dangerous codepoints
19
+ * the URI check rejects, since react-markdown forwards CommonMark
20
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
21
+ * smuggle bidi-override or zero-width characters there to visually
22
+ * impersonate the link target on hover.
23
+ */
24
+ export declare function SafeAnchor({ href, children, title, className }: SafeAnchorProps): React.ReactElement;
25
+ export {};