@jupyter-ai/acp-client 0.0.5 → 0.0.7

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/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
- import { IChatCommandProvider, IInputModel, ChatCommand } from '@jupyter/chat';
2
+ import { IChatCommandProvider, IInputModel, IInputToolbarRegistryFactory, ChatCommand } from '@jupyter/chat';
3
3
  /**
4
4
  * A command provider that provides completions for slash commands and handles
5
5
  * slash command calls.
@@ -36,4 +36,11 @@ export declare class SlashCommandProvider implements IChatCommandProvider {
36
36
  onSubmit(inputModel: IInputModel): Promise<void>;
37
37
  }
38
38
  export declare const slashCommandPlugin: JupyterFrontEndPlugin<void>;
39
- export default slashCommandPlugin;
39
+ /**
40
+ * Plugin that provides a custom input toolbar factory with the ACP stop button.
41
+ * The chat panel picks this up and uses it to build the toolbar for each chat.
42
+ */
43
+ export declare const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory>;
44
+ declare const _default: (JupyterFrontEndPlugin<void> | JupyterFrontEndPlugin<IInputToolbarRegistryFactory>)[];
45
+ export default _default;
46
+ export { stopStreaming } from './request';
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { IChatCommandRegistry, IMessagePreambleRegistry } from '@jupyter/chat';
1
+ import { IChatCommandRegistry, IMessagePreambleRegistry, IInputToolbarRegistryFactory, InputToolbarRegistry } from '@jupyter/chat';
2
2
  import { ToolCallsComponent } from './tool-calls';
3
3
  import { getAcpSlashCommands } from './request';
4
+ import { AcpStopButton } from './stop-button';
4
5
  const SLASH_COMMAND_PROVIDER_ID = '@jupyter-ai/acp-client:slash-command-provider';
5
6
  /**
6
7
  * A command provider that provides completions for slash commands and handles
@@ -110,4 +111,29 @@ export const slashCommandPlugin = {
110
111
  }
111
112
  }
112
113
  };
113
- export default slashCommandPlugin;
114
+ /**
115
+ * Plugin that provides a custom input toolbar factory with the ACP stop button.
116
+ * The chat panel picks this up and uses it to build the toolbar for each chat.
117
+ */
118
+ export const toolbarPlugin = {
119
+ id: '@jupyter-ai/acp-client:toolbar',
120
+ description: 'Provides a chat input toolbar with ACP stop streaming button.',
121
+ autoStart: true,
122
+ provides: IInputToolbarRegistryFactory,
123
+ activate: () => {
124
+ return {
125
+ create: () => {
126
+ // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
127
+ const registry = InputToolbarRegistry.defaultToolbarRegistry();
128
+ // Add our stop button (position 90 = just before Send at 100)
129
+ registry.addItem('stop', {
130
+ element: AcpStopButton,
131
+ position: 10
132
+ });
133
+ return registry;
134
+ }
135
+ };
136
+ }
137
+ };
138
+ export default [slashCommandPlugin, toolbarPlugin];
139
+ export { stopStreaming } from './request';
package/lib/request.d.ts CHANGED
@@ -15,4 +15,5 @@ export declare function getAcpSlashCommands(chatPath: string, personaMentionName
15
15
  * Send the user's permission decision to the backend.
16
16
  */
17
17
  export declare function submitPermissionDecision(sessionId: string, toolCallId: string, optionId: string): Promise<void>;
18
+ export declare function stopStreaming(chatPath: string, personaMentionName?: string | null): Promise<void>;
18
19
  export {};
package/lib/request.js CHANGED
@@ -63,3 +63,18 @@ export async function submitPermissionDecision(sessionId, toolCallId, optionId)
63
63
  })
64
64
  });
65
65
  }
