@jupyter-ai/acp-client 0.0.4 → 0.0.6

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
@@ -51,12 +51,11 @@ class ClaudeAcpPersona(BaseAcpPersona):
51
51
  )
52
52
  ```
53
53
 
54
- Currently, this package provides 3 personas:
54
+ Currently, this package provides 2 personas:
55
55
 
56
- 1. `@Test-ACP` (a test persona that echoes responses)
57
- 2. `@Claude-ACP`
56
+ 1. `@Claude-ACP`
58
57
  - requires `claude-code-acp`, installed via `npm install -g @zed-industries/claude-code-acp`
59
- 3. `@Kiro`
58
+ 2. `@Kiro`
60
59
  - requires `kiro-cli`, installed from https://kiro.dev
61
60
 
62
61
  ## Dependencies
@@ -0,0 +1,8 @@
1
+ import { IToolCallDiff } from '@jupyter/chat';
2
+ /**
3
+ * Renders one or more file diffs.
4
+ */
5
+ export declare function DiffView({ diffs, onOpenFile }: {
6
+ diffs: IToolCallDiff[];
7
+ onOpenFile?: (path: string) => void;
8
+ }): JSX.Element;
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import { structuredPatch } from 'diff';
3
+ /** Maximum number of diff lines shown before truncation. */
4
+ const MAX_DIFF_LINES = 20;
5
+ /**
6
+ * Renders a single file diff block with filename header, line-level
7
+ * highlighting, and click-to-expand truncation.
8
+ */
9
+ function DiffBlock({ diff, onOpenFile }) {
10
+ var _a, _b;
11
+ const patch = structuredPatch(diff.path, diff.path, (_a = diff.old_text) !== null && _a !== void 0 ? _a : '', diff.new_text, undefined, undefined, { context: Infinity });
12
+ const filename = (_b = diff.path.split('/').pop()) !== null && _b !== void 0 ? _b : diff.path;
13
+ const [expanded, setExpanded] = React.useState(false);
14
+ // Flatten hunks into renderable lines
15
+ const allLines = [];
16
+ for (const hunk of patch.hunks) {
17
+ hunk.lines
18
+ .filter(line => !line.startsWith('\\'))
19
+ .forEach((line, j) => {
20
+ const prefix = line[0];
21
+ const text = line.slice(1);
22
+ const isAdded = prefix === '+';
23
+ const isRemoved = prefix === '-';
24
+ allLines.push({
25
+ cls: isAdded
26
+ ? 'jp-jupyter-ai-acp-client-diff-added'
27
+ : isRemoved
28
+ ? 'jp-jupyter-ai-acp-client-diff-removed'
29
+ : 'jp-jupyter-ai-acp-client-diff-context',
30
+ prefix,
31
+ text,
32
+ key: `${hunk.oldStart}-${j}`
33
+ });
34
+ });
35
+ }
36
+ const canTruncate = allLines.length > MAX_DIFF_LINES;
37
+ const visible = canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
38
+ const hiddenCount = allLines.length - MAX_DIFF_LINES;
39
+ return (React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-block" },
40
+ React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-header", onClick: onOpenFile ? () => onOpenFile(diff.path) : undefined, title: diff.path }, filename),
41
+ React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-content" },
42
+ visible.map((line) => (React.createElement("div", { key: line.key, className: `jp-jupyter-ai-acp-client-diff-line ${line.cls}` },
43
+ React.createElement("span", { className: "jp-jupyter-ai-acp-client-diff-line-text" },
44
+ line.prefix,
45
+ " ",
46
+ line.text)))),
47
+ canTruncate && !expanded && (React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-toggle", onClick: () => setExpanded(true) },
48
+ "... ",
49
+ hiddenCount,
50
+ " more lines")),
51
+ canTruncate && expanded && (React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-toggle", onClick: () => setExpanded(false) }, "show less")))));
52
+ }
53
+ /**
54
+ * Renders one or more file diffs.
55
+ */
56
+ export function DiffView({ diffs, onOpenFile }) {
57
+ return (React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-container" }, diffs.map((d, i) => (React.createElement(DiffBlock, { key: i, diff: d, onOpenFile: onOpenFile })))));
58
+ }
package/lib/request.d.ts CHANGED
@@ -11,4 +11,8 @@ type AcpSlashCommand = {
11
11
  description: string;
12
12
  };
13
13
  export declare function getAcpSlashCommands(chatPath: string, personaMentionName?: string | null): Promise<AcpSlashCommand[]>;
14
+ /**
15
+ * Send the user's permission decision to the backend.
16
+ */
17
+ export declare function submitPermissionDecision(sessionId: string, toolCallId: string, optionId: string): Promise<void>;
14
18
  export {};
package/lib/request.js CHANGED
@@ -49,3 +49,17 @@ export async function getAcpSlashCommands(chatPath, personaMentionName = null) {
49
49
  }
50
50
  return response.commands;
51
51
  }
52
+ /**
53
+ * Send the user's permission decision to the backend.
54
+ */
55
+ export async function submitPermissionDecision(sessionId, toolCallId, optionId) {
56
+ await requestAPI('/permissions', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({
60
+ session_id: sessionId,
61
+ tool_call_id: toolCallId,
62
+ option_id: optionId
63
+ })
64
+ });
65
+ }
package/lib/tool-calls.js CHANGED
@@ -1,15 +1,22 @@
1
1
  import React from 'react';
2
+ import { submitPermissionDecision } from './request';
3
+ import clsx from 'clsx';
4
+ import { DiffView } from './diff-view';
2
5
  /**
3
6
  * Preamble component that renders tool call status lines above message body.
4
7
  * Returns null if the message has no tool calls.
5
8
  */
6
9
  export function ToolCallsComponent(props) {
7
10
  var _a, _b, _c, _d;
8
- const { message } = props;
11
+ const { message, model } = props;
9
12
  if (!((_b = (_a = message.metadata) === null || _a === void 0 ? void 0 : _a.tool_calls) === null || _b === void 0 ? void 0 : _b.length)) {
10
13
  return null;
11
14
  }
12
- return (React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-calls" }, ((_d = (_c = message.metadata) === null || _c === void 0 ? void 0 : _c.tool_calls) !== null && _d !== void 0 ? _d : []).map((tc) => (React.createElement(ToolCallLine, { key: tc.tool_call_id, toolCall: tc })))));
15
+ const onOpenFile = (path) => {
16
+ var _a;
17
+ (_a = model.documentManager) === null || _a === void 0 ? void 0 : _a.openOrReveal(path);
18
+ };
19
+ return (React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-calls" }, ((_d = (_c = message.metadata) === null || _c === void 0 ? void 0 : _c.tool_calls) !== null && _d !== void 0 ? _d : []).map((tc) => (React.createElement(ToolCallLine, { key: tc.tool_call_id, toolCall: tc, onOpenFile: onOpenFile })))));
13
20
  }
14
21
  /**
15
22
  * Format raw_output for display. Handles string, object, and array values.
@@ -56,15 +63,21 @@ function buildDetailsLines(toolCall) {
56
63
  /**
57
64
  * Renders a single tool call line with status icon and optional expandable output.
58
65
  */
59
- function ToolCallLine({ toolCall }) {
66
+ function ToolCallLine({ toolCall, onOpenFile }) {
67
+ var _a, _b, _c;
60
68
  const { title, status, kind } = toolCall;
61
69
  const displayTitle = title ||
62
70
  (kind
63
71
  ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)}...`
