@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 +16 -9
- package/lib/diff-view.d.ts +3 -1
- package/lib/diff-view.js +18 -6
- package/lib/index.d.ts +9 -2
- package/lib/index.js +28 -2
- package/lib/request.d.ts +1 -0
- package/lib/request.js +15 -0
- package/lib/stop-button.d.ts +7 -0
- package/lib/stop-button.js +51 -0
- package/lib/tool-calls.js +117 -6
- package/package.json +4 -1
- package/src/diff-view.tsx +34 -7
- package/src/index.ts +33 -1
- package/src/jupyter-chat-augment.d.ts +5 -0
- package/src/request.ts +17 -0
- package/src/stop-button.tsx +68 -0
- package/src/tool-calls.tsx +162 -6
- package/style/base.css +27 -1
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
|
|
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-
|
|
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
|
|
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
|
|
54
|
+
Currently, this package provides 4 personas:
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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-
|
|
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
|
|
package/lib/diff-view.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
|
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=
|
|
71
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tool-calls.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|