66
+ export async function stopStreaming(chatPath, personaMentionName = null) {
67
+ try {
68
+ if (personaMentionName === null) {
69
+ await requestAPI(`/stop?chat_path=${chatPath}`, { method: 'POST' });
70
+ }
71
+ else {
72
+ await requestAPI(`/stop/${personaMentionName}?chat_path=${chatPath}`, {
73
+ method: 'POST'
74
+ });
75
+ }
76
+ }
77
+ catch (e) {
78
+ console.warn('Error stopping stream: ', e);
79
+ }
80
+ }
@@ -0,0 +1,7 @@
1
+ import { InputToolbarRegistry } from '@jupyter/chat';
2
+ /**
3
+ * A stop button for the chat input toolbar. Observes the chat model's
4
+ * writers list to enable itself when an AI bot is actively writing,
5
+ * and calls the ACP stop streaming endpoint on click.
6
+ */
7
+ export declare function AcpStopButton(props: InputToolbarRegistry.IToolbarItemProps): JSX.Element;
@@ -0,0 +1,51 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import StopIcon from '@mui/icons-material/Stop';
3
+ import { TooltippedIconButton } from '@jupyter/chat';
4
+ import { stopStreaming } from './request';
5
+ const STOP_BUTTON_CLASS = 'jp-jupyter-ai-acp-client-stopButton';
6
+ /**
7
+ * A stop button for the chat input toolbar. Observes the chat model's
8
+ * writers list to enable itself when an AI bot is actively writing,
9
+ * and calls the ACP stop streaming endpoint on click.
10
+ */
11
+ export function AcpStopButton(props) {
12
+ const { chatModel } = props;
13
+ const [disabled, setDisabled] = useState(true);
14
+ const [inFlight, setInFlight] = useState(false);
15
+ const tooltip = 'Stop generating';
16
+ useEffect(() => {
17
+ var _a;
18
+ if (!chatModel) {
19
+ setDisabled(true);
20
+ return;
21
+ }
22
+ const checkWriters = () => {
23
+ const hasAIWriter = chatModel.writers.some(w => w.user.bot);
24
+ setDisabled(!hasAIWriter);
25
+ };
26
+ checkWriters();
27
+ (_a = chatModel.writersChanged) === null || _a === void 0 ? void 0 : _a.connect(checkWriters);
28
+ return () => {
29
+ var _a;
30
+ (_a = chatModel.writersChanged) === null || _a === void 0 ? void 0 : _a.disconnect(checkWriters);
31
+ };
32
+ }, [chatModel]);
33
+ async function handleStop() {
34
+ if (!chatModel) {
35
+ return;
36
+ }
37
+ setInFlight(true);
38
+ try {
39
+ // Call stop with no persona name, backend stops all personas
40
+ await stopStreaming(chatModel.name, null);
41
+ }
42
+ finally {
43
+ setInFlight(false);
44
+ }
45
+ }
46
+ return (React.createElement(TooltippedIconButton, { onClick: handleStop, tooltip: tooltip, disabled: disabled || inFlight, buttonProps: {
47
+ title: tooltip,
48
+ className: STOP_BUTTON_CLASS
49
+ }, "aria-label": tooltip },
50
+ React.createElement(StopIcon, null)));
51
+ }
package/lib/tool-calls.js CHANGED
@@ -31,6 +31,80 @@ function formatOutput(rawOutput) {
31
31
  }
32
32
  return JSON.stringify(rawOutput, null, 2);
33
33
  }