64
72
  : 'Working...');
65
- const isInProgress = status === 'in_progress' || status === 'pending';
73
+ const selectedOpt = (_a = toolCall.permission_options) === null || _a === void 0 ? void 0 : _a.find(opt => opt.option_id === toolCall.selected_option_id);
74
+ const isRejected = toolCall.permission_status === 'resolved' &&
75
+ !!((_b = selectedOpt === null || selectedOpt === void 0 ? void 0 : selectedOpt.kind) === null || _b === void 0 ? void 0 : _b.includes('reject'));
76
+ const hasPendingPermission = toolCall.permission_status === 'pending';
77
+ const isInProgress = !isRejected &&
78
+ (status === 'in_progress' || status === 'pending' || hasPendingPermission);
66
79
  const isCompleted = status === 'completed';
67
- const isFailed = status === 'failed';
80
+ const isFailed = status === 'failed' || isRejected;
68
81
  // Unicode text glyphs — consistent across OS/browser
69
82
  const icon = isInProgress
70
83
  ? '\u2022'
@@ -73,28 +86,90 @@ function ToolCallLine({ toolCall }) {
73
86
  : isFailed
74
87
  ? '\u2717'
75
88
  : '\u2022';
76
- const cssClass = `jp-jupyter-ai-acp-client-tool-call jp-jupyter-ai-acp-client-tool-call-${status || 'in_progress'}`;
77
- // Progressive disclosure: completed/failed tool calls with metadata get expandable details
78
- const detailsLines = isCompleted || isFailed ? buildDetailsLines(toolCall) : [];
79
- const showDetails = detailsLines.length > 0;
80
- if (showDetails) {
89
+ // Force 'failed' class when rejected
90
+ const effectiveStatus = isRejected ? 'failed' : status || 'in_progress';
91
+ const cssClass = clsx('jp-jupyter-ai-acp-client-tool-call', `jp-jupyter-ai-acp-client-tool-call-${effectiveStatus}`);
92
+ const hasDiffs = !!((_c = toolCall.diffs) === null || _c === void 0 ? void 0 : _c.length);
93
+ // Pending permission with diffs: expanded diff + permission buttons outside
94
+ if (hasDiffs && hasPendingPermission) {
95
+ return (React.createElement("div", { className: cssClass },
96
+ React.createElement("details", { open: true },
97
+ React.createElement("summary", null,
98
+ React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
99
+ ' ',
100
+ React.createElement("em", null, displayTitle)),
101
+ React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: onOpenFile })),
102
+ React.createElement(PermissionButtons, { toolCall: toolCall })));
103
+ }
104
+ // Completed/failed with expandable content (diffs or metadata)
105
+ const detailsLines = !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
106
+ const hasExpandableContent = hasDiffs || detailsLines.length > 0;
107
+ if ((isCompleted || isFailed) && hasExpandableContent) {
81
108
  return (React.createElement("details", { className: cssClass },
82
109
  React.createElement("summary", null,
83
110
  React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
84
111
  ' ',
85
- displayTitle),
86
- React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-call-detail" }, detailsLines.join('\n'))));
112
+ displayTitle,
113
+ React.createElement(PermissionLabel, { toolCall: toolCall })),
114
+ hasDiffs ? (React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: onOpenFile })) : (React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-call-detail" }, detailsLines.join('\n')))));
87
115
  }
