@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 +6 -0
- package/lib/api.d.ts +5 -0
- package/lib/api.js +7 -0
- package/lib/chat-sidebar.js +158 -41
- package/lib/components/tool-call-card.d.ts +26 -0
- package/lib/components/tool-call-card.js +54 -0
- package/lib/components/tool-call-group.d.ts +11 -0
- package/lib/components/tool-call-group.js +33 -0
- package/lib/icons.d.ts +3 -0
- package/lib/icons.js +3 -0
- package/lib/index.js +5 -7
- package/lib/tokens.d.ts +1 -0
- package/lib/tokens.js +1 -0
- package/lib/tool-call-stream.d.ts +24 -0
- package/lib/tool-call-stream.js +28 -0
- package/lib/utils.js +3 -2
- package/package.json +7 -1
- package/src/api.ts +8 -0
- package/src/chat-sidebar.tsx +190 -44
- package/src/components/tool-call-card.tsx +146 -0
- package/src/components/tool-call-group.tsx +65 -0
- package/src/icons.ts +3 -0
- package/src/index.ts +6 -6
- package/src/tokens.ts +1 -0
- package/src/tool-call-stream.ts +44 -0
- package/src/utils.ts +3 -2
- package/style/base.css +173 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plmbr/notebook-intelligence",
|
|
3
|
-
"version": "5.0.1",
|
|
3
|
+
"version": "5.1.0-a.1",
|
|
4
4
|
"description": "AI coding assistant for JupyterLab",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"AI",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"install:extension": "jlpm build",
|
|
50
50
|
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
|
|
51
51
|
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
|
|
52
|
+
"postinstall": "husky || exit 0",
|
|
52
53
|
"prettier": "jlpm prettier:base --write --list-different",
|
|
53
54
|
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
|
|
54
55
|
"prettier:check": "jlpm prettier:base --check",
|
|
@@ -98,8 +99,10 @@
|
|
|
98
99
|
"eslint": "^8.36.0",
|
|
99
100
|
"eslint-config-prettier": "^8.8.0",
|
|
100
101
|
"eslint-plugin-prettier": "^5.0.0",
|
|
102
|
+
"husky": "^9.1.7",
|
|
101
103
|
"jest": "^29.7.0",
|
|
102
104
|
"jest-environment-jsdom": "^29.7.0",
|
|
105
|
+
"lint-staged": "^15.2.10",
|
|
103
106
|
"mkdirp": "^1.0.3",
|
|
104
107
|
"monaco-editor-webpack-plugin": "^2.0.0",
|
|
105
108
|
"npm-run-all": "^4.1.5",
|
|
@@ -238,6 +241,9 @@
|
|
|
238
241
|
}
|
|
239
242
|
]
|
|
240
243
|
},
|
|
244
|
+
"lint-staged": {
|
|
245
|
+
"*": "prettier --write --ignore-unknown"
|
|
246
|
+
},
|
|
241
247
|
"stylelint": {
|
|
242
248
|
"extends": [
|
|
243
249
|
"stylelint-config-recommended",
|
package/src/api.ts
CHANGED
|
@@ -388,6 +388,10 @@ export class NBIConfig {
|
|
|
388
388
|
return this.capabilities.claude_settings;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
get spinnerVerbs(): { mode: string; verbs: string[] } | null {
|
|
392
|
+
return this.capabilities.spinner_verbs ?? null;
|
|
393
|
+
}
|
|
394
|
+
|
|
391
395
|
get claudeModels(): IClaudeModelInfo[] {
|
|
392
396
|
return (this.capabilities.claude_models ?? []).map(claudeModelFromWire);
|
|
393
397
|
}
|
|
@@ -435,6 +439,10 @@ export class NBIConfig {
|
|
|
435
439
|
return this.capabilities.chat_feedback_enabled === true;
|
|
436
440
|
}
|
|
437
441
|
|
|
442
|
+
get chatFeedbackAlwaysVisible(): boolean {
|
|
443
|
+
return this.capabilities.chat_feedback_always_visible === true;
|
|
444
|
+
}
|
|
445
|
+
|
|
438
446
|
// Admin-supplied tour-copy overrides, served from the capabilities
|
|
439
447
|
// response after server-side validation. Returns the raw dict; the
|
|
440
448
|
// tour module decides how to apply it. Defaults to a shared frozen
|
package/src/chat-sidebar.tsx
CHANGED
|
@@ -80,6 +80,8 @@ import { mcpServerSettingsToEnabledState } from './components/mcp-util';
|
|
|
80
80
|
import claudeSvgStr from '../style/icons/claude.svg';
|
|
81
81
|
import { AskUserQuestion } from './components/ask-user-question';
|
|
82
82
|
import { ClaudeSessionPicker } from './components/claude-session-picker';
|
|
83
|
+
import { ToolCallGroup } from './components/tool-call-group';
|
|
84
|
+
import { upsertToolCallContent } from './tool-call-stream';
|
|
83
85
|
import { TourOverlay } from './tour/tour-overlay';
|
|
84
86
|
import { TOUR_ANCHOR } from './tour/tour-anchors';
|
|
85
87
|
import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
|
|
@@ -522,6 +524,92 @@ function ChatResponseHTMLFrame(props: any) {
|
|
|
522
524
|
// Memoize ChatResponse for performance
|
|
523
525
|
function ChatResponse(props: any) {
|
|
524
526
|
const [renderCount, setRenderCount] = useState(0);
|
|
527
|
+
const shuffledOrder = useRef<number[]>([]);
|
|
528
|
+
const shufflePos = useRef(0);
|
|
529
|
+
|
|
530
|
+
const _spinnerVerbs = NBIAPI.config.isInClaudeCodeMode
|
|
531
|
+
? (NBIAPI.config.spinnerVerbs ?? null)
|
|
532
|
+
: null;
|
|
533
|
+
const hasCustomVerbs =
|
|
534
|
+
_spinnerVerbs?.mode === 'replace' &&
|
|
535
|
+
Array.isArray(_spinnerVerbs.verbs) &&
|
|
536
|
+
_spinnerVerbs.verbs.length > 0;
|
|
537
|
+
|
|
538
|
+
// Fisher-Yates shuffle. When `avoidFirst` is set, swap it out of
|
|
539
|
+
// position 0 so the new first verb is never the same as the last shown
|
|
540
|
+
// (prevents an identical repeat at the wrap point between passes).
|
|
541
|
+
const shuffleVerbs = (verbs: any[], avoidFirst?: number): number[] => {
|
|
542
|
+
const order = verbs.map((_, i) => i);
|
|
543
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
544
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
545
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
avoidFirst !== undefined &&
|
|
549
|
+
order[0] === avoidFirst &&
|
|
550
|
+
order.length > 1
|
|
551
|
+
) {
|
|
552
|
+
[order[0], order[1]] = [order[1], order[0]];
|
|
553
|
+
}
|
|
554
|
+
return order;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// Initialize the shuffle synchronously in useState so the correct verb
|
|
558
|
+
// is shown on the very first paint. A useEffect-based init fires after
|
|
559
|
+
// paint and causes a single-frame flash of verbs[0] before correcting.
|
|
560
|
+
const [verbIndex, setVerbIndex] = useState(() => {
|
|
561
|
+
const sv = NBIAPI.config.isInClaudeCodeMode
|
|
562
|
+
? (NBIAPI.config.spinnerVerbs ?? null)
|
|
563
|
+
: null;
|
|
564
|
+
if (
|
|
565
|
+
!sv ||
|
|
566
|
+
sv.mode !== 'replace' ||
|
|
567
|
+
!Array.isArray(sv.verbs) ||
|
|
568
|
+
sv.verbs.length === 0
|
|
569
|
+
) {
|
|
570
|
+
return 0;
|
|
571
|
+
}
|
|
572
|
+
const order = shuffleVerbs(sv.verbs);
|
|
573
|
+
shuffledOrder.current = order;
|
|
574
|
+
shufflePos.current = 0;
|
|
575
|
+
return order[0];
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
if (!props.showGenerating || !hasCustomVerbs) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const verbs = _spinnerVerbs!.verbs;
|
|
584
|
+
|
|
585
|
+
// Shuffle already initialized by useState on mount. Only re-initialize
|
|
586
|
+
// if hasCustomVerbs just became true after a capabilities refresh
|
|
587
|
+
// (shuffledOrder would be empty because the lazy init found no verbs).
|
|
588
|
+
if (shuffledOrder.current.length === 0) {
|
|
589
|
+
shuffledOrder.current = shuffleVerbs(verbs);
|
|
590
|
+
shufflePos.current = 0;
|
|
591
|
+
setVerbIndex(shuffledOrder.current[0]);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let id: ReturnType<typeof setTimeout>;
|
|
595
|
+
const scheduleNext = () => {
|
|
596
|
+
const delay = 4000 + Math.random() * 3000;
|
|
597
|
+
id = setTimeout(() => {
|
|
598
|
+
shufflePos.current++;
|
|
599
|
+
if (shufflePos.current >= shuffledOrder.current.length) {
|
|
600
|
+
const lastShown =
|
|
601
|
+
shuffledOrder.current[shuffledOrder.current.length - 1];
|
|
602
|
+
shuffledOrder.current = shuffleVerbs(verbs, lastShown);
|
|
603
|
+
shufflePos.current = 0;
|
|
604
|
+
}
|
|
605
|
+
setVerbIndex(shuffledOrder.current[shufflePos.current]);
|
|
606
|
+
scheduleNext();
|
|
607
|
+
}, delay);
|
|
608
|
+
};
|
|
609
|
+
scheduleNext();
|
|
610
|
+
return () => clearTimeout(id);
|
|
611
|
+
}, [props.showGenerating, hasCustomVerbs]);
|
|
612
|
+
|
|
525
613
|
const msg: IChatMessage = props.message;
|
|
526
614
|
const timestamp = msg.date.toLocaleTimeString('en-US', { hour12: false });
|
|
527
615
|
|
|
@@ -630,6 +718,19 @@ function ChatResponse(props: any) {
|
|
|
630
718
|
if (item.reasoningFinished) {
|
|
631
719
|
lastItem.reasoningFinished = true;
|
|
632
720
|
}
|
|
721
|
+
} else if (
|
|
722
|
+
item.type === ResponseStreamDataType.ToolCall &&
|
|
723
|
+
lastItemType === ResponseStreamDataType.ToolCall
|
|
724
|
+
) {
|
|
725
|
+
// Bundle a run of consecutive tool calls into one group item so the
|
|
726
|
+
// renderer can collapse them; ToolCallGroup unwraps content.toolCalls.
|
|
727
|
+
const lastItem = groupedContents[groupedContents.length - 1];
|
|
728
|
+
lastItem.content.toolCalls.push(structuredClone(item.content));
|
|
729
|
+
} else if (item.type === ResponseStreamDataType.ToolCall) {
|
|
730
|
+
const grouped = structuredClone(item);
|
|
731
|
+
grouped.content = { toolCalls: [structuredClone(item.content)] };
|
|
732
|
+
groupedContents.push(grouped);
|
|
733
|
+
lastItemType = item.type;
|
|
633
734
|
} else {
|
|
634
735
|
groupedContents.push(structuredClone(item));
|
|
635
736
|
lastItemType = item.type;
|
|
@@ -712,18 +813,26 @@ function ChatResponse(props: any) {
|
|
|
712
813
|
}`}
|
|
713
814
|
aria-hidden="true"
|
|
714
815
|
/>
|
|
715
|
-
<div
|
|
716
|
-
className="generating-label"
|
|
717
|
-
aria-live="polite"
|
|
718
|
-
aria-atomic="true"
|
|
719
|
-
>
|
|
816
|
+
<div className="generating-label" aria-hidden="true">
|
|
720
817
|
{props.isStalled
|
|
721
818
|
? 'Still working, server may be slow'
|
|
722
|
-
:
|
|
819
|
+
: hasCustomVerbs
|
|
820
|
+
? _spinnerVerbs!.verbs[verbIndex]
|
|
821
|
+
: 'Generating'}
|
|
723
822
|
{props.showGenerating && props.elapsedSeconds > 0
|
|
724
823
|
? ` (${formatElapsedSeconds(props.elapsedSeconds)})`
|
|
725
824
|
: ''}
|
|
726
825
|
</div>
|
|
826
|
+
{/* aria-live region contains only the verb — no elapsed suffix —
|
|
827
|
+
so screen readers announce only on verb changes, not on every
|
|
828
|
+
elapsed-seconds tick. */}
|
|
829
|
+
<div className="nbi-sr-only" aria-live="polite" aria-atomic="true">
|
|
830
|
+
{props.isStalled
|
|
831
|
+
? 'Still working, server may be slow'
|
|
832
|
+
: hasCustomVerbs
|
|
833
|
+
? _spinnerVerbs!.verbs[verbIndex]
|
|
834
|
+
: 'Generating'}
|
|
835
|
+
</div>
|
|
727
836
|
</div>
|
|
728
837
|
</div>
|
|
729
838
|
<div className="chat-message-timestamp">{timestamp}</div>
|
|
@@ -857,6 +966,17 @@ function ChatResponse(props: any) {
|
|
|
857
966
|
{item.content}
|
|
858
967
|
</div>
|
|
859
968
|
) : null;
|
|
969
|
+
case ResponseStreamDataType.ToolCall:
|
|
970
|
+
// Unlike Progress, tool-call cards persist after the turn ends,
|
|
971
|
+
// so they render regardless of `showGenerating`. The grouping
|
|
972
|
+
// pass bundled this run's calls into content.toolCalls; the
|
|
973
|
+
// group renders a single card or a collapsible group.
|
|
974
|
+
return (
|
|
975
|
+
<ToolCallGroup
|
|
976
|
+
key={`key-${index}`}
|
|
977
|
+
toolCalls={item.content.toolCalls}
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
860
980
|
case ResponseStreamDataType.Confirmation:
|
|
861
981
|
return answeredForms.get(item.id) ===
|
|
862
982
|
'confirmed' ? null : answeredForms.get(item.id) ===
|
|
@@ -973,9 +1093,13 @@ function ChatResponse(props: any) {
|
|
|
973
1093
|
)}
|
|
974
1094
|
</div>
|
|
975
1095
|
{msg.from === 'copilot' &&
|
|
976
|
-
!props.showGenerating &&
|
|
1096
|
+
(NBIAPI.config.chatFeedbackAlwaysVisible || !props.showGenerating) &&
|
|
977
1097
|
NBIAPI.config.chatFeedbackEnabled && (
|
|
978
|
-
<div
|
|
1098
|
+
<div
|
|
1099
|
+
className={`chat-message-feedback${
|
|
1100
|
+
NBIAPI.config.chatFeedbackAlwaysVisible ? ' always-visible' : ''
|
|
1101
|
+
}`}
|
|
1102
|
+
>
|
|
979
1103
|
<button
|
|
980
1104
|
className={`chat-feedback-btn ${msg.feedback === 'positive' ? 'selected' : ''}`}
|
|
981
1105
|
onClick={() => {
|
|
@@ -994,9 +1118,9 @@ function ChatResponse(props: any) {
|
|
|
994
1118
|
});
|
|
995
1119
|
}
|
|
996
1120
|
}}
|
|
997
|
-
aria-label="Rate as
|
|
1121
|
+
aria-label="Rate response as good"
|
|
998
1122
|
aria-pressed={msg.feedback === 'positive'}
|
|
999
|
-
title="
|
|
1123
|
+
title="Good response"
|
|
1000
1124
|
>
|
|
1001
1125
|
{msg.feedback === 'positive' ? (
|
|
1002
1126
|
<VscThumbsupFilled />
|
|
@@ -1022,9 +1146,9 @@ function ChatResponse(props: any) {
|
|
|
1022
1146
|
});
|
|
1023
1147
|
}
|
|
1024
1148
|
}}
|
|
1025
|
-
aria-label="Rate as
|
|
1149
|
+
aria-label="Rate response as bad"
|
|
1026
1150
|
aria-pressed={msg.feedback === 'negative'}
|
|
1027
|
-
title="
|
|
1151
|
+
title="Bad response"
|
|
1028
1152
|
>
|
|
1029
1153
|
{msg.feedback === 'negative' ? (
|
|
1030
1154
|
<VscThumbsdownFilled />
|
|
@@ -2920,22 +3044,33 @@ function SidebarComponent(props: any) {
|
|
|
2920
3044
|
}
|
|
2921
3045
|
if (delta['nbiContent']) {
|
|
2922
3046
|
const nbiContent = delta['nbiContent'];
|
|
2923
|
-
|
|
2924
|
-
id
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
3047
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
3048
|
+
// A tool call streams twice under one id (start, then finish);
|
|
3049
|
+
// merge by id so it stays one persistent card. See
|
|
3050
|
+
// upsertToolCallContent.
|
|
3051
|
+
upsertToolCallContent(
|
|
3052
|
+
contents,
|
|
3053
|
+
nbiContent.content,
|
|
3054
|
+
new Date(response.created)
|
|
3055
|
+
);
|
|
3056
|
+
} else {
|
|
3057
|
+
contents.push({
|
|
3058
|
+
id: UUID.uuid4(),
|
|
3059
|
+
type: nbiContent.type,
|
|
3060
|
+
content: nbiContent.content || '',
|
|
3061
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
3062
|
+
reasoningTag: nbiContent.reasoning_content
|
|
3063
|
+
? '<think>'
|
|
3064
|
+
: undefined,
|
|
3065
|
+
reasoningFinished:
|
|
3066
|
+
nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
3067
|
+
nbiContent.reasoning_content
|
|
3068
|
+
? true
|
|
3069
|
+
: false,
|
|
3070
|
+
contentDetail: nbiContent.detail,
|
|
3071
|
+
created: new Date(response.created)
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
2939
3074
|
} else {
|
|
2940
3075
|
responseMessage =
|
|
2941
3076
|
response.data['choices']?.[0]?.['delta']?.['content'];
|
|
@@ -3356,22 +3491,33 @@ function SidebarComponent(props: any) {
|
|
|
3356
3491
|
|
|
3357
3492
|
if (delta['nbiContent']) {
|
|
3358
3493
|
const nbiContent = delta['nbiContent'];
|
|
3359
|
-
|
|
3360
|
-
id
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3494
|
+
if (nbiContent.type === ResponseStreamDataType.ToolCall) {
|
|
3495
|
+
// A tool call streams twice under one id (start, then finish);
|
|
3496
|
+
// merge by id so it stays one persistent card. See
|
|
3497
|
+
// upsertToolCallContent.
|
|
3498
|
+
upsertToolCallContent(
|
|
3499
|
+
contents,
|
|
3500
|
+
nbiContent.content,
|
|
3501
|
+
new Date(response.created)
|
|
3502
|
+
);
|
|
3503
|
+
} else {
|
|
3504
|
+
contents.push({
|
|
3505
|
+
id: UUID.uuid4(),
|
|
3506
|
+
type: nbiContent.type,
|
|
3507
|
+
content: nbiContent.content || '',
|
|
3508
|
+
reasoningContent: nbiContent.reasoning_content || '',
|
|
3509
|
+
reasoningTag: nbiContent.reasoning_content
|
|
3510
|
+
? '<think>'
|
|
3511
|
+
: undefined,
|
|
3512
|
+
reasoningFinished:
|
|
3513
|
+
nbiContent.type === ResponseStreamDataType.Markdown &&
|
|
3514
|
+
nbiContent.reasoning_content
|
|
3515
|
+
? true
|
|
3516
|
+
: false,
|
|
3517
|
+
contentDetail: nbiContent.detail,
|
|
3518
|
+
created: new Date(response.created)
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3375
3521
|
} else {
|
|
3376
3522
|
const responseMessage =
|
|
3377
3523
|
response.data['choices']?.[0]?.['delta']?.['content'];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
VscChevronDown,
|
|
4
|
+
VscChevronRight,
|
|
5
|
+
VscClose,
|
|
6
|
+
VscEdit,
|
|
7
|
+
VscError,
|
|
8
|
+
VscEye,
|
|
9
|
+
VscPassFilled,
|
|
10
|
+
VscSync,
|
|
11
|
+
VscTerminal,
|
|
12
|
+
VscTools
|
|
13
|
+
} from '../icons';
|
|
14
|
+
|
|
15
|
+
export interface IToolCallDiffLine {
|
|
16
|
+
type: 'add' | 'remove' | 'context' | string;
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IToolCallDiff {
|
|
21
|
+
path: string;
|
|
22
|
+
lines: IToolCallDiffLine[];
|
|
23
|
+
truncated?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A single agent tool call surfaced as a persistent chat card. Mirrors the
|
|
28
|
+
* `ToolCallData` payload emitted by the server: it stays in the transcript
|
|
29
|
+
* after the turn ends and carries its final status, unlike the transient
|
|
30
|
+
* single progress line it replaces. File-edit tools also carry inline diffs.
|
|
31
|
+
*/
|
|
32
|
+
export interface IToolCall {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
// Coarse category, used only to pick the leading icon.
|
|
36
|
+
kind: 'read' | 'edit' | 'execute' | 'other' | string;
|
|
37
|
+
status: 'in_progress' | 'completed' | 'failed' | 'cancelled' | string;
|
|
38
|
+
diffs?: IToolCallDiff[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const KIND_ICONS: Record<string, React.FC<any>> = {
|
|
42
|
+
read: VscEye,
|
|
43
|
+
edit: VscEdit,
|
|
44
|
+
execute: VscTerminal,
|
|
45
|
+
other: VscTools
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
49
|
+
in_progress: 'in progress',
|
|
50
|
+
completed: 'completed',
|
|
51
|
+
failed: 'failed',
|
|
52
|
+
cancelled: 'cancelled'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const GUTTER: Record<string, string> = { add: '+', remove: '-' };
|
|
56
|
+
|
|
57
|
+
function ToolCallDiffView(props: { diffs: IToolCallDiff[] }): JSX.Element {
|
|
58
|
+
return (
|
|
59
|
+
<div className="nbi-tool-call-diffs">
|
|
60
|
+
{props.diffs.map((diff, i) => (
|
|
61
|
+
<div className="nbi-tool-call-diff" key={i}>
|
|
62
|
+
{diff.path ? (
|
|
63
|
+
<div className="nbi-tool-call-diff-path" title={diff.path}>
|
|
64
|
+
{diff.path}
|
|
65
|
+
</div>
|
|
66
|
+
) : null}
|
|
67
|
+
<div className="nbi-tool-call-diff-body">
|
|
68
|
+
{diff.lines.map((line, j) => (
|
|
69
|
+
<div
|
|
70
|
+
className={`nbi-tool-call-diff-line nbi-diff-${line.type}`}
|
|
71
|
+
key={j}
|
|
72
|
+
>
|
|
73
|
+
<span className="nbi-diff-gutter" aria-hidden="true">
|
|
74
|
+
{GUTTER[line.type] ?? ' '}
|
|
75
|
+
</span>
|
|
76
|
+
{/* The +/- gutter is decorative; give screen readers the
|
|
77
|
+
add/remove distinction as text instead. */}
|
|
78
|
+
{line.type === 'add' || line.type === 'remove' ? (
|
|
79
|
+
<span className="nbi-sr-only">
|
|
80
|
+
{line.type === 'add' ? 'added ' : 'removed '}
|
|
81
|
+
</span>
|
|
82
|
+
) : null}
|
|
83
|
+
<span className="nbi-diff-text">{line.content}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
{diff.truncated ? (
|
|
88
|
+
<div className="nbi-tool-call-diff-truncated">diff truncated</div>
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function ToolCallCard(props: { toolCall: IToolCall }): JSX.Element {
|
|
97
|
+
const { title, kind, status, diffs } = props.toolCall;
|
|
98
|
+
const [expanded, setExpanded] = useState(true);
|
|
99
|
+
|
|
100
|
+
const KindIcon = KIND_ICONS[kind] ?? VscTools;
|
|
101
|
+
|
|
102
|
+
let StatusIcon = VscSync;
|
|
103
|
+
if (status === 'completed') {
|
|
104
|
+
StatusIcon = VscPassFilled;
|
|
105
|
+
} else if (status === 'failed') {
|
|
106
|
+
StatusIcon = VscError;
|
|
107
|
+
} else if (status === 'cancelled') {
|
|
108
|
+
StatusIcon = VscClose;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const statusLabel = STATUS_LABELS[status] ?? status;
|
|
112
|
+
const statusModifier = status.replace(/_/g, '-');
|
|
113
|
+
const hasDiffs = Array.isArray(diffs) && diffs.length > 0;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="nbi-tool-call-wrapper">
|
|
117
|
+
<div className={`nbi-tool-call nbi-tool-call-${statusModifier}`}>
|
|
118
|
+
<KindIcon className="nbi-tool-call-kind-icon" aria-hidden="true" />
|
|
119
|
+
<span className="nbi-tool-call-title" title={title}>
|
|
120
|
+
{title}
|
|
121
|
+
</span>
|
|
122
|
+
{hasDiffs ? (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
className="nbi-tool-call-diff-toggle"
|
|
126
|
+
onClick={() => setExpanded(e => !e)}
|
|
127
|
+
aria-expanded={expanded}
|
|
128
|
+
aria-label={expanded ? 'Hide diff' : 'Show diff'}
|
|
129
|
+
>
|
|
130
|
+
{expanded ? (
|
|
131
|
+
<VscChevronDown aria-hidden="true" />
|
|
132
|
+
) : (
|
|
133
|
+
<VscChevronRight aria-hidden="true" />
|
|
134
|
+
)}
|
|
135
|
+
</button>
|
|
136
|
+
) : null}
|
|
137
|
+
<StatusIcon className="nbi-tool-call-status-icon" aria-hidden="true" />
|
|
138
|
+
{/* Status reaches screen readers as text; the icons are decorative.
|
|
139
|
+
The visible title is already in the accessibility tree, so this
|
|
140
|
+
span carries only the status to avoid announcing the title twice. */}
|
|
141
|
+
<span className="nbi-sr-only">{statusLabel}</span>
|
|
142
|
+
</div>
|
|
143
|
+
{hasDiffs && expanded ? <ToolCallDiffView diffs={diffs!} /> : null}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { VscChevronDown, VscChevronRight } from '../icons';
|
|
3
|
+
import { IToolCall, ToolCallCard } from './tool-call-card';
|
|
4
|
+
|
|
5
|
+
// Groups with more calls than this start collapsed so a tool-heavy turn (or a
|
|
6
|
+
// reloaded transcript) doesn't flood the chat. A live turn mounts the group at
|
|
7
|
+
// length 1, so it starts expanded and stays visible as calls stream in.
|
|
8
|
+
const GROUP_COLLAPSE_THRESHOLD = 3;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders a run of consecutive tool calls. A single call is shown as a bare
|
|
12
|
+
* card; multiple calls are wrapped in one collapsible group with a summary
|
|
13
|
+
* header, so consecutive agent activity reads as one unit instead of a wall
|
|
14
|
+
* of rows.
|
|
15
|
+
*/
|
|
16
|
+
export function ToolCallGroup(props: { toolCalls: IToolCall[] }): JSX.Element {
|
|
17
|
+
const { toolCalls } = props;
|
|
18
|
+
// Hooks must run unconditionally and in a stable order: the group's length
|
|
19
|
+
// grows as calls stream in, so call useState before any length branch.
|
|
20
|
+
// Start collapsed only for a large group whose calls are all settled --
|
|
21
|
+
// never hide a still-running or failed call (matters on transcript reload,
|
|
22
|
+
// where a group can mount already large).
|
|
23
|
+
const [expanded, setExpanded] = useState(
|
|
24
|
+
() =>
|
|
25
|
+
toolCalls.length <= GROUP_COLLAPSE_THRESHOLD ||
|
|
26
|
+
toolCalls.some(t => t.status === 'in_progress' || t.status === 'failed')
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (toolCalls.length <= 1) {
|
|
30
|
+
return toolCalls.length === 1 ? (
|
|
31
|
+
<ToolCallCard toolCall={toolCalls[0]} />
|
|
32
|
+
) : (
|
|
33
|
+
<></>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const failed = toolCalls.filter(t => t.status === 'failed').length;
|
|
38
|
+
const summary =
|
|
39
|
+
`${toolCalls.length} tool calls` + (failed ? ` (${failed} failed)` : '');
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="nbi-tool-call-group">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
className="nbi-tool-call-group-header"
|
|
46
|
+
onClick={() => setExpanded(e => !e)}
|
|
47
|
+
aria-expanded={expanded}
|
|
48
|
+
>
|
|
49
|
+
{expanded ? (
|
|
50
|
+
<VscChevronDown aria-hidden="true" />
|
|
51
|
+
) : (
|
|
52
|
+
<VscChevronRight aria-hidden="true" />
|
|
53
|
+
)}
|
|
54
|
+
<span className="nbi-tool-call-group-summary">{summary}</span>
|
|
55
|
+
</button>
|
|
56
|
+
{expanded ? (
|
|
57
|
+
<div className="nbi-tool-call-group-body">
|
|
58
|
+
{toolCalls.map(toolCall => (
|
|
59
|
+
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/src/icons.ts
CHANGED
|
@@ -41,6 +41,7 @@ export const VscClose = asIcon(Vsc.VscClose);
|
|
|
41
41
|
export const VscCloudUpload = asIcon(Vsc.VscCloudUpload);
|
|
42
42
|
export const VscCopy = asIcon(Vsc.VscCopy);
|
|
43
43
|
export const VscEdit = asIcon(Vsc.VscEdit);
|
|
44
|
+
export const VscError = asIcon(Vsc.VscError);
|
|
44
45
|
export const VscEye = asIcon(Vsc.VscEye);
|
|
45
46
|
export const VscEyeClosed = asIcon(Vsc.VscEyeClosed);
|
|
46
47
|
export const VscFile = asIcon(Vsc.VscFile);
|
|
@@ -55,6 +56,8 @@ export const VscSend = asIcon(Vsc.VscSend);
|
|
|
55
56
|
export const VscSettingsGear = asIcon(Vsc.VscSettingsGear);
|
|
56
57
|
export const VscSparkle = asIcon(Vsc.VscSparkle);
|
|
57
58
|
export const VscStopCircle = asIcon(Vsc.VscStopCircle);
|
|
59
|
+
export const VscSync = asIcon(Vsc.VscSync);
|
|
60
|
+
export const VscTerminal = asIcon(Vsc.VscTerminal);
|
|
58
61
|
export const VscThumbsdown = asIcon(Vsc.VscThumbsdown);
|
|
59
62
|
export const VscThumbsdownFilled = asIcon(Vsc.VscThumbsdownFilled);
|
|
60
63
|
export const VscThumbsup = asIcon(Vsc.VscThumbsup);
|
package/src/index.ts
CHANGED
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
cellOutputHasError,
|
|
107
107
|
chooseWorkspaceDirectory,
|
|
108
108
|
compareSelections,
|
|
109
|
+
buildResumeCommand,
|
|
109
110
|
extractLLMGeneratedCode,
|
|
110
111
|
getSelectionInEditor,
|
|
111
112
|
getTokenCount,
|
|
@@ -1403,10 +1404,9 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
|
|
|
1403
1404
|
return React.createElement(LauncherPicker, {
|
|
1404
1405
|
onSessionSelected: (session: IClaudeSessionInfo) => {
|
|
1405
1406
|
dialog.close();
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
launchCliInTerminal(cmd);
|
|
1407
|
+
launchCliInTerminal(
|
|
1408
|
+
buildResumeCommand(session.cwd ?? '', session.session_id)
|
|
1409
|
+
);
|
|
1410
1410
|
}
|
|
1411
1411
|
});
|
|
1412
1412
|
}
|
|
@@ -1734,7 +1734,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
|
|
|
1734
1734
|
source: args.source as string
|
|
1735
1735
|
});
|
|
1736
1736
|
|
|
1737
|
-
return
|
|
1737
|
+
return { cellIndex: newCellIndex };
|
|
1738
1738
|
}
|
|
1739
1739
|
});
|
|
1740
1740
|
|
|
@@ -1755,7 +1755,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
|
|
|
1755
1755
|
source: args.source as string
|
|
1756
1756
|
});
|
|
1757
1757
|
|
|
1758
|
-
return
|
|
1758
|
+
return { cellIndex: newCellIndex };
|
|
1759
1759
|
}
|
|
1760
1760
|
});
|
|
1761
1761
|
|