@plmbr/notebook-intelligence 5.0.1 → 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
@@ -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
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
@@ -20,6 +20,8 @@ import { mcpServerSettingsToEnabledState } from './components/mcp-util';
20
20
  import claudeSvgStr from '../style/icons/claude.svg';
21
21
  import { AskUserQuestion } from './components/ask-user-question';
22
22
  import { ClaudeSessionPicker } from './components/claude-session-picker';
23
+ import { ToolCallGroup } from './components/tool-call-group';
24
+ import { upsertToolCallContent } from './tool-call-stream';
23
25
  import { TourOverlay } from './tour/tour-overlay';
24
26
  import { TOUR_ANCHOR } from './tour/tour-anchors';
25
27
  import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
@@ -282,8 +284,81 @@ function ChatResponseHTMLFrame(props) {
282
284
  }
283
285
  // Memoize ChatResponse for performance
284
286
  function ChatResponse(props) {
285
- var _a, _b, _c;
287
+ var _a, _b, _c, _d;
286
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]);
287
362
  const msg = props.message;
288
363
  const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
289
364
  const openNotebook = (event) => {
@@ -377,6 +452,19 @@ function ChatResponse(props) {
377
452
  lastItem.reasoningFinished = true;
378
453
  }
379
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
+ }
380
468
  else {
381
469
  groupedContents.push(structuredClone(item));
382
470
  lastItemType = item.type;
@@ -418,28 +506,35 @@ function ChatResponse(props) {
418
506
  ? 'Thought'
419
507
  : `Thinking (${Math.floor(item.reasoningTime)} s)`;
420
508
  };
421
- 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';
422
510
  return (React.createElement("div", { className: `chat-message chat-message-${msg.from}`, "data-render-count": renderCount },
423
511
  React.createElement("div", { className: "chat-message-header" },
424
512
  React.createElement("div", { className: "chat-message-from" },
425
- ((_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' : ''}` },
426
514
  React.createElement("img", { src: msg.participant.iconPath, alt: "" }))),
427
515
  React.createElement("div", { className: "chat-message-from-title" }, msg.from === 'user'
428
516
  ? 'User'
429
- : ((_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'),
430
518
  React.createElement("div", { className: "chat-message-from-progress", style: { display: `${props.showGenerating ? 'visible' : 'none'}` } },
431
519
  React.createElement("span", {
432
520
  // Key on the heartbeat tick so React re-mounts the dot on
433
521
  // every beat; CSS-animation restart from an attribute-only
434
522
  // change is not reliable across browsers.
435
523
  key: props.heartbeatTick, className: `generating-pulse${props.isStalled ? ' is-stalled' : ''}`, "aria-hidden": "true" }),
436
- React.createElement("div", { className: "generating-label", "aria-live": "polite", "aria-atomic": "true" },
524
+ React.createElement("div", { className: "generating-label", "aria-hidden": "true" },
437
525
  props.isStalled
438
526
  ? 'Still working, server may be slow'
439
- : 'Generating',
527
+ : hasCustomVerbs
528
+ ? _spinnerVerbs.verbs[verbIndex]
529
+ : 'Generating',
440
530
  props.showGenerating && props.elapsedSeconds > 0
441
531
  ? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
442
- : ''))),
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'))),
443
538
  React.createElement("div", { className: "chat-message-timestamp" }, timestamp)),
444
539
  React.createElement("div", { className: "chat-message-content" },
445
540
  groupedContents.map((item, index) => {
@@ -487,6 +582,12 @@ function ChatResponse(props) {
487
582
  // ✗ for error) rather than forcing a single rendering here.
488
583
  return index === groupedContents.length - 1 &&
489
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 }));
490
591
  case ResponseStreamDataType.Confirmation:
491
592
  return answeredForms.get(item.id) ===
492
593
  'confirmed' ? null : answeredForms.get(item.id) ===
@@ -540,8 +641,8 @@ function ChatResponse(props) {
540
641
  }),
541
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"))),
542
643
  msg.from === 'copilot' &&
543
- !props.showGenerating &&
544
- 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' : ''}` },
545
646
  React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`, onClick: () => {
546
647
  var _a;
547
648
  props.onFeedback(msg.id, 'positive');
@@ -558,7 +659,7 @@ function ChatResponse(props) {
558
659
  }
559
660
  });
560
661
  }
561
- }, "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))),
562
663
  React.createElement("button", { className: `chat-feedback-btn ${msg.feedback === 'negative' ? 'selected' : ''}`, onClick: () => {
563
664
  var _a;
564
665
  props.onFeedback(msg.id, 'negative');
@@ -575,7 +676,7 @@ function ChatResponse(props) {
575
676
  }
576
677
  });
577
678
  }
578
- }, "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)))))));
579
680
  }
580
681
  const MemoizedChatResponse = memo(ChatResponse);
581
682
  async function submitCompletionRequest(request, responseEmitter) {
@@ -2123,21 +2224,29 @@ function SidebarComponent(props) {
2123
2224
  }
2124
2225
  if (delta['nbiContent']) {
2125
2226
  const nbiContent = delta['nbiContent'];
2126
- contents.push({
2127
- id: UUID.uuid4(),
2128
- type: nbiContent.type,
2129
- content: nbiContent.content || '',
2130
- reasoningContent: nbiContent.reasoning_content || '',
2131
- reasoningTag: nbiContent.reasoning_content
2132
- ? '<think>'
2133
- : undefined,
2134
- reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2135
- nbiContent.reasoning_content
2136
- ? true
2137
- : false,
2138
- contentDetail: nbiContent.detail,
2139
- created: new Date(response.created)
2140
- });
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
+ }
2141
2250
  }
2142
2251
  else {
2143
2252
  responseMessage =
@@ -2497,21 +2606,29 @@ function SidebarComponent(props) {
2497
2606
  }
2498
2607
  if (delta['nbiContent']) {
2499
2608
  const nbiContent = delta['nbiContent'];
2500
- contents.push({
2501
- id: UUID.uuid4(),
2502
- type: nbiContent.type,
2503
- content: nbiContent.content || '',
2504
- reasoningContent: nbiContent.reasoning_content || '',
2505
- reasoningTag: nbiContent.reasoning_content
2506
- ? '<think>'
2507
- : undefined,
2508
- reasoningFinished: nbiContent.type === ResponseStreamDataType.Markdown &&
2509
- nbiContent.reasoning_content
2510
- ? true
2511
- : false,
2512
- contentDetail: nbiContent.detail,
2513
- created: new Date(response.created)
2514
- });
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
+ }
2515
2632
  }
2516
2633
  else {
2517
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'];
@@ -0,0 +1,26 @@
1
+ /// <reference types="react" />
2
+ export interface IToolCallDiffLine {
3
+ type: 'add' | 'remove' | 'context' | string;
4
+ content: string;
5
+ }
6
+ export interface IToolCallDiff {
7
+ path: string;
8
+ lines: IToolCallDiffLine[];
9
+ truncated?: boolean;
10
+ }
11
+ /**
12
+ * A single agent tool call surfaced as a persistent chat card. Mirrors the
13
+ * `ToolCallData` payload emitted by the server: it stays in the transcript
14
+ * after the turn ends and carries its final status, unlike the transient
15
+ * single progress line it replaces. File-edit tools also carry inline diffs.
16
+ */
17
+ export interface IToolCall {
18
+ id: string;
19
+ title: string;
20
+ kind: 'read' | 'edit' | 'execute' | 'other' | string;
21
+ status: 'in_progress' | 'completed' | 'failed' | 'cancelled' | string;
22
+ diffs?: IToolCallDiff[];
23
+ }
24
+ export declare function ToolCallCard(props: {
25
+ toolCall: IToolCall;
26
+ }): JSX.Element;
@@ -0,0 +1,54 @@
1
+ import React, { useState } from 'react';
2
+ import { VscChevronDown, VscChevronRight, VscClose, VscEdit, VscError, VscEye, VscPassFilled, VscSync, VscTerminal, VscTools } from '../icons';
3
+ const KIND_ICONS = {
4
+ read: VscEye,
5
+ edit: VscEdit,
6
+ execute: VscTerminal,
7
+ other: VscTools
8
+ };
9
+ const STATUS_LABELS = {
10
+ in_progress: 'in progress',
11
+ completed: 'completed',
12
+ failed: 'failed',
13
+ cancelled: 'cancelled'
14
+ };
15
+ const GUTTER = { add: '+', remove: '-' };
16
+ function ToolCallDiffView(props) {
17
+ return (React.createElement("div", { className: "nbi-tool-call-diffs" }, props.diffs.map((diff, i) => (React.createElement("div", { className: "nbi-tool-call-diff", key: i },
18
+ diff.path ? (React.createElement("div", { className: "nbi-tool-call-diff-path", title: diff.path }, diff.path)) : null,
19
+ React.createElement("div", { className: "nbi-tool-call-diff-body" }, diff.lines.map((line, j) => {
20
+ var _a;
21
+ return (React.createElement("div", { className: `nbi-tool-call-diff-line nbi-diff-${line.type}`, key: j },
22
+ React.createElement("span", { className: "nbi-diff-gutter", "aria-hidden": "true" }, (_a = GUTTER[line.type]) !== null && _a !== void 0 ? _a : ' '),
23
+ line.type === 'add' || line.type === 'remove' ? (React.createElement("span", { className: "nbi-sr-only" }, line.type === 'add' ? 'added ' : 'removed ')) : null,
24
+ React.createElement("span", { className: "nbi-diff-text" }, line.content)));
25
+ })),
26
+ diff.truncated ? (React.createElement("div", { className: "nbi-tool-call-diff-truncated" }, "diff truncated")) : null)))));
27
+ }
28
+ export function ToolCallCard(props) {
29
+ var _a, _b;
30
+ const { title, kind, status, diffs } = props.toolCall;
31
+ const [expanded, setExpanded] = useState(true);
32
+ const KindIcon = (_a = KIND_ICONS[kind]) !== null && _a !== void 0 ? _a : VscTools;
33
+ let StatusIcon = VscSync;
34
+ if (status === 'completed') {
35
+ StatusIcon = VscPassFilled;
36
+ }
37
+ else if (status === 'failed') {
38
+ StatusIcon = VscError;
39
+ }
40
+ else if (status === 'cancelled') {
41
+ StatusIcon = VscClose;
42
+ }
43
+ const statusLabel = (_b = STATUS_LABELS[status]) !== null && _b !== void 0 ? _b : status;
44
+ const statusModifier = status.replace(/_/g, '-');
45
+ const hasDiffs = Array.isArray(diffs) && diffs.length > 0;
46
+ return (React.createElement("div", { className: "nbi-tool-call-wrapper" },
47
+ React.createElement("div", { className: `nbi-tool-call nbi-tool-call-${statusModifier}` },
48
+ React.createElement(KindIcon, { className: "nbi-tool-call-kind-icon", "aria-hidden": "true" }),
49
+ React.createElement("span", { className: "nbi-tool-call-title", title: title }, title),
50
+ hasDiffs ? (React.createElement("button", { type: "button", className: "nbi-tool-call-diff-toggle", onClick: () => setExpanded(e => !e), "aria-expanded": expanded, "aria-label": expanded ? 'Hide diff' : 'Show diff' }, expanded ? (React.createElement(VscChevronDown, { "aria-hidden": "true" })) : (React.createElement(VscChevronRight, { "aria-hidden": "true" })))) : null,
51
+ React.createElement(StatusIcon, { className: "nbi-tool-call-status-icon", "aria-hidden": "true" }),
52
+ React.createElement("span", { className: "nbi-sr-only" }, statusLabel)),
53
+ hasDiffs && expanded ? React.createElement(ToolCallDiffView, { diffs: diffs }) : null));
54
+ }
@@ -0,0 +1,11 @@
1
+ /// <reference types="react" />
2
+ import { IToolCall } from './tool-call-card';
3
+ /**
4
+ * Renders a run of consecutive tool calls. A single call is shown as a bare
5
+ * card; multiple calls are wrapped in one collapsible group with a summary
6
+ * header, so consecutive agent activity reads as one unit instead of a wall
7
+ * of rows.
8
+ */
9
+ export declare function ToolCallGroup(props: {
10
+ toolCalls: IToolCall[];
11
+ }): JSX.Element;
@@ -0,0 +1,33 @@
1
+ import React, { useState } from 'react';
2
+ import { VscChevronDown, VscChevronRight } from '../icons';
3
+ import { ToolCallCard } from './tool-call-card';
4
+ // Groups with more calls than this start collapsed so a tool-heavy turn (or a
5
+ // reloaded transcript) doesn't flood the chat. A live turn mounts the group at
6
+ // length 1, so it starts expanded and stays visible as calls stream in.
7
+ const GROUP_COLLAPSE_THRESHOLD = 3;
8
+ /**
9
+ * Renders a run of consecutive tool calls. A single call is shown as a bare
10
+ * card; multiple calls are wrapped in one collapsible group with a summary
11
+ * header, so consecutive agent activity reads as one unit instead of a wall
12
+ * of rows.
13
+ */
14
+ export function ToolCallGroup(props) {
15
+ const { toolCalls } = props;
16
+ // Hooks must run unconditionally and in a stable order: the group's length
17
+ // grows as calls stream in, so call useState before any length branch.
18
+ // Start collapsed only for a large group whose calls are all settled --
19
+ // never hide a still-running or failed call (matters on transcript reload,
20
+ // where a group can mount already large).
21
+ const [expanded, setExpanded] = useState(() => toolCalls.length <= GROUP_COLLAPSE_THRESHOLD ||
22
+ toolCalls.some(t => t.status === 'in_progress' || t.status === 'failed'));
23
+ if (toolCalls.length <= 1) {
24
+ return toolCalls.length === 1 ? (React.createElement(ToolCallCard, { toolCall: toolCalls[0] })) : (React.createElement(React.Fragment, null));
25
+ }
26
+ const failed = toolCalls.filter(t => t.status === 'failed').length;
27
+ const summary = `${toolCalls.length} tool calls` + (failed ? ` (${failed} failed)` : '');
28
+ return (React.createElement("div", { className: "nbi-tool-call-group" },
29
+ React.createElement("button", { type: "button", className: "nbi-tool-call-group-header", onClick: () => setExpanded(e => !e), "aria-expanded": expanded },
30
+ expanded ? (React.createElement(VscChevronDown, { "aria-hidden": "true" })) : (React.createElement(VscChevronRight, { "aria-hidden": "true" })),
31
+ React.createElement("span", { className: "nbi-tool-call-group-summary" }, summary)),
32
+ expanded ? (React.createElement("div", { className: "nbi-tool-call-group-body" }, toolCalls.map(toolCall => (React.createElement(ToolCallCard, { key: toolCall.id, toolCall: toolCall }))))) : null));
33
+ }
package/lib/icons.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare const VscClose: IconLike;
17
17
  export declare const VscCloudUpload: IconLike;
18
18
  export declare const VscCopy: IconLike;
19
19
  export declare const VscEdit: IconLike;
20
+ export declare const VscError: IconLike;
20
21
  export declare const VscEye: IconLike;
21
22
  export declare const VscEyeClosed: IconLike;
22
23
  export declare const VscFile: IconLike;
@@ -31,6 +32,8 @@ export declare const VscSend: IconLike;
31
32
  export declare const VscSettingsGear: IconLike;
32
33
  export declare const VscSparkle: IconLike;
33
34
  export declare const VscStopCircle: IconLike;
35
+ export declare const VscSync: IconLike;
36
+ export declare const VscTerminal: IconLike;
34
37
  export declare const VscThumbsdown: IconLike;
35
38
  export declare const VscThumbsdownFilled: IconLike;
36
39
  export declare const VscThumbsup: IconLike;
package/lib/icons.js CHANGED
@@ -27,6 +27,7 @@ export const VscClose = asIcon(Vsc.VscClose);
27
27
  export const VscCloudUpload = asIcon(Vsc.VscCloudUpload);
28
28
  export const VscCopy = asIcon(Vsc.VscCopy);
29
29
  export const VscEdit = asIcon(Vsc.VscEdit);
30
+ export const VscError = asIcon(Vsc.VscError);
30
31
  export const VscEye = asIcon(Vsc.VscEye);
31
32
  export const VscEyeClosed = asIcon(Vsc.VscEyeClosed);
32
33
  export const VscFile = asIcon(Vsc.VscFile);
@@ -41,6 +42,8 @@ export const VscSend = asIcon(Vsc.VscSend);
41
42
  export const VscSettingsGear = asIcon(Vsc.VscSettingsGear);
42
43
  export const VscSparkle = asIcon(Vsc.VscSparkle);
43
44
  export const VscStopCircle = asIcon(Vsc.VscStopCircle);
45
+ export const VscSync = asIcon(Vsc.VscSync);
46
+ export const VscTerminal = asIcon(Vsc.VscTerminal);
44
47
  export const VscThumbsdown = asIcon(Vsc.VscThumbsdown);
45
48
  export const VscThumbsdownFilled = asIcon(Vsc.VscThumbsdownFilled);
46
49
  export const VscThumbsup = asIcon(Vsc.VscThumbsup);
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import sparklesWarningSvgstr from '../style/icons/sparkles-warning.svg';
36
36
  import claudeSvgstr from '../style/icons/claude.svg';
37
37
  import openaiSvgstr from '../style/icons/openai.svg';
38
38
  import opencodeSvgstr from '../style/icons/opencode.svg';
39
- import { applyCodeToSelectionInEditor, cellOutputAsText, cellOutputHasError, chooseWorkspaceDirectory, compareSelections, extractLLMGeneratedCode, getSelectionInEditor, getTokenCount, getWholeNotebookContent, isSelectionEmpty, markdownToComment, waitForDuration } from './utils';
39
+ import { applyCodeToSelectionInEditor, cellOutputAsText, cellOutputHasError, chooseWorkspaceDirectory, compareSelections, buildResumeCommand, extractLLMGeneratedCode, getSelectionInEditor, getTokenCount, getWholeNotebookContent, isSelectionEmpty, markdownToComment, waitForDuration } from './utils';
40
40
  import { cellOutputAsContextBundle } from './cell-output-bundle';
41
41
  import { UUID } from '@lumino/coreutils';
42
42
  import * as path from 'path';
@@ -1108,11 +1108,9 @@ const plugin = {
1108
1108
  render() {
1109
1109
  return React.createElement(LauncherPicker, {
1110
1110
  onSessionSelected: (session) => {
1111
+ var _a;
1111
1112
  dialog.close();
1112
- const cmd = session.cwd
1113
- ? `cd ${session.cwd} && claude --resume ${session.session_id}`
1114
- : `claude --resume ${session.session_id}`;
1115
- launchCliInTerminal(cmd);
1113
+ launchCliInTerminal(buildResumeCommand((_a = session.cwd) !== null && _a !== void 0 ? _a : '', session.session_id));
1116
1114
  }
1117
1115
  });
1118
1116
  }
@@ -1360,7 +1358,7 @@ const plugin = {
1360
1358
  metadata: { trusted: true },
1361
1359
  source: args.source
1362
1360
  });
1363
- return true;
1361
+ return { cellIndex: newCellIndex };
1364
1362
  }
1365
1363
  });
1366
1364
  app.commands.addCommand(CommandIDs.addCodeCellToActiveNotebook, {
@@ -1378,7 +1376,7 @@ const plugin = {
1378
1376
  metadata: { trusted: true },
1379
1377
  source: args.source
1380
1378
  });
1381
- return true;
1379
+ return { cellIndex: newCellIndex };
1382
1380
  }
1383
1381
  });
1384
1382
  app.commands.addCommand(CommandIDs.getCellTypeAndSource, {
package/lib/tokens.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare enum ResponseStreamDataType {
41
41
  Button = "button",
42
42
  Anchor = "anchor",
43
43
  Progress = "progress",
44
+ ToolCall = "tool-call",
44
45
  Confirmation = "confirmation",
45
46
  AskUserQuestion = "ask-user-question"
46
47
  }
package/lib/tokens.js CHANGED
@@ -32,6 +32,7 @@ export var ResponseStreamDataType;
32
32
  ResponseStreamDataType["Button"] = "button";
33
33
  ResponseStreamDataType["Anchor"] = "anchor";
34
34
  ResponseStreamDataType["Progress"] = "progress";
35
+ ResponseStreamDataType["ToolCall"] = "tool-call";
35
36
  ResponseStreamDataType["Confirmation"] = "confirmation";
36
37
  ResponseStreamDataType["AskUserQuestion"] = "ask-user-question";
37
38
  })(ResponseStreamDataType || (ResponseStreamDataType = {}));
@@ -0,0 +1,24 @@
1
+ import { ResponseStreamDataType } from './tokens';
2
+ /**
3
+ * The subset of a chat message's stream-content item this helper needs. A
4
+ * structural subset of `IChatMessageContent` (defined in chat-sidebar) so it
5
+ * can be unit-tested without importing the sidebar (and creating a cycle).
6
+ */
7
+ export interface IToolCallStreamItem {
8
+ id: string;
9
+ type: ResponseStreamDataType;
10
+ content: any;
11
+ created: Date;
12
+ }
13
+ /**
14
+ * Merge a streamed tool-call payload into `contents` by its tool-call id.
15
+ *
16
+ * A tool call streams twice under one id (once when it starts, once when it
17
+ * finishes). The first emission pushes a new card; the second updates that
18
+ * card's content (its status) in place, so the call stays a single persistent
19
+ * row rather than appending a duplicate. Mutates `contents`.
20
+ */
21
+ export declare function upsertToolCallContent(contents: IToolCallStreamItem[], content: {
22
+ id: string;
23
+ [key: string]: any;
24
+ }, created: Date): void;
@@ -0,0 +1,28 @@
1
+ import { UUID } from '@lumino/coreutils';
2
+ import { ResponseStreamDataType } from './tokens';
3
+ /**
4
+ * Merge a streamed tool-call payload into `contents` by its tool-call id.
5
+ *
6
+ * A tool call streams twice under one id (once when it starts, once when it
7
+ * finishes). The first emission pushes a new card; the second updates that
8
+ * card's content (its status) in place, so the call stays a single persistent
9
+ * row rather than appending a duplicate. Mutates `contents`.
10
+ */
11
+ export function upsertToolCallContent(contents, content, created) {
12
+ const existing = contents.find(c => {
13
+ var _a;
14
+ return c.type === ResponseStreamDataType.ToolCall &&
15
+ ((_a = c.content) === null || _a === void 0 ? void 0 : _a.id) === (content === null || content === void 0 ? void 0 : content.id);
16
+ });
17
+ if (existing) {
18
+ existing.content = content;
19
+ }
20
+ else {
21
+ contents.push({
22
+ id: UUID.uuid4(),
23
+ type: ResponseStreamDataType.ToolCall,
24
+ content,
25
+ created
26
+ });
27
+ }
28
+ }
package/lib/utils.js CHANGED
@@ -341,10 +341,11 @@ export function safeAnchorUri(uri) {
341
341
  * user happens to be in the JupyterLab working directory.
342
342
  */
343
343
  export function buildResumeCommand(cwd, sessionId) {
344
+ const quotedSessionId = shellSingleQuote(sessionId);
344
345
  if (!cwd) {
345
- return `claude --resume ${sessionId}`;
346
+ return `claude --resume ${quotedSessionId}`;
346
347
  }
347
- return `cd ${shellSingleQuote(cwd)} && claude --resume ${sessionId}`;
348
+ return `cd ${shellSingleQuote(cwd)} && claude --resume ${quotedSessionId}`;
348
349
  }
349
350
  /**
350
351
  * Write `text` to the system clipboard. Falls back to a hidden textarea +