88
116
  // In-progress — italic
89
117
  if (isInProgress) {
90
118
  return (React.createElement("div", { className: cssClass },
91
119
  React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
92
120
  ' ',
93
- React.createElement("em", null, displayTitle)));
121
+ React.createElement("em", null, displayTitle),
122
+ React.createElement(PermissionButtons, { toolCall: toolCall })));
94
123
  }
95
124
  // Completed/failed without metadata
96
125
  return (React.createElement("div", { className: cssClass },
97
126
  React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
98
127
  ' ',
99
- displayTitle));
128
+ displayTitle,
129
+ React.createElement(PermissionLabel, { toolCall: toolCall })));
130
+ }
131
+ /**
132
+ * Shows the user's permission selection.
133
+ */
134
+ function PermissionLabel({ toolCall }) {
135
+ var _a, _b;
136
+ if (toolCall.permission_status !== 'resolved' ||
137
+ !toolCall.selected_option_id) {
138
+ return null;
139
+ }
140
+ const selectedName = (_b = (_a = toolCall.permission_options) === null || _a === void 0 ? void 0 : _a.find(opt => opt.option_id === toolCall.selected_option_id)) === null || _b === void 0 ? void 0 : _b.name;
141
+ if (!selectedName) {
142
+ return null;
143
+ }
144
+ return (React.createElement("span", { className: "jp-jupyter-ai-acp-client-permission-label" },
145
+ ' ',
146
+ "\u2014 ",
147
+ selectedName));
148
+ }
149
+ /**
150
+ * Renders the permission buttons.
151
+ */
152
+ function PermissionButtons({ toolCall }) {
153
+ var _a;
154
+ const [submitting, setSubmitting] = React.useState(false);
155
+ if (!((_a = toolCall.permission_options) === null || _a === void 0 ? void 0 : _a.length) ||
156
+ toolCall.permission_status !== 'pending' ||
157
+ !toolCall.session_id) {
158
+ return null;
159
+ }
160
+ const handleClick = async (optionId) => {
161
+ setSubmitting(true);
162
+ try {
163
+ await submitPermissionDecision(toolCall.session_id, toolCall.tool_call_id, optionId);
164
+ }
165
+ catch (err) {
166
+ console.error('Failed to submit permission decision:', err);
167
+ setSubmitting(false);
168
+ }
169
+ };
170
+ return (React.createElement("div", { className: "jp-jupyter-ai-acp-client-permission-buttons" },
171
+ React.createElement("span", { className: "jp-jupyter-ai-acp-client-permission-tree" }, "\u2514\u2500"),
172
+ React.createElement("span", null, "Allow?"),
173
+ toolCall.permission_options.map((opt) => (React.createElement("button", { key: opt.option_id, className: clsx('jp-jupyter-ai-acp-client-permission-btn', opt.kind &&
174
+ `jp-jupyter-ai-acp-client-permission-btn-${opt.kind.replace(/_/g, '-')}`), onClick: () => handleClick(opt.option_id), disabled: submitting, title: opt.kind }, opt.name)))));
100
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/acp-client",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "The ACP client for Jupyter AI, allowing for ACP agents to be used in JupyterLab",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -59,11 +59,14 @@
59
59
  "@jupyter/chat": "0.20.0-alpha.2",
60
60
  "@jupyterlab/application": "^4.0.0",
61
61
  "@jupyterlab/coreutils": "^6.0.0",
62
- "@jupyterlab/services": "^7.0.0"
62
+ "@jupyterlab/services": "^7.0.0",
63
+ "clsx": "^2.1.1",
64
+ "diff": "^8.0.0"
63
65
  },