34
+ /**
35
+ * Format tool input for display. Flat objects (all primitive values) render as
36
+ * key-value pairs; nested/complex values fall back to JSON.
37
+ */
38
+ function formatToolInput(input) {
39
+ if (typeof input === 'string') {
40
+ return input;
41
+ }
42
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
43
+ return JSON.stringify(input, null, 2);
44
+ }
45
+ const entries = Object.entries(input);
46
+ const isFlat = entries.every(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
47
+ if (isFlat) {
48
+ return entries.map(([k, v]) => `${k}: ${v}`).join('\n');
49
+ }
50
+ return JSON.stringify(input, null, 2);
51
+ }
52
+ /**
53
+ * Compute the pre-permission detail text for a tool call, or null if nothing
54
+ * to show beyond the title. Returns a plain string so callers can check null.
55
+ */
56
+ function buildPermissionDetail(toolCall) {
57
+ const { kind, title, locations, raw_input } = toolCall;
58
+ if (kind === 'execute') {
59
+ // Prefer raw_input.command (ACP-compliant agents)
60
+ const rawObj = typeof raw_input === 'object' && raw_input !== null
61
+ ? raw_input
62
+ : null;
63
+ const cmd = rawObj && typeof rawObj.command === 'string'
64
+ ? rawObj.command
65
+ : (title === null || title === void 0 ? void 0 : title.replace(/^Running:\s*/i, '').replace(/\.\.\.$/, '').trim()) || null;
66
+ // If stripping produced nothing new, don't show.
67
+ if (!cmd || cmd === title) {
68
+ return null;
69
+ }
70
+ return '$ ' + cmd;
71
+ }
72
+ if ((kind === 'delete' || kind === 'move' || kind === 'read') &&
73
+ (locations === null || locations === void 0 ? void 0 : locations.length)) {
74
+ return kind === 'move' && locations.length >= 2
75
+ ? locations[0] + ' \u2192 ' + locations[1]
76
+ : locations.join('\n');
77
+ }
78
+ // Generic fallback for unknown/MCP kinds with raw_input.
79
+ if (raw_input !== null &&
80
+ typeof raw_input === 'object' &&
81
+ !Array.isArray(raw_input)) {
82
+ const obj = raw_input;
83
+ const purpose = typeof obj.__tool_use_purpose === 'string'
84
+ ? obj.__tool_use_purpose
85
+ : null;
86
+ // Filter remaining __-prefixed internal keys for the params display.
87
+ const paramEntries = Object.entries(obj).filter(([k]) => !k.startsWith('__'));
88
+ const params = paramEntries.length > 0
89
+ ? formatToolInput(Object.fromEntries(paramEntries))
90
+ : null;
91
+ if (purpose && params) {
92
+ return purpose + '\n' + params;
93
+ }
94
+ if (purpose) {
95
+ return purpose;
96
+ }
97
+ if (params) {
98
+ return params;
99
+ }
100
+ return null;
101
+ }
102
+ // Non-object raw_input (string, array, primitive) — pass through.
103
+ if (raw_input !== null && raw_input !== undefined) {
104
+ return formatToolInput(raw_input);
105
+ }
106
+ return null;
107
+ }
34
108
  /** Tool kinds where expanded view shows full file path(s) from locations. */
35
109
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
36
110
  /** Tool kinds where expanded view shows raw_output (stdout, search results, etc.). */
@@ -101,6 +175,20 @@ function ToolCallLine({ toolCall, onOpenFile }) {
101
175
  React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: onOpenFile })),
102
176
  React.createElement(PermissionButtons, { toolCall: toolCall })));
103
177
  }
178
+ // Pending permission without diffs: show kind-specific detail if available
179
+ if (!hasDiffs && hasPendingPermission) {
180
+ const permissionDetail = buildPermissionDetail(toolCall);
181
+ if (permissionDetail !== null) {
182
+ return (React.createElement("div", { className: cssClass },
183
+ React.createElement("details", { open: true },
184
+ React.createElement("summary", null,
185
+ React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
186
+ ' ',
187
+ React.createElement("em", null, displayTitle)),
188
+ React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-call-detail" }, permissionDetail)),
189
+ React.createElement(PermissionButtons, { toolCall: toolCall })));
190
+ }
191
+ }
104
192
  // Completed/failed with expandable content (diffs or metadata)
105
193
  const detailsLines = !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
106
194
  const hasExpandableContent = hasDiffs || detailsLines.length > 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/acp-client",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "The ACP client for Jupyter AI, allowing for ACP agents to be used in JupyterLab",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -116,6 +116,9 @@
116
116
  },
117
117
  "extension": true,
118
118
  "outputDir": "jupyter_ai_acp_client/labextension",
