@jupyter-ai/acp-client 0.0.6 → 0.0.8

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
@@ -28,13 +28,13 @@ package also provides a default ACP client implementation as `JaiAcpClient`.
28
28
  the persona name, the persona avatar, and the `executable` starting the ACP
29
29
  agent server.
30
30
 
31
- For example, the `@Claude-ACP` persona is defined in `claude.py` using less than
31
+ For example, the `@Claude` persona is defined in `claude.py` using less than
32
32
  20 lines of code:
33
33
 
34
34
  ```py
35
35
  class ClaudeAcpPersona(BaseAcpPersona):
36
36
  def __init__(self, *args, **kwargs):
37
- executable = ["claude-code-acp"]
37
+ executable = ["claude-agent-acp"]
38
38
  super().__init__(*args, executable=executable, **kwargs)
39
39
 
40
40
  @property
@@ -44,19 +44,24 @@ class ClaudeAcpPersona(BaseAcpPersona):
44
44
  ))
45
45
 
46
46
  return PersonaDefaults(
47
- name="Claude-ACP",
47
+ name="Claude",
48
48
  description="Claude Code as an ACP agent persona.",
49
49
  avatar_path=avatar_path,
50
50
  system_prompt="unused"
51
51
  )
52
52
  ```
53
53
 
54
- Currently, this package provides 2 personas:
54
+ Currently, this package provides 4 personas:
55
55
 
56
- 1. `@Claude-ACP`
57
- - requires `claude-code-acp`, installed via `npm install -g @zed-industries/claude-code-acp`
58
- 2. `@Kiro`
59
- - requires `kiro-cli`, installed from https://kiro.dev
56
+ - `@Claude`
57
+ - requires `claude-agent-acp`, installed via `npm install -g @zed-industries/claude-agent-acp`
58
+ - optional env variable `CLAUDE_CODE_EXECUTABLE` points to your custom-installed Claude executable location. By default, claude-agent-acp uses Claude packaged in `@zed-industries/claude-agent-acp`.
59
+ - `@Gemini`
60
+ - requires `gemini` CLI (>= 0.34.0), installed via https://geminicli.com/
61
+ - `@Kiro`
62
+ - requires `kiro-cli` (>= 1.25.0, < 2), installed via https://kiro.dev
63
+ - `@Mistral-Vibe`
64
+ - requires `vibe-acp`, installed via `uv tool install mistral-vibe` or `pip install mistral-vibe`
60
65
 
61
66
  ## Dependencies
62
67
 
@@ -68,8 +73,10 @@ Currently, this package provides 2 personas:
68
73
 
69
74
  **Optional**
70
75
 
71
- - `claude-code-acp` (enables `@Claude-ACP`)
76
+ - `claude-agent-acp` (enables `@Claude`)
77
+ - `gemini` (enables `@Gemini`)
72
78
  - `kiro-cli` (enables `@Kiro`)
79
+ - `mistral-vibe` (enables `@Mistral-Vibe` via the `vibe-acp` command)
73
80
 
74
81
  ## Install
75
82
 
@@ -2,7 +2,9 @@ import { IToolCallDiff } from '@jupyter/chat';
2
2
  /**
3
3
  * Renders one or more file diffs.
4
4
  */