64
66
  "devDependencies": {
65
67
  "@jupyterlab/builder": "^4.0.0",
66
68
  "@jupyterlab/testutils": "^4.0.0",
69
+ "@types/diff": "^7.0.0",
67
70
  "@types/jest": "^29.2.0",
68
71
  "@types/json-schema": "^7.0.11",
69
72
  "@types/react": "^18.0.26",
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { IToolCallDiff } from '@jupyter/chat';
3
+ import { structuredPatch } from 'diff';
4
+
5
+ /** Maximum number of diff lines shown before truncation. */
6
+ const MAX_DIFF_LINES = 20;
7
+
8
+ /** A single flattened diff line with its styling metadata. */
9
+ interface IDiffLineInfo {
10
+ cls: string;
11
+ prefix: string;
12
+ text: string;
13
+ key: string;
14
+ }
15
+
16
+ /**
17
+ * Renders a single file diff block with filename header, line-level
18
+ * highlighting, and click-to-expand truncation.
19
+ */
20
+ function DiffBlock({
21
+ diff,
22
+ onOpenFile
23
+ }: {
24
+ diff: IToolCallDiff;
25
+ onOpenFile?: (path: string) => void;
26
+ }): JSX.Element {
27
+ const patch = structuredPatch(
28
+ diff.path,
29
+ diff.path,
30
+ diff.old_text ?? '',
31
+ diff.new_text,
32
+ undefined,
33
+ undefined,
34
+ { context: Infinity }
35
+ );
36
+ const filename = diff.path.split('/').pop() ?? diff.path;
37
+ const [expanded, setExpanded] = React.useState(false);
38
+
39
+ // Flatten hunks into renderable lines
40
+ const allLines: IDiffLineInfo[] = [];
41
+ for (const hunk of patch.hunks) {
42
+ hunk.lines
43
+ .filter(line => !line.startsWith('\\'))
44
+ .forEach((line, j) => {
45
+ const prefix = line[0];
46
+ const text = line.slice(1);
47
+ const isAdded = prefix === '+';
48
+ const isRemoved = prefix === '-';
49
+ allLines.push({
50
+ cls: isAdded
51
+ ? 'jp-jupyter-ai-acp-client-diff-added'
52
+ : isRemoved
53
+ ? 'jp-jupyter-ai-acp-client-diff-removed'
54
+ : 'jp-jupyter-ai-acp-client-diff-context',
55
+ prefix,
56
+ text,
57
+ key: `${hunk.oldStart}-${j}`
58
+ });
59
+ });
60
+ }
61
+
62
+ const canTruncate = allLines.length > MAX_DIFF_LINES;
63
+ const visible =
64
+ canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
65
+ const hiddenCount = allLines.length - MAX_DIFF_LINES;
66
+
67
+ return (
68
+ <div className="jp-jupyter-ai-acp-client-diff-block">
69
+ <div
70
+ className="jp-jupyter-ai-acp-client-diff-header"
71
+ onClick={onOpenFile ? () => onOpenFile(diff.path) : undefined}
72
+ title={diff.path}
73
+ >
74
+ {filename}
75
+ </div>
76
+ <div className="jp-jupyter-ai-acp-client-diff-content">
77
+ {visible.map((line: IDiffLineInfo) => (
78
+ <div
79
+ key={line.key}
80
+ className={`jp-jupyter-ai-acp-client-diff-line ${line.cls}`}
81
+ >
82
+ <span className="jp-jupyter-ai-acp-client-diff-line-text">
83
+ {line.prefix} {line.text}
84
+ </span>
85
+ </div>
86
+ ))}
87
+ {canTruncate && !expanded && (
88
+ <div
89
+ className="jp-jupyter-ai-acp-client-diff-toggle"
90
+ onClick={() => setExpanded(true)}
91
+ >
92
+ ... {hiddenCount} more lines
93
+ </div>
94
+ )}
95
+ {canTruncate && expanded && (
96
+ <div
97
+ className="jp-jupyter-ai-acp-client-diff-toggle"
98
+ onClick={() => setExpanded(false)}
99
+ >
100
+ show less
101
+ </div>
102
+ )}
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Renders one or more file diffs.
110
+ */
111
+ export function DiffView({
112
+ diffs,
113
+ onOpenFile
114
+ }: {
115
+ diffs: IToolCallDiff[];
116
+ onOpenFile?: (path: string) => void;
117
+ }): JSX.Element {
118
+ return (
119
+ <div className="jp-jupyter-ai-acp-client-diff-container">
120
+ {diffs.map((d, i) => (
121
+ <DiffBlock key={i} diff={d} onOpenFile={onOpenFile} />
122
+ ))}
123
+ </div>
124
+ );
125
+ }
@@ -3,6 +3,12 @@
3
3
  export {};
4
4
 
5
5
  declare module '@jupyter/chat' {
6
+ export interface IToolCallDiff {
7
+ path: string;
8
+ new_text: string;
9
+ old_text?: string;
10
+ }
11
+
6
12
  export interface IToolCall {
7
13
  /**
8
14
  * Unique identifier for this tool call, used to correlate events
@@ -39,6 +45,32 @@ declare module '@jupyter/chat' {
39
45
  * File paths or resource URIs involved in this tool call.
40
46
  */
41
47
  locations?: string[];
48
+ /**
49
+ * Permission options from the ACP agent.
50
+ */
51
+ permission_options?: IPermissionOption[];
52
+ /**
53
+ * Whether the permission request is waiting for user.
54
+ */
55
+ permission_status?: 'pending' | 'resolved';
56
+ /**
57
+ * The option_id the user selected.
58
+ */
59
+ selected_option_id?: string;
60
+ /**
61
+ * The ACP session ID this tool call belongs to.
62
+ */
63
+ session_id?: string;
64
+ /**
65
+ * File diffs from ACP FileEditToolCallContent.
66
+ */
67
+ diffs?: IToolCallDiff[];
68
+ }
69
+
70
+ export interface IPermissionOption {
71
+ option_id: string;
72
+ name: string;
73
+ kind?: string;
42
74
  }
43
75
 
44
76
  export interface IMessageMetadata {
package/src/request.ts CHANGED
@@ -74,3 +74,21 @@ export async function getAcpSlashCommands(
74
74
 
75
75
  return response.commands;
76
76
  }
77
+ /**
78
+ * Send the user's permission decision to the backend.
79
+ */
80
+ export async function submitPermissionDecision(
81
+ sessionId: string,
82
+ toolCallId: string,
83
+ optionId: string
84
+ ): Promise<void> {
85
+ await requestAPI('/permissions', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({
89
+ session_id: sessionId,
90
+ tool_call_id: toolCallId,
91
+ option_id: optionId
92
+ })
93
+ });
94
+ }
@@ -1,5 +1,12 @@
1
1
  import React from 'react';
2
- import { IToolCall, MessagePreambleProps } from '@jupyter/chat';
2
+ import {
3
+ IToolCall,
4
+ IPermissionOption,
5
+ MessagePreambleProps
6
+ } from '@jupyter/chat';
7
+ import { submitPermissionDecision } from './request';
8
+ import clsx from 'clsx';
9
+ import { DiffView } from './diff-view';
3
10
 
4
11
  /**
5
12
  * Preamble component that renders tool call status lines above message body.
@@ -8,15 +15,23 @@ import { IToolCall, MessagePreambleProps } from '@jupyter/chat';
8
15
  export function ToolCallsComponent(
9
16
  props: MessagePreambleProps
10
17
  ): JSX.Element | null {
11
- const { message } = props;
18
+ const { message, model } = props;
12
19
  if (!message.metadata?.tool_calls?.length) {
13
20
  return null;
14
21
  }
15
22
 
23
+ const onOpenFile = (path: string) => {
24
+ model.documentManager?.openOrReveal(path);
25
+ };
26
+
16
27
  return (
17
28
  <div className="jp-jupyter-ai-acp-client-tool-calls">
18
29
  {(message.metadata?.tool_calls ?? []).map((tc: IToolCall) => (
19
- <ToolCallLine key={tc.tool_call_id} toolCall={tc} />
30
+ <ToolCallLine
31
+ key={tc.tool_call_id}
32
+ toolCall={tc}
33
+ onOpenFile={onOpenFile}
34
+ />
20
35
  ))}
21
36
  </div>
22
37
  );
@@ -70,16 +85,31 @@ function buildDetailsLines(toolCall: IToolCall): string[] {
70
85
  /**
71
86
  * Renders a single tool call line with status icon and optional expandable output.
72
87
  */
73
- function ToolCallLine({ toolCall }: { toolCall: IToolCall }): JSX.Element {
88
+ function ToolCallLine({
89
+ toolCall,
90
+ onOpenFile
91
+ }: {
92
+ toolCall: IToolCall;
93
+ onOpenFile?: (path: string) => void;
94
+ }): JSX.Element {
74
95
  const { title, status, kind } = toolCall;
75
96
  const displayTitle =
76
97
  title ||
77
98
  (kind
78
99
  ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)}...`
79
100
  : 'Working...');
80
- const isInProgress = status === 'in_progress' || status === 'pending';
101
+ const selectedOpt = toolCall.permission_options?.find(
102
+ opt => opt.option_id === toolCall.selected_option_id
103
+ );
104
+ const isRejected =
105
+ toolCall.permission_status === 'resolved' &&
106
+ !!selectedOpt?.kind?.includes('reject');
107
+ const hasPendingPermission = toolCall.permission_status === 'pending';
108
+ const isInProgress =
109
+ !isRejected &&
110
+ (status === 'in_progress' || status === 'pending' || hasPendingPermission);
81
111
  const isCompleted = status === 'completed';
82
- const isFailed = status === 'failed';
112
+ const isFailed = status === 'failed' || isRejected;
83
113
 
84
114
  // Unicode text glyphs — consistent across OS/browser
85
115
  const icon = isInProgress
@@ -89,14 +119,40 @@ function ToolCallLine({ toolCall }: { toolCall: IToolCall }): JSX.Element {
89
119
  : isFailed
90
120
  ? '\u2717'
91
121
  : '\u2022';
92
- const cssClass = `jp-jupyter-ai-acp-client-tool-call jp-jupyter-ai-acp-client-tool-call-${status || 'in_progress'}`;
122
+ // Force 'failed' class when rejected
123
+ const effectiveStatus = isRejected ? 'failed' : status || 'in_progress';
124
+
125
+ const cssClass = clsx(
126
+ 'jp-jupyter-ai-acp-client-tool-call',
127
+ `jp-jupyter-ai-acp-client-tool-call-${effectiveStatus}`
128
+ );
129
+
130
+ const hasDiffs = !!toolCall.diffs?.length;
131
+
132
+ // Pending permission with diffs: expanded diff + permission buttons outside
133
+ if (hasDiffs && hasPendingPermission) {
134
+ return (
135
+ <div className={cssClass}>
136
+ <details open>
137
+ <summary>
138
+ <span className="jp-jupyter-ai-acp-client-tool-call-icon">
139
+ {icon}
140
+ </span>{' '}
141
+ <em>{displayTitle}</em>
142
+ </summary>
143
+ <DiffView diffs={toolCall.diffs!} onOpenFile={onOpenFile} />
144
+ </details>
145
+ <PermissionButtons toolCall={toolCall} />
146
+ </div>
147
+ );
148
+ }
93
149
 
94
- // Progressive disclosure: completed/failed tool calls with metadata get expandable details
150
+ // Completed/failed with expandable content (diffs or metadata)
95
151
  const detailsLines =
96
- isCompleted || isFailed ? buildDetailsLines(toolCall) : [];
97
- const showDetails = detailsLines.length > 0;
152
+ !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
153
+ const hasExpandableContent = hasDiffs || detailsLines.length > 0;
98
154
 
99
- if (showDetails) {
155
+ if ((isCompleted || isFailed) && hasExpandableContent) {
100
156
  return (
101
157
  <details className={cssClass}>
102
158
  <summary>
@@ -104,10 +160,15 @@ function ToolCallLine({ toolCall }: { toolCall: IToolCall }): JSX.Element {
104
160
  {icon}
105
161
  </span>{' '}
106
162
  {displayTitle}
163
+ <PermissionLabel toolCall={toolCall} />
107
164
  </summary>
108
- <div className="jp-jupyter-ai-acp-client-tool-call-detail">
109
- {detailsLines.join('\n')}
110
- </div>
165
+ {hasDiffs ? (
166
+ <DiffView diffs={toolCall.diffs!} onOpenFile={onOpenFile} />
167
+ ) : (
168
+ <div className="jp-jupyter-ai-acp-client-tool-call-detail">
169
+ {detailsLines.join('\n')}
170
+ </div>
171
+ )}
111
172
  </details>
112
173
  );
113
174
  }
@@ -118,6 +179,7 @@ function ToolCallLine({ toolCall }: { toolCall: IToolCall }): JSX.Element {
118
179
  <div className={cssClass}>
119
180
  <span className="jp-jupyter-ai-acp-client-tool-call-icon">{icon}</span>{' '}
120
181
  <em>{displayTitle}</em>
182
+ <PermissionButtons toolCall={toolCall} />
121
183
  </div>
122
184
  );
123
185
  }
@@ -127,6 +189,90 @@ function ToolCallLine({ toolCall }: { toolCall: IToolCall }): JSX.Element {
127
189
  <div className={cssClass}>
128
190
  <span className="jp-jupyter-ai-acp-client-tool-call-icon">{icon}</span>{' '}
129
191
  {displayTitle}
192
+ <PermissionLabel toolCall={toolCall} />
193
+ </div>
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Shows the user's permission selection.
199
+ */
200
+ function PermissionLabel({
201
+ toolCall
202
+ }: {
203
+ toolCall: IToolCall;
204
+ }): JSX.Element | null {
205
+ if (
206
+ toolCall.permission_status !== 'resolved' ||
207
+ !toolCall.selected_option_id
208
+ ) {
209
+ return null;
210
+ }
211
+ const selectedName = toolCall.permission_options?.find(
212
+ opt => opt.option_id === toolCall.selected_option_id
213
+ )?.name;
214
+ if (!selectedName) {
215
+ return null;
216
+ }
217
+ return (
218
+ <span className="jp-jupyter-ai-acp-client-permission-label">
219
+ {' '}
220
+ — {selectedName}
221
+ </span>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * Renders the permission buttons.
227
+ */
228
+ function PermissionButtons({
229
+ toolCall
230
+ }: {
231
+ toolCall: IToolCall;
232
+ }): JSX.Element | null {
233
+ const [submitting, setSubmitting] = React.useState(false);
234
+
235
+ if (
236
+ !toolCall.permission_options?.length ||
237
+ toolCall.permission_status !== 'pending' ||
238
+ !toolCall.session_id
239
+ ) {
240
+ return null;
241
+ }
242
+
243
+ const handleClick = async (optionId: string) => {
244
+ setSubmitting(true);
245
+ try {
246
+ await submitPermissionDecision(
247
+ toolCall.session_id!,
248
+ toolCall.tool_call_id,
249
+ optionId
250
+ );
251
+ } catch (err) {
252
+ console.error('Failed to submit permission decision:', err);
253
+ setSubmitting(false);
254
+ }
255
+ };
256
+
257
+ return (
258
+ <div className="jp-jupyter-ai-acp-client-permission-buttons">
259
+ <span className="jp-jupyter-ai-acp-client-permission-tree">└─</span>
260
+ <span>Allow?</span>
261
+ {toolCall.permission_options.map((opt: IPermissionOption) => (
262
+ <button
263
+ key={opt.option_id}
264
+ className={clsx(
265
+ 'jp-jupyter-ai-acp-client-permission-btn',
266
+ opt.kind &&
267
+ `jp-jupyter-ai-acp-client-permission-btn-${opt.kind.replace(/_/g, '-')}`
268
+ )}
269
+ onClick={() => handleClick(opt.option_id)}
270
+ disabled={submitting}
271
+ title={opt.kind}
272
+ >
273
+ {opt.name}
274
+ </button>
275
+ ))}
130
276
  </div>
131
277
  );
132
278
  }
package/style/base.css CHANGED
@@ -37,16 +37,19 @@
37
37
  color: var(--jp-error-color1, #d32f2f);
38
38
  }
39
39
 
40
- details.jp-jupyter-ai-acp-client-tool-call summary {
40
+ details.jp-jupyter-ai-acp-client-tool-call summary,
41
+ .jp-jupyter-ai-acp-client-tool-call details summary {
41
42
  cursor: pointer;
42
43
  list-style: none;
43
44
  }
44
45
 
45
- details.jp-jupyter-ai-acp-client-tool-call summary::-webkit-details-marker {
46
+ details.jp-jupyter-ai-acp-client-tool-call summary::-webkit-details-marker,
47
+ .jp-jupyter-ai-acp-client-tool-call details summary::-webkit-details-marker {
46
48
  display: none;
47
49
  }
48
50
 
49
- details.jp-jupyter-ai-acp-client-tool-call summary::after {
51
+ details.jp-jupyter-ai-acp-client-tool-call summary::after,
52
+ .jp-jupyter-ai-acp-client-tool-call details summary::after {
50
53
  content: '';
51
54
  display: inline-block;
52
55
  border-left: 4px solid transparent;
@@ -57,7 +60,8 @@ details.jp-jupyter-ai-acp-client-tool-call summary::after {
57
60
  opacity: 0.5;
58
61
  }
59
62
 
60
- details[open].jp-jupyter-ai-acp-client-tool-call summary::after {
63
+ details[open].jp-jupyter-ai-acp-client-tool-call summary::after,
64
+ .jp-jupyter-ai-acp-client-tool-call details[open] summary::after {
61
65
  border-top: none;
62
66
  border-bottom: 5px solid currentcolor;
63
67
  }
@@ -70,3 +74,140 @@ details[open].jp-jupyter-ai-acp-client-tool-call summary::after {
70
74
  white-space: pre-wrap;
71
75
  word-break: break-all;
72
76
  }
77
+
78
+ /* Permission approval buttons */
79
+
80
+ .jp-jupyter-ai-acp-client-permission-buttons {
81
+ display: flex;
82
+ gap: 6px;
83
+ margin: 4px 0;
84
+ align-items: center;
85
+ }
86
+
87
+ .jp-jupyter-ai-acp-client-permission-tree {
88
+ color: var(--jp-ui-font-color2);
89
+ font-family: monospace;
90
+ }
91
+
92
+ .jp-jupyter-ai-acp-client-permission-btn {
93
+ padding: 2px 6px;
94
+ font-size: var(--jp-ui-font-size1);
95
+ font-family: var(--jp-ui-font-family);
96
+ border-radius: 3px;
97
+ border: 1px solid var(--jp-border-color1);
98
+ cursor: pointer;
99
+ background: var(--jp-layout-color1);
100
+ color: var(--jp-ui-font-color1);
101
+ }
102
+
103
+ .jp-jupyter-ai-acp-client-permission-btn:disabled {
104
+ opacity: 0.5;
105
+ cursor: not-allowed;
106
+ }
107
+
108
+ .jp-jupyter-ai-acp-client-permission-btn:hover:not(:disabled) {
109
+ background: var(--jp-layout-color2);
110
+ }
111
+
112
+ .jp-jupyter-ai-acp-client-permission-btn-allow-once,
113
+ .jp-jupyter-ai-acp-client-permission-btn-allow-always {
114
+ border-color: var(--jp-success-color1, #388e3c);
115
+ color: var(--jp-success-color1, #388e3c);
116
+ }
117
+
118
+ .jp-jupyter-ai-acp-client-permission-btn-reject-once {
119
+ border-color: var(--jp-error-color1, #d32f2f);
120
+ color: var(--jp-error-color1, #d32f2f);
121
+ }
122
+
123
+ /* Permission selection label */
124
+
125
+ .jp-jupyter-ai-acp-client-permission-label {
126
+ font-size: var(--jp-ui-font-size0);
127
+ color: var(--jp-ui-font-color2);
128
+ }
129
+
130
+ /* Diff view */
131
+
132
+ .jp-jupyter-ai-acp-client-diff-container {
133
+ margin: 4px 0 4px 20px;
134
+ }
135
+
136
+ .jp-jupyter-ai-acp-client-diff-block {
137
+ margin-bottom: 4px;
138
+ border: 1px solid var(--jp-border-color2);
139
+ border-radius: 3px;
140
+ overflow: hidden;
141
+ }
142
+
143
+ .jp-jupyter-ai-acp-client-diff-header {
144
+ padding: 0 8px;
145
+ font-size: var(--jp-ui-font-size0);
146
+ font-family: var(--jp-code-font-family);
147
+ color: var(--jp-ui-font-color2);
148
+ cursor: pointer;
149
+ }
150
+
151
+ .jp-jupyter-ai-acp-client-diff-header:hover {
152
+ text-decoration: underline;
153
+ }
154
+
155
+ .jp-jupyter-ai-acp-client-diff-content {
156
+ margin: 0;
157
+ padding: 0;
158
+ font-size: var(--jp-code-font-size);
159
+ font-family: var(--jp-code-font-family);
160
+ line-height: var(--jp-code-line-height);
161
+ }
162
+
163
+ .jp-jupyter-ai-acp-client-diff-line {
164
+ display: flex;
165
+ }
166
+
167
+ .jp-jupyter-ai-acp-client-diff-gutter {
168
+ flex-shrink: 0;
169
+ width: 3ch;
170
+ text-align: right;
171
+ color: var(--jp-ui-font-color3);
172
+ user-select: none;
173
+ padding-right: 2px;
174
+ }
175
+
176
+ .jp-jupyter-ai-acp-client-diff-line-text {
177
+ flex: 1;
178
+ white-space: pre-wrap;
179
+ word-break: break-all;
180
+ padding-left: 4px;
181
+ }
182
+
183
+ .jp-jupyter-ai-acp-client-diff-toggle {
184
+ display: block;
185
+ color: var(--jp-ui-font-color3);
186
+ font-style: italic;
187
+ padding: 0 8px;
188
+ cursor: pointer;
189
+ }
190
+
191
+ .jp-jupyter-ai-acp-client-diff-toggle:hover {
192
+ text-decoration: underline;
193
+ }
194
+
195
+ .jp-jupyter-ai-acp-client-diff-added {
196
+ background: color-mix(
197
+ in srgb,
198
+ var(--jp-success-color1, #388e3c) 15%,
199
+ transparent
200
+ );
201
+ }
202
+
203
+ .jp-jupyter-ai-acp-client-diff-removed {
204
+ background: color-mix(
205
+ in srgb,
206
+ var(--jp-error-color1, #d32f2f) 15%,
207
+ transparent
208
+ );
209
+ }
210
+
211
+ .jp-jupyter-ai-acp-client-diff-context {
212
+ color: var(--jp-ui-font-color2);
213
+ }