119
+ "disabledExtensions": [
120
+ "jupyterlab-chat-extension:inputToolbarFactory"
121
+ ],
119
122
  "sharedPackages": {
120
123
  "@jupyter/chat": {
121
124
  "bundled": false,
package/src/index.ts CHANGED
@@ -8,12 +8,15 @@ import {
8
8
  IChatCommandRegistry,
9
9
  IInputModel,
10
10
  IMessagePreambleRegistry,
11
+ IInputToolbarRegistryFactory,
12
+ InputToolbarRegistry,
11
13
  ChatCommand
12
14
  } from '@jupyter/chat';
13
15
 
14
16
  import { ToolCallsComponent } from './tool-calls';
15
17
 
16
18
  import { getAcpSlashCommands } from './request';
19
+ import { AcpStopButton } from './stop-button';
17
20
 
18
21
  const SLASH_COMMAND_PROVIDER_ID =
19
22
  '@jupyter-ai/acp-client:slash-command-provider';
@@ -146,4 +149,33 @@ export const slashCommandPlugin: JupyterFrontEndPlugin<void> = {
146
149
  }
147
150
  };
148
151
 
149
- export default slashCommandPlugin;
152
+ /**
153
+ * Plugin that provides a custom input toolbar factory with the ACP stop button.
154
+ * The chat panel picks this up and uses it to build the toolbar for each chat.
155
+ */
156
+ export const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> =
157
+ {
158
+ id: '@jupyter-ai/acp-client:toolbar',
159
+ description:
160
+ 'Provides a chat input toolbar with ACP stop streaming button.',
161
+ autoStart: true,
162
+ provides: IInputToolbarRegistryFactory,
163
+ activate: (): IInputToolbarRegistryFactory => {
164
+ return {
165
+ create: () => {
166
+ // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
167
+ const registry = InputToolbarRegistry.defaultToolbarRegistry();
168
+ // Add our stop button (position 90 = just before Send at 100)
169
+ registry.addItem('stop', {
170
+ element: AcpStopButton,
171
+ position: 10
172
+ });
173
+ return registry;
174
+ }
175
+ };
176
+ }
177
+ };
178
+
179
+ export default [slashCommandPlugin, toolbarPlugin];
180
+
181
+ export { stopStreaming } from './request';
@@ -61,6 +61,11 @@ declare module '@jupyter/chat' {
61
61
  * The ACP session ID this tool call belongs to.
62
62
  */
63
63
  session_id?: string;
64
+ /**
65
+ * Raw parameters sent to the tool, from the ACP agent.
66
+ * Used to show tool input before the user approves a permission request.
67
+ */
68
+ raw_input?: unknown;
64
69
  /**
65
70
  * File diffs from ACP FileEditToolCallContent.
66
71
  */
package/src/request.ts CHANGED
@@ -92,3 +92,20 @@ export async function submitPermissionDecision(
92
92
  })
93
93
  });
94
94
  }
