@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.
Files changed (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. 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
+ }