5
- export declare function DiffView({ diffs, onOpenFile }: {
5
+ export declare function DiffView({ diffs, onOpenFile, toDisplayPath, pendingPermission }: {
6
6
  diffs: IToolCallDiff[];
7
7
  onOpenFile?: (path: string) => void;
8
+ toDisplayPath?: (path: string) => string;
9
+ pendingPermission?: boolean;
8
10
  }): JSX.Element;
package/lib/diff-view.js CHANGED
@@ -1,15 +1,25 @@
1
1
  import React from 'react';
2
+ import { PathExt } from '@jupyterlab/coreutils';
2
3
  import { structuredPatch } from 'diff';
4
+ import clsx from 'clsx';
3
5
  /** Maximum number of diff lines shown before truncation. */
4
6
  const MAX_DIFF_LINES = 20;
5
7
  /**
6
8
  * Renders a single file diff block with filename header, line-level
7
9
  * highlighting, and click-to-expand truncation.
8
10
  */
9
- function DiffBlock({ diff, onOpenFile }) {
10
- var _a, _b;
11
+ function DiffBlock({ diff, onOpenFile, toDisplayPath, pendingPermission }) {
12
+ var _a;
11
13
  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;
14
+ const displayPath = toDisplayPath
15
+ ? toDisplayPath(diff.path)
16
+ : PathExt.basename(diff.path);
17
+ // toDisplayPath makes paths inside the server root relative. A leading '/'
18
+ // means the file is outside it and cannot be opened via the Contents API.
19
+ const isOutsideRoot = displayPath.startsWith('/');
20
+ const isClickable = !!onOpenFile &&
21
+ !isOutsideRoot &&
22
+ !(pendingPermission && diff.old_text === undefined);
13
23
  const [expanded, setExpanded] = React.useState(false);
14
24
  // Flatten hunks into renderable lines
15
25
  const allLines = [];
@@ -37,7 +47,9 @@ function DiffBlock({ diff, onOpenFile }) {
37
47
  const visible = canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
38
48
  const hiddenCount = allLines.length - MAX_DIFF_LINES;
39
49
  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),
50
+ React.createElement("div", { className: clsx('jp-jupyter-ai-acp-client-diff-header', {
51
+ 'jp-jupyter-ai-acp-client-diff-header-clickable': isClickable
52
+ }), onClick: isClickable ? () => onOpenFile(diff.path) : undefined, title: diff.path }, displayPath),
41
53
  React.createElement("div", { className: "jp-jupyter-ai-acp-client-diff-content" },
42
54
  visible.map((line) => (React.createElement("div", { key: line.key, className: `jp-jupyter-ai-acp-client-diff-line ${line.cls}` },
43
55
  React.createElement("span", { className: "jp-jupyter-ai-acp-client-diff-line-text" },
@@ -53,6 +65,6 @@ function DiffBlock({ diff, onOpenFile }) {
53
65
  /**
54
66
  * Renders one or more file diffs.
55
67
  */
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 })))));
68
+ export function DiffView({ diffs, onOpenFile, toDisplayPath, pendingPermission }) {
69
+ 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, toDisplayPath: toDisplayPath, pendingPermission: pendingPermission })))));
58
70
  }
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
@@ -1,7 +1,28 @@
1
1
  import React from 'react';
2
+ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
2
3
  import { submitPermissionDecision } from './request';
3
4
  import clsx from 'clsx';
4
5
  import { DiffView } from './diff-view';
