@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,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
|
+
}
|