@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,1107 @@
1
+ import * as React from "react";
2
+ const { useRef, useState, useEffect, useCallback, useMemo } = React;
3
+ import { setIcon, Notice } from "obsidian";
4
+
5
+ import type AgentClientPlugin from "../plugin";
6
+ import type { IChatViewHost } from "./view-host";
7
+ import type { NoteMetadata } from "../services/vault-service";
8
+ import type {
9
+ SlashCommand,
10
+ SessionModeState,
11
+ SessionModelState,
12
+ SessionUsage,
13
+ SessionConfigOption,
14
+ } from "../types/session";
15
+ import type { AttachedFile, ChatMessage } from "../types/chat";
16
+ import type { UseSuggestionsReturn } from "../hooks/useSuggestions";
17
+ import { SuggestionPopup } from "./SuggestionPopup";
18
+ import { ErrorBanner } from "./ErrorBanner";
19
+ import { AttachmentStrip } from "./shared/AttachmentStrip";
20
+ import { InputToolbar } from "./InputToolbar";
21
+ import { getLogger } from "../utils/logger";
22
+ import type { ErrorInfo } from "../types/errors";
23
+ import type { AgentUpdateNotification } from "../services/update-checker";
24
+ import { useSettings } from "../hooks/useSettings";
25
+
26
+ // ============================================================================
27
+ // Image Constants
28
+ // ============================================================================
29
+
30
+ /** Maximum image size in MB */
31
+ const MAX_IMAGE_SIZE_MB = 5;
32
+
33
+ /** Maximum image size in bytes */
34
+ const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024;
35
+
36
+ /** Maximum number of attachments per message (images + files combined) */
37
+ const MAX_ATTACHMENT_COUNT = 10;
38
+
39
+ /** Supported image MIME types (whitelist) */
40
+ const SUPPORTED_IMAGE_TYPES = [
41
+ "image/png",
42
+ "image/jpeg",
43
+ "image/gif",
44
+ "image/webp",
45
+ ] as const;
46
+
47
+ type SupportedImageType = (typeof SUPPORTED_IMAGE_TYPES)[number];
48
+
49
+ /**
50
+ * Props for InputArea component
51
+ */
52
+ // ============================================================================
53
+ // Input History Hook
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Hook for navigating through previous user messages with ArrowUp/ArrowDown.
58
+ */
59
+ function useInputHistory(
60
+ messages: ChatMessage[],
61
+ onInputChange: (value: string) => void,
62
+ ): {
63
+ handleHistoryKeyDown: (
64
+ e: React.KeyboardEvent,
65
+ textareaEl: HTMLTextAreaElement | null,
66
+ ) => boolean;
67
+ resetHistory: () => void;
68
+ } {
69
+ const historyIndexRef = useRef(-1);
70
+ const restoredTextRef = useRef<string | null>(null);
71
+
72
+ const userMessages = useMemo(() => {
73
+ return messages
74
+ .filter((m) => m.role === "user")
75
+ .map((m) => {
76
+ const textContent = m.content.find(
77
+ (c) => c.type === "text" || c.type === "text_with_context",
78
+ );
79
+ return textContent && "text" in textContent
80
+ ? textContent.text
81
+ : "";
82
+ })
83
+ .filter((text) => text.trim() !== "");
84
+ }, [messages]);
85
+
86
+ const handleHistoryKeyDown = useCallback(
87
+ (
88
+ e: React.KeyboardEvent,
89
+ textareaEl: HTMLTextAreaElement | null,
90
+ ): boolean => {
91
+ if (!textareaEl) return false;
92
+ if (e.nativeEvent.isComposing) return false;
93
+ if (userMessages.length === 0) return false;
94
+
95
+ // Exit history mode if user edited text or moved cursor
96
+ if (historyIndexRef.current !== -1) {
97
+ if (
98
+ e.key === "ArrowLeft" ||
99
+ e.key === "ArrowRight" ||
100
+ (restoredTextRef.current !== null &&
101
+ textareaEl.value !== restoredTextRef.current)
102
+ ) {
103
+ historyIndexRef.current = -1;
104
+ restoredTextRef.current = null;
105
+ return false;
106
+ }
107
+ }
108
+
109
+ if (e.key === "ArrowUp") {
110
+ if (
111
+ textareaEl.value.trim() !== "" &&
112
+ historyIndexRef.current === -1
113
+ )
114
+ return false;
115
+
116
+ e.preventDefault();
117
+
118
+ const nextIndex = historyIndexRef.current + 1;
119
+ if (nextIndex >= userMessages.length) {
120
+ return true;
121
+ }
122
+
123
+ historyIndexRef.current = nextIndex;
124
+ const messageText =
125
+ userMessages[userMessages.length - 1 - nextIndex];
126
+ restoredTextRef.current = messageText;
127
+ onInputChange(messageText);
128
+
129
+ window.setTimeout(() => {
130
+ textareaEl.selectionStart = messageText.length;
131
+ textareaEl.selectionEnd = messageText.length;
132
+ }, 0);
133
+
134
+ return true;
135
+ }
136
+
137
+ if (e.key === "ArrowDown") {
138
+ const currentIndex = historyIndexRef.current;
139
+ if (currentIndex === -1) return false;
140
+
141
+ e.preventDefault();
142
+
143
+ const nextIndex = currentIndex - 1;
144
+ historyIndexRef.current = nextIndex;
145
+
146
+ if (nextIndex === -1) {
147
+ restoredTextRef.current = null;
148
+ onInputChange("");
149
+ } else {
150
+ const messageText =
151
+ userMessages[userMessages.length - 1 - nextIndex];
152
+ restoredTextRef.current = messageText;
153
+ onInputChange(messageText);
154
+
155
+ window.setTimeout(() => {
156
+ textareaEl.selectionStart = messageText.length;
157
+ textareaEl.selectionEnd = messageText.length;
158
+ }, 0);
159
+ }
160
+
161
+ return true;
162
+ }
163
+
164
+ return false;
165
+ },
166
+ [userMessages, onInputChange],
167
+ );
168
+
169
+ const resetHistory = useCallback(() => {
170
+ historyIndexRef.current = -1;
171
+ restoredTextRef.current = null;
172
+ }, []);
173
+
174
+ return { handleHistoryKeyDown, resetHistory };
175
+ }
176
+
177
+ // ============================================================================
178
+ // InputArea Component
179
+ // ============================================================================
180
+
181
+ export interface InputAreaProps {
182
+ /** Whether a message is currently being sent */
183
+ isSending: boolean;
184
+ /** Whether the session is ready for user input */
185
+ isSessionReady: boolean;
186
+ /** Whether a session is being restored (load/resume/fork) */
187
+ isRestoringSession: boolean;
188
+ /** Display name of the active agent */
189
+ agentLabel: string;
190
+ /** Available slash commands */
191
+ availableCommands: SlashCommand[];
192
+ /** Whether auto-mention setting is enabled */
193
+ autoMentionEnabled: boolean;
194
+ /** Message to restore (e.g., after cancellation) */
195
+ restoredMessage: string | null;
196
+ /** Input suggestions (mentions + slash commands) */
197
+ suggestions: UseSuggestionsReturn;
198
+ /** Plugin instance */
199
+ plugin: AgentClientPlugin;
200
+ /** View instance for event registration */
201
+ view: IChatViewHost;
202
+ /** Callback to send a message with optional attachments */
203
+ onSendMessage: (
204
+ content: string,
205
+ attachments?: AttachedFile[],
206
+ ) => Promise<void>;
207
+ /** Callback to stop the current generation */
208
+ onStopGeneration: () => Promise<void>;
209
+ /** Callback when restored message has been consumed */
210
+ onRestoredMessageConsumed: () => void;
211
+ /** Session mode state (available modes and current mode) */
212
+ modes?: SessionModeState;
213
+ /** Callback when mode is changed */
214
+ onModeChange?: (modeId: string) => void;
215
+ /** Session model state (available models and current model) - experimental */
216
+ models?: SessionModelState;
217
+ /** Callback when model is changed */
218
+ onModelChange?: (modelId: string) => void;
219
+ /** Session config options (supersedes modes/models when present) */
220
+ configOptions?: SessionConfigOption[];
221
+ /** Callback when a config option is changed */
222
+ onConfigOptionChange?: (configId: string, value: string) => void;
223
+ /** Context window usage (shown as percentage indicator) */
224
+ usage?: SessionUsage;
225
+ /** Whether the agent supports image attachments */
226
+ supportsImages?: boolean;
227
+ /** Current agent ID (used to clear images on agent switch) */
228
+ agentId: string;
229
+ // Controlled component props (for broadcast commands)
230
+ /** Current input text value */
231
+ inputValue: string;
232
+ /** Callback when input text changes */
233
+ onInputChange: (value: string) => void;
234
+ /** Currently attached files (images and non-image files) */
235
+ attachedFiles: AttachedFile[];
236
+ /** Callback when attached files change */
237
+ onAttachedFilesChange: (files: AttachedFile[]) => void;
238
+ /** Error information to display as overlay */
239
+ errorInfo: ErrorInfo | null;
240
+ /** Callback to clear the error */
241
+ onClearError: () => void;
242
+ /** Agent update notification (version update or migration) */
243
+ agentUpdateNotification: AgentUpdateNotification | null;
244
+ /** Callback to dismiss the agent update notification */
245
+ onClearAgentUpdate: () => void;
246
+ /** Gemini CLI deprecation notice (shown while Gemini agent is selected) */
247
+ geminiNotice: AgentUpdateNotification | null;
248
+ /** Callback to dismiss the Gemini notice */
249
+ onClearGeminiNotice: () => void;
250
+ /** Messages array for input history navigation */
251
+ messages: ChatMessage[];
252
+ }
253
+
254
+ /**
255
+ * Input component for the chat view.
256
+ *
257
+ * Handles:
258
+ * - Text input with auto-resize
259
+ * - Mention dropdown (@-mentions)
260
+ * - Slash command dropdown (/-commands)
261
+ * - Auto-mention badge
262
+ * - Hint overlay for slash commands
263
+ * - Send/stop button
264
+ * - Keyboard navigation
265
+ */
266
+ export function InputArea({
267
+ isSending,
268
+ isSessionReady,
269
+ isRestoringSession,
270
+ agentLabel,
271
+ availableCommands,
272
+ autoMentionEnabled,
273
+ restoredMessage,
274
+ suggestions,
275
+ plugin,
276
+ view,
277
+ onSendMessage,
278
+ onStopGeneration,
279
+ onRestoredMessageConsumed,
280
+ modes,
281
+ onModeChange,
282
+ models,
283
+ onModelChange,
284
+ configOptions,
285
+ onConfigOptionChange,
286
+ usage,
287
+ supportsImages = false,
288
+ agentId,
289
+ // Controlled component props
290
+ inputValue,
291
+ onInputChange,
292
+ attachedFiles,
293
+ onAttachedFilesChange,
294
+ // Error overlay props
295
+ errorInfo,
296
+ onClearError,
297
+ // Agent update notification props
298
+ agentUpdateNotification,
299
+ onClearAgentUpdate,
300
+ // Gemini CLI deprecation notice props
301
+ geminiNotice,
302
+ onClearGeminiNotice,
303
+ // Input history
304
+ messages,
305
+ }: InputAreaProps) {
306
+ const { mentions, commands: slashCommands } = suggestions;
307
+ const logger = getLogger();
308
+ const settings = useSettings(plugin);
309
+ const showEmojis = plugin.settings.displaySettings.showEmojis;
310
+
311
+ // Unofficial Obsidian API (see src/types/obsidian-internals.d.ts)
312
+ const obsidianSpellcheck =
313
+ (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? true;
314
+
315
+ // Local state (hint and command are still local - not needed for broadcast)
316
+ const [hintText, setHintText] = useState<string | null>(null);
317
+ const [commandText, setCommandText] = useState<string>("");
318
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
319
+
320
+ const { handleHistoryKeyDown, resetHistory } = useInputHistory(
321
+ messages,
322
+ onInputChange,
323
+ );
324
+
325
+ // Refs
326
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
327
+ const dragCounterRef = useRef(0);
328
+
329
+ // Clear attached files when agent changes
330
+ useEffect(() => {
331
+ onAttachedFilesChange([]);
332
+ }, [agentId, onAttachedFilesChange]);
333
+
334
+ /**
335
+ * Add multiple attachments at once with limit enforcement.
336
+ * Single state update avoids stale closure issues.
337
+ */
338
+ const addAttachments = useCallback(
339
+ (newFiles: AttachedFile[]) => {
340
+ if (newFiles.length === 0) return;
341
+ const remaining = MAX_ATTACHMENT_COUNT - attachedFiles.length;
342
+ if (remaining <= 0) {
343
+ new Notice(
344
+ `[Agent Client] Maximum ${MAX_ATTACHMENT_COUNT} attachments allowed`,
345
+ );
346
+ return;
347
+ }
348
+ const toAdd = newFiles.slice(0, remaining);
349
+ if (toAdd.length < newFiles.length) {
350
+ new Notice(
351
+ `[Agent Client] Maximum ${MAX_ATTACHMENT_COUNT} attachments allowed`,
352
+ );
353
+ }
354
+ onAttachedFilesChange([...attachedFiles, ...toAdd]);
355
+ },
356
+ [attachedFiles, onAttachedFilesChange],
357
+ );
358
+
359
+ /**
360
+ * Remove a file from the attached files list.
361
+ */
362
+ const removeFile = useCallback(
363
+ (id: string) => {
364
+ onAttachedFilesChange(attachedFiles.filter((f) => f.id !== id));
365
+ },
366
+ [attachedFiles, onAttachedFilesChange],
367
+ );
368
+
369
+ /**
370
+ * Convert a File to Base64 string.
371
+ */
372
+ const fileToBase64 = useCallback(async (file: File): Promise<string> => {
373
+ return new Promise((resolve, reject) => {
374
+ const reader = new FileReader();
375
+ reader.onload = () => {
376
+ const result = reader.result as string;
377
+ // Extract base64 part from "data:image/png;base64,..."
378
+ const base64 = result.split(",")[1];
379
+ resolve(base64);
380
+ };
381
+ reader.onerror = reject;
382
+ reader.readAsDataURL(file);
383
+ });
384
+ }, []);
385
+
386
+ /**
387
+ * Convert image files to Base64 AttachedFile objects.
388
+ * Returns the converted attachments without updating state.
389
+ */
390
+ const convertImagesToAttachments = useCallback(
391
+ async (files: File[]): Promise<AttachedFile[]> => {
392
+ const result: AttachedFile[] = [];
393
+ for (const file of files) {
394
+ if (file.size > MAX_IMAGE_SIZE_BYTES) {
395
+ new Notice(
396
+ `[Agent Client] Image too large (max ${MAX_IMAGE_SIZE_MB}MB)`,
397
+ );
398
+ continue;
399
+ }
400
+ try {
401
+ const base64 = await fileToBase64(file);
402
+ result.push({
403
+ id: crypto.randomUUID(),
404
+ kind: "image",
405
+ data: base64,
406
+ mimeType: file.type,
407
+ });
408
+ } catch (error) {
409
+ console.error("Failed to convert image:", error);
410
+ new Notice("[Agent Client] Failed to attach image");
411
+ }
412
+ }
413
+ return result;
414
+ },
415
+ [fileToBase64],
416
+ );
417
+
418
+ /**
419
+ * Convert files to resource_link AttachedFile objects.
420
+ * Returns the converted attachments without updating state.
421
+ */
422
+ const convertFilesToAttachments = useCallback(
423
+ (files: File[]): AttachedFile[] => {
424
+ // Get file path via Electron's webUtils API (File.path was removed in Electron 32)
425
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- electron is a runtime-only module provided by Obsidian's host environment
426
+ const { webUtils } = require("electron") as {
427
+ webUtils: { getPathForFile: (file: File) => string };
428
+ };
429
+ const result: AttachedFile[] = [];
430
+ for (const file of files) {
431
+ const filePath = webUtils.getPathForFile(file);
432
+ if (!filePath) {
433
+ new Notice("[Agent Client] Could not determine file path");
434
+ continue;
435
+ }
436
+ result.push({
437
+ id: crypto.randomUUID(),
438
+ kind: "file",
439
+ mimeType: file.type || "application/octet-stream",
440
+ name: file.name,
441
+ path: filePath,
442
+ size: file.size,
443
+ });
444
+ }
445
+ return result;
446
+ },
447
+ [],
448
+ );
449
+
450
+ /**
451
+ * Handle paste event for file attachment.
452
+ * Images are embedded as Base64 if agent supports it, otherwise sent as resource_link.
453
+ * Non-image files are sent as resource_link.
454
+ */
455
+ const handlePaste = useCallback(
456
+ async (e: React.ClipboardEvent) => {
457
+ const items = e.clipboardData?.items;
458
+ if (!items) return;
459
+
460
+ // Extract files from clipboard, split by type
461
+ const imageFiles: File[] = [];
462
+ const nonImageFiles: File[] = [];
463
+
464
+ for (const item of Array.from(items)) {
465
+ if (item.kind !== "file") continue;
466
+ const file = item.getAsFile();
467
+ if (!file) continue;
468
+
469
+ if (
470
+ SUPPORTED_IMAGE_TYPES.includes(
471
+ item.type as SupportedImageType,
472
+ )
473
+ ) {
474
+ imageFiles.push(file);
475
+ } else {
476
+ nonImageFiles.push(file);
477
+ }
478
+ }
479
+
480
+ if (imageFiles.length === 0 && nonImageFiles.length === 0) return;
481
+
482
+ e.preventDefault();
483
+
484
+ const newAttachments: AttachedFile[] = [];
485
+
486
+ if (imageFiles.length > 0) {
487
+ if (supportsImages) {
488
+ newAttachments.push(
489
+ ...(await convertImagesToAttachments(imageFiles)),
490
+ );
491
+ } else {
492
+ // Try resource_link fallback (works for files copied from Finder, not for screenshots)
493
+ const converted = convertFilesToAttachments(imageFiles);
494
+ if (converted.length > 0) {
495
+ newAttachments.push(...converted);
496
+ } else {
497
+ new Notice(
498
+ "[Agent Client] This agent does not support image paste. Try drag & drop instead.",
499
+ );
500
+ }
501
+ }
502
+ }
503
+
504
+ if (nonImageFiles.length > 0) {
505
+ newAttachments.push(
506
+ ...convertFilesToAttachments(nonImageFiles),
507
+ );
508
+ }
509
+
510
+ addAttachments(newAttachments);
511
+ },
512
+ [
513
+ supportsImages,
514
+ convertImagesToAttachments,
515
+ convertFilesToAttachments,
516
+ addAttachments,
517
+ ],
518
+ );
519
+
520
+ /**
521
+ * Handle drag over event to allow drop.
522
+ */
523
+ const handleDragOver = useCallback((e: React.DragEvent) => {
524
+ if (e.dataTransfer?.types.includes("Files")) {
525
+ e.preventDefault();
526
+ e.dataTransfer.dropEffect = "copy";
527
+ }
528
+ }, []);
529
+
530
+ /**
531
+ * Handle drag enter event for visual feedback.
532
+ * Uses counter to handle child element enter/leave correctly.
533
+ */
534
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
535
+ if (e.dataTransfer?.types.includes("Files")) {
536
+ e.preventDefault();
537
+ dragCounterRef.current++;
538
+ if (dragCounterRef.current === 1) {
539
+ setIsDraggingOver(true);
540
+ }
541
+ }
542
+ }, []);
543
+
544
+ /**
545
+ * Handle drag leave event to reset visual feedback.
546
+ */
547
+ const handleDragLeave = useCallback((_e: React.DragEvent) => {
548
+ dragCounterRef.current--;
549
+ if (dragCounterRef.current === 0) {
550
+ setIsDraggingOver(false);
551
+ }
552
+ }, []);
553
+
554
+ /**
555
+ * Handle drop event for file attachments.
556
+ * Images are embedded as Base64 if agent supports it, otherwise sent as resource_link.
557
+ * Non-image files are always sent as resource_link.
558
+ */
559
+ const handleDrop = useCallback(
560
+ async (e: React.DragEvent) => {
561
+ dragCounterRef.current = 0;
562
+ setIsDraggingOver(false);
563
+
564
+ const files = e.dataTransfer?.files;
565
+ if (!files || files.length === 0) return;
566
+
567
+ e.preventDefault();
568
+
569
+ const droppedFiles = Array.from(files);
570
+ const imageFiles: File[] = [];
571
+ const nonImageFiles: File[] = [];
572
+
573
+ for (const file of droppedFiles) {
574
+ if (
575
+ SUPPORTED_IMAGE_TYPES.includes(
576
+ file.type as SupportedImageType,
577
+ )
578
+ ) {
579
+ imageFiles.push(file);
580
+ } else if (file.type || file.name) {
581
+ nonImageFiles.push(file);
582
+ }
583
+ }
584
+
585
+ // Convert all files, then update state once
586
+ const newAttachments: AttachedFile[] = [];
587
+
588
+ if (imageFiles.length > 0) {
589
+ if (supportsImages) {
590
+ newAttachments.push(
591
+ ...(await convertImagesToAttachments(imageFiles)),
592
+ );
593
+ } else {
594
+ newAttachments.push(
595
+ ...convertFilesToAttachments(imageFiles),
596
+ );
597
+ }
598
+ }
599
+
600
+ if (nonImageFiles.length > 0) {
601
+ newAttachments.push(
602
+ ...convertFilesToAttachments(nonImageFiles),
603
+ );
604
+ }
605
+
606
+ addAttachments(newAttachments);
607
+ },
608
+ [
609
+ supportsImages,
610
+ convertImagesToAttachments,
611
+ convertFilesToAttachments,
612
+ addAttachments,
613
+ ],
614
+ );
615
+
616
+ /**
617
+ * Common logic for setting cursor position after text replacement.
618
+ */
619
+ const setTextAndFocus = useCallback(
620
+ (newText: string) => {
621
+ onInputChange(newText);
622
+
623
+ // Set cursor position to end of text
624
+ window.setTimeout(() => {
625
+ const textarea = textareaRef.current;
626
+ if (textarea) {
627
+ const cursorPos = newText.length;
628
+ textarea.selectionStart = cursorPos;
629
+ textarea.selectionEnd = cursorPos;
630
+ textarea.focus();
631
+ }
632
+ }, 0);
633
+ },
634
+ [onInputChange],
635
+ );
636
+
637
+ /**
638
+ * Handle mention selection from dropdown.
639
+ */
640
+ const selectMention = useCallback(
641
+ (suggestion: NoteMetadata) => {
642
+ const newText = mentions.selectSuggestion(inputValue, suggestion);
643
+ setTextAndFocus(newText);
644
+ },
645
+ [mentions, inputValue, setTextAndFocus],
646
+ );
647
+
648
+ /**
649
+ * Handle slash command selection from dropdown.
650
+ */
651
+ const handleSelectSlashCommand = useCallback(
652
+ (command: SlashCommand) => {
653
+ const newText = slashCommands.selectSuggestion(inputValue, command);
654
+ onInputChange(newText);
655
+
656
+ // Setup hint overlay if command has hint
657
+ if (command.hint) {
658
+ const cmdText = `/${command.name} `;
659
+ setCommandText(cmdText);
660
+ setHintText(command.hint);
661
+ } else {
662
+ // No hint - clear hint state
663
+ setHintText(null);
664
+ setCommandText("");
665
+ }
666
+
667
+ // Place cursor right after command name (before hint text)
668
+ window.setTimeout(() => {
669
+ const textarea = textareaRef.current;
670
+ if (textarea) {
671
+ const cursorPos = command.hint
672
+ ? `/${command.name} `.length
673
+ : newText.length;
674
+ textarea.selectionStart = cursorPos;
675
+ textarea.selectionEnd = cursorPos;
676
+ textarea.focus();
677
+ }
678
+ }, 0);
679
+ },
680
+ [slashCommands, inputValue, onInputChange],
681
+ );
682
+
683
+ /**
684
+ * Adjust textarea height based on content.
685
+ */
686
+ const adjustTextareaHeight = useCallback(() => {
687
+ const textarea = textareaRef.current;
688
+ if (textarea) {
689
+ // Remove previous dynamic height classes
690
+ textarea.classList.remove(
691
+ "agent-client-textarea-auto-height",
692
+ "agent-client-textarea-expanded",
693
+ );
694
+
695
+ // Temporarily use auto to measure
696
+ textarea.classList.add("agent-client-textarea-auto-height");
697
+ const scrollHeight = textarea.scrollHeight;
698
+ const minHeight = 80;
699
+ const maxHeight = 300;
700
+
701
+ // Calculate height
702
+ const calculatedHeight = Math.max(
703
+ minHeight,
704
+ Math.min(scrollHeight, maxHeight),
705
+ );
706
+
707
+ // Apply expanded class if needed
708
+ if (calculatedHeight > minHeight) {
709
+ textarea.classList.add("agent-client-textarea-expanded");
710
+ // Set CSS variable for dynamic height
711
+ textarea.style.setProperty(
712
+ "--textarea-height",
713
+ `${calculatedHeight}px`,
714
+ );
715
+ } else {
716
+ textarea.style.removeProperty("--textarea-height");
717
+ }
718
+
719
+ textarea.classList.remove("agent-client-textarea-auto-height");
720
+ }
721
+ }, []);
722
+
723
+ /**
724
+ * Handle sending or stopping based on current state.
725
+ */
726
+ const handleSendOrStop = useCallback(async () => {
727
+ if (isSending) {
728
+ await onStopGeneration();
729
+ return;
730
+ }
731
+
732
+ // Allow sending if there's text OR attachments
733
+ if (!inputValue.trim() && attachedFiles.length === 0) return;
734
+
735
+ // Save input value and files before clearing
736
+ const messageToSend = inputValue.trim();
737
+ const filesToSend =
738
+ attachedFiles.length > 0 ? [...attachedFiles] : undefined;
739
+
740
+ // Clear input, files, and hint state immediately
741
+ onInputChange("");
742
+ onAttachedFilesChange([]);
743
+ setHintText(null);
744
+ setCommandText("");
745
+ resetHistory();
746
+
747
+ await onSendMessage(messageToSend, filesToSend);
748
+ }, [
749
+ isSending,
750
+ inputValue,
751
+ attachedFiles,
752
+ onSendMessage,
753
+ onStopGeneration,
754
+ onInputChange,
755
+ onAttachedFilesChange,
756
+ resetHistory,
757
+ ]);
758
+
759
+ /**
760
+ * Handle dropdown keyboard navigation.
761
+ */
762
+ const handleDropdownKeyPress = useCallback(
763
+ (e: React.KeyboardEvent): boolean => {
764
+ const isSlashCommandActive = slashCommands.isOpen;
765
+ const isMentionActive = mentions.isOpen;
766
+
767
+ if (!isSlashCommandActive && !isMentionActive) {
768
+ return false;
769
+ }
770
+
771
+ // Arrow navigation
772
+ if (e.key === "ArrowDown") {
773
+ e.preventDefault();
774
+ if (isSlashCommandActive) {
775
+ slashCommands.navigate("down");
776
+ } else {
777
+ mentions.navigate("down");
778
+ }
779
+ return true;
780
+ }
781
+
782
+ if (e.key === "ArrowUp") {
783
+ e.preventDefault();
784
+ if (isSlashCommandActive) {
785
+ slashCommands.navigate("up");
786
+ } else {
787
+ mentions.navigate("up");
788
+ }
789
+ return true;
790
+ }
791
+
792
+ // Select item (Enter or Tab)
793
+ if (e.key === "Enter" || e.key === "Tab") {
794
+ // Skip Enter during IME composition (allow Tab to still work)
795
+ if (e.key === "Enter" && e.nativeEvent.isComposing) {
796
+ return false;
797
+ }
798
+ e.preventDefault();
799
+ if (isSlashCommandActive) {
800
+ const selectedCommand =
801
+ slashCommands.suggestions[slashCommands.selectedIndex];
802
+ if (selectedCommand) {
803
+ handleSelectSlashCommand(selectedCommand);
804
+ }
805
+ } else {
806
+ const selectedSuggestion =
807
+ mentions.suggestions[mentions.selectedIndex];
808
+ if (selectedSuggestion) {
809
+ selectMention(selectedSuggestion);
810
+ }
811
+ }
812
+ return true;
813
+ }
814
+
815
+ // Close dropdown (Escape)
816
+ if (e.key === "Escape") {
817
+ e.preventDefault();
818
+ if (isSlashCommandActive) {
819
+ slashCommands.close();
820
+ } else {
821
+ mentions.close();
822
+ }
823
+ return true;
824
+ }
825
+
826
+ return false;
827
+ },
828
+ [slashCommands, mentions, handleSelectSlashCommand, selectMention],
829
+ );
830
+
831
+ // Button disabled state - also allow sending if files are attached
832
+ const isButtonDisabled =
833
+ !isSending &&
834
+ ((inputValue.trim() === "" && attachedFiles.length === 0) ||
835
+ !isSessionReady ||
836
+ isRestoringSession);
837
+
838
+ /**
839
+ * Handle keyboard events in the textarea.
840
+ */
841
+ const handleKeyDown = useCallback(
842
+ (e: React.KeyboardEvent) => {
843
+ // Handle dropdown navigation first
844
+ if (handleDropdownKeyPress(e)) {
845
+ return;
846
+ }
847
+
848
+ // Handle input history navigation (ArrowUp/ArrowDown)
849
+ if (handleHistoryKeyDown(e, textareaRef.current)) {
850
+ return;
851
+ }
852
+
853
+ // Normal input handling - check if should send based on shortcut setting
854
+ const hasCmdCtrl = e.metaKey || e.ctrlKey;
855
+ if (
856
+ e.key === "Enter" &&
857
+ (!e.nativeEvent.isComposing || hasCmdCtrl)
858
+ ) {
859
+ const shouldSend =
860
+ settings.sendMessageShortcut === "enter"
861
+ ? !e.shiftKey // Enter mode: send unless Shift is pressed
862
+ : hasCmdCtrl; // Cmd+Enter mode: send only with Cmd/Ctrl
863
+
864
+ if (shouldSend) {
865
+ e.preventDefault();
866
+ if (!isButtonDisabled && !isSending) {
867
+ void handleSendOrStop();
868
+ }
869
+ }
870
+ // If not shouldSend, allow default behavior (newline)
871
+ }
872
+ },
873
+ [
874
+ handleDropdownKeyPress,
875
+ handleHistoryKeyDown,
876
+ isSending,
877
+ isButtonDisabled,
878
+ handleSendOrStop,
879
+ settings.sendMessageShortcut,
880
+ ],
881
+ );
882
+
883
+ /**
884
+ * Handle input changes in the textarea.
885
+ */
886
+ const handleInputChange = useCallback(
887
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
888
+ const newValue = e.target.value;
889
+ const cursorPosition = e.target.selectionStart || 0;
890
+
891
+ onInputChange(newValue);
892
+
893
+ // Hide hint overlay when user modifies the input
894
+ if (hintText) {
895
+ const expectedText = commandText + hintText;
896
+ if (newValue !== expectedText) {
897
+ setHintText(null);
898
+ setCommandText("");
899
+ }
900
+ }
901
+
902
+ // Update mention suggestions
903
+ void mentions.updateSuggestions(newValue, cursorPosition);
904
+
905
+ // Update slash command suggestions
906
+ slashCommands.updateSuggestions(newValue, cursorPosition);
907
+ },
908
+ [logger, hintText, commandText, mentions, slashCommands, onInputChange],
909
+ );
910
+
911
+ // Adjust textarea height when input changes
912
+ useEffect(() => {
913
+ adjustTextareaHeight();
914
+ }, [inputValue, adjustTextareaHeight]);
915
+
916
+ // Auto-focus textarea on mount
917
+ useEffect(() => {
918
+ window.setTimeout(() => {
919
+ if (textareaRef.current) {
920
+ textareaRef.current.focus();
921
+ }
922
+ }, 0);
923
+ }, []);
924
+
925
+ // Restore message when provided (e.g., after cancellation)
926
+ // Only restore if input is empty to avoid overwriting user's new input
927
+ useEffect(() => {
928
+ if (restoredMessage) {
929
+ if (!inputValue.trim()) {
930
+ onInputChange(restoredMessage);
931
+ // Focus and place cursor at end
932
+ window.setTimeout(() => {
933
+ if (textareaRef.current) {
934
+ textareaRef.current.focus();
935
+ textareaRef.current.selectionStart =
936
+ restoredMessage.length;
937
+ textareaRef.current.selectionEnd =
938
+ restoredMessage.length;
939
+ }
940
+ }, 0);
941
+ }
942
+ onRestoredMessageConsumed();
943
+ }
944
+ }, [restoredMessage, onRestoredMessageConsumed, inputValue, onInputChange]);
945
+
946
+ // Placeholder text
947
+ const placeholder = `Message ${agentLabel} - @ to mention notes${availableCommands.length > 0 ? ", / for commands" : ""}`;
948
+
949
+ return (
950
+ <div className="agent-client-chat-input-container">
951
+ {/* Error Overlay - displayed above input */}
952
+ {errorInfo && (
953
+ <ErrorBanner
954
+ errorInfo={errorInfo}
955
+ onClose={onClearError}
956
+ showEmojis={showEmojis}
957
+ view={view}
958
+ />
959
+ )}
960
+
961
+ {/* Agent Update Notification - hidden when error is showing */}
962
+ {!errorInfo && agentUpdateNotification && (
963
+ <ErrorBanner
964
+ errorInfo={agentUpdateNotification}
965
+ onClose={onClearAgentUpdate}
966
+ showEmojis={showEmojis}
967
+ view={view}
968
+ variant={agentUpdateNotification.variant}
969
+ />
970
+ )}
971
+
972
+ {/* Gemini CLI deprecation notice - lowest priority overlay */}
973
+ {!errorInfo && !agentUpdateNotification && geminiNotice && (
974
+ <ErrorBanner
975
+ errorInfo={geminiNotice}
976
+ onClose={onClearGeminiNotice}
977
+ showEmojis={showEmojis}
978
+ view={view}
979
+ variant={geminiNotice.variant}
980
+ />
981
+ )}
982
+
983
+ {/* Mention Dropdown */}
984
+ {mentions.isOpen && (
985
+ <SuggestionPopup
986
+ type="mention"
987
+ items={mentions.suggestions}
988
+ selectedIndex={mentions.selectedIndex}
989
+ onSelect={selectMention}
990
+ onClose={mentions.close}
991
+ />
992
+ )}
993
+
994
+ {/* Slash Command Dropdown */}
995
+ {slashCommands.isOpen && (
996
+ <SuggestionPopup
997
+ type="slash-command"
998
+ items={slashCommands.suggestions}
999
+ selectedIndex={slashCommands.selectedIndex}
1000
+ onSelect={handleSelectSlashCommand}
1001
+ onClose={slashCommands.close}
1002
+ />
1003
+ )}
1004
+
1005
+ {/* Input Box - flexbox container with border */}
1006
+ <div
1007
+ className={`agent-client-chat-input-box ${isDraggingOver ? "agent-client-dragging-over" : ""}`}
1008
+ onDragOver={handleDragOver}
1009
+ onDragEnter={handleDragEnter}
1010
+ onDragLeave={handleDragLeave}
1011
+ onDrop={(e) => void handleDrop(e)}
1012
+ >
1013
+ {/* Auto-mention Badge */}
1014
+ {autoMentionEnabled && mentions.activeNote && (
1015
+ <button
1016
+ className="agent-client-auto-mention-inline"
1017
+ onClick={() =>
1018
+ mentions.toggleAutoMention(
1019
+ !mentions.isAutoMentionDisabled,
1020
+ )
1021
+ }
1022
+ title={
1023
+ mentions.isAutoMentionDisabled
1024
+ ? "Enable auto-mention"
1025
+ : "Temporarily disable auto-mention"
1026
+ }
1027
+ >
1028
+ <span
1029
+ className={`agent-client-mention-badge ${mentions.isAutoMentionDisabled ? "agent-client-disabled" : ""}`}
1030
+ >
1031
+ @{mentions.activeNote.name}
1032
+ {mentions.activeNote.selection && (
1033
+ <span className="agent-client-selection-indicator">
1034
+ {":"}
1035
+ {mentions.activeNote.selection.from.line +
1036
+ 1}
1037
+ -{mentions.activeNote.selection.to.line + 1}
1038
+ </span>
1039
+ )}
1040
+ </span>
1041
+ <span
1042
+ className="agent-client-auto-mention-toggle-icon"
1043
+ ref={(el) => {
1044
+ if (el) {
1045
+ const iconName =
1046
+ mentions.isAutoMentionDisabled
1047
+ ? "plus"
1048
+ : "x";
1049
+ setIcon(el, iconName);
1050
+ }
1051
+ }}
1052
+ />
1053
+ </button>
1054
+ )}
1055
+
1056
+ {/* Textarea with Hint Overlay */}
1057
+ <div className="agent-client-textarea-wrapper">
1058
+ <textarea
1059
+ ref={textareaRef}
1060
+ value={inputValue}
1061
+ onChange={handleInputChange}
1062
+ onKeyDown={handleKeyDown}
1063
+ onPaste={(e) => void handlePaste(e)}
1064
+ placeholder={placeholder}
1065
+ className={`agent-client-chat-input-textarea ${autoMentionEnabled && mentions.activeNote ? "has-auto-mention" : ""}`}
1066
+ rows={1}
1067
+ spellCheck={obsidianSpellcheck}
1068
+ />
1069
+ {hintText && (
1070
+ <div
1071
+ className="agent-client-hint-overlay"
1072
+ aria-hidden="true"
1073
+ >
1074
+ <span className="agent-client-invisible">
1075
+ {commandText}
1076
+ </span>
1077
+ <span className="agent-client-hint-text">
1078
+ {hintText}
1079
+ </span>
1080
+ </div>
1081
+ )}
1082
+ </div>
1083
+
1084
+ {/* Attachment Preview Strip (images + file references) */}
1085
+ <AttachmentStrip files={attachedFiles} onRemove={removeFile} />
1086
+
1087
+ {/* Input Actions (Config Options / Mode Selector / Model Selector + Send Button) */}
1088
+ <InputToolbar
1089
+ isSending={isSending}
1090
+ isButtonDisabled={isButtonDisabled}
1091
+ hasContent={
1092
+ inputValue.trim() !== "" || attachedFiles.length > 0
1093
+ }
1094
+ onSendOrStop={() => void handleSendOrStop()}
1095
+ modes={modes}
1096
+ onModeChange={onModeChange}
1097
+ models={models}
1098
+ onModelChange={onModelChange}
1099
+ configOptions={configOptions}
1100
+ onConfigOptionChange={onConfigOptionChange}
1101
+ usage={usage}
1102
+ isSessionReady={isSessionReady}
1103
+ />
1104
+ </div>
1105
+ </div>
1106
+ );
1107
+ }