95
+
96
+ export async function stopStreaming(
97
+ chatPath: string,
98
+ personaMentionName: string | null = null
99
+ ): Promise<void> {
100
+ try {
101
+ if (personaMentionName === null) {
102
+ await requestAPI(`/stop?chat_path=${chatPath}`, { method: 'POST' });
103
+ } else {
104
+ await requestAPI(`/stop/${personaMentionName}?chat_path=${chatPath}`, {
105
+ method: 'POST'
106
+ });
107
+ }
108
+ } catch (e) {
109
+ console.warn('Error stopping stream: ', e);
110
+ }
111
+ }
@@ -0,0 +1,68 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import StopIcon from '@mui/icons-material/Stop';
3
+ import { InputToolbarRegistry, TooltippedIconButton } from '@jupyter/chat';
4
+ import { stopStreaming } from './request';
5
+
6
+ const STOP_BUTTON_CLASS = 'jp-jupyter-ai-acp-client-stopButton';
7
+
8
+ /**
9
+ * A stop button for the chat input toolbar. Observes the chat model's
10
+ * writers list to enable itself when an AI bot is actively writing,
11
+ * and calls the ACP stop streaming endpoint on click.
12
+ */
13
+ export function AcpStopButton(
14
+ props: InputToolbarRegistry.IToolbarItemProps
15
+ ): JSX.Element {
16
+ const { chatModel } = props;
17
+ const [disabled, setDisabled] = useState(true);
18
+ const [inFlight, setInFlight] = useState(false);
19
+ const tooltip = 'Stop generating';
20
+
21
+ useEffect(() => {
22
+ if (!chatModel) {
23
+ setDisabled(true);
24
+ return;
25
+ }
26
+
27
+ const checkWriters = () => {
28
+ const hasAIWriter = chatModel.writers.some(w => w.user.bot);
29
+ setDisabled(!hasAIWriter);
30
+ };
31
+
32
+ checkWriters();
33
+ chatModel.writersChanged?.connect(checkWriters);
34
+
35
+ return () => {
36
+ chatModel.writersChanged?.disconnect(checkWriters);
37
+ };
38
+ }, [chatModel]);
39
+
40
+ async function handleStop() {
41
+ if (!chatModel) {
42
+ return;
43
+ }
44
+
45
+ setInFlight(true);
46
+ try {
47
+ // Call stop with no persona name, backend stops all personas
48
+ await stopStreaming(chatModel.name, null);
49
+ } finally {
50
+ setInFlight(false);
51
+ }
52
+ }
53
+
54
+ return (
55
+ <TooltippedIconButton
56
+ onClick={handleStop}
57
+ tooltip={tooltip}
58
+ disabled={disabled || inFlight}
59
+ buttonProps={{
60
+ title: tooltip,
61
+ className: STOP_BUTTON_CLASS
62
+ }}
63
+ aria-label={tooltip}
64
+ >
65
+ <StopIcon />
66
+ </TooltippedIconButton>
67
+ );
68
+ }
@@ -51,6 +51,106 @@ function formatOutput(rawOutput: unknown): string {
51
51
  return JSON.stringify(rawOutput, null, 2);
52
52
  }
53
53
 