6
+ /**
7
+ * Convert an absolute filesystem path to a server-relative path.
8
+ * Returns the path unchanged if the server root is not set or the path
9
+ * is outside it.
10
+ */
11
+ function toServerRelativePath(absolutePath) {
12
+ const rootUri = PageConfig.getOption('rootUri');
13
+ const serverRoot = rootUri
14
+ ? new URL(rootUri).pathname
15
+ : PageConfig.getOption('serverRoot');
16
+ if (!serverRoot) {
17
+ return absolutePath;
18
+ }
19
+ const relativePath = PathExt.relative(serverRoot, absolutePath);
20
+ // Path is outside server root — keep absolute
21
+ if (relativePath.startsWith('..')) {
22
+ return absolutePath;
23
+ }
24
+ return relativePath;
25
+ }
5
26
  /**
6
27
  * Preamble component that renders tool call status lines above message body.
7
28
  * Returns null if the message has no tool calls.
@@ -14,7 +35,7 @@ export function ToolCallsComponent(props) {
14
35
  }
15
36
  const onOpenFile = (path) => {
16
37
  var _a;
17
- (_a = model.documentManager) === null || _a === void 0 ? void 0 : _a.openOrReveal(path);
38
+ (_a = model.documentManager) === null || _a === void 0 ? void 0 : _a.openOrReveal(toServerRelativePath(path));
18
39
  };
19
40
  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 })))));
20
41
  }
@@ -31,7 +52,83 @@ function formatOutput(rawOutput) {
31
52
  }
32
53
  return JSON.stringify(rawOutput, null, 2);
33
54
  }
34
- /** Tool kinds where expanded view shows full file path(s) from locations. */
55
+ /**
56
+ * Format tool input for display. Flat objects (all primitive values) render as
57
+ * key-value pairs; nested/complex values fall back to JSON.
58
+ */
59
+ function formatToolInput(input) {
60
+ if (typeof input === 'string') {
61
+ return input;
62
+ }
63
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
64
+ return JSON.stringify(input, null, 2);
65
+ }
66
+ const entries = Object.entries(input);
67
+ const isFlat = entries.every(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
68
+ if (isFlat) {
69
+ return entries.map(([k, v]) => `${k}: ${v}`).join('\n');
70
+ }
71
+ return JSON.stringify(input, null, 2);
72
+ }
73
+ /**
74
+ * Compute the pre-permission detail text for a tool call, or null if nothing
75
+ * to show beyond the title.
76
+ */
77
+ function buildPermissionDetail(toolCall) {
78
+ const { kind, title, locations, raw_input } = toolCall;
79
+ if (kind === 'execute') {
80
+ // Prefer raw_input.command (ACP-compliant agents)
81
+ const rawObj = typeof raw_input === 'object' && raw_input !== null
82
+ ? raw_input
83
+ : null;
84
+ const cmd = rawObj && typeof rawObj.command === 'string'
85
+ ? rawObj.command
86
+ : (title === null || title === void 0 ? void 0 : title.replace(/^Running:\s*/i, '').replace(/\.\.\.$/, '').trim()) || null;
87
+ // If stripping produced nothing new, don't show.
88
+ if (!cmd || cmd === title) {
89
+ return null;
90
+ }
91
+ return '$ ' + cmd;
92
+ }
93
+ if ((kind === 'delete' || kind === 'move' || kind === 'read') &&
94
+ (locations === null || locations === void 0 ? void 0 : locations.length)) {
95
+ return kind === 'move' && locations.length >= 2
96
+ ? toServerRelativePath(locations[0]) +
97
+ ' \u2192 ' +
98
+ toServerRelativePath(locations[1])
99
+ : locations.map(toServerRelativePath).join('\n');
100
+ }
101
+ // Generic fallback for unknown/MCP kinds with raw_input.
102
+ if (raw_input !== null &&
103
+ typeof raw_input === 'object' &&
104
+ !Array.isArray(raw_input)) {
105
+ const obj = raw_input;
106
+ const purpose = typeof obj.__tool_use_purpose === 'string'
107
+ ? obj.__tool_use_purpose
108
+ : null;
109
+ // Filter remaining __-prefixed internal keys for the params display.
110
+ const paramEntries = Object.entries(obj).filter(([k]) => !k.startsWith('__'));
111
+ const params = paramEntries.length > 0
112
+ ? formatToolInput(Object.fromEntries(paramEntries))
113
+ : null;
114
+ if (purpose && params) {
115
+ return purpose + '\n' + params;
116
+ }
117
+ if (purpose) {
118
+ return purpose;
119
+ }
120
+ if (params) {
121
+ return params;
122
+ }
123
+ return null;
124
+ }
125
+ // Non-object raw_input (string, array, primitive) — pass through.
126
+ if (raw_input !== null && raw_input !== undefined) {
127
+ return formatToolInput(raw_input);
128
+ }
129
+ return null;
130
+ }
131
+ /** Tool kinds where expanded view shows file path(s) from locations. */
35
132
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
36
133
  /** Tool kinds where expanded view shows raw_output (stdout, search results, etc.). */
37
134
  const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
@@ -39,7 +136,7 @@ const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
39
136
  * Build the expandable details content for a tool call.
40
137
  * Returns lines of metadata to display, or empty array if nothing to show.
41
138
  *
42
- * File operations show full paths; output operations show raw_output;
139
+ * File operations show server-relative paths; output operations show raw_output;
43
140
  * switch_mode/other/None show nothing (clean title only).
44
141
  */
