@mseep/obsidian-agent-client 0.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/gh-setup.sh +49 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/release-notes/SKILL.md +331 -0
- package/.editorconfig +10 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
- package/.github/copilot-instructions.md +45 -0
- package/.github/pull_request_template.md +32 -0
- package/.github/workflows/ci.yaml +25 -0
- package/.github/workflows/docs.yml +58 -0
- package/.github/workflows/relay_to_openclaw.yml +59 -0
- package/.github/workflows/release.yaml +45 -0
- package/.prettierignore +10 -0
- package/.prettierrc +13 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +37 -0
- package/.zed/settings.json +42 -0
- package/AGENTS.md +330 -0
- package/ARCHITECTURE.md +390 -0
- package/CONTRIBUTING.md +216 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.ja.md +121 -0
- package/README.md +125 -0
- package/docs/.vitepress/config.mts +124 -0
- package/docs/.vitepress/theme/custom.css +111 -0
- package/docs/.vitepress/theme/index.ts +4 -0
- package/docs/agent-setup/claude-code.md +84 -0
- package/docs/agent-setup/codex.md +76 -0
- package/docs/agent-setup/custom-agents.md +67 -0
- package/docs/agent-setup/gemini-cli.md +99 -0
- package/docs/agent-setup/index.md +34 -0
- package/docs/announcements/gemini-cli-deprecation.md +73 -0
- package/docs/getting-started/index.md +78 -0
- package/docs/getting-started/quick-start.md +38 -0
- package/docs/help/faq.md +181 -0
- package/docs/help/troubleshooting.md +221 -0
- package/docs/index.md +63 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/demo.mp4 +0 -0
- package/docs/public/favicon-16x16.png +0 -0
- package/docs/public/favicon-32x32.png +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/images/editing.webp +0 -0
- package/docs/public/images/export.webp +0 -0
- package/docs/public/images/floating-chat-button.webp +0 -0
- package/docs/public/images/floating-chat-instance-menu.webp +0 -0
- package/docs/public/images/floating-chat-view.webp +0 -0
- package/docs/public/images/mode-selection.webp +0 -0
- package/docs/public/images/model-selection.webp +0 -0
- package/docs/public/images/multi-session.webp +0 -0
- package/docs/public/images/remove-image.webp +0 -0
- package/docs/public/images/ribbon-icon.webp +0 -0
- package/docs/public/images/selection-context.gif +0 -0
- package/docs/public/images/sending-images.webp +0 -0
- package/docs/public/images/sending-messages.webp +0 -0
- package/docs/public/images/session-history-button.webp +0 -0
- package/docs/public/images/slash-commands-1.webp +0 -0
- package/docs/public/images/slash-commands-2.webp +0 -0
- package/docs/public/images/switch-agent.webp +0 -0
- package/docs/public/images/switch-default-agent.webp +0 -0
- package/docs/public/images/temporary-disable.gif +0 -0
- package/docs/reference/acp-support.md +110 -0
- package/docs/usage/chat-export.md +80 -0
- package/docs/usage/commands.md +51 -0
- package/docs/usage/context-files.md +57 -0
- package/docs/usage/editing.md +69 -0
- package/docs/usage/floating-chat.md +84 -0
- package/docs/usage/index.md +97 -0
- package/docs/usage/mcp-tools.md +33 -0
- package/docs/usage/mentions.md +70 -0
- package/docs/usage/mode-selection.md +28 -0
- package/docs/usage/model-selection.md +32 -0
- package/docs/usage/multi-session.md +68 -0
- package/docs/usage/sending-images.md +64 -0
- package/docs/usage/session-history.md +91 -0
- package/docs/usage/slash-commands.md +44 -0
- package/esbuild.config.mjs +49 -0
- package/eslint.config.mjs +25 -0
- package/main.js +228 -0
- package/manifest.json +11 -0
- package/package.json +52 -0
- package/src/acp/acp-client.ts +921 -0
- package/src/acp/acp-handler.ts +252 -0
- package/src/acp/permission-handler.ts +282 -0
- package/src/acp/terminal-handler.ts +264 -0
- package/src/acp/type-converter.ts +272 -0
- package/src/hooks/useAgent.ts +250 -0
- package/src/hooks/useAgentMessages.ts +470 -0
- package/src/hooks/useAgentSession.ts +544 -0
- package/src/hooks/useChatActions.ts +400 -0
- package/src/hooks/useHistoryModal.ts +219 -0
- package/src/hooks/useSessionHistory.ts +863 -0
- package/src/hooks/useSettings.ts +19 -0
- package/src/hooks/useSuggestions.ts +342 -0
- package/src/main.ts +9 -0
- package/src/plugin.ts +1126 -0
- package/src/services/chat-exporter.ts +552 -0
- package/src/services/message-sender.ts +755 -0
- package/src/services/message-state.ts +375 -0
- package/src/services/session-helpers.ts +211 -0
- package/src/services/session-state.ts +130 -0
- package/src/services/session-storage.ts +267 -0
- package/src/services/settings-normalizer.ts +255 -0
- package/src/services/settings-service.ts +285 -0
- package/src/services/update-checker.ts +128 -0
- package/src/services/vault-service.ts +558 -0
- package/src/services/view-registry.ts +345 -0
- package/src/types/agent.ts +92 -0
- package/src/types/chat.ts +351 -0
- package/src/types/errors.ts +136 -0
- package/src/types/obsidian-internals.d.ts +14 -0
- package/src/types/session.ts +731 -0
- package/src/ui/ChangeDirectoryModal.ts +137 -0
- package/src/ui/ChatContext.ts +25 -0
- package/src/ui/ChatHeader.tsx +295 -0
- package/src/ui/ChatPanel.tsx +1162 -0
- package/src/ui/ChatView.tsx +348 -0
- package/src/ui/ErrorBanner.tsx +104 -0
- package/src/ui/FloatingButton.tsx +351 -0
- package/src/ui/FloatingChatView.tsx +531 -0
- package/src/ui/InputArea.tsx +1107 -0
- package/src/ui/InputToolbar.tsx +371 -0
- package/src/ui/MessageBubble.tsx +442 -0
- package/src/ui/MessageList.tsx +265 -0
- package/src/ui/PermissionBanner.tsx +61 -0
- package/src/ui/SessionHistoryModal.tsx +821 -0
- package/src/ui/SettingsTab.ts +1337 -0
- package/src/ui/SuggestionPopup.tsx +138 -0
- package/src/ui/TerminalBlock.tsx +107 -0
- package/src/ui/ToolCallBlock.tsx +456 -0
- package/src/ui/shared/AttachmentStrip.tsx +57 -0
- package/src/ui/shared/IconButton.tsx +55 -0
- package/src/ui/shared/MarkdownRenderer.tsx +103 -0
- package/src/ui/view-host.ts +56 -0
- package/src/utils/error-utils.ts +274 -0
- package/src/utils/logger.ts +44 -0
- package/src/utils/mention-parser.ts +129 -0
- package/src/utils/paths.ts +246 -0
- package/src/utils/platform.ts +425 -0
- package/styles.css +2322 -0
- package/tsconfig.json +18 -0
- package/version-bump.mjs +18 -0
- package/versions.json +3 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
const { useRef, useEffect } = React;
|
|
3
|
+
import type { NoteMetadata } from "../services/vault-service";
|
|
4
|
+
import type { SlashCommand } from "../types/session";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dropdown type for suggestion display.
|
|
8
|
+
*/
|
|
9
|
+
type DropdownType = "mention" | "slash-command";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for the SuggestionPopup component.
|
|
13
|
+
*
|
|
14
|
+
* This component can display either note mentions or slash commands
|
|
15
|
+
* based on the `type` prop.
|
|
16
|
+
*/
|
|
17
|
+
interface SuggestionPopupProps {
|
|
18
|
+
/** Type of dropdown to display */
|
|
19
|
+
type: DropdownType;
|
|
20
|
+
|
|
21
|
+
/** Items to display (NoteMetadata for mentions, SlashCommand for commands) */
|
|
22
|
+
items: NoteMetadata[] | SlashCommand[];
|
|
23
|
+
|
|
24
|
+
/** Currently selected item index */
|
|
25
|
+
selectedIndex: number;
|
|
26
|
+
|
|
27
|
+
/** Callback when an item is selected */
|
|
28
|
+
onSelect: (item: NoteMetadata | SlashCommand) => void;
|
|
29
|
+
|
|
30
|
+
/** Callback to close the dropdown */
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generic suggestion popup component.
|
|
36
|
+
*
|
|
37
|
+
* Displays either:
|
|
38
|
+
* - Note mentions (@[[note]])
|
|
39
|
+
* - Slash commands (/command)
|
|
40
|
+
*
|
|
41
|
+
* Handles keyboard navigation, mouse selection, and outside click detection.
|
|
42
|
+
*/
|
|
43
|
+
export function SuggestionPopup({
|
|
44
|
+
type,
|
|
45
|
+
items,
|
|
46
|
+
selectedIndex,
|
|
47
|
+
onSelect,
|
|
48
|
+
onClose,
|
|
49
|
+
}: SuggestionPopupProps) {
|
|
50
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
// Handle mouse clicks outside dropdown to close
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
55
|
+
if (
|
|
56
|
+
dropdownRef.current &&
|
|
57
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
58
|
+
) {
|
|
59
|
+
onClose();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const doc = activeDocument;
|
|
64
|
+
doc.addEventListener("mousedown", handleClickOutside);
|
|
65
|
+
return () => {
|
|
66
|
+
doc.removeEventListener("mousedown", handleClickOutside);
|
|
67
|
+
};
|
|
68
|
+
}, [onClose]);
|
|
69
|
+
|
|
70
|
+
// Scroll selected item into view
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!dropdownRef.current) return;
|
|
73
|
+
const selectedElement = dropdownRef.current.children[selectedIndex] as
|
|
74
|
+
| HTMLElement
|
|
75
|
+
| undefined;
|
|
76
|
+
selectedElement?.scrollIntoView({ block: "nearest" });
|
|
77
|
+
}, [selectedIndex]);
|
|
78
|
+
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render a single dropdown item based on type.
|
|
85
|
+
*/
|
|
86
|
+
const renderItem = (item: NoteMetadata | SlashCommand, index: number) => {
|
|
87
|
+
const isSelected = index === selectedIndex;
|
|
88
|
+
const hasBorder = index < items.length - 1;
|
|
89
|
+
|
|
90
|
+
if (type === "mention") {
|
|
91
|
+
const note = item as NoteMetadata;
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
key={`mention-${index}`}
|
|
95
|
+
className={`agent-client-mention-dropdown-item ${isSelected ? "agent-client-selected" : ""} ${hasBorder ? "agent-client-has-border" : ""}`}
|
|
96
|
+
onClick={() => onSelect(note)}
|
|
97
|
+
onMouseEnter={() => {
|
|
98
|
+
// Could update selected index on hover
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div className="agent-client-mention-dropdown-item-name">
|
|
102
|
+
{note.name}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="agent-client-mention-dropdown-item-path">
|
|
105
|
+
{note.path}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
// type === "slash-command"
|
|
111
|
+
const command = item as SlashCommand;
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
key={`command-${index}`}
|
|
115
|
+
className={`agent-client-mention-dropdown-item ${isSelected ? "agent-client-selected" : ""} ${hasBorder ? "agent-client-has-border" : ""}`}
|
|
116
|
+
onClick={() => onSelect(command)}
|
|
117
|
+
onMouseEnter={() => {
|
|
118
|
+
// Could update selected index on hover
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div className="agent-client-mention-dropdown-item-name">
|
|
122
|
+
/{command.name}
|
|
123
|
+
</div>
|
|
124
|
+
<div className="agent-client-mention-dropdown-item-path">
|
|
125
|
+
{command.description}
|
|
126
|
+
{command.hint && ` (${command.hint})`}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div ref={dropdownRef} className="agent-client-mention-dropdown">
|
|
135
|
+
{items.map((item, index) => renderItem(item, index))}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
const { useState, useRef, useEffect } = React;
|
|
3
|
+
import type { AcpClient } from "../acp/acp-client";
|
|
4
|
+
import { getLogger } from "../utils/logger";
|
|
5
|
+
interface TerminalBlockProps {
|
|
6
|
+
terminalId: string;
|
|
7
|
+
terminalClient: AcpClient | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TerminalBlock = React.memo(function TerminalBlock({
|
|
11
|
+
terminalId,
|
|
12
|
+
terminalClient,
|
|
13
|
+
}: TerminalBlockProps) {
|
|
14
|
+
const logger = getLogger();
|
|
15
|
+
const [output, setOutput] = useState("");
|
|
16
|
+
const [exitStatus, setExitStatus] = useState<{
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
signal: string | null;
|
|
19
|
+
} | null>(null);
|
|
20
|
+
const [isRunning, setIsRunning] = useState(true);
|
|
21
|
+
const intervalRef = useRef<number | null>(null);
|
|
22
|
+
|
|
23
|
+
logger.log(
|
|
24
|
+
`[TerminalBlock] Component rendered for terminal ${terminalId}, terminalClient: ${!!terminalClient}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
logger.log(
|
|
29
|
+
`[TerminalBlock] useEffect triggered for ${terminalId}, terminalClient: ${!!terminalClient}`,
|
|
30
|
+
);
|
|
31
|
+
if (!terminalId || !terminalClient) return;
|
|
32
|
+
|
|
33
|
+
const pollOutput = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const result =
|
|
36
|
+
await terminalClient.getTerminalOutput(terminalId);
|
|
37
|
+
logger.log(
|
|
38
|
+
`[TerminalBlock] Poll result for ${terminalId}:`,
|
|
39
|
+
result,
|
|
40
|
+
);
|
|
41
|
+
setOutput(result.output);
|
|
42
|
+
if (result.exitStatus) {
|
|
43
|
+
setExitStatus({
|
|
44
|
+
exitCode: result.exitStatus.exitCode ?? null,
|
|
45
|
+
signal: result.exitStatus.signal ?? null,
|
|
46
|
+
});
|
|
47
|
+
setIsRunning(false);
|
|
48
|
+
if (intervalRef.current) {
|
|
49
|
+
window.clearInterval(intervalRef.current);
|
|
50
|
+
intervalRef.current = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const errorMessage =
|
|
55
|
+
error instanceof Error ? error.message : String(error);
|
|
56
|
+
|
|
57
|
+
logger.log(
|
|
58
|
+
`[TerminalBlock] Polling error for terminal ${terminalId}: ${errorMessage}`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
setIsRunning(false);
|
|
62
|
+
if (intervalRef.current) {
|
|
63
|
+
window.clearInterval(intervalRef.current);
|
|
64
|
+
intervalRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Start polling immediately
|
|
70
|
+
void pollOutput();
|
|
71
|
+
|
|
72
|
+
// Set up polling interval with shorter interval to catch fast commands
|
|
73
|
+
intervalRef.current = window.setInterval(() => {
|
|
74
|
+
void pollOutput();
|
|
75
|
+
}, 100);
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
if (intervalRef.current) {
|
|
79
|
+
window.clearInterval(intervalRef.current);
|
|
80
|
+
intervalRef.current = null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}, [terminalId, terminalClient, logger]);
|
|
84
|
+
|
|
85
|
+
// Separate effect to stop polling when no longer running
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!isRunning && intervalRef.current) {
|
|
88
|
+
window.clearInterval(intervalRef.current);
|
|
89
|
+
intervalRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
}, [isRunning]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="agent-client-terminal-renderer">
|
|
95
|
+
{output || (isRunning ? "Waiting for output..." : "No output")}
|
|
96
|
+
|
|
97
|
+
{exitStatus && (
|
|
98
|
+
<div
|
|
99
|
+
className={`agent-client-terminal-renderer-exit ${exitStatus.exitCode === 0 ? "agent-client-success" : "agent-client-error"}`}
|
|
100
|
+
>
|
|
101
|
+
Exit Code: {exitStatus.exitCode}
|
|
102
|
+
{exitStatus.signal && ` | Signal: ${exitStatus.signal}`}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
});
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
const { useState, useMemo } = React;
|
|
3
|
+
import { FileSystemAdapter } from "obsidian";
|
|
4
|
+
import type { MessageContent } from "../types/chat";
|
|
5
|
+
import type { AcpClient } from "../acp/acp-client";
|
|
6
|
+
import type AgentClientPlugin from "../plugin";
|
|
7
|
+
import { TerminalBlock } from "./TerminalBlock";
|
|
8
|
+
import { PermissionBanner } from "./PermissionBanner";
|
|
9
|
+
import { LucideIcon } from "./shared/IconButton";
|
|
10
|
+
import { toRelativePath } from "../utils/paths";
|
|
11
|
+
import * as Diff from "diff";
|
|
12
|
+
// import { MarkdownRenderer } from "./shared/MarkdownRenderer";
|
|
13
|
+
|
|
14
|
+
interface ToolCallBlockProps {
|
|
15
|
+
content: Extract<MessageContent, { type: "tool_call" }>;
|
|
16
|
+
plugin: AgentClientPlugin;
|
|
17
|
+
terminalClient?: AcpClient;
|
|
18
|
+
/** Callback to approve a permission request */
|
|
19
|
+
onApprovePermission?: (
|
|
20
|
+
requestId: string,
|
|
21
|
+
optionId: string,
|
|
22
|
+
) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ToolCallBlock = React.memo(function ToolCallBlock({
|
|
26
|
+
content,
|
|
27
|
+
plugin,
|
|
28
|
+
terminalClient,
|
|
29
|
+
onApprovePermission,
|
|
30
|
+
}: ToolCallBlockProps) {
|
|
31
|
+
const {
|
|
32
|
+
kind,
|
|
33
|
+
title,
|
|
34
|
+
status,
|
|
35
|
+
permissionRequest,
|
|
36
|
+
locations,
|
|
37
|
+
rawInput,
|
|
38
|
+
content: toolContent,
|
|
39
|
+
} = content;
|
|
40
|
+
|
|
41
|
+
// Local state for selected option (for immediate UI feedback)
|
|
42
|
+
const [selectedOptionId, setSelectedOptionId] = useState<
|
|
43
|
+
string | undefined
|
|
44
|
+
>(permissionRequest?.selectedOptionId);
|
|
45
|
+
|
|
46
|
+
// Update selectedOptionId when permissionRequest changes
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
if (permissionRequest?.selectedOptionId !== selectedOptionId) {
|
|
49
|
+
setSelectedOptionId(permissionRequest?.selectedOptionId);
|
|
50
|
+
}
|
|
51
|
+
}, [permissionRequest?.selectedOptionId]);
|
|
52
|
+
|
|
53
|
+
// Get vault path for relative path display
|
|
54
|
+
const vaultPath = useMemo(() => {
|
|
55
|
+
const adapter = plugin.app.vault.adapter;
|
|
56
|
+
if (adapter instanceof FileSystemAdapter) {
|
|
57
|
+
return adapter.getBasePath();
|
|
58
|
+
}
|
|
59
|
+
return "";
|
|
60
|
+
}, [plugin]);
|
|
61
|
+
|
|
62
|
+
// Get showEmojis setting
|
|
63
|
+
const showEmojis = plugin.settings.displaySettings.showEmojis;
|
|
64
|
+
|
|
65
|
+
// Get Lucide icon name based on tool kind
|
|
66
|
+
const getKindIconName = (kind?: string): string => {
|
|
67
|
+
switch (kind) {
|
|
68
|
+
case "read":
|
|
69
|
+
return "book-open";
|
|
70
|
+
case "edit":
|
|
71
|
+
return "pencil";
|
|
72
|
+
case "delete":
|
|
73
|
+
return "trash";
|
|
74
|
+
case "move":
|
|
75
|
+
return "folder-open";
|
|
76
|
+
case "search":
|
|
77
|
+
return "search";
|
|
78
|
+
case "execute":
|
|
79
|
+
return "square-terminal";
|
|
80
|
+
case "think":
|
|
81
|
+
return "message-circle-more";
|
|
82
|
+
case "fetch":
|
|
83
|
+
return "globe";
|
|
84
|
+
case "switch_mode":
|
|
85
|
+
return "arrow-left-right";
|
|
86
|
+
default:
|
|
87
|
+
return "hammer";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="agent-client-message-tool-call">
|
|
93
|
+
{/* Header */}
|
|
94
|
+
<div className="agent-client-message-tool-call-header">
|
|
95
|
+
<div className="agent-client-message-tool-call-title">
|
|
96
|
+
{showEmojis && (
|
|
97
|
+
<LucideIcon
|
|
98
|
+
name={getKindIconName(kind)}
|
|
99
|
+
className="agent-client-message-tool-call-icon"
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<span className="agent-client-message-tool-call-title-text">
|
|
103
|
+
{title}
|
|
104
|
+
</span>
|
|
105
|
+
{status !== "completed" && (
|
|
106
|
+
<LucideIcon
|
|
107
|
+
name={status === "failed" ? "x" : "ellipsis"}
|
|
108
|
+
className={`agent-client-message-tool-call-status-icon agent-client-status-${status}`}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
{kind === "execute" &&
|
|
113
|
+
rawInput &&
|
|
114
|
+
typeof rawInput.command === "string" && (
|
|
115
|
+
<div className="agent-client-message-tool-call-command">
|
|
116
|
+
<code>
|
|
117
|
+
{rawInput.command}
|
|
118
|
+
{Array.isArray(rawInput.args) &&
|
|
119
|
+
rawInput.args.length > 0 &&
|
|
120
|
+
` ${(rawInput.args as string[]).join(" ")}`}
|
|
121
|
+
</code>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
{locations && locations.length > 0 && (
|
|
125
|
+
<div className="agent-client-message-tool-call-locations">
|
|
126
|
+
{locations.map((loc, idx) => (
|
|
127
|
+
<span
|
|
128
|
+
key={idx}
|
|
129
|
+
className="agent-client-message-tool-call-location"
|
|
130
|
+
>
|
|
131
|
+
{toRelativePath(loc.path, vaultPath)}
|
|
132
|
+
{loc.line != null && `:${loc.line}`}
|
|
133
|
+
</span>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Tool call content (diffs, terminal output, etc.) */}
|
|
140
|
+
{toolContent &&
|
|
141
|
+
toolContent.map((item, index) => {
|
|
142
|
+
if (item.type === "terminal") {
|
|
143
|
+
return (
|
|
144
|
+
<TerminalBlock
|
|
145
|
+
key={index}
|
|
146
|
+
terminalId={item.terminalId}
|
|
147
|
+
terminalClient={terminalClient || null}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (item.type === "diff") {
|
|
152
|
+
return (
|
|
153
|
+
<DiffRenderer
|
|
154
|
+
key={index}
|
|
155
|
+
diff={item}
|
|
156
|
+
plugin={plugin}
|
|
157
|
+
autoCollapse={
|
|
158
|
+
plugin.settings.displaySettings
|
|
159
|
+
.autoCollapseDiffs
|
|
160
|
+
}
|
|
161
|
+
collapseThreshold={
|
|
162
|
+
plugin.settings.displaySettings
|
|
163
|
+
.diffCollapseThreshold
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
})}
|
|
170
|
+
|
|
171
|
+
{/* Permission request section */}
|
|
172
|
+
{permissionRequest && (
|
|
173
|
+
<PermissionBanner
|
|
174
|
+
permissionRequest={{
|
|
175
|
+
...permissionRequest,
|
|
176
|
+
selectedOptionId: selectedOptionId,
|
|
177
|
+
}}
|
|
178
|
+
onApprovePermission={onApprovePermission}
|
|
179
|
+
onOptionSelected={setSelectedOptionId}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ============================================================
|
|
187
|
+
// Diff renderer component
|
|
188
|
+
// ============================================================
|
|
189
|
+
interface DiffRendererProps {
|
|
190
|
+
diff: {
|
|
191
|
+
type: "diff";
|
|
192
|
+
path: string;
|
|
193
|
+
oldText?: string | null;
|
|
194
|
+
newText: string;
|
|
195
|
+
};
|
|
196
|
+
plugin: AgentClientPlugin;
|
|
197
|
+
autoCollapse?: boolean;
|
|
198
|
+
collapseThreshold?: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Represents a single line in a diff view
|
|
203
|
+
* @property type - The type of change: added, removed, or unchanged context
|
|
204
|
+
* @property oldLineNumber - Line number in the old file (undefined for added lines)
|
|
205
|
+
* @property newLineNumber - Line number in the new file (undefined for removed lines)
|
|
206
|
+
* @property content - The text content of the line
|
|
207
|
+
* @property wordDiff - Optional word-level diff for lines that were modified (adjacent removed+added pairs)
|
|
208
|
+
*/
|
|
209
|
+
interface DiffLine {
|
|
210
|
+
type: "added" | "removed" | "context";
|
|
211
|
+
oldLineNumber?: number;
|
|
212
|
+
newLineNumber?: number;
|
|
213
|
+
content: string;
|
|
214
|
+
wordDiff?: { type: "added" | "removed" | "context"; value: string }[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if the diff represents a new file (no old content)
|
|
219
|
+
*/
|
|
220
|
+
function isNewFile(diff: DiffRendererProps["diff"]): boolean {
|
|
221
|
+
return (
|
|
222
|
+
diff.oldText === null ||
|
|
223
|
+
diff.oldText === undefined ||
|
|
224
|
+
diff.oldText === ""
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Helper function to map diff parts to our internal format
|
|
229
|
+
function mapDiffParts(
|
|
230
|
+
parts: Diff.Change[],
|
|
231
|
+
): { type: "added" | "removed" | "context"; value: string }[] {
|
|
232
|
+
return parts.map((part) => ({
|
|
233
|
+
type: part.added ? "added" : part.removed ? "removed" : "context",
|
|
234
|
+
value: part.value,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Helper function to render word-level diffs
|
|
239
|
+
function renderWordDiff(
|
|
240
|
+
wordDiff: { type: "added" | "removed" | "context"; value: string }[],
|
|
241
|
+
lineType: "added" | "removed",
|
|
242
|
+
) {
|
|
243
|
+
// Filter parts based on line type to avoid rendering null elements
|
|
244
|
+
const filteredParts = wordDiff.filter((part) => {
|
|
245
|
+
// For removed lines, skip added parts
|
|
246
|
+
if (lineType === "removed" && part.type === "added") {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// For added lines, skip removed parts
|
|
250
|
+
if (lineType === "added" && part.type === "removed") {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
{filteredParts.map((part, partIdx) => {
|
|
259
|
+
if (part.type === "added") {
|
|
260
|
+
return (
|
|
261
|
+
<span
|
|
262
|
+
key={partIdx}
|
|
263
|
+
className="agent-client-diff-word-added"
|
|
264
|
+
>
|
|
265
|
+
{part.value}
|
|
266
|
+
</span>
|
|
267
|
+
);
|
|
268
|
+
} else if (part.type === "removed") {
|
|
269
|
+
return (
|
|
270
|
+
<span
|
|
271
|
+
key={partIdx}
|
|
272
|
+
className="agent-client-diff-word-removed"
|
|
273
|
+
>
|
|
274
|
+
{part.value}
|
|
275
|
+
</span>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return <span key={partIdx}>{part.value}</span>;
|
|
279
|
+
})}
|
|
280
|
+
</>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Number of context lines to show around changes
|
|
285
|
+
const CONTEXT_LINES = 3;
|
|
286
|
+
|
|
287
|
+
function DiffRenderer({
|
|
288
|
+
diff,
|
|
289
|
+
autoCollapse = false,
|
|
290
|
+
collapseThreshold = 10,
|
|
291
|
+
}: DiffRendererProps) {
|
|
292
|
+
// Generate diff using the diff library
|
|
293
|
+
const diffLines = useMemo(() => {
|
|
294
|
+
if (isNewFile(diff)) {
|
|
295
|
+
// New file - all lines are added
|
|
296
|
+
const lines = diff.newText.split("\n");
|
|
297
|
+
return lines.map(
|
|
298
|
+
(line, idx): DiffLine => ({
|
|
299
|
+
type: "added",
|
|
300
|
+
newLineNumber: idx + 1,
|
|
301
|
+
content: line,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Use structuredPatch to get a proper unified diff
|
|
307
|
+
// At this point, oldText is guaranteed to be a non-empty string (checked by isNewFile)
|
|
308
|
+
const oldText = diff.oldText || "";
|
|
309
|
+
const patch = Diff.structuredPatch(
|
|
310
|
+
"old",
|
|
311
|
+
"new",
|
|
312
|
+
oldText,
|
|
313
|
+
diff.newText,
|
|
314
|
+
"",
|
|
315
|
+
"",
|
|
316
|
+
{ context: CONTEXT_LINES },
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const result: DiffLine[] = [];
|
|
320
|
+
let oldLineNum = 0;
|
|
321
|
+
let newLineNum = 0;
|
|
322
|
+
|
|
323
|
+
// Process hunks
|
|
324
|
+
for (const hunk of patch.hunks) {
|
|
325
|
+
// Add hunk header only if there are multiple hunks
|
|
326
|
+
// (helps users see gaps between different sections of changes)
|
|
327
|
+
if (patch.hunks.length > 1) {
|
|
328
|
+
result.push({
|
|
329
|
+
type: "context",
|
|
330
|
+
content: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
oldLineNum = hunk.oldStart;
|
|
335
|
+
newLineNum = hunk.newStart;
|
|
336
|
+
|
|
337
|
+
for (const line of hunk.lines) {
|
|
338
|
+
const marker = line[0];
|
|
339
|
+
const content = line.substring(1);
|
|
340
|
+
|
|
341
|
+
if (marker === "+") {
|
|
342
|
+
result.push({
|
|
343
|
+
type: "added",
|
|
344
|
+
newLineNumber: newLineNum++,
|
|
345
|
+
content,
|
|
346
|
+
});
|
|
347
|
+
} else if (marker === "-") {
|
|
348
|
+
result.push({
|
|
349
|
+
type: "removed",
|
|
350
|
+
oldLineNumber: oldLineNum++,
|
|
351
|
+
content,
|
|
352
|
+
});
|
|
353
|
+
} else {
|
|
354
|
+
// Context line (unchanged)
|
|
355
|
+
result.push({
|
|
356
|
+
type: "context",
|
|
357
|
+
oldLineNumber: oldLineNum++,
|
|
358
|
+
newLineNumber: newLineNum++,
|
|
359
|
+
content,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Add word-level diff for modified lines that are adjacent
|
|
366
|
+
for (let i = 0; i < result.length - 1; i++) {
|
|
367
|
+
const current = result[i];
|
|
368
|
+
const next = result[i + 1];
|
|
369
|
+
|
|
370
|
+
// If we have a removed line followed by an added line, compute word diff
|
|
371
|
+
if (current.type === "removed" && next.type === "added") {
|
|
372
|
+
const wordDiff = Diff.diffWords(current.content, next.content);
|
|
373
|
+
const mappedDiff = mapDiffParts(wordDiff);
|
|
374
|
+
current.wordDiff = mappedDiff;
|
|
375
|
+
next.wordDiff = mappedDiff;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return result;
|
|
380
|
+
}, [diff.oldText, diff.newText]);
|
|
381
|
+
|
|
382
|
+
const renderLine = (line: DiffLine, idx: number) => {
|
|
383
|
+
const isHunkHeader =
|
|
384
|
+
line.type === "context" && line.content.startsWith("@@");
|
|
385
|
+
|
|
386
|
+
if (isHunkHeader) {
|
|
387
|
+
return (
|
|
388
|
+
<div key={idx} className="agent-client-diff-hunk-header">
|
|
389
|
+
{line.content}
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let lineClass = "agent-client-diff-line";
|
|
395
|
+
|
|
396
|
+
if (line.type === "added") {
|
|
397
|
+
lineClass += " agent-client-diff-line-added";
|
|
398
|
+
} else if (line.type === "removed") {
|
|
399
|
+
lineClass += " agent-client-diff-line-removed";
|
|
400
|
+
} else {
|
|
401
|
+
lineClass += " agent-client-diff-line-context";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<div key={idx} className={lineClass}>
|
|
406
|
+
<span className="agent-client-diff-line-content">
|
|
407
|
+
{line.wordDiff &&
|
|
408
|
+
(line.type === "added" || line.type === "removed")
|
|
409
|
+
? renderWordDiff(line.wordDiff, line.type)
|
|
410
|
+
: line.content}
|
|
411
|
+
</span>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Determine if collapsing is needed (only when exceeding threshold)
|
|
417
|
+
const shouldCollapse = autoCollapse && diffLines.length > collapseThreshold;
|
|
418
|
+
|
|
419
|
+
// Collapse state (initially collapsed if shouldCollapse is true)
|
|
420
|
+
const [isCollapsed, setIsCollapsed] = useState(shouldCollapse);
|
|
421
|
+
|
|
422
|
+
// Lines to display (threshold lines when collapsed)
|
|
423
|
+
const visibleLines = isCollapsed
|
|
424
|
+
? diffLines.slice(0, collapseThreshold)
|
|
425
|
+
: diffLines;
|
|
426
|
+
|
|
427
|
+
// Remaining lines count
|
|
428
|
+
const remainingLines = diffLines.length - collapseThreshold;
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div className="agent-client-tool-call-diff">
|
|
432
|
+
{isNewFile(diff) ? (
|
|
433
|
+
<div className="agent-client-diff-line-info">New file</div>
|
|
434
|
+
) : null}
|
|
435
|
+
<div className="agent-client-tool-call-diff-content">
|
|
436
|
+
{visibleLines.map((line, idx) => renderLine(line, idx))}
|
|
437
|
+
</div>
|
|
438
|
+
{shouldCollapse && (
|
|
439
|
+
<div
|
|
440
|
+
className="agent-client-diff-expand-bar"
|
|
441
|
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
442
|
+
>
|
|
443
|
+
<span className="agent-client-diff-expand-text">
|
|
444
|
+
{isCollapsed
|
|
445
|
+
? `${remainingLines} more lines`
|
|
446
|
+
: "Collapse"}
|
|
447
|
+
</span>
|
|
448
|
+
<LucideIcon
|
|
449
|
+
name={isCollapsed ? "chevron-right" : "chevron-up"}
|
|
450
|
+
className="agent-client-diff-expand-icon"
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|