54
+ /**
55
+ * Format tool input for display. Flat objects (all primitive values) render as
56
+ * key-value pairs; nested/complex values fall back to JSON.
57
+ */
58
+ function formatToolInput(input: unknown): string {
59
+ if (typeof input === 'string') {
60
+ return input;
61
+ }
62
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
63
+ return JSON.stringify(input, null, 2);
64
+ }
65
+ const entries = Object.entries(input as Record<string, unknown>);
66
+ const isFlat = entries.every(
67
+ ([, v]) =>
68
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
69
+ );
70
+ if (isFlat) {
71
+ return entries.map(([k, v]) => `${k}: ${v}`).join('\n');
72
+ }
73
+ return JSON.stringify(input, null, 2);
74
+ }
75
+
76
+ /**
77
+ * Compute the pre-permission detail text for a tool call, or null if nothing
78
+ * to show beyond the title. Returns a plain string so callers can check null.
79
+ */
80
+ function buildPermissionDetail(toolCall: IToolCall): string | null {
81
+ const { kind, title, locations, raw_input } = toolCall;
82
+
83
+ if (kind === 'execute') {
84
+ // Prefer raw_input.command (ACP-compliant agents)
85
+ const rawObj =
86
+ typeof raw_input === 'object' && raw_input !== null
87
+ ? (raw_input as Record<string, unknown>)
88
+ : null;
89
+ const cmd =
90
+ rawObj && typeof rawObj.command === 'string'
91
+ ? rawObj.command
92
+ : title
93
+ ?.replace(/^Running:\s*/i, '')
94
+ .replace(/\.\.\.$/, '')
95
+ .trim() || null;
96
+ // If stripping produced nothing new, don't show.
97
+ if (!cmd || cmd === title) {
98
+ return null;
99
+ }
100
+ return '$ ' + cmd;
101
+ }
102
+
103
+ if (
104
+ (kind === 'delete' || kind === 'move' || kind === 'read') &&
105
+ locations?.length
106
+ ) {
107
+ return kind === 'move' && locations.length >= 2
108
+ ? locations[0] + ' \u2192 ' + locations[1]
109
+ : locations.join('\n');
110
+ }
111
+
112
+ // Generic fallback for unknown/MCP kinds with raw_input.
113
+ if (
114
+ raw_input !== null &&
115
+ typeof raw_input === 'object' &&
116
+ !Array.isArray(raw_input)
117
+ ) {
118
+ const obj = raw_input as Record<string, unknown>;
119
+
120
+ const purpose =
121
+ typeof obj.__tool_use_purpose === 'string'
122
+ ? obj.__tool_use_purpose
123
+ : null;
124
+
125
+ // Filter remaining __-prefixed internal keys for the params display.
126
+ const paramEntries = Object.entries(obj).filter(
127
+ ([k]) => !k.startsWith('__')
128
+ );
129
+ const params =
130
+ paramEntries.length > 0
131
+ ? formatToolInput(Object.fromEntries(paramEntries))
132
+ : null;
133
+
134
+ if (purpose && params) {
135
+ return purpose + '\n' + params;
136
+ }
137
+ if (purpose) {
138
+ return purpose;
139
+ }
140
+ if (params) {
141
+ return params;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ // Non-object raw_input (string, array, primitive) — pass through.
147
+ if (raw_input !== null && raw_input !== undefined) {
148
+ return formatToolInput(raw_input);
149
+ }
150
+
151
+ return null;
152
+ }
153
+
54
154
  /** Tool kinds where expanded view shows full file path(s) from locations. */
55
155
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
56
156
 
@@ -147,6 +247,29 @@ function ToolCallLine({
147
247
  );
148
248
  }
149
249
 
250
+ // Pending permission without diffs: show kind-specific detail if available
251
+ if (!hasDiffs && hasPendingPermission) {
252
+ const permissionDetail = buildPermissionDetail(toolCall);
253
+ if (permissionDetail !== null) {
254
+ return (
255
+ <div className={cssClass}>
256
+ <details open>
257
+ <summary>
258
+ <span className="jp-jupyter-ai-acp-client-tool-call-icon">
259
+ {icon}
260
+ </span>{' '}
261
+ <em>{displayTitle}</em>
262
+ </summary>
263
+ <div className="jp-jupyter-ai-acp-client-tool-call-detail">
264
+ {permissionDetail}
265
+ </div>
266
+ </details>
267
+ <PermissionButtons toolCall={toolCall} />
268
+ </div>
269
+ );
270
+ }
271
+ }
272
+
150
273
  // Completed/failed with expandable content (diffs or metadata)
151
274
  const detailsLines =
152
275
  !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
package/style/base.css CHANGED
@@ -211,3 +211,26 @@ details[open].jp-jupyter-ai-acp-client-tool-call summary::after,
211
211
  .jp-jupyter-ai-acp-client-diff-context {
212
212
  color: var(--jp-ui-font-color2);
213
213
  }
214
+
215
+ /* Stop streaming button */
216
+ .jp-jupyter-ai-acp-client-stopButton {
217
+ display: inline-flex;
218
+ align-items: center;
219
+ gap: 4px;
220
+ padding: 4px 12px;
221
+ border: 1px solid var(--jp-border-color1);
222
+ border-radius: 4px;
223
+ background: var(--jp-layout-color2);
224
+ color: var(--jp-ui-font-color1);
225
+ font-size: var(--jp-ui-font-size1);
226
+ cursor: pointer;
227
+ }
228
+
229
+ .jp-jupyter-ai-acp-client-stopButton:hover {
230
+ background: var(--jp-layout-color3);
231
+ }
232
+
233
+ .jp-jupyter-ai-acp-client-stopButton:disabled {
234
+ opacity: 0.5;
235
+ cursor: not-allowed;
236
+ }