45
142
  function buildDetailsLines(toolCall) {
@@ -48,7 +145,7 @@ function buildDetailsLines(toolCall) {
48
145
  const kind = toolCall.kind;
49
146
  if (kind && FILE_KINDS.has(kind) && ((_a = toolCall.locations) === null || _a === void 0 ? void 0 : _a.length)) {
50
147
  for (const loc of toolCall.locations) {
51
- lines.push(loc);
148
+ lines.push(toServerRelativePath(loc));
52
149
  }
53
150
  }
54
151
  else if (kind && OUTPUT_KINDS.has(kind) && toolCall.raw_output) {
@@ -98,9 +195,23 @@ function ToolCallLine({ toolCall, onOpenFile }) {
98
195
  React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
99
196
  ' ',
100
197
  React.createElement("em", null, displayTitle)),
101
- React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: onOpenFile })),
198
+ React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: toolCall.kind === 'edit' ? onOpenFile : undefined, toDisplayPath: toServerRelativePath, pendingPermission: true })),
102
199
  React.createElement(PermissionButtons, { toolCall: toolCall })));
103
200
  }
201
+ // Pending permission without diffs: show kind-specific detail if available
202
+ if (!hasDiffs && hasPendingPermission) {
203
+ const permissionDetail = buildPermissionDetail(toolCall);
204
+ if (permissionDetail !== null) {
205
+ return (React.createElement("div", { className: cssClass },
206
+ React.createElement("details", { open: true },
207
+ React.createElement("summary", null,
208
+ React.createElement("span", { className: "jp-jupyter-ai-acp-client-tool-call-icon" }, icon),
209
+ ' ',
210
+ React.createElement("em", null, displayTitle)),
211
+ React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-call-detail" }, permissionDetail)),
212
+ React.createElement(PermissionButtons, { toolCall: toolCall })));
213
+ }
214
+ }
104
215
  // Completed/failed with expandable content (diffs or metadata)
105
216
  const detailsLines = !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
106
217
  const hasExpandableContent = hasDiffs || detailsLines.length > 0;
@@ -111,7 +222,7 @@ function ToolCallLine({ toolCall, onOpenFile }) {
111
222
  ' ',
112
223
  displayTitle,
113
224
  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')))));
225
+ hasDiffs ? (React.createElement(DiffView, { diffs: toolCall.diffs, onOpenFile: onOpenFile, toDisplayPath: toServerRelativePath })) : (React.createElement("div", { className: "jp-jupyter-ai-acp-client-tool-call-detail" }, detailsLines.join('\n')))));
115
226
  }
116
227
  // In-progress — italic
117
228
  if (isInProgress) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/acp-client",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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/diff-view.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import { IToolCallDiff } from '@jupyter/chat';
3
+ import { PathExt } from '@jupyterlab/coreutils';
3
4
  import { structuredPatch } from 'diff';
5
+ import clsx from 'clsx';
4
6
 
5
7
  /** Maximum number of diff lines shown before truncation. */
6
8
  const MAX_DIFF_LINES = 20;
@@ -19,10 +21,14 @@ interface IDiffLineInfo {
19
21
  */
20
22
  function DiffBlock({
21
23
  diff,
22
- onOpenFile
24
+ onOpenFile,
25
+ toDisplayPath,
26
+ pendingPermission
23
27
  }: {
24
28
  diff: IToolCallDiff;
25
29
  onOpenFile?: (path: string) => void;
30
+ toDisplayPath?: (path: string) => string;
31
+ pendingPermission?: boolean;
26
32
  }): JSX.Element {
27
33
  const patch = structuredPatch(
28
34
  diff.path,
@@ -33,7 +39,16 @@ function DiffBlock({
33
39
  undefined,
34
40
  { context: Infinity }
35
41
  );
36
- const filename = diff.path.split('/').pop() ?? diff.path;
42
+ const displayPath = toDisplayPath
43
+ ? toDisplayPath(diff.path)
44
+ : PathExt.basename(diff.path);
45
+ // toDisplayPath makes paths inside the server root relative. A leading '/'
46
+ // means the file is outside it and cannot be opened via the Contents API.
47
+ const isOutsideRoot = displayPath.startsWith('/');
48
+ const isClickable =
49
+ !!onOpenFile &&
50
+ !isOutsideRoot &&
51
+ !(pendingPermission && diff.old_text === undefined);
37
52
  const [expanded, setExpanded] = React.useState(false);
38
53
 
39
54
  // Flatten hunks into renderable lines
@@ -67,11 +82,13 @@ function DiffBlock({
67
82
  return (
68
83
  <div className="jp-jupyter-ai-acp-client-diff-block">
69
84
  <div
70
- className="jp-jupyter-ai-acp-client-diff-header"
71
- onClick={onOpenFile ? () => onOpenFile(diff.path) : undefined}
85
+ className={clsx('jp-jupyter-ai-acp-client-diff-header', {
86
+ 'jp-jupyter-ai-acp-client-diff-header-clickable': isClickable
87
+ })}
88
+ onClick={isClickable ? () => onOpenFile!(diff.path) : undefined}
72
89
  title={diff.path}
73
90
  >
74
- {filename}
91
+ {displayPath}
75
92
  </div>
76
93
  <div className="jp-jupyter-ai-acp-client-diff-content">
77
94
  {visible.map((line: IDiffLineInfo) => (
@@ -110,15 +127,25 @@ function DiffBlock({
110
127
  */
111
128
  export function DiffView({
112
129
  diffs,
113
- onOpenFile
130
+ onOpenFile,
131
+ toDisplayPath,
132
+ pendingPermission
114
133
  }: {
115
134
  diffs: IToolCallDiff[];
116
135
  onOpenFile?: (path: string) => void;
136
+ toDisplayPath?: (path: string) => string;
137
+ pendingPermission?: boolean;
117
138
  }): JSX.Element {
118
139
  return (
119
140
  <div className="jp-jupyter-ai-acp-client-diff-container">
120
141
  {diffs.map((d, i) => (
121
- <DiffBlock key={i} diff={d} onOpenFile={onOpenFile} />
142
+ <DiffBlock
143
+ key={i}
144
+ diff={d}
145
+ onOpenFile={onOpenFile}
146
+ toDisplayPath={toDisplayPath}
147
+ pendingPermission={pendingPermission}
148
+ />
122
149
  ))}
123
150
  </div>
124
151
  );
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
+ }
@@ -4,10 +4,32 @@ import {
4
4
  IPermissionOption,
5
5
  MessagePreambleProps
6
6
  } from '@jupyter/chat';
7
+ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
7
8
  import { submitPermissionDecision } from './request';
8
9
  import clsx from 'clsx';
9
10
  import { DiffView } from './diff-view';
10
11
 
12
+ /**
13
+ * Convert an absolute filesystem path to a server-relative path.
14
+ * Returns the path unchanged if the server root is not set or the path
15
+ * is outside it.
16
+ */
17
+ function toServerRelativePath(absolutePath: string): string {
18
+ const rootUri = PageConfig.getOption('rootUri');
19
+ const serverRoot = rootUri
20
+ ? new URL(rootUri).pathname
21
+ : PageConfig.getOption('serverRoot');
22
+ if (!serverRoot) {
23
+ return absolutePath;
24
+ }
25
+ const relativePath = PathExt.relative(serverRoot, absolutePath);
26
+ // Path is outside server root — keep absolute
27
+ if (relativePath.startsWith('..')) {
28
+ return absolutePath;
29
+ }
30
+ return relativePath;
31
+ }
32
+
11
33
  /**
12
34
  * Preamble component that renders tool call status lines above message body.
13
35
  * Returns null if the message has no tool calls.
@@ -21,7 +43,7 @@ export function ToolCallsComponent(
21
43
  }
22
44
 
23
45
  const onOpenFile = (path: string) => {
24
- model.documentManager?.openOrReveal(path);
46
+ model.documentManager?.openOrReveal(toServerRelativePath(path));
25
47
  };
26
48
 
27
49
  return (
@@ -51,7 +73,109 @@ function formatOutput(rawOutput: unknown): string {
51
73
  return JSON.stringify(rawOutput, null, 2);
52
74
  }
53
75
 
54
- /** Tool kinds where expanded view shows full file path(s) from locations. */
76
+ /**
77
+ * Format tool input for display. Flat objects (all primitive values) render as
78
+ * key-value pairs; nested/complex values fall back to JSON.
79
+ */
80
+ function formatToolInput(input: unknown): string {
81
+ if (typeof input === 'string') {
82
+ return input;
83
+ }
84
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
85
+ return JSON.stringify(input, null, 2);
86
+ }
87
+ const entries = Object.entries(input as Record<string, unknown>);
88
+ const isFlat = entries.every(
89
+ ([, v]) =>
90
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
91
+ );
92
+ if (isFlat) {
93
+ return entries.map(([k, v]) => `${k}: ${v}`).join('\n');
94
+ }
95
+ return JSON.stringify(input, null, 2);
96
+ }
97
+
98
+ /**
99
+ * Compute the pre-permission detail text for a tool call, or null if nothing
100
+ * to show beyond the title.
101
+ */
102
+ function buildPermissionDetail(toolCall: IToolCall): string | null {
103
+ const { kind, title, locations, raw_input } = toolCall;
104
+
105
+ if (kind === 'execute') {
106
+ // Prefer raw_input.command (ACP-compliant agents)
107
+ const rawObj =
108
+ typeof raw_input === 'object' && raw_input !== null
109
+ ? (raw_input as Record<string, unknown>)
110
+ : null;
111
+ const cmd =
112
+ rawObj && typeof rawObj.command === 'string'
113
+ ? rawObj.command
114
+ : title
115
+ ?.replace(/^Running:\s*/i, '')
116
+ .replace(/\.\.\.$/, '')
117
+ .trim() || null;
118
+ // If stripping produced nothing new, don't show.
119
+ if (!cmd || cmd === title) {
120
+ return null;
121
+ }
122
+ return '$ ' + cmd;
123
+ }
124
+
125
+ if (
126
+ (kind === 'delete' || kind === 'move' || kind === 'read') &&
127
+ locations?.length
128
+ ) {
129
+ return kind === 'move' && locations.length >= 2
130
+ ? toServerRelativePath(locations[0]) +
131
+ ' \u2192 ' +
132
+ toServerRelativePath(locations[1])
133
+ : locations.map(toServerRelativePath).join('\n');
134
+ }
135
+
136
+ // Generic fallback for unknown/MCP kinds with raw_input.
137
+ if (
138
+ raw_input !== null &&
139
+ typeof raw_input === 'object' &&
140
+ !Array.isArray(raw_input)
141
+ ) {
142
+ const obj = raw_input as Record<string, unknown>;
143
+
144
+ const purpose =
145
+ typeof obj.__tool_use_purpose === 'string'
146
+ ? obj.__tool_use_purpose
147
+ : null;
148
+
149
+ // Filter remaining __-prefixed internal keys for the params display.
150
+ const paramEntries = Object.entries(obj).filter(
151
+ ([k]) => !k.startsWith('__')
152
+ );
153
+ const params =
154
+ paramEntries.length > 0
155
+ ? formatToolInput(Object.fromEntries(paramEntries))
156
+ : null;
157
+
158
+ if (purpose && params) {
159
+ return purpose + '\n' + params;
160
+ }
161
+ if (purpose) {
162
+ return purpose;
163
+ }
164
+ if (params) {
165
+ return params;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // Non-object raw_input (string, array, primitive) — pass through.
171
+ if (raw_input !== null && raw_input !== undefined) {
172
+ return formatToolInput(raw_input);
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ /** Tool kinds where expanded view shows file path(s) from locations. */
55
179
  const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
56
180
 
57
181
  /** Tool kinds where expanded view shows raw_output (stdout, search results, etc.). */
@@ -61,7 +185,7 @@ const OUTPUT_KINDS = new Set(['search', 'execute', 'think', 'fetch']);
61
185
  * Build the expandable details content for a tool call.
62
186
  * Returns lines of metadata to display, or empty array if nothing to show.
63
187
  *
64
- * File operations show full paths; output operations show raw_output;
188
+ * File operations show server-relative paths; output operations show raw_output;
65
189
  * switch_mode/other/None show nothing (clean title only).
66
190
  */
67
191
  function buildDetailsLines(toolCall: IToolCall): string[] {
@@ -70,7 +194,7 @@ function buildDetailsLines(toolCall: IToolCall): string[] {
70
194
 
71
195
  if (kind && FILE_KINDS.has(kind) && toolCall.locations?.length) {
72
196
  for (const loc of toolCall.locations) {
73
- lines.push(loc);
197
+ lines.push(toServerRelativePath(loc));
74
198
  }
75
199
  } else if (kind && OUTPUT_KINDS.has(kind) && toolCall.raw_output) {
76
200
  lines.push(formatOutput(toolCall.raw_output));
@@ -140,13 +264,41 @@ function ToolCallLine({
140
264
  </span>{' '}
141
265
  <em>{displayTitle}</em>
142
266
  </summary>
143
- <DiffView diffs={toolCall.diffs!} onOpenFile={onOpenFile} />
267
+ <DiffView
268
+ diffs={toolCall.diffs!}
269
+ onOpenFile={toolCall.kind === 'edit' ? onOpenFile : undefined}
270
+ toDisplayPath={toServerRelativePath}
271
+ pendingPermission
272
+ />
144
273
  </details>
145
274
  <PermissionButtons toolCall={toolCall} />
146
275
  </div>
147
276
  );
148
277
  }
149
278
 
279
+ // Pending permission without diffs: show kind-specific detail if available
280
+ if (!hasDiffs && hasPendingPermission) {
281
+ const permissionDetail = buildPermissionDetail(toolCall);
282
+ if (permissionDetail !== null) {
283
+ return (
284
+ <div className={cssClass}>
285
+ <details open>
286
+ <summary>
287
+ <span className="jp-jupyter-ai-acp-client-tool-call-icon">
288
+ {icon}
289
+ </span>{' '}
290
+ <em>{displayTitle}</em>
291
+ </summary>
292
+ <div className="jp-jupyter-ai-acp-client-tool-call-detail">
293
+ {permissionDetail}
294
+ </div>
295
+ </details>
296
+ <PermissionButtons toolCall={toolCall} />
297
+ </div>
298
+ );
299
+ }
300
+ }
301
+
150
302
  // Completed/failed with expandable content (diffs or metadata)
151
303
  const detailsLines =
152
304
  !hasDiffs && (isCompleted || isFailed) ? buildDetailsLines(toolCall) : [];
@@ -163,7 +315,11 @@ function ToolCallLine({
163
315
  <PermissionLabel toolCall={toolCall} />
164
316
  </summary>
165
317
  {hasDiffs ? (
166
- <DiffView diffs={toolCall.diffs!} onOpenFile={onOpenFile} />
318
+ <DiffView
319
+ diffs={toolCall.diffs!}
320
+ onOpenFile={onOpenFile}
321
+ toDisplayPath={toServerRelativePath}
322
+ />
167
323
  ) : (
168
324
  <div className="jp-jupyter-ai-acp-client-tool-call-detail">
169
325
  {detailsLines.join('\n')}
package/style/base.css CHANGED
@@ -145,10 +145,13 @@ details[open].jp-jupyter-ai-acp-client-tool-call summary::after,
145
145
  font-size: var(--jp-ui-font-size0);
146
146
  font-family: var(--jp-code-font-family);
147
147
  color: var(--jp-ui-font-color2);
148
+ }
149
+
150
+ .jp-jupyter-ai-acp-client-diff-header-clickable {
148
151
  cursor: pointer;
149
152
  }
150
153
 
151
- .jp-jupyter-ai-acp-client-diff-header:hover {
154
+ .jp-jupyter-ai-acp-client-diff-header-clickable:hover {
152
155
  text-decoration: underline;
153
156
  }
154
157
 
@@ -211,3 +214,26 @@ details[open].jp-jupyter-ai-acp-client-tool-call summary::after,
211
214
  .jp-jupyter-ai-acp-client-diff-context {
212
215
  color: var(--jp-ui-font-color2);
213
216
  }
217
+
218
+ /* Stop streaming button */
219
+ .jp-jupyter-ai-acp-client-stopButton {
220
+ display: inline-flex;
221
+ align-items: center;
222
+ gap: 4px;
223
+ padding: 4px 12px;
224
+ border: 1px solid var(--jp-border-color1);
225
+ border-radius: 4px;
226
+ background: var(--jp-layout-color2);
227
+ color: var(--jp-ui-font-color1);
228
+ font-size: var(--jp-ui-font-size1);
229
+ cursor: pointer;
230
+ }
231
+
232
+ .jp-jupyter-ai-acp-client-stopButton:hover {
233
+ background: var(--jp-layout-color3);
234
+ }
235
+
236
+ .jp-jupyter-ai-acp-client-stopButton:disabled {
237
+ opacity: 0.5;
238
+ cursor: not-allowed;
239
+ }