jupyter-chat-components 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
2
+ import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
3
+
4
+ import * as React from 'react';
5
+ import { structuredPatch } from 'diff';
6
+ import type { StructuredPatchHunk } from 'diff';
7
+
8
+ import {
9
+ IComponentProps,
10
+ IToolCallDiff,
11
+ IToolCallsEntry,
12
+ IToolCallsMetadata,
13
+ OpenToolCallPath,
14
+ ToolCallPermissionDecision
15
+ } from '../token';
16
+
17
+ /** Maximum number of rendered diff lines before truncation. */
18
+ const MAX_DIFF_LINES = 20;
19
+
20
+ /** Tool kinds where expanded view shows file paths from locations. */
21
+ const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
22
+
23
+ /** Tool kinds where expanded view shows raw output. */
24
+ const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
25
+
26
+ const TOOL_KIND_LABELS: Record<string, string> = {
27
+ read: 'Reading',
28
+ edit: 'Editing',
29
+ delete: 'Deleting',
30
+ move: 'Moving',
31
+ search: 'Searching',
32
+ execute: 'Running command',
33
+ think: 'Thinking',
34
+ fetch: 'Fetching',
35
+ switch_mode: 'Switching mode'
36
+ };
37
+
38
+ interface IDiffLineInfo {
39
+ cssClass: string;
40
+ prefix: string;
41
+ text: string;
42
+ key: string;
43
+ }
44
+
45
+ /**
46
+ * Props for rendering grouped tool calls.
47
+ */
48
+ export interface IGroupedToolCallsProps
49
+ extends IComponentProps, IToolCallsMetadata {
50
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
51
+ openToolCallPath?: OpenToolCallPath;
52
+ }
53
+
54
+ function getConfiguredServerRoot(): string | null {
55
+ const rootUri = PageConfig.getOption('rootUri');
56
+
57
+ if (rootUri) {
58
+ try {
59
+ return new URL(rootUri, 'http://localhost').pathname;
60
+ } catch (error) {
61
+ console.warn(
62
+ 'Could not parse rootUri while rendering tool calls.',
63
+ error
64
+ );
65
+ }
66
+ }
67
+
68
+ const serverRoot = PageConfig.getOption('serverRoot');
69
+ return serverRoot || null;
70
+ }
71
+
72
+ /**
73
+ * Convert an absolute filesystem path to a server-relative path when possible.
74
+ */
75
+ export function toServerRelativePath(absolutePath: string): string {
76
+ const serverRoot = getConfiguredServerRoot();
77
+
78
+ if (!serverRoot) {
79
+ return absolutePath;
80
+ }
81
+
82
+ const relativePath = PathExt.relative(serverRoot, absolutePath);
83
+ if (relativePath.startsWith('..')) {
84
+ return absolutePath;
85
+ }
86
+
87
+ return relativePath;
88
+ }
89
+
90
+ /**
91
+ * Format tool output for display.
92
+ */
93
+ export function formatToolCallOutput(rawOutput: unknown): string {
94
+ if (typeof rawOutput === 'string') {
95
+ return rawOutput;
96
+ }
97
+
98
+ if (
99
+ Array.isArray(rawOutput) &&
100
+ rawOutput.every(
101
+ item =>
102
+ typeof item === 'object' &&
103
+ item !== null &&
104
+ typeof (item as { text?: unknown }).text === 'string'
105
+ )
106
+ ) {
107
+ return rawOutput.map(item => (item as { text: string }).text).join('\n');
108
+ }
109
+
110
+ return JSON.stringify(rawOutput, null, 2);
111
+ }
112
+
113
+ /**
114
+ * Format tool input for display.
115
+ */
116
+ export function formatToolCallInput(input: unknown): string {
117
+ if (typeof input === 'string') {
118
+ return input;
119
+ }
120
+
121
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
122
+ return JSON.stringify(input, null, 2);
123
+ }
124
+
125
+ const entries = Object.entries(input as Record<string, unknown>);
126
+ const isFlat = entries.every(
127
+ ([, value]) =>
128
+ typeof value === 'string' ||
129
+ typeof value === 'number' ||
130
+ typeof value === 'boolean'
131
+ );
132
+
133
+ if (isFlat) {
134
+ return entries.map(([key, value]) => `${key}: ${value}`).join('\n');
135
+ }
136
+
137
+ return JSON.stringify(input, null, 2);
138
+ }
139
+
140
+ function getLocationSummary(toolCall: IToolCallsEntry): string | null {
141
+ const firstLocation = toolCall.locations?.[0];
142
+
143
+ if (!firstLocation) {
144
+ return null;
145
+ }
146
+
147
+ return PathExt.basename(firstLocation) || firstLocation;
148
+ }
149
+
150
+ /**
151
+ * Compute the line title shown for a tool call.
152
+ */
153
+ export function getToolCallDisplayTitle(toolCall: IToolCallsEntry): string {
154
+ const title = toolCall.title?.trim();
155
+
156
+ if (title) {
157
+ return title;
158
+ }
159
+
160
+ const verb = TOOL_KIND_LABELS[toolCall.kind ?? ''] ?? 'Working';
161
+ const location = getLocationSummary(toolCall);
162
+
163
+ if (location) {
164
+ return `${verb} ${location}`;
165
+ }
166
+
167
+ return `${verb}...`;
168
+ }
169
+
170
+ /**
171
+ * Compute the pre-permission detail text for a tool call, or null if the
172
+ * title alone is enough.
173
+ */
174
+ export function buildPermissionDetail(
175
+ toolCall: IToolCallsEntry
176
+ ): string | null {
177
+ const { kind, title, locations, rawInput } = toolCall;
178
+
179
+ if (kind === 'execute') {
180
+ const rawObject =
181
+ typeof rawInput === 'object' &&
182
+ rawInput !== null &&
183
+ !Array.isArray(rawInput)
184
+ ? (rawInput as Record<string, unknown>)
185
+ : null;
186
+ const command =
187
+ rawObject && typeof rawObject.command === 'string'
188
+ ? rawObject.command
189
+ : title
190
+ ?.replace(/^Running:\s*/i, '')
191
+ .replace(/\.\.\.$/, '')
192
+ .trim() || null;
193
+
194
+ if (!command || command === title) {
195
+ return null;
196
+ }
197
+
198
+ return '$ ' + command;
199
+ }
200
+
201
+ if (
202
+ (kind === 'delete' || kind === 'move' || kind === 'read') &&
203
+ locations?.length
204
+ ) {
205
+ return kind === 'move' && locations.length >= 2
206
+ ? `${toServerRelativePath(locations[0])} \u2192 ${toServerRelativePath(
207
+ locations[1]
208
+ )}`
209
+ : locations.map(toServerRelativePath).join('\n');
210
+ }
211
+
212
+ if (
213
+ rawInput !== null &&
214
+ rawInput !== undefined &&
215
+ typeof rawInput === 'object' &&
216
+ !Array.isArray(rawInput)
217
+ ) {
218
+ const rawObject = rawInput as Record<string, unknown>;
219
+ const purpose =
220
+ typeof rawObject.__tool_use_purpose === 'string'
221
+ ? rawObject.__tool_use_purpose
222
+ : null;
223
+ const paramEntries = Object.entries(rawObject).filter(
224
+ ([key]) => !key.startsWith('__')
225
+ );
226
+ const filteredParams = paramEntries.reduce<Record<string, unknown>>(
227
+ (result, [key, value]) => {
228
+ result[key] = value;
229
+ return result;
230
+ },
231
+ {}
232
+ );
233
+ const params =
234
+ paramEntries.length > 0 ? formatToolCallInput(filteredParams) : null;
235
+
236
+ if (purpose && params) {
237
+ return `${purpose}\n${params}`;
238
+ }
239
+
240
+ if (purpose) {
241
+ return purpose;
242
+ }
243
+
244
+ if (params) {
245
+ return params;
246
+ }
247
+
248
+ return null;
249
+ }
250
+
251
+ if (rawInput !== null && rawInput !== undefined) {
252
+ return formatToolCallInput(rawInput);
253
+ }
254
+
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Build the expandable detail lines shown for completed and failed tool calls.
260
+ */
261
+ export function buildToolCallDetailsLines(toolCall: IToolCallsEntry): string[] {
262
+ const lines: string[] = [];
263
+ const { kind, locations, rawOutput } = toolCall;
264
+
265
+ if (kind && FILE_KINDS.has(kind) && locations?.length) {
266
+ lines.push(...locations.map(toServerRelativePath));
267
+ } else if (
268
+ kind &&
269
+ OUTPUT_KINDS.has(kind) &&
270
+ rawOutput !== null &&
271
+ rawOutput !== undefined
272
+ ) {
273
+ lines.push(formatToolCallOutput(rawOutput));
274
+ } else if (typeof rawOutput === 'string') {
275
+ lines.push(rawOutput);
276
+ }
277
+
278
+ return lines;
279
+ }
280
+
281
+ function toDiffLineInfo(
282
+ type: 'added' | 'removed' | 'context',
283
+ text: string,
284
+ key: string
285
+ ): IDiffLineInfo {
286
+ switch (type) {
287
+ case 'added':
288
+ return {
289
+ cssClass: 'jp-mod-added',
290
+ prefix: '+',
291
+ text,
292
+ key
293
+ };
294
+ case 'removed':
295
+ return {
296
+ cssClass: 'jp-mod-removed',
297
+ prefix: '-',
298
+ text,
299
+ key
300
+ };
301
+ case 'context':
302
+ return {
303
+ cssClass: 'jp-mod-context',
304
+ prefix: ' ',
305
+ text,
306
+ key
307
+ };
308
+ }
309
+ }
310
+
311
+ function buildDiffLinesFromHunk(
312
+ hunk: StructuredPatchHunk,
313
+ hunkIndex: number
314
+ ): IDiffLineInfo[] {
315
+ return hunk.lines
316
+ .filter(line => !line.startsWith('\\'))
317
+ .map((line, lineIndex) => {
318
+ const prefix = line[0] ?? ' ';
319
+ const text = line.slice(1);
320
+ const key = `${hunkIndex}-${hunk.oldStart}-${hunk.newStart}-${lineIndex}`;
321
+
322
+ if (prefix === '+') {
323
+ return toDiffLineInfo('added', text, key);
324
+ }
325
+
326
+ if (prefix === '-') {
327
+ return toDiffLineInfo('removed', text, key);
328
+ }
329
+
330
+ return toDiffLineInfo('context', text, key);
331
+ });
332
+ }
333
+
334
+ function buildDiffLines(diff: IToolCallDiff): IDiffLineInfo[] {
335
+ const patch = structuredPatch(
336
+ diff.path,
337
+ diff.path,
338
+ diff.oldText ?? '',
339
+ diff.newText,
340
+ undefined,
341
+ undefined,
342
+ { context: Infinity }
343
+ );
344
+
345
+ return patch.hunks.reduce<IDiffLineInfo[]>((lines, hunk, index) => {
346
+ lines.push(...buildDiffLinesFromHunk(hunk, index));
347
+ return lines;
348
+ }, []);
349
+ }
350
+
351
+ function ToolCallDiffBlock({
352
+ diff,
353
+ trans,
354
+ openToolCallPath,
355
+ pendingPermission
356
+ }: {
357
+ diff: IToolCallDiff;
358
+ trans: TranslationBundle;
359
+ openToolCallPath?: OpenToolCallPath;
360
+ pendingPermission?: boolean;
361
+ }): JSX.Element {
362
+ const [expanded, setExpanded] = React.useState(false);
363
+ const allLines = React.useMemo(() => buildDiffLines(diff), [diff]);
364
+ const canTruncate = allLines.length > MAX_DIFF_LINES;
365
+ const visibleLines =
366
+ canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
367
+ const hiddenCount = allLines.length - MAX_DIFF_LINES;
368
+ const displayPath = toServerRelativePath(diff.path);
369
+ const canOpenPath =
370
+ !!openToolCallPath && !(pendingPermission && !diff.oldText);
371
+
372
+ return (
373
+ <div className="jp-ai-tool-call-diff-block">
374
+ <div
375
+ className={`jp-ai-tool-call-diff-header${
376
+ canOpenPath ? ' jp-ai-tool-call-diff-header-clickable' : ''
377
+ }`}
378
+ onClick={canOpenPath ? () => openToolCallPath!(displayPath) : undefined}
379
+ title={displayPath}
380
+ >
381
+ {displayPath}
382
+ </div>
383
+ <div className="jp-ai-tool-call-diff-content">
384
+ {visibleLines.length ? (
385
+ visibleLines.map(line => (
386
+ <div
387
+ key={line.key}
388
+ className={`jp-ai-tool-call-diff-line ${line.cssClass}`}
389
+ >
390
+ <span className="jp-ai-tool-call-diff-line-prefix">
391
+ {line.prefix}
392
+ </span>
393
+ <span className="jp-ai-tool-call-diff-line-text">
394
+ {line.text}
395
+ </span>
396
+ </div>
397
+ ))
398
+ ) : (
399
+ <div className="jp-ai-tool-call-diff-empty">
400
+ {trans.__('No changes')}
401
+ </div>
402
+ )}
403
+ {canTruncate && !expanded && (
404
+ <button
405
+ className="jp-ai-tool-call-diff-toggle"
406
+ onClick={() => setExpanded(true)}
407
+ type="button"
408
+ >
409
+ {trans.__('... %1 more lines', hiddenCount)}
410
+ </button>
411
+ )}
412
+ {canTruncate && expanded && (
413
+ <button
414
+ className="jp-ai-tool-call-diff-toggle"
415
+ onClick={() => setExpanded(false)}
416
+ type="button"
417
+ >
418
+ {trans.__('Show less')}
419
+ </button>
420
+ )}
421
+ </div>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ function ToolCallDiffView({
427
+ diffs,
428
+ trans,
429
+ openToolCallPath,
430
+ pendingPermission
431
+ }: {
432
+ diffs: IToolCallDiff[];
433
+ trans: TranslationBundle;
434
+ openToolCallPath?: OpenToolCallPath;
435
+ pendingPermission?: boolean;
436
+ }): JSX.Element {
437
+ return (
438
+ <div className="jp-ai-tool-call-diff-container">
439
+ {diffs.map((diff, index) => (
440
+ <ToolCallDiffBlock
441
+ key={`${diff.path}-${index}`}
442
+ diff={diff}
443
+ trans={trans}
444
+ openToolCallPath={openToolCallPath}
445
+ pendingPermission={pendingPermission}
446
+ />
447
+ ))}
448
+ </div>
449
+ );
450
+ }
451
+
452
+ function PermissionLabel({
453
+ toolCall
454
+ }: {
455
+ toolCall: IToolCallsEntry;
456
+ }): JSX.Element | null {
457
+ if (toolCall.permissionStatus !== 'resolved' || !toolCall.selectedOptionId) {
458
+ return null;
459
+ }
460
+
461
+ const selectedName = toolCall.permissionOptions?.find(
462
+ option => option.optionId === toolCall.selectedOptionId
463
+ )?.name;
464
+
465
+ if (!selectedName) {
466
+ return null;
467
+ }
468
+
469
+ return (
470
+ <span className="jp-ai-tool-call-permission-label"> - {selectedName}</span>
471
+ );
472
+ }
473
+
474
+ function PermissionButtons({
475
+ toolCall,
476
+ trans,
477
+ toolCallPermissionDecision
478
+ }: {
479
+ toolCall: IToolCallsEntry;
480
+ trans: TranslationBundle;
481
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
482
+ }): JSX.Element | null {
483
+ const [submitting, setSubmitting] = React.useState(false);
484
+
485
+ if (
486
+ !toolCall.permissionOptions?.length ||
487
+ toolCall.permissionStatus !== 'pending'
488
+ ) {
489
+ return null;
490
+ }
491
+
492
+ const canSubmit =
493
+ !!toolCallPermissionDecision &&
494
+ !!toolCall.sessionId &&
495
+ !!toolCall.toolCallId;
496
+
497
+ const handleClick = async (optionId: string) => {
498
+ if (!canSubmit) {
499
+ return;
500
+ }
501
+
502
+ setSubmitting(true);
503
+
504
+ try {
505
+ await toolCallPermissionDecision!(
506
+ toolCall.sessionId!,
507
+ toolCall.toolCallId,
508
+ optionId
509
+ );
510
+ } catch (error) {
511
+ console.error('Failed to submit tool call permission decision:', error);
512
+ setSubmitting(false);
513
+ }
514
+ };
515
+
516
+ return (
517
+ <div className="jp-ai-tool-call-permission-buttons">
518
+ <span className="jp-ai-tool-call-permission-tree">{'\u2514\u2500'}</span>
519
+ <span>{trans.__('Allow?')}</span>
520
+ {toolCall.permissionOptions.map(option => {
521
+ const optionClass = option.kind
522
+ ? ` jp-ai-tool-call-permission-btn-${option.kind.replace(/_/g, '-')}`
523
+ : '';
524
+
525
+ return (
526
+ <button
527
+ key={option.optionId}
528
+ className={`jp-ai-tool-call-permission-btn${optionClass}`}
529
+ onClick={() => handleClick(option.optionId)}
530
+ disabled={submitting || !canSubmit}
531
+ title={option.kind}
532
+ type="button"
533
+ >
534
+ {option.name}
535
+ </button>
536
+ );
537
+ })}
538
+ </div>
539
+ );
540
+ }
541
+
542
+ function ToolCallRow({
543
+ toolCall,
544
+ trans,
545
+ openToolCallPath,
546
+ toolCallPermissionDecision
547
+ }: {
548
+ toolCall: IToolCallsEntry;
549
+ trans: TranslationBundle;
550
+ openToolCallPath?: OpenToolCallPath;
551
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
552
+ }): JSX.Element {
553
+ const displayTitle = getToolCallDisplayTitle(toolCall);
554
+ const selectedOption = toolCall.permissionOptions?.find(
555
+ option => option.optionId === toolCall.selectedOptionId
556
+ );
557
+ const isRejected =
558
+ toolCall.permissionStatus === 'resolved' &&
559
+ !!selectedOption?.kind?.includes('reject');
560
+ const hasPendingPermission = toolCall.permissionStatus === 'pending';
561
+ const status = toolCall.status ?? 'in_progress';
562
+ const isInProgress =
563
+ !isRejected &&
564
+ (status === 'in_progress' || status === 'pending' || hasPendingPermission);
565
+ const isCompleted = status === 'completed';
566
+ const isFailed = status === 'failed' || isRejected;
567
+ const icon = isInProgress
568
+ ? '\u2022'
569
+ : isCompleted
570
+ ? '\u2713'
571
+ : isFailed
572
+ ? '\u2717'
573
+ : '\u2022';
574
+ const effectiveStatus = (isRejected ? 'failed' : status).replace(/_/g, '-');
575
+ const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}`;
576
+ const hasDiffs = !!toolCall.diffs?.length;
577
+
578
+ if (hasDiffs && hasPendingPermission) {
579
+ return (
580
+ <div className={cssClass}>
581
+ <details open>
582
+ <summary>
583
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
584
+ <em>{displayTitle}</em>
585
+ </summary>
586
+ <ToolCallDiffView
587
+ diffs={toolCall.diffs!}
588
+ trans={trans}
589
+ openToolCallPath={openToolCallPath}
590
+ pendingPermission
591
+ />
592
+ </details>
593
+ <PermissionButtons
594
+ toolCall={toolCall}
595
+ trans={trans}
596
+ toolCallPermissionDecision={toolCallPermissionDecision}
597
+ />
598
+ </div>
599
+ );
600
+ }
601
+
602
+ if (!hasDiffs && hasPendingPermission) {
603
+ const permissionDetail = buildPermissionDetail(toolCall);
604
+
605
+ if (permissionDetail !== null) {
606
+ return (
607
+ <div className={cssClass}>
608
+ <details open>
609
+ <summary>
610
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
611
+ <em>{displayTitle}</em>
612
+ </summary>
613
+ <div className="jp-ai-tool-call-item-detail">
614
+ {permissionDetail}
615
+ </div>
616
+ </details>
617
+ <PermissionButtons
618
+ toolCall={toolCall}
619
+ trans={trans}
620
+ toolCallPermissionDecision={toolCallPermissionDecision}
621
+ />
622
+ </div>
623
+ );
624
+ }
625
+ }
626
+
627
+ const detailLines =
628
+ !hasDiffs && (isCompleted || isFailed)
629
+ ? buildToolCallDetailsLines(toolCall)
630
+ : [];
631
+ const hasExpandableContent = hasDiffs || detailLines.length > 0;
632
+
633
+ if ((isCompleted || isFailed) && hasExpandableContent) {
634
+ return (
635
+ <details className={cssClass}>
636
+ <summary>
637
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
638
+ {displayTitle}
639
+ <PermissionLabel toolCall={toolCall} />
640
+ </summary>
641
+ {hasDiffs ? (
642
+ <ToolCallDiffView
643
+ diffs={toolCall.diffs!}
644
+ trans={trans}
645
+ openToolCallPath={openToolCallPath}
646
+ />
647
+ ) : (
648
+ <div className="jp-ai-tool-call-item-detail">
649
+ {detailLines.join('\n')}
650
+ </div>
651
+ )}
652
+ </details>
653
+ );
654
+ }
655
+
656
+ if (isInProgress) {
657
+ return (
658
+ <div className={cssClass}>
659
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
660
+ <em>{displayTitle}</em>
661
+ <PermissionButtons
662
+ toolCall={toolCall}
663
+ trans={trans}
664
+ toolCallPermissionDecision={toolCallPermissionDecision}
665
+ />
666
+ </div>
667
+ );
668
+ }
669
+
670
+ return (
671
+ <div className={cssClass}>
672
+ <span className="jp-ai-tool-call-item-icon">{icon}</span> {displayTitle}
673
+ <PermissionLabel toolCall={toolCall} />
674
+ </div>
675
+ );
676
+ }
677
+
678
+ /**
679
+ * React component for rendering grouped tool calls.
680
+ */
681
+ export const GroupedToolCalls: React.FC<IGroupedToolCallsProps> = props => {
682
+ const trans = props.trans ?? nullTranslator.load('jupyterlab');
683
+ const { toolCalls } = props;
684
+
685
+ if (!toolCalls.length) {
686
+ return null;
687
+ }
688
+
689
+ return (
690
+ <div className="jp-ai-tool-calls">
691
+ {toolCalls.map(toolCall => (
692
+ <ToolCallRow
693
+ key={toolCall.toolCallId}
694
+ toolCall={toolCall}
695
+ trans={trans}
696
+ openToolCallPath={props.openToolCallPath}
697
+ toolCallPermissionDecision={props.toolCallPermissionDecision}
698
+ />
699
+ ))}
700
+ </div>
701
+ );
702
+ };
@@ -1,2 +1,4 @@
1
1
  export * from './inline-diff';
2
+ export * from './message-queue';
2
3
  export * from './tool-call';
4
+ export * from './grouped-tool-calls';