jupyter-chat-components 0.4.0 → 0.4.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
@@ -26,38 +26,7 @@ The registry is available directly on the `IComponentsRendererFactory` token as
26
26
 
27
27
  Other JupyterLab extensions can consume the `IComponentsRendererFactory` token and use `registry.add()` to register their own components, which will then be available for rendering via the MIME bundle.
28
28
 
29
- ## Available components
30
-
31
- ### `tool-call`
32
-
33
- Renders an AI tool call, displaying the tool name, input arguments, and output in a structured and readable format. Useful for visualizing function calls made by AI assistants during a conversation.
34
-
35
- ### `grouped-tool-calls`
36
-
37
- Renders grouped tool calls, including:
38
-
39
- - grouped in-progress, completed, and failed tool rows
40
- - expandable raw output and file path details
41
- - inline file diffs for edit operations
42
- - permission options for approval flows
43
-
44
- The component uses a camelCase metadata API.
45
-
46
- If your host extension wants the approval buttons to be interactive, set the renderer factory callbacks:
47
-
48
- ```ts
49
- rendererFactory.toolCallPermissionDecision = async (
50
- sessionId,
51
- toolCallId,
52
- optionId
53
- ) => {
54
- // submit the decision to your backend
55
- };
56
-
57
- rendererFactory.openToolCallPath = path => {
58
- // optionally open the referenced file or resource
59
- };
60
- ```
29
+ For live end-to-end metadata examples, see the deployed demo notebook at [brichet.github.io/jupyter-chat-components/lab/index.html?path=components_demo.ipynb](https://brichet.github.io/jupyter-chat-components/lab/index.html?path=components_demo.ipynb). The source notebook lives at [demo/contents/components_demo.ipynb](./demo/contents/components_demo.ipynb).
61
30
 
62
31
  ## Requirements
63
32
 
@@ -12,13 +12,9 @@ export interface IGroupedToolCallsProps extends IComponentProps, IToolCallsMetad
12
12
  */
13
13
  export declare function toServerRelativePath(absolutePath: string): string;
14
14
  /**
15
- * Format tool output for display.
15
+ * Format tool payload (input/output) for display.
16
16
  */
17
- export declare function formatToolCallOutput(rawOutput: unknown): string;
18
- /**
19
- * Format tool input for display.
20
- */
21
- export declare function formatToolCallInput(input: unknown): string;
17
+ export declare function formatToolCallIO(payload: unknown): string;
22
18
  /**
23
19
  * Compute the line title shown for a tool call.
24
20
  */
@@ -28,10 +24,6 @@ export declare function getToolCallDisplayTitle(toolCall: IToolCallsEntry): stri
28
24
  * title alone is enough.
29
25
  */
30
26
  export declare function buildPermissionDetail(toolCall: IToolCallsEntry): string | null;
31
- /**
32
- * Build the expandable detail lines shown for completed and failed tool calls.
33
- */
34
- export declare function buildToolCallDetailsLines(toolCall: IToolCallsEntry): string[];
35
27
  /**
36
28
  * React component for rendering grouped tool calls.
37
29
  */
@@ -4,10 +4,10 @@ import * as React from 'react';
4
4
  import { structuredPatch } from 'diff';
5
5
  /** Maximum number of rendered diff lines before truncation. */
6
6
  const MAX_DIFF_LINES = 20;
7
+ /** Maximum number of lines shown in an expanded detail. */
8
+ const MAX_DETAIL_LINES = 15;
7
9
  /** Tool kinds where expanded view shows file paths from locations. */
8
10
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
9
- /** Tool kinds where expanded view shows raw output. */
10
- const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
11
11
  const TOOL_KIND_LABELS = {
12
12
  read: 'Reading',
13
13
  edit: 'Editing',
@@ -47,38 +47,19 @@ export function toServerRelativePath(absolutePath) {
47
47
  return relativePath;
48
48
  }
49
49
  /**
50
- * Format tool output for display.
50
+ * Format tool payload (input/output) for display.
51
51
  */
52
- export function formatToolCallOutput(rawOutput) {
53
- if (typeof rawOutput === 'string') {
54
- return rawOutput;
52
+ export function formatToolCallIO(payload) {
53
+ if (typeof payload === 'string') {
54
+ return payload;
55
55
  }
56
- if (Array.isArray(rawOutput) &&
57
- rawOutput.every(item => typeof item === 'object' &&
56
+ if (Array.isArray(payload) &&
57
+ payload.every(item => typeof item === 'object' &&
58
58
  item !== null &&
59
59
  typeof item.text === 'string')) {
60
- return rawOutput.map(item => item.text).join('\n');
60
+ return payload.map(item => item.text).join('\n');
61
61
  }
62
- return JSON.stringify(rawOutput, null, 2);
63
- }
64
- /**
65
- * Format tool input for display.
66
- */
67
- export function formatToolCallInput(input) {
68
- if (typeof input === 'string') {
69
- return input;
70
- }
71
- if (typeof input !== 'object' || input === null || Array.isArray(input)) {
72
- return JSON.stringify(input, null, 2);
73
- }
74
- const entries = Object.entries(input);
75
- const isFlat = entries.every(([, value]) => typeof value === 'string' ||
76
- typeof value === 'number' ||
77
- typeof value === 'boolean');
78
- if (isFlat) {
79
- return entries.map(([key, value]) => `${key}: ${value}`).join('\n');
80
- }
81
- return JSON.stringify(input, null, 2);
62
+ return JSON.stringify(payload, null, 2);
82
63
  }
83
64
  function getLocationSummary(toolCall) {
84
65
  var _a;
@@ -143,7 +124,7 @@ export function buildPermissionDetail(toolCall) {
143
124
  result[key] = value;
144
125
  return result;
145
126
  }, {});
146
- const params = paramEntries.length > 0 ? formatToolCallInput(filteredParams) : null;
127
+ const params = paramEntries.length > 0 ? formatToolCallIO(filteredParams) : null;
147
128
  if (purpose && params) {
148
129
  return `${purpose}\n${params}`;
149
130
  }
@@ -156,29 +137,21 @@ export function buildPermissionDetail(toolCall) {
156
137
  return null;
157
138
  }
158
139
  if (rawInput !== null && rawInput !== undefined) {
159
- return formatToolCallInput(rawInput);
140
+ return formatToolCallIO(rawInput);
160
141
  }
161
142
  return null;
162
143
  }
163
144
  /**
164
- * Build the expandable detail lines shown for completed and failed tool calls.
145
+ * Returns true when a completed/failed tool call has expandable detail content.
165
146
  */
166
- export function buildToolCallDetailsLines(toolCall) {
167
- const lines = [];
168
- const { kind, locations, rawOutput } = toolCall;
147
+ function hasDetailContent(toolCall) {
148
+ const { kind, locations, rawInput, rawOutput } = toolCall;
149
+ const hasInput = rawInput !== null && rawInput !== undefined;
150
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
169
151
  if (kind && FILE_KINDS.has(kind) && (locations === null || locations === void 0 ? void 0 : locations.length)) {
170
- lines.push(...locations.map(toServerRelativePath));
171
- }
172
- else if (kind &&
173
- OUTPUT_KINDS.has(kind) &&
174
- rawOutput !== null &&
175
- rawOutput !== undefined) {
176
- lines.push(formatToolCallOutput(rawOutput));
152
+ return true;
177
153
  }
178
- else if (typeof rawOutput === 'string') {
179
- lines.push(rawOutput);
180
- }
181
- return lines;
154
+ return hasInput || hasOutput;
182
155
  }
183
156
  function toDiffLineInfo(type, text, key) {
184
157
  switch (type) {
@@ -230,6 +203,67 @@ function buildDiffLines(diff) {
230
203
  return lines;
231
204
  }, []);
232
205
  }
206
+ /**
207
+ * A labeled section with a capped height and show-all/show-less toggle.
208
+ */
209
+ function ToolCallSection({ label, children, trans }) {
210
+ const [expanded, setExpanded] = React.useState(false);
211
+ const [isOverflowing, setIsOverflowing] = React.useState(false);
212
+ const preRef = React.useRef(null);
213
+ React.useEffect(() => {
214
+ var _a;
215
+ const details = (_a = preRef.current) === null || _a === void 0 ? void 0 : _a.closest('details');
216
+ if (!details) {
217
+ return;
218
+ }
219
+ const handleToggle = () => {
220
+ if (!details.open) {
221
+ setExpanded(false);
222
+ }
223
+ };
224
+ details.addEventListener('toggle', handleToggle);
225
+ return () => details.removeEventListener('toggle', handleToggle);
226
+ }, []);
227
+ React.useLayoutEffect(() => {
228
+ const el = preRef.current;
229
+ if (!el) {
230
+ return;
231
+ }
232
+ const measure = () => {
233
+ if (!expanded) {
234
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
235
+ }
236
+ };
237
+ const observer = new ResizeObserver(measure);
238
+ observer.observe(el);
239
+ measure();
240
+ return () => observer.disconnect();
241
+ }, [expanded]);
242
+ return (React.createElement("div", { className: "jp-ai-tool-call-detail-section" },
243
+ React.createElement("div", { className: "jp-ai-tool-call-detail-label" }, label),
244
+ React.createElement("pre", { ref: preRef, className: "jp-ai-tool-call-detail-code", style: {
245
+ maxHeight: expanded
246
+ ? undefined
247
+ : `calc(${MAX_DETAIL_LINES} * var(--jp-content-line-height) * var(--jp-ui-font-size1))`
248
+ } },
249
+ React.createElement("code", null, children)),
250
+ !expanded && isOverflowing && (React.createElement("button", { className: "jp-ai-tool-call-diff-toggle", onClick: () => setExpanded(true), type: "button" }, trans.__('Show all'))),
251
+ expanded && (React.createElement("button", { className: "jp-ai-tool-call-diff-toggle", onClick: () => setExpanded(false), type: "button" }, trans.__('Show less')))));
252
+ }
253
+ /**
254
+ * Expandable detail view for a completed or failed tool call.
255
+ */
256
+ function ToolCallDetail({ toolCall, trans }) {
257
+ const { kind, locations, rawInput, rawOutput } = toolCall;
258
+ const hasInput = rawInput !== null && rawInput !== undefined;
259
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
260
+ if (kind && FILE_KINDS.has(kind) && (locations === null || locations === void 0 ? void 0 : locations.length)) {
261
+ return (React.createElement("div", { className: "jp-ai-tool-call-item-detail" }, locations.map((loc, i) => (React.createElement("div", { key: i, className: "jp-ai-tool-call-item-detail-path" }, toServerRelativePath(loc))))));
262
+ }
263
+ return (React.createElement("div", { className: "jp-ai-tool-call-item-detail" },
264
+ hasInput && (React.createElement(ToolCallSection, { label: trans.__('Input'), trans: trans }, formatToolCallIO(rawInput))),
265
+ hasOutput && (React.createElement(ToolCallSection, { label: trans.__('Output'), trans: trans }, formatToolCallIO(rawOutput)))));
266
+ }
233
267
  function ToolCallDiffBlock({ diff, trans, openToolCallPath, pendingPermission }) {
234
268
  const [expanded, setExpanded] = React.useState(false);
235
269
  const allLines = React.useMemo(() => buildDiffLines(diff), [diff]);
@@ -300,10 +334,10 @@ function ToolCallRow({ toolCall, trans, openToolCallPath, toolCallPermissionDeci
300
334
  var _a, _b, _c, _d;
301
335
  const displayTitle = getToolCallDisplayTitle(toolCall);
302
336
  const selectedOption = (_a = toolCall.permissionOptions) === null || _a === void 0 ? void 0 : _a.find(option => option.optionId === toolCall.selectedOptionId);
337
+ const status = (_b = toolCall.status) !== null && _b !== void 0 ? _b : 'in_progress';
303
338
  const isRejected = toolCall.permissionStatus === 'resolved' &&
304
- !!((_b = selectedOption === null || selectedOption === void 0 ? void 0 : selectedOption.kind) === null || _b === void 0 ? void 0 : _b.includes('reject'));
339
+ (!!((_c = selectedOption === null || selectedOption === void 0 ? void 0 : selectedOption.kind) === null || _c === void 0 ? void 0 : _c.includes('reject')) || status === 'rejected');
305
340
  const hasPendingPermission = toolCall.permissionStatus === 'pending';
306
- const status = (_c = toolCall.status) !== null && _c !== void 0 ? _c : 'in_progress';
307
341
  const isInProgress = !isRejected &&
308
342
  (status === 'in_progress' || status === 'pending' || hasPendingPermission);
309
343
  const isCompleted = status === 'completed';
@@ -316,15 +350,21 @@ function ToolCallRow({ toolCall, trans, openToolCallPath, toolCallPermissionDeci
316
350
  ? '\u2717'
317
351
  : '\u2022';
318
352
  const effectiveStatus = (isRejected ? 'failed' : status).replace(/_/g, '-');
319
- const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}`;
320
353
  const hasDiffs = !!((_d = toolCall.diffs) === null || _d === void 0 ? void 0 : _d.length);
354
+ const hasExpandableContent = hasDiffs ||
355
+ (!hasDiffs && (isCompleted || isFailed) && hasDetailContent(toolCall));
356
+ const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}${!hasExpandableContent && !hasPendingPermission ? ' jp-ai-tool-call-item-no-detail' : ''}`;
321
357
  if (hasDiffs && hasPendingPermission) {
322
358
  return (React.createElement("div", { className: cssClass },
323
359
  React.createElement("details", { open: true },
324
360
  React.createElement("summary", null,
325
361
  React.createElement("span", { className: "jp-ai-tool-call-item-icon" }, icon),
326
362
  ' ',
327
- React.createElement("em", null, displayTitle)),
363
+ React.createElement("div", { className: "jp-ai-tool-call-item-title" },
364
+ displayTitle,
365
+ toolCall.summary && (React.createElement("span", { className: "jp-ai-tool-call-item-summary" },
366
+ ' ',
367
+ toolCall.summary)))),
328
368
  React.createElement(ToolCallDiffView, { diffs: toolCall.diffs, trans: trans, openToolCallPath: openToolCallPath, pendingPermission: true })),
329
369
  React.createElement(PermissionButtons, { toolCall: toolCall, trans: trans, toolCallPermissionDecision: toolCallPermissionDecision })));
330
370
  }
@@ -336,35 +376,46 @@ function ToolCallRow({ toolCall, trans, openToolCallPath, toolCallPermissionDeci
336
376
  React.createElement("summary", null,
337
377
  React.createElement("span", { className: "jp-ai-tool-call-item-icon" }, icon),
338
378
  ' ',
339
- React.createElement("em", null, displayTitle)),
379
+ React.createElement("div", { className: "jp-ai-tool-call-item-title" },
380
+ displayTitle,
381
+ toolCall.summary && (React.createElement("span", { className: "jp-ai-tool-call-item-summary" },
382
+ ' ',
383
+ toolCall.summary)))),
340
384
  React.createElement("div", { className: "jp-ai-tool-call-item-detail" }, permissionDetail)),
341
385
  React.createElement(PermissionButtons, { toolCall: toolCall, trans: trans, toolCallPermissionDecision: toolCallPermissionDecision })));
342
386
  }
343
387
  }
344
- const detailLines = !hasDiffs && (isCompleted || isFailed)
345
- ? buildToolCallDetailsLines(toolCall)
346
- : [];
347
- const hasExpandableContent = hasDiffs || detailLines.length > 0;
348
388
  if ((isCompleted || isFailed) && hasExpandableContent) {
349
389
  return (React.createElement("details", { className: cssClass },
350
390
  React.createElement("summary", null,
351
391
  React.createElement("span", { className: "jp-ai-tool-call-item-icon" }, icon),
352
392
  ' ',
353
- displayTitle,
393
+ React.createElement("div", { className: "jp-ai-tool-call-item-title" },
394
+ displayTitle,
395
+ toolCall.summary && (React.createElement("span", { className: "jp-ai-tool-call-item-summary" },
396
+ ' ',
397
+ toolCall.summary))),
354
398
  React.createElement(PermissionLabel, { toolCall: toolCall })),
355
- hasDiffs ? (React.createElement(ToolCallDiffView, { diffs: toolCall.diffs, trans: trans, openToolCallPath: openToolCallPath })) : (React.createElement("div", { className: "jp-ai-tool-call-item-detail" }, detailLines.join('\n')))));
399
+ hasDiffs ? (React.createElement(ToolCallDiffView, { diffs: toolCall.diffs, trans: trans, openToolCallPath: openToolCallPath })) : (React.createElement(ToolCallDetail, { toolCall: toolCall, trans: trans }))));
356
400
  }
357
401
  if (isInProgress) {
358
402
  return (React.createElement("div", { className: cssClass },
359
403
  React.createElement("span", { className: "jp-ai-tool-call-item-icon" }, icon),
360
404
  ' ',
361
- React.createElement("em", null, displayTitle),
405
+ React.createElement("div", { className: "jp-ai-tool-call-item-title" },
406
+ displayTitle,
407
+ toolCall.summary && (React.createElement("span", { className: "jp-ai-tool-call-item-summary" },
408
+ ' ',
409
+ toolCall.summary))),
362
410
  React.createElement(PermissionButtons, { toolCall: toolCall, trans: trans, toolCallPermissionDecision: toolCallPermissionDecision })));
363
411
  }
364
412
  return (React.createElement("div", { className: cssClass },
365
413
  React.createElement("span", { className: "jp-ai-tool-call-item-icon" }, icon),
366
- " ",
367
- displayTitle,
414
+ React.createElement("div", { className: "jp-ai-tool-call-item-title" },
415
+ displayTitle,
416
+ toolCall.summary && (React.createElement("span", { className: "jp-ai-tool-call-item-summary" },
417
+ ' ',
418
+ toolCall.summary))),
368
419
  React.createElement(PermissionLabel, { toolCall: toolCall })));
369
420
  }
370
421
  /**
package/lib/token.d.ts CHANGED
@@ -134,6 +134,10 @@ export interface IToolCallsEntry {
134
134
  * Human-readable title displayed in the UI.
135
135
  */
136
136
  title?: string;
137
+ /**
138
+ * Human-readable summary from tool input displayed with the title
139
+ */
140
+ summary?: string;
137
141
  /**
138
142
  * Tool operation category.
139
143
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyter-chat-components",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Components to displayed in jupyter chat",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -17,12 +17,12 @@ import {
17
17
  /** Maximum number of rendered diff lines before truncation. */
18
18
  const MAX_DIFF_LINES = 20;
19
19
 
20
+ /** Maximum number of lines shown in an expanded detail. */
21
+ const MAX_DETAIL_LINES = 15;
22
+
20
23
  /** Tool kinds where expanded view shows file paths from locations. */
21
24
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
22
25
 
23
- /** Tool kinds where expanded view shows raw output. */
24
- const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
25
-
26
26
  const TOOL_KIND_LABELS: Record<string, string> = {
27
27
  read: 'Reading',
28
28
  edit: 'Editing',
@@ -88,53 +88,26 @@ export function toServerRelativePath(absolutePath: string): string {
88
88
  }
89
89
 
90
90
  /**
91
- * Format tool output for display.
91
+ * Format tool payload (input/output) for display.
92
92
  */
93
- export function formatToolCallOutput(rawOutput: unknown): string {
94
- if (typeof rawOutput === 'string') {
95
- return rawOutput;
93
+ export function formatToolCallIO(payload: unknown): string {
94
+ if (typeof payload === 'string') {
95
+ return payload;
96
96
  }
97
97
 
98
98
  if (
99
- Array.isArray(rawOutput) &&
100
- rawOutput.every(
99
+ Array.isArray(payload) &&
100
+ payload.every(
101
101
  item =>
102
102
  typeof item === 'object' &&
103
103
  item !== null &&
104
104
  typeof (item as { text?: unknown }).text === 'string'
105
105
  )
106
106
  ) {
107
- return rawOutput.map(item => (item as { text: string }).text).join('\n');
107
+ return payload.map(item => (item as { text: string }).text).join('\n');
108
108
  }
109
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);
110
+ return JSON.stringify(payload, null, 2);
138
111
  }
139
112
 
140
113
  function getLocationSummary(toolCall: IToolCallsEntry): string | null {
@@ -231,7 +204,7 @@ export function buildPermissionDetail(
231
204
  {}
232
205
  );
233
206
  const params =
234
- paramEntries.length > 0 ? formatToolCallInput(filteredParams) : null;
207
+ paramEntries.length > 0 ? formatToolCallIO(filteredParams) : null;
235
208
 
236
209
  if (purpose && params) {
237
210
  return `${purpose}\n${params}`;
@@ -249,33 +222,24 @@ export function buildPermissionDetail(
249
222
  }
250
223
 
251
224
  if (rawInput !== null && rawInput !== undefined) {
252
- return formatToolCallInput(rawInput);
225
+ return formatToolCallIO(rawInput);
253
226
  }
254
227
 
255
228
  return null;
256
229
  }
257
230
 
258
231
  /**
259
- * Build the expandable detail lines shown for completed and failed tool calls.
232
+ * Returns true when a completed/failed tool call has expandable detail content.
260
233
  */
261
- export function buildToolCallDetailsLines(toolCall: IToolCallsEntry): string[] {
262
- const lines: string[] = [];
263
- const { kind, locations, rawOutput } = toolCall;
234
+ function hasDetailContent(toolCall: IToolCallsEntry): boolean {
235
+ const { kind, locations, rawInput, rawOutput } = toolCall;
236
+ const hasInput = rawInput !== null && rawInput !== undefined;
237
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
264
238
 
265
239
  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);
240
+ return true;
276
241
  }
277
-
278
- return lines;
242
+ return hasInput || hasOutput;
279
243
  }
280
244
 
281
245
  function toDiffLineInfo(
@@ -348,6 +312,130 @@ function buildDiffLines(diff: IToolCallDiff): IDiffLineInfo[] {
348
312
  }, []);
349
313
  }
350
314
 
315
+ /**
316
+ * A labeled section with a capped height and show-all/show-less toggle.
317
+ */
318
+ function ToolCallSection({
319
+ label,
320
+ children,
321
+ trans
322
+ }: {
323
+ label: string;
324
+ children: React.ReactNode;
325
+ trans: TranslationBundle;
326
+ }): JSX.Element {
327
+ const [expanded, setExpanded] = React.useState(false);
328
+ const [isOverflowing, setIsOverflowing] = React.useState(false);
329
+ const preRef = React.useRef<HTMLPreElement>(null);
330
+
331
+ React.useEffect(() => {
332
+ const details = preRef.current?.closest('details');
333
+ if (!details) {
334
+ return;
335
+ }
336
+ const handleToggle = () => {
337
+ if (!details.open) {
338
+ setExpanded(false);
339
+ }
340
+ };
341
+ details.addEventListener('toggle', handleToggle);
342
+ return () => details.removeEventListener('toggle', handleToggle);
343
+ }, []);
344
+
345
+ React.useLayoutEffect(() => {
346
+ const el = preRef.current;
347
+ if (!el) {
348
+ return;
349
+ }
350
+ const measure = () => {
351
+ if (!expanded) {
352
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
353
+ }
354
+ };
355
+ const observer = new ResizeObserver(measure);
356
+ observer.observe(el);
357
+ measure();
358
+ return () => observer.disconnect();
359
+ }, [expanded]);
360
+
361
+ return (
362
+ <div className="jp-ai-tool-call-detail-section">
363
+ <div className="jp-ai-tool-call-detail-label">{label}</div>
364
+ <pre
365
+ ref={preRef}
366
+ className="jp-ai-tool-call-detail-code"
367
+ style={{
368
+ maxHeight: expanded
369
+ ? undefined
370
+ : `calc(${MAX_DETAIL_LINES} * var(--jp-content-line-height) * var(--jp-ui-font-size1))`
371
+ }}
372
+ >
373
+ <code>{children}</code>
374
+ </pre>
375
+ {!expanded && isOverflowing && (
376
+ <button
377
+ className="jp-ai-tool-call-diff-toggle"
378
+ onClick={() => setExpanded(true)}
379
+ type="button"
380
+ >
381
+ {trans.__('Show all')}
382
+ </button>
383
+ )}
384
+ {expanded && (
385
+ <button
386
+ className="jp-ai-tool-call-diff-toggle"
387
+ onClick={() => setExpanded(false)}
388
+ type="button"
389
+ >
390
+ {trans.__('Show less')}
391
+ </button>
392
+ )}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ /**
398
+ * Expandable detail view for a completed or failed tool call.
399
+ */
400
+ function ToolCallDetail({
401
+ toolCall,
402
+ trans
403
+ }: {
404
+ toolCall: IToolCallsEntry;
405
+ trans: TranslationBundle;
406
+ }): JSX.Element {
407
+ const { kind, locations, rawInput, rawOutput } = toolCall;
408
+ const hasInput = rawInput !== null && rawInput !== undefined;
409
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
410
+
411
+ if (kind && FILE_KINDS.has(kind) && locations?.length) {
412
+ return (
413
+ <div className="jp-ai-tool-call-item-detail">
414
+ {locations.map((loc, i) => (
415
+ <div key={i} className="jp-ai-tool-call-item-detail-path">
416
+ {toServerRelativePath(loc)}
417
+ </div>
418
+ ))}
419
+ </div>
420
+ );
421
+ }
422
+
423
+ return (
424
+ <div className="jp-ai-tool-call-item-detail">
425
+ {hasInput && (
426
+ <ToolCallSection label={trans.__('Input')} trans={trans}>
427
+ {formatToolCallIO(rawInput)}
428
+ </ToolCallSection>
429
+ )}
430
+ {hasOutput && (
431
+ <ToolCallSection label={trans.__('Output')} trans={trans}>
432
+ {formatToolCallIO(rawOutput)}
433
+ </ToolCallSection>
434
+ )}
435
+ </div>
436
+ );
437
+ }
438
+
351
439
  function ToolCallDiffBlock({
352
440
  diff,
353
441
  trans,
@@ -554,11 +642,12 @@ function ToolCallRow({
554
642
  const selectedOption = toolCall.permissionOptions?.find(
555
643
  option => option.optionId === toolCall.selectedOptionId
556
644
  );
645
+ const status = toolCall.status ?? 'in_progress';
646
+
557
647
  const isRejected =
558
648
  toolCall.permissionStatus === 'resolved' &&
559
- !!selectedOption?.kind?.includes('reject');
649
+ (!!selectedOption?.kind?.includes('reject') || status === 'rejected');
560
650
  const hasPendingPermission = toolCall.permissionStatus === 'pending';
561
- const status = toolCall.status ?? 'in_progress';
562
651
  const isInProgress =
563
652
  !isRejected &&
564
653
  (status === 'in_progress' || status === 'pending' || hasPendingPermission);
@@ -572,8 +661,13 @@ function ToolCallRow({
572
661
  ? '\u2717'
573
662
  : '\u2022';
574
663
  const effectiveStatus = (isRejected ? 'failed' : status).replace(/_/g, '-');
575
- const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}`;
664
+
576
665
  const hasDiffs = !!toolCall.diffs?.length;
666
+ const hasExpandableContent =
667
+ hasDiffs ||
668
+ (!hasDiffs && (isCompleted || isFailed) && hasDetailContent(toolCall));
669
+
670
+ const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}${!hasExpandableContent && !hasPendingPermission ? ' jp-ai-tool-call-item-no-detail' : ''}`;
577
671
 
578
672
  if (hasDiffs && hasPendingPermission) {
579
673
  return (
@@ -581,7 +675,15 @@ function ToolCallRow({
581
675
  <details open>
582
676
  <summary>
583
677
  <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
584
- <em>{displayTitle}</em>
678
+ <div className="jp-ai-tool-call-item-title">
679
+ {displayTitle}
680
+ {toolCall.summary && (
681
+ <span className="jp-ai-tool-call-item-summary">
682
+ {' '}
683
+ {toolCall.summary}
684
+ </span>
685
+ )}
686
+ </div>
585
687
  </summary>
586
688
  <ToolCallDiffView
587
689
  diffs={toolCall.diffs!}
@@ -608,7 +710,15 @@ function ToolCallRow({
608
710
  <details open>
609
711
  <summary>
610
712
  <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
611
- <em>{displayTitle}</em>
713
+ <div className="jp-ai-tool-call-item-title">
714
+ {displayTitle}
715
+ {toolCall.summary && (
716
+ <span className="jp-ai-tool-call-item-summary">
717
+ {' '}
718
+ {toolCall.summary}
719
+ </span>
720
+ )}
721
+ </div>
612
722
  </summary>
613
723
  <div className="jp-ai-tool-call-item-detail">
614
724
  {permissionDetail}
@@ -624,18 +734,20 @@ function ToolCallRow({
624
734
  }
625
735
  }
626
736
 
627
- const detailLines =
628
- !hasDiffs && (isCompleted || isFailed)
629
- ? buildToolCallDetailsLines(toolCall)
630
- : [];
631
- const hasExpandableContent = hasDiffs || detailLines.length > 0;
632
-
633
737
  if ((isCompleted || isFailed) && hasExpandableContent) {
634
738
  return (
635
739
  <details className={cssClass}>
636
740
  <summary>
637
741
  <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
638
- {displayTitle}
742
+ <div className="jp-ai-tool-call-item-title">
743
+ {displayTitle}
744
+ {toolCall.summary && (
745
+ <span className="jp-ai-tool-call-item-summary">
746
+ {' '}
747
+ {toolCall.summary}
748
+ </span>
749
+ )}
750
+ </div>
639
751
  <PermissionLabel toolCall={toolCall} />
640
752
  </summary>
641
753
  {hasDiffs ? (
@@ -645,9 +757,7 @@ function ToolCallRow({
645
757
  openToolCallPath={openToolCallPath}
646
758
  />
647
759
  ) : (
648
- <div className="jp-ai-tool-call-item-detail">
649
- {detailLines.join('\n')}
650
- </div>
760
+ <ToolCallDetail toolCall={toolCall} trans={trans} />
651
761
  )}
652
762
  </details>
653
763
  );
@@ -657,7 +767,15 @@ function ToolCallRow({
657
767
  return (
658
768
  <div className={cssClass}>
659
769
  <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
660
- <em>{displayTitle}</em>
770
+ <div className="jp-ai-tool-call-item-title">
771
+ {displayTitle}
772
+ {toolCall.summary && (
773
+ <span className="jp-ai-tool-call-item-summary">
774
+ {' '}
775
+ {toolCall.summary}
776
+ </span>
777
+ )}
778
+ </div>
661
779
  <PermissionButtons
662
780
  toolCall={toolCall}
663
781
  trans={trans}
@@ -669,7 +787,16 @@ function ToolCallRow({
669
787
 
670
788
  return (
671
789
  <div className={cssClass}>
672
- <span className="jp-ai-tool-call-item-icon">{icon}</span> {displayTitle}
790
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>
791
+ <div className="jp-ai-tool-call-item-title">
792
+ {displayTitle}
793
+ {toolCall.summary && (
794
+ <span className="jp-ai-tool-call-item-summary">
795
+ {' '}
796
+ {toolCall.summary}
797
+ </span>
798
+ )}
799
+ </div>
673
800
  <PermissionLabel toolCall={toolCall} />
674
801
  </div>
675
802
  );
package/src/token.ts CHANGED
@@ -169,6 +169,10 @@ export interface IToolCallsEntry {
169
169
  * Human-readable title displayed in the UI.
170
170
  */
171
171
  title?: string;
172
+ /**
173
+ * Human-readable summary from tool input displayed with the title
174
+ */
175
+ summary?: string;
172
176
  /**
173
177
  * Tool operation category.
174
178
  */
package/style/base.css CHANGED
@@ -233,15 +233,6 @@
233
233
  .jp-ai-tool-call-item {
234
234
  padding: 1px 0;
235
235
  font-family: var(--jp-content-font-family);
236
- }
237
-
238
- .jp-ai-tool-call-item-in-progress,
239
- .jp-ai-tool-call-item-pending {
240
- color: var(--jp-ui-font-color2);
241
- }
242
-
243
- .jp-ai-tool-call-item-completed,
244
- .jp-ai-tool-call-item-failed {
245
236
  color: var(--jp-ui-font-color2);
246
237
  }
247
238
 
@@ -256,7 +247,30 @@
256
247
  details.jp-ai-tool-call-item summary,
257
248
  .jp-ai-tool-call-item details summary {
258
249
  cursor: pointer;
250
+ }
251
+
252
+ details.jp-ai-tool-call-item summary,
253
+ .jp-ai-tool-call-item details summary,
254
+ .jp-ai-tool-call-item.jp-ai-tool-call-item-no-detail {
259
255
  list-style: none;
256
+ display: flex;
257
+ align-items: center;
258
+ padding: 4px 8px;
259
+ background: var(--jp-layout-color1);
260
+ gap: 8px;
261
+ transition: background-color 0.2s ease;
262
+ }
263
+
264
+ .jp-ai-tool-call-item-title {
265
+ font-size: var(--jp-ui-font-size1);
266
+ font-weight: 500;
267
+ flex: 1;
268
+ }
269
+
270
+ .jp-ai-tool-call-item-summary {
271
+ font-weight: 400;
272
+ opacity: 0.8;
273
+ font-size: var(--jp-ui-font-size0);
260
274
  }
261
275
 
262
276
  details.jp-ai-tool-call-item summary::-webkit-details-marker,
@@ -284,11 +298,42 @@ details[open].jp-ai-tool-call-item summary::after,
284
298
 
285
299
  .jp-ai-tool-call-item-detail {
286
300
  margin: 2px 0 2px 20px;
287
- font-size: var(--jp-code-font-size);
301
+ font-size: var(--jp-ui-font-size1);
288
302
  font-family: var(--jp-code-font-family);
289
- color: var(--jp-ui-font-color2);
290
303
  white-space: pre-wrap;
291
304
  word-break: break-all;
305
+ padding-left: 5px;
306
+ }
307
+
308
+ .jp-ai-tool-call-detail-section {
309
+ margin-bottom: 8px;
310
+ }
311
+
312
+ .jp-ai-tool-call-detail-section:last-child {
313
+ margin-bottom: 0;
314
+ }
315
+
316
+ .jp-ai-tool-call-detail-label {
317
+ font-family: var(--jp-ui-font-family);
318
+ font-size: var(--jp-ui-font-size0);
319
+ font-weight: 600;
320
+ margin-bottom: 4px;
321
+ text-transform: uppercase;
322
+ letter-spacing: 0.5px;
323
+ }
324
+
325
+ .jp-ai-tool-call-detail-code {
326
+ background: var(--jp-layout-color2) !important;
327
+ border: 1px solid var(--jp-border-color1) !important;
328
+ border-radius: 4px !important;
329
+ padding: 8px !important;
330
+ margin: 0 !important;
331
+ font-size: var(--jp-ui-font-size1) !important;
332
+ overflow: auto;
333
+ }
334
+
335
+ .jp-ai-tool-call-detail-code code {
336
+ font-size: var(--jp-ui-font-size1) !important;
292
337
  }
293
338
 
294
339
  .jp-ai-tool-call-permission-buttons {
@@ -297,6 +342,7 @@ details[open].jp-ai-tool-call-item summary::after,
297
342
  margin: 4px 0;
298
343
  align-items: center;
299
344
  flex-wrap: wrap;
345
+ padding-left: 5px;
300
346
  }
301
347
 
302
348
  .jp-ai-tool-call-permission-tree {