@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,1337 @@
1
+ import {
2
+ App,
3
+ PluginSettingTab,
4
+ Setting,
5
+ DropdownComponent,
6
+ Platform,
7
+ } from "obsidian";
8
+ import type AgentClientPlugin from "../plugin";
9
+ import type {
10
+ CustomAgentSettings,
11
+ AgentEnvVar,
12
+ ChatViewLocation,
13
+ } from "../plugin";
14
+ import { resolveCommandPath, resolveCommandPathInWsl } from "../utils/paths";
15
+ import {
16
+ normalizeEnvVars,
17
+ CHAT_FONT_SIZE_MAX,
18
+ CHAT_FONT_SIZE_MIN,
19
+ parseChatFontSize,
20
+ } from "../services/settings-normalizer";
21
+
22
+ export class AgentClientSettingTab extends PluginSettingTab {
23
+ plugin: AgentClientPlugin;
24
+ private agentSelector: DropdownComponent | null = null;
25
+ private unsubscribe: (() => void) | null = null;
26
+
27
+ constructor(app: App, plugin: AgentClientPlugin) {
28
+ super(app, plugin);
29
+ this.plugin = plugin;
30
+ }
31
+
32
+ display(): void {
33
+ const { containerEl } = this;
34
+
35
+ containerEl.empty();
36
+ this.agentSelector = null;
37
+
38
+ // Cleanup previous subscription if exists
39
+ if (this.unsubscribe) {
40
+ this.unsubscribe();
41
+ this.unsubscribe = null;
42
+ }
43
+
44
+ // Documentation link
45
+ const docContainer = containerEl.createDiv({
46
+ cls: "agent-client-doc-link",
47
+ });
48
+ docContainer.createSpan({ text: "Need help? Check out the " });
49
+ docContainer.createEl("a", {
50
+ text: "documentation",
51
+ href: "https://rait-09.github.io/obsidian-agent-client/",
52
+ attr: { target: "_blank" },
53
+ });
54
+ docContainer.createSpan({ text: "." });
55
+
56
+ // ─────────────────────────────────────────────────────────────────────
57
+ // Top-level settings (no header)
58
+ // ─────────────────────────────────────────────────────────────────────
59
+
60
+ this.renderAgentSelector(containerEl);
61
+
62
+ // Subscribe to settings changes to update agent dropdown
63
+ this.unsubscribe = this.plugin.settingsService.subscribe(() => {
64
+ this.updateAgentDropdown();
65
+ });
66
+
67
+ // Also update immediately on display to sync with current settings
68
+ this.updateAgentDropdown();
69
+
70
+ const nodePathSetting = new Setting(containerEl)
71
+ .setName("Node.js path")
72
+ .setDesc(
73
+ "Path to Node.js. Usually leave blank. Only needed if node is in a non-standard location (enter absolute path, e.g. /usr/local/bin/node).",
74
+ )
75
+ .addText((text) => {
76
+ text.setPlaceholder("Leave blank (login shell auto-resolves)")
77
+ .setValue(this.plugin.settings.nodePath)
78
+ .onChange(async (value) => {
79
+ this.plugin.settings.nodePath = value.trim();
80
+ await this.plugin.saveSettings();
81
+ });
82
+ });
83
+ this.addAutoDetectButton(nodePathSetting, "node", async (path) => {
84
+ this.plugin.settings.nodePath = path;
85
+ await this.plugin.saveSettings();
86
+ });
87
+
88
+ new Setting(containerEl)
89
+ .setName("Send message shortcut")
90
+ .setDesc(
91
+ "Choose the keyboard shortcut to send messages. Note: If using Cmd/Ctrl+Enter, you may need to remove any hotkeys assigned to Cmd/Ctrl+Enter (Settings → Hotkeys).",
92
+ )
93
+ .addDropdown((dropdown) =>
94
+ dropdown
95
+ .addOption(
96
+ "enter",
97
+ "Enter to send, Shift+Enter for newline",
98
+ )
99
+ .addOption(
100
+ "cmd-enter",
101
+ "Cmd/Ctrl+Enter to send, Enter for newline",
102
+ )
103
+ .setValue(this.plugin.settings.sendMessageShortcut)
104
+ .onChange(async (value) => {
105
+ this.plugin.settings.sendMessageShortcut = value as
106
+ | "enter"
107
+ | "cmd-enter";
108
+ await this.plugin.saveSettings();
109
+ }),
110
+ );
111
+
112
+ // ─────────────────────────────────────────────────────────────────────
113
+ // Mentions
114
+ // ─────────────────────────────────────────────────────────────────────
115
+
116
+ new Setting(containerEl).setName("Mentions").setHeading();
117
+
118
+ new Setting(containerEl)
119
+ .setName("Auto-mention active note")
120
+ .setDesc(
121
+ "Include the current note in your messages automatically. The agent will have access to its content without typing @notename.",
122
+ )
123
+ .addToggle((toggle) =>
124
+ toggle
125
+ .setValue(this.plugin.settings.autoMentionActiveNote)
126
+ .onChange(async (value) => {
127
+ this.plugin.settings.autoMentionActiveNote = value;
128
+ await this.plugin.saveSettings();
129
+ }),
130
+ );
131
+
132
+ new Setting(containerEl)
133
+ .setName("Max note length")
134
+ .setDesc(
135
+ "Maximum characters per mentioned note. Notes longer than this will be truncated.",
136
+ )
137
+ .addText((text) =>
138
+ text
139
+ .setPlaceholder("10000")
140
+ .setValue(
141
+ String(
142
+ this.plugin.settings.displaySettings.maxNoteLength,
143
+ ),
144
+ )
145
+ .onChange(async (value) => {
146
+ const num = parseInt(value, 10);
147
+ if (!isNaN(num) && num >= 1) {
148
+ this.plugin.settings.displaySettings.maxNoteLength =
149
+ num;
150
+ await this.plugin.saveSettings();
151
+ }
152
+ }),
153
+ );
154
+
155
+ new Setting(containerEl)
156
+ .setName("Max selection length")
157
+ .setDesc(
158
+ "Maximum characters for text selection in auto-mention. Selections longer than this will be truncated.",
159
+ )
160
+ .addText((text) =>
161
+ text
162
+ .setPlaceholder("10000")
163
+ .setValue(
164
+ String(
165
+ this.plugin.settings.displaySettings
166
+ .maxSelectionLength,
167
+ ),
168
+ )
169
+ .onChange(async (value) => {
170
+ const num = parseInt(value, 10);
171
+ if (!isNaN(num) && num >= 1) {
172
+ this.plugin.settings.displaySettings.maxSelectionLength =
173
+ num;
174
+ await this.plugin.saveSettings();
175
+ }
176
+ }),
177
+ );
178
+
179
+ // ─────────────────────────────────────────────────────────────────────
180
+ // Display
181
+ // ─────────────────────────────────────────────────────────────────────
182
+
183
+ new Setting(containerEl).setName("Display").setHeading();
184
+
185
+ new Setting(containerEl)
186
+ .setName("Chat view location")
187
+ .setDesc("Where to open new chat views")
188
+ .addDropdown((dropdown) =>
189
+ dropdown
190
+ .addOption("right-tab", "Right pane (tabs)")
191
+ .addOption("right-split", "Right pane (split)")
192
+ .addOption("editor-tab", "Editor area (tabs)")
193
+ .addOption("editor-split", "Editor area (split)")
194
+ .setValue(this.plugin.settings.chatViewLocation)
195
+ .onChange(async (value) => {
196
+ this.plugin.settings.chatViewLocation =
197
+ value as ChatViewLocation;
198
+ await this.plugin.saveSettings();
199
+ }),
200
+ );
201
+
202
+ new Setting(containerEl)
203
+ .setName("Chat font size")
204
+ .setDesc(
205
+ `Adjust the font size of the chat message area (${CHAT_FONT_SIZE_MIN}-${CHAT_FONT_SIZE_MAX}px).`,
206
+ )
207
+ .addText((text) => {
208
+ const getCurrentDisplayValue = (): string => {
209
+ const currentFontSize =
210
+ this.plugin.settings.displaySettings.fontSize;
211
+ return currentFontSize === null
212
+ ? ""
213
+ : String(currentFontSize);
214
+ };
215
+
216
+ const persistChatFontSize = async (
217
+ fontSize: number | null,
218
+ ): Promise<void> => {
219
+ if (
220
+ this.plugin.settings.displaySettings.fontSize ===
221
+ fontSize
222
+ ) {
223
+ return;
224
+ }
225
+
226
+ const nextSettings = {
227
+ ...this.plugin.settings,
228
+ displaySettings: {
229
+ ...this.plugin.settings.displaySettings,
230
+ fontSize,
231
+ },
232
+ };
233
+ await this.plugin.saveSettingsAndNotify(nextSettings);
234
+ };
235
+
236
+ text.setPlaceholder(
237
+ `${CHAT_FONT_SIZE_MIN}-${CHAT_FONT_SIZE_MAX}`,
238
+ )
239
+ .setValue(getCurrentDisplayValue())
240
+ .onChange(async (value) => {
241
+ if (value.trim().length === 0) {
242
+ await persistChatFontSize(null);
243
+ return;
244
+ }
245
+
246
+ const trimmedValue = value.trim();
247
+ if (!/^-?\d+$/.test(trimmedValue)) {
248
+ return;
249
+ }
250
+
251
+ const numericValue = Number.parseInt(trimmedValue, 10);
252
+ if (
253
+ numericValue < CHAT_FONT_SIZE_MIN ||
254
+ numericValue > CHAT_FONT_SIZE_MAX
255
+ ) {
256
+ return;
257
+ }
258
+
259
+ const parsedFontSize = parseChatFontSize(numericValue);
260
+ if (parsedFontSize === null) {
261
+ return;
262
+ }
263
+
264
+ const hasChanged =
265
+ this.plugin.settings.displaySettings.fontSize !==
266
+ parsedFontSize;
267
+ if (hasChanged) {
268
+ await persistChatFontSize(parsedFontSize);
269
+ }
270
+ });
271
+
272
+ text.inputEl.addEventListener("blur", () => {
273
+ const currentInputValue = text.getValue();
274
+ const parsedFontSize = parseChatFontSize(currentInputValue);
275
+
276
+ if (
277
+ currentInputValue.trim().length > 0 &&
278
+ parsedFontSize === null
279
+ ) {
280
+ text.setValue(getCurrentDisplayValue());
281
+ return;
282
+ }
283
+
284
+ if (parsedFontSize !== null) {
285
+ text.setValue(String(parsedFontSize));
286
+ const hasChanged =
287
+ this.plugin.settings.displaySettings.fontSize !==
288
+ parsedFontSize;
289
+ if (hasChanged) {
290
+ void persistChatFontSize(parsedFontSize);
291
+ }
292
+ return;
293
+ }
294
+
295
+ text.setValue("");
296
+ });
297
+ });
298
+
299
+ new Setting(containerEl)
300
+ .setName("Show emojis")
301
+ .setDesc(
302
+ "Display emoji icons in tool calls, thoughts, plans, and terminal blocks.",
303
+ )
304
+ .addToggle((toggle) =>
305
+ toggle
306
+ .setValue(this.plugin.settings.displaySettings.showEmojis)
307
+ .onChange(async (value) => {
308
+ this.plugin.settings.displaySettings.showEmojis = value;
309
+ await this.plugin.saveSettings();
310
+ }),
311
+ );
312
+
313
+ new Setting(containerEl)
314
+ .setName("Auto-collapse long diffs")
315
+ .setDesc(
316
+ "Automatically collapse diffs that exceed the line threshold.",
317
+ )
318
+ .addToggle((toggle) =>
319
+ toggle
320
+ .setValue(
321
+ this.plugin.settings.displaySettings.autoCollapseDiffs,
322
+ )
323
+ .onChange(async (value) => {
324
+ this.plugin.settings.displaySettings.autoCollapseDiffs =
325
+ value;
326
+ await this.plugin.saveSettings();
327
+ this.display();
328
+ }),
329
+ );
330
+
331
+ if (this.plugin.settings.displaySettings.autoCollapseDiffs) {
332
+ new Setting(containerEl)
333
+ .setName("Collapse threshold")
334
+ .setDesc(
335
+ "Diffs with more lines than this will be collapsed by default.",
336
+ )
337
+ .addText((text) =>
338
+ text
339
+ .setPlaceholder("10")
340
+ .setValue(
341
+ String(
342
+ this.plugin.settings.displaySettings
343
+ .diffCollapseThreshold,
344
+ ),
345
+ )
346
+ .onChange(async (value) => {
347
+ const num = parseInt(value, 10);
348
+ if (!isNaN(num) && num > 0) {
349
+ this.plugin.settings.displaySettings.diffCollapseThreshold =
350
+ num;
351
+ await this.plugin.saveSettings();
352
+ }
353
+ }),
354
+ );
355
+ }
356
+
357
+ // ─────────────────────────────────────────────────────────────────────
358
+ // Floating chat
359
+ // ─────────────────────────────────────────────────────────────────────
360
+
361
+ new Setting(containerEl).setName("Floating chat").setHeading();
362
+
363
+ new Setting(containerEl)
364
+ .setName("Enable floating chat")
365
+ .setDesc(
366
+ "Enable the floating chat button and draggable chat windows.",
367
+ )
368
+ .addToggle((toggle) =>
369
+ toggle
370
+ .setValue(this.plugin.settings.enableFloatingChat)
371
+ .onChange(async (value) => {
372
+ const wasEnabled =
373
+ this.plugin.settings.enableFloatingChat;
374
+ await this.plugin.settingsService.updateSettings({
375
+ enableFloatingChat: value,
376
+ });
377
+
378
+ // Handle dynamic toggle of floating chat
379
+ if (value && !wasEnabled) {
380
+ // Turning ON: create floating chat instance
381
+ this.plugin.openNewFloatingChat();
382
+ } else if (!value && wasEnabled) {
383
+ // Turning OFF: close all floating chat instances
384
+ const instances =
385
+ this.plugin.getFloatingChatInstances();
386
+ for (const instanceId of instances) {
387
+ this.plugin.closeFloatingChat(instanceId);
388
+ }
389
+ }
390
+ }),
391
+ );
392
+
393
+ new Setting(containerEl)
394
+ .setName("Floating button image")
395
+ .setDesc(
396
+ "URL or path to an image for the floating button. Leave empty for default icon.",
397
+ )
398
+ .addText((text) =>
399
+ text
400
+ .setPlaceholder("https://example.com/avatar.png")
401
+ .setValue(this.plugin.settings.floatingButtonImage)
402
+ .onChange(async (value) => {
403
+ this.plugin.settings.floatingButtonImage = value.trim();
404
+ await this.plugin.saveSettings();
405
+ }),
406
+ );
407
+
408
+ // ─────────────────────────────────────────────────────────────────────
409
+ // Permissions
410
+ // ─────────────────────────────────────────────────────────────────────
411
+
412
+ new Setting(containerEl).setName("Permissions").setHeading();
413
+
414
+ new Setting(containerEl)
415
+ .setName("Auto-allow permissions")
416
+ .setDesc(
417
+ "Automatically allow all permission requests from agents. ⚠️ Use with caution - this gives agents full access to your system.",
418
+ )
419
+ .addToggle((toggle) =>
420
+ toggle
421
+ .setValue(this.plugin.settings.autoAllowPermissions)
422
+ .onChange(async (value) => {
423
+ this.plugin.settings.autoAllowPermissions = value;
424
+ await this.plugin.saveSettings();
425
+ }),
426
+ );
427
+
428
+ // ─────────────────────────────────────────────────────────────────────
429
+ // Notifications
430
+ // ─────────────────────────────────────────────────────────────────────
431
+
432
+ new Setting(containerEl).setName("Notifications").setHeading();
433
+
434
+ new Setting(containerEl)
435
+ .setName("System notifications")
436
+ .setDesc(
437
+ "Show OS notifications when the agent completes a response or requests permission. Notifications are suppressed while Obsidian is focused.",
438
+ )
439
+ .addToggle((toggle) =>
440
+ toggle
441
+ .setValue(this.plugin.settings.enableSystemNotifications)
442
+ .onChange(async (value) => {
443
+ this.plugin.settings.enableSystemNotifications = value;
444
+ await this.plugin.saveSettings();
445
+ }),
446
+ );
447
+
448
+ // ─────────────────────────────────────────────────────────────────────
449
+ // Windows WSL Settings (Windows only)
450
+ // ─────────────────────────────────────────────────────────────────────
451
+
452
+ if (Platform.isWin) {
453
+ new Setting(containerEl)
454
+ .setName("Windows Subsystem for Linux")
455
+ .setHeading();
456
+
457
+ new Setting(containerEl)
458
+ .setName("Enable WSL mode")
459
+ .setDesc(
460
+ "Run agents inside Windows Subsystem for Linux. Recommended for agents like Codex that don't work well in native Windows environments.",
461
+ )
462
+ .addToggle((toggle) =>
463
+ toggle
464
+ .setValue(this.plugin.settings.windowsWslMode)
465
+ .onChange(async (value) => {
466
+ this.plugin.settings.windowsWslMode = value;
467
+ await this.plugin.saveSettings();
468
+ this.display(); // Refresh to show/hide distribution setting
469
+ }),
470
+ );
471
+
472
+ if (this.plugin.settings.windowsWslMode) {
473
+ new Setting(containerEl)
474
+ .setName("WSL distribution")
475
+ .setDesc(
476
+ "Specify WSL distribution name (leave empty for default). Example: Ubuntu, Debian",
477
+ )
478
+ .addText((text) =>
479
+ text
480
+ .setPlaceholder("Leave empty for default")
481
+ .setValue(
482
+ this.plugin.settings.windowsWslDistribution ||
483
+ "",
484
+ )
485
+ .onChange(async (value) => {
486
+ this.plugin.settings.windowsWslDistribution =
487
+ value.trim() || undefined;
488
+ await this.plugin.saveSettings();
489
+ }),
490
+ );
491
+ }
492
+ }
493
+
494
+ // ─────────────────────────────────────────────────────────────────────
495
+ // Agents
496
+ // ─────────────────────────────────────────────────────────────────────
497
+
498
+ new Setting(containerEl).setName("Built-in agents").setHeading();
499
+
500
+ this.renderClaudeSettings(containerEl);
501
+ this.renderCodexSettings(containerEl);
502
+ this.renderGeminiSettings(containerEl);
503
+
504
+ new Setting(containerEl).setName("Custom agents").setHeading();
505
+
506
+ this.renderCustomAgents(containerEl);
507
+
508
+ // ─────────────────────────────────────────────────────────────────────
509
+ // Export
510
+ // ─────────────────────────────────────────────────────────────────────
511
+
512
+ new Setting(containerEl).setName("Export").setHeading();
513
+
514
+ new Setting(containerEl)
515
+ .setName("Export folder")
516
+ .setDesc("Folder where chat exports will be saved")
517
+ .addText((text) =>
518
+ text
519
+ .setPlaceholder("Agent Client")
520
+ .setValue(this.plugin.settings.exportSettings.defaultFolder)
521
+ .onChange(async (value) => {
522
+ this.plugin.settings.exportSettings.defaultFolder =
523
+ value;
524
+ await this.plugin.saveSettings();
525
+ }),
526
+ );
527
+
528
+ new Setting(containerEl)
529
+ .setName("Filename")
530
+ .setDesc(
531
+ "Template for exported filenames. Use {date} for date and {time} for time",
532
+ )
533
+ .addText((text) =>
534
+ text
535
+ .setPlaceholder("agent_client_{date}_{time}")
536
+ .setValue(
537
+ this.plugin.settings.exportSettings.filenameTemplate,
538
+ )
539
+ .onChange(async (value) => {
540
+ this.plugin.settings.exportSettings.filenameTemplate =
541
+ value;
542
+ await this.plugin.saveSettings();
543
+ }),
544
+ );
545
+
546
+ new Setting(containerEl)
547
+ .setName("Frontmatter tag")
548
+ .setDesc(
549
+ "Tag to add to exported notes. Supports nested tags (e.g., projects/agent-client). Leave empty to disable.",
550
+ )
551
+ .addText((text) =>
552
+ text
553
+ .setPlaceholder("agent-client")
554
+ .setValue(
555
+ this.plugin.settings.exportSettings.frontmatterTag,
556
+ )
557
+ .onChange(async (value) => {
558
+ this.plugin.settings.exportSettings.frontmatterTag =
559
+ value;
560
+ await this.plugin.saveSettings();
561
+ }),
562
+ );
563
+
564
+ new Setting(containerEl)
565
+ .setName("Include images")
566
+ .setDesc("Include images in exported markdown files")
567
+ .addToggle((toggle) =>
568
+ toggle
569
+ .setValue(this.plugin.settings.exportSettings.includeImages)
570
+ .onChange(async (value) => {
571
+ this.plugin.settings.exportSettings.includeImages =
572
+ value;
573
+ await this.plugin.saveSettings();
574
+ this.display();
575
+ }),
576
+ );
577
+
578
+ if (this.plugin.settings.exportSettings.includeImages) {
579
+ new Setting(containerEl)
580
+ .setName("Image location")
581
+ .setDesc("Where to save exported images")
582
+ .addDropdown((dropdown) =>
583
+ dropdown
584
+ .addOption(
585
+ "obsidian",
586
+ "Use Obsidian's attachment setting",
587
+ )
588
+ .addOption("custom", "Save to custom folder")
589
+ .addOption(
590
+ "base64",
591
+ "Embed as Base64 (not recommended)",
592
+ )
593
+ .setValue(
594
+ this.plugin.settings.exportSettings.imageLocation,
595
+ )
596
+ .onChange(async (value) => {
597
+ this.plugin.settings.exportSettings.imageLocation =
598
+ value as "obsidian" | "custom" | "base64";
599
+ await this.plugin.saveSettings();
600
+ this.display();
601
+ }),
602
+ );
603
+
604
+ if (
605
+ this.plugin.settings.exportSettings.imageLocation === "custom"
606
+ ) {
607
+ new Setting(containerEl)
608
+ .setName("Custom image folder")
609
+ .setDesc(
610
+ "Folder path for exported images (relative to vault root)",
611
+ )
612
+ .addText((text) =>
613
+ text
614
+ .setPlaceholder("Agent Client")
615
+ .setValue(
616
+ this.plugin.settings.exportSettings
617
+ .imageCustomFolder,
618
+ )
619
+ .onChange(async (value) => {
620
+ this.plugin.settings.exportSettings.imageCustomFolder =
621
+ value;
622
+ await this.plugin.saveSettings();
623
+ }),
624
+ );
625
+ }
626
+ }
627
+
628
+ new Setting(containerEl)
629
+ .setName("Auto-export on new chat")
630
+ .setDesc(
631
+ "Automatically export the current chat when starting a new chat",
632
+ )
633
+ .addToggle((toggle) =>
634
+ toggle
635
+ .setValue(
636
+ this.plugin.settings.exportSettings.autoExportOnNewChat,
637
+ )
638
+ .onChange(async (value) => {
639
+ this.plugin.settings.exportSettings.autoExportOnNewChat =
640
+ value;
641
+ await this.plugin.saveSettings();
642
+ }),
643
+ );
644
+
645
+ new Setting(containerEl)
646
+ .setName("Auto-export on close chat")
647
+ .setDesc(
648
+ "Automatically export the current chat when closing the chat view",
649
+ )
650
+ .addToggle((toggle) =>
651
+ toggle
652
+ .setValue(
653
+ this.plugin.settings.exportSettings
654
+ .autoExportOnCloseChat,
655
+ )
656
+ .onChange(async (value) => {
657
+ this.plugin.settings.exportSettings.autoExportOnCloseChat =
658
+ value;
659
+ await this.plugin.saveSettings();
660
+ }),
661
+ );
662
+
663
+ new Setting(containerEl)
664
+ .setName("Open note after export")
665
+ .setDesc("Automatically open the exported note after exporting")
666
+ .addToggle((toggle) =>
667
+ toggle
668
+ .setValue(
669
+ this.plugin.settings.exportSettings.openFileAfterExport,
670
+ )
671
+ .onChange(async (value) => {
672
+ this.plugin.settings.exportSettings.openFileAfterExport =
673
+ value;
674
+ await this.plugin.saveSettings();
675
+ }),
676
+ );
677
+
678
+ // ─────────────────────────────────────────────────────────────────────
679
+ // Developer
680
+ // ─────────────────────────────────────────────────────────────────────
681
+
682
+ new Setting(containerEl).setName("Developer").setHeading();
683
+
684
+ new Setting(containerEl)
685
+ .setName("Debug mode")
686
+ .setDesc(
687
+ "Enable debug logging to console. Useful for development and troubleshooting.",
688
+ )
689
+ .addToggle((toggle) =>
690
+ toggle
691
+ .setValue(this.plugin.settings.debugMode)
692
+ .onChange(async (value) => {
693
+ this.plugin.settings.debugMode = value;
694
+ await this.plugin.saveSettings();
695
+ }),
696
+ );
697
+ }
698
+
699
+ /**
700
+ * Update the agent dropdown when settings change.
701
+ * Only updates if the value is different to avoid infinite loops.
702
+ */
703
+ private updateAgentDropdown(): void {
704
+ if (!this.agentSelector) {
705
+ return;
706
+ }
707
+
708
+ // Get latest settings from store snapshot
709
+ const settings = this.plugin.settingsService.getSnapshot();
710
+ const currentValue = this.agentSelector.getValue();
711
+
712
+ // Only update if different to avoid triggering onChange
713
+ if (settings.defaultAgentId !== currentValue) {
714
+ this.agentSelector.setValue(settings.defaultAgentId);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Called when the settings tab is hidden.
720
+ * Clean up subscriptions to prevent memory leaks.
721
+ */
722
+ hide(): void {
723
+ if (this.unsubscribe) {
724
+ this.unsubscribe();
725
+ this.unsubscribe = null;
726
+ }
727
+ }
728
+
729
+ private renderAgentSelector(containerEl: HTMLElement) {
730
+ this.plugin.ensureDefaultAgentId();
731
+
732
+ new Setting(containerEl)
733
+ .setName("Default agent")
734
+ .setDesc("Choose which agent is used when opening a new chat view.")
735
+ .addDropdown((dropdown) => {
736
+ this.agentSelector = dropdown;
737
+ this.populateAgentDropdown(dropdown);
738
+ dropdown.setValue(this.plugin.settings.defaultAgentId);
739
+ dropdown.onChange(async (value) => {
740
+ const nextSettings = {
741
+ ...this.plugin.settings,
742
+ defaultAgentId: value,
743
+ };
744
+ this.plugin.ensureDefaultAgentId();
745
+ await this.plugin.saveSettingsAndNotify(nextSettings);
746
+ });
747
+ });
748
+ }
749
+
750
+ private populateAgentDropdown(dropdown: DropdownComponent) {
751
+ dropdown.selectEl.empty();
752
+ for (const option of this.getAgentOptions()) {
753
+ dropdown.addOption(option.id, option.label);
754
+ }
755
+ }
756
+
757
+ private refreshAgentDropdown() {
758
+ if (!this.agentSelector) {
759
+ return;
760
+ }
761
+ this.populateAgentDropdown(this.agentSelector);
762
+ this.agentSelector.setValue(this.plugin.settings.defaultAgentId);
763
+ }
764
+
765
+ private getAgentOptions(): { id: string; label: string }[] {
766
+ const toOption = (id: string, displayName: string) => ({
767
+ id,
768
+ label: `${displayName} (${id})`,
769
+ });
770
+ const options: { id: string; label: string }[] = [
771
+ toOption(
772
+ this.plugin.settings.claude.id,
773
+ this.plugin.settings.claude.displayName ||
774
+ this.plugin.settings.claude.id,
775
+ ),
776
+ toOption(
777
+ this.plugin.settings.codex.id,
778
+ this.plugin.settings.codex.displayName ||
779
+ this.plugin.settings.codex.id,
780
+ ),
781
+ toOption(
782
+ this.plugin.settings.gemini.id,
783
+ this.plugin.settings.gemini.displayName ||
784
+ this.plugin.settings.gemini.id,
785
+ ),
786
+ ];
787
+ for (const agent of this.plugin.settings.customAgents) {
788
+ if (agent.id && agent.id.length > 0) {
789
+ const labelSource =
790
+ agent.displayName && agent.displayName.length > 0
791
+ ? agent.displayName
792
+ : agent.id;
793
+ options.push(toOption(agent.id, labelSource));
794
+ }
795
+ }
796
+ const seen = new Set<string>();
797
+ return options.filter(({ id }) => {
798
+ if (seen.has(id)) {
799
+ return false;
800
+ }
801
+ seen.add(id);
802
+ return true;
803
+ });
804
+ }
805
+
806
+ private renderGeminiSettings(sectionEl: HTMLElement) {
807
+ const gemini = this.plugin.settings.gemini;
808
+
809
+ new Setting(sectionEl)
810
+ .setName(gemini.displayName || "Gemini CLI")
811
+ .setHeading();
812
+
813
+ new Setting(sectionEl)
814
+ .setName("API key")
815
+ .setDesc(
816
+ "Gemini API key. Required if not logging in with a Google account. (Stored as plain text)",
817
+ )
818
+ .addText((text) => {
819
+ text.setPlaceholder("Enter your Gemini API key")
820
+ .setValue(gemini.apiKey)
821
+ .onChange(async (value) => {
822
+ this.plugin.settings.gemini.apiKey = value.trim();
823
+ await this.plugin.saveSettings();
824
+ });
825
+ text.inputEl.type = "password";
826
+ });
827
+
828
+ const geminiPathSetting = new Setting(sectionEl)
829
+ .setName("Path")
830
+ .setDesc(
831
+ 'Command name or path to the Gemini CLI. Use just "gemini" to let the login shell resolve it, or enter an absolute path for a specific version.',
832
+ )
833
+ .addText((text) => {
834
+ text.setPlaceholder("gemini")
835
+ .setValue(gemini.command)
836
+ .onChange(async (value) => {
837
+ this.plugin.settings.gemini.command = value.trim();
838
+ await this.plugin.saveSettings();
839
+ });
840
+ });
841
+ this.addAutoDetectButton(geminiPathSetting, "gemini", async (path) => {
842
+ this.plugin.settings.gemini.command = path;
843
+ await this.plugin.saveSettings();
844
+ });
845
+ this.addInstallHint(sectionEl, "@google/gemini-cli");
846
+
847
+ new Setting(sectionEl)
848
+ .setName("Arguments")
849
+ .setDesc(
850
+ 'Enter one argument per line. Leave empty to run without arguments.(Currently, the Gemini CLI requires the "--experimental-acp" option.)',
851
+ )
852
+ .addTextArea((text) => {
853
+ text.setPlaceholder("")
854
+ .setValue(this.formatArgs(gemini.args))
855
+ .onChange(async (value) => {
856
+ this.plugin.settings.gemini.args =
857
+ this.parseArgs(value);
858
+ await this.plugin.saveSettings();
859
+ });
860
+ text.inputEl.rows = 3;
861
+ });
862
+
863
+ new Setting(sectionEl)
864
+ .setName("Environment variables")
865
+ .setDesc(
866
+ "Enter KEY=VALUE pairs, one per line. Required to authenticate with Vertex AI. GEMINI_API_KEY is derived from the field above.(Stored as plain text)",
867
+ )
868
+ .addTextArea((text) => {
869
+ text.setPlaceholder("GOOGLE_CLOUD_PROJECT=...")
870
+ .setValue(this.formatEnv(gemini.env))
871
+ .onChange(async (value) => {
872
+ this.plugin.settings.gemini.env = this.parseEnv(value);
873
+ await this.plugin.saveSettings();
874
+ });
875
+ text.inputEl.rows = 3;
876
+ });
877
+ }
878
+
879
+ private renderClaudeSettings(sectionEl: HTMLElement) {
880
+ const claude = this.plugin.settings.claude;
881
+
882
+ new Setting(sectionEl)
883
+ .setName(claude.displayName || "Claude Code (ACP)")
884
+ .setHeading();
885
+
886
+ new Setting(sectionEl)
887
+ .setName("API key")
888
+ .setDesc(
889
+ "Anthropic API key. Required if not logging in with an Anthropic account. (Stored as plain text)",
890
+ )
891
+ .addText((text) => {
892
+ text.setPlaceholder("Enter your Anthropic API key")
893
+ .setValue(claude.apiKey)
894
+ .onChange(async (value) => {
895
+ this.plugin.settings.claude.apiKey = value.trim();
896
+ await this.plugin.saveSettings();
897
+ });
898
+ text.inputEl.type = "password";
899
+ });
900
+
901
+ const claudePathSetting = new Setting(sectionEl)
902
+ .setName("Path")
903
+ .setDesc(
904
+ 'Command name or path to claude-agent-acp. Use just "claude-agent-acp" to let the login shell resolve it, or enter an absolute path.',
905
+ )
906
+ .addText((text) => {
907
+ text.setPlaceholder("claude-agent-acp")
908
+ .setValue(claude.command)
909
+ .onChange(async (value) => {
910
+ this.plugin.settings.claude.command = value.trim();
911
+ await this.plugin.saveSettings();
912
+ });
913
+ });
914
+ this.addAutoDetectButton(
915
+ claudePathSetting,
916
+ "claude-agent-acp",
917
+ async (path) => {
918
+ this.plugin.settings.claude.command = path;
919
+ await this.plugin.saveSettings();
920
+ },
921
+ );
922
+ this.addInstallHint(sectionEl, "@agentclientprotocol/claude-agent-acp");
923
+
924
+ new Setting(sectionEl)
925
+ .setName("Arguments")
926
+ .setDesc(
927
+ "Enter one argument per line. Leave empty to run without arguments.",
928
+ )
929
+ .addTextArea((text) => {
930
+ text.setPlaceholder("")
931
+ .setValue(this.formatArgs(claude.args))
932
+ .onChange(async (value) => {
933
+ this.plugin.settings.claude.args =
934
+ this.parseArgs(value);
935
+ await this.plugin.saveSettings();
936
+ });
937
+ text.inputEl.rows = 3;
938
+ });
939
+
940
+ new Setting(sectionEl)
941
+ .setName("Environment variables")
942
+ .setDesc(
943
+ "Enter KEY=VALUE pairs, one per line. ANTHROPIC_API_KEY is derived from the field above.",
944
+ )
945
+ .addTextArea((text) => {
946
+ text.setPlaceholder("")
947
+ .setValue(this.formatEnv(claude.env))
948
+ .onChange(async (value) => {
949
+ this.plugin.settings.claude.env = this.parseEnv(value);
950
+ await this.plugin.saveSettings();
951
+ });
952
+ text.inputEl.rows = 3;
953
+ });
954
+ }
955
+
956
+ private renderCodexSettings(sectionEl: HTMLElement) {
957
+ const codex = this.plugin.settings.codex;
958
+
959
+ new Setting(sectionEl)
960
+ .setName(codex.displayName || "Codex")
961
+ .setHeading();
962
+
963
+ new Setting(sectionEl)
964
+ .setName("API key")
965
+ .setDesc(
966
+ "OpenAI API key. Required if not logging in with an OpenAI account. (Stored as plain text)",
967
+ )
968
+ .addText((text) => {
969
+ text.setPlaceholder("Enter your OpenAI API key")
970
+ .setValue(codex.apiKey)
971
+ .onChange(async (value) => {
972
+ this.plugin.settings.codex.apiKey = value.trim();
973
+ await this.plugin.saveSettings();
974
+ });
975
+ text.inputEl.type = "password";
976
+ });
977
+
978
+ const codexPathSetting = new Setting(sectionEl)
979
+ .setName("Path")
980
+ .setDesc(
981
+ 'Command name or path to codex-acp. Use just "codex-acp" to let the login shell resolve it, or enter an absolute path.',
982
+ )
983
+ .addText((text) => {
984
+ text.setPlaceholder("codex-acp")
985
+ .setValue(codex.command)
986
+ .onChange(async (value) => {
987
+ this.plugin.settings.codex.command = value.trim();
988
+ await this.plugin.saveSettings();
989
+ });
990
+ });
991
+ this.addAutoDetectButton(
992
+ codexPathSetting,
993
+ "codex-acp",
994
+ async (path) => {
995
+ this.plugin.settings.codex.command = path;
996
+ await this.plugin.saveSettings();
997
+ },
998
+ );
999
+ this.addInstallHint(sectionEl, "@zed-industries/codex-acp");
1000
+
1001
+ new Setting(sectionEl)
1002
+ .setName("Arguments")
1003
+ .setDesc(
1004
+ "Enter one argument per line. Leave empty to run without arguments.",
1005
+ )
1006
+ .addTextArea((text) => {
1007
+ text.setPlaceholder("")
1008
+ .setValue(this.formatArgs(codex.args))
1009
+ .onChange(async (value) => {
1010
+ this.plugin.settings.codex.args = this.parseArgs(value);
1011
+ await this.plugin.saveSettings();
1012
+ });
1013
+ text.inputEl.rows = 3;
1014
+ });
1015
+
1016
+ new Setting(sectionEl)
1017
+ .setName("Environment variables")
1018
+ .setDesc(
1019
+ "Enter KEY=VALUE pairs, one per line. OPENAI_API_KEY is derived from the field above.",
1020
+ )
1021
+ .addTextArea((text) => {
1022
+ text.setPlaceholder("")
1023
+ .setValue(this.formatEnv(codex.env))
1024
+ .onChange(async (value) => {
1025
+ this.plugin.settings.codex.env = this.parseEnv(value);
1026
+ await this.plugin.saveSettings();
1027
+ });
1028
+ text.inputEl.rows = 3;
1029
+ });
1030
+ }
1031
+
1032
+ private renderCustomAgents(containerEl: HTMLElement) {
1033
+ if (this.plugin.settings.customAgents.length === 0) {
1034
+ containerEl.createEl("p", {
1035
+ text: "No custom agents configured yet.",
1036
+ });
1037
+ } else {
1038
+ this.plugin.settings.customAgents.forEach((agent, index) => {
1039
+ this.renderCustomAgent(containerEl, agent, index);
1040
+ });
1041
+ }
1042
+
1043
+ new Setting(containerEl).addButton((button) => {
1044
+ button
1045
+ .setButtonText("Add custom agent")
1046
+ .setCta()
1047
+ .onClick(async () => {
1048
+ const newId = this.generateCustomAgentId();
1049
+ const newDisplayName =
1050
+ this.generateCustomAgentDisplayName();
1051
+ this.plugin.settings.customAgents.push({
1052
+ id: newId,
1053
+ displayName: newDisplayName,
1054
+ command: "",
1055
+ args: [],
1056
+ env: [],
1057
+ });
1058
+ this.plugin.ensureDefaultAgentId();
1059
+ await this.plugin.saveSettings();
1060
+ this.display();
1061
+ });
1062
+ });
1063
+ }
1064
+
1065
+ private renderCustomAgent(
1066
+ containerEl: HTMLElement,
1067
+ agent: CustomAgentSettings,
1068
+ index: number,
1069
+ ) {
1070
+ const blockEl = containerEl.createDiv({
1071
+ cls: "agent-client-custom-agent",
1072
+ });
1073
+
1074
+ const idSetting = new Setting(blockEl)
1075
+ .setName("Agent ID")
1076
+ .setDesc("Unique identifier used to reference this agent.")
1077
+ .addText((text) => {
1078
+ text.setPlaceholder("custom-agent")
1079
+ .setValue(agent.id)
1080
+ .onChange(async (value) => {
1081
+ const previousId =
1082
+ this.plugin.settings.customAgents[index].id;
1083
+ const trimmed = value.trim();
1084
+ let nextId = trimmed;
1085
+ if (nextId.length === 0) {
1086
+ nextId = this.generateCustomAgentId();
1087
+ text.setValue(nextId);
1088
+ }
1089
+ this.plugin.settings.customAgents[index].id = nextId;
1090
+ if (
1091
+ this.plugin.settings.defaultAgentId === previousId
1092
+ ) {
1093
+ this.plugin.settings.defaultAgentId = nextId;
1094
+ }
1095
+ this.plugin.ensureDefaultAgentId();
1096
+ await this.plugin.saveSettings();
1097
+ this.refreshAgentDropdown();
1098
+ });
1099
+ });
1100
+
1101
+ idSetting.addExtraButton((button) => {
1102
+ button
1103
+ .setIcon("trash")
1104
+ .setTooltip("Delete this agent")
1105
+ .onClick(async () => {
1106
+ this.plugin.settings.customAgents.splice(index, 1);
1107
+ this.plugin.ensureDefaultAgentId();
1108
+ await this.plugin.saveSettings();
1109
+ this.display();
1110
+ });
1111
+ });
1112
+
1113
+ new Setting(blockEl)
1114
+ .setName("Display name")
1115
+ .setDesc("Shown in menus and headers.")
1116
+ .addText((text) => {
1117
+ text.setPlaceholder("Custom agent")
1118
+ .setValue(agent.displayName || agent.id)
1119
+ .onChange(async (value) => {
1120
+ const trimmed = value.trim();
1121
+ this.plugin.settings.customAgents[index].displayName =
1122
+ trimmed.length > 0
1123
+ ? trimmed
1124
+ : this.plugin.settings.customAgents[index].id;
1125
+ await this.plugin.saveSettings();
1126
+ this.refreshAgentDropdown();
1127
+ });
1128
+ });
1129
+
1130
+ new Setting(blockEl)
1131
+ .setName("Path")
1132
+ .setDesc(
1133
+ "Command name or path to the custom agent. Use just the command name to let the login shell resolve it, or enter an absolute path.",
1134
+ )
1135
+ .addText((text) => {
1136
+ text.setPlaceholder("Command name or path")
1137
+ .setValue(agent.command)
1138
+ .onChange(async (value) => {
1139
+ this.plugin.settings.customAgents[index].command =
1140
+ value.trim();
1141
+ await this.plugin.saveSettings();
1142
+ });
1143
+ });
1144
+
1145
+ new Setting(blockEl)
1146
+ .setName("Arguments")
1147
+ .setDesc(
1148
+ "Enter one argument per line. Leave empty to run without arguments.",
1149
+ )
1150
+ .addTextArea((text) => {
1151
+ text.setPlaceholder("--flag\n--another=value")
1152
+ .setValue(this.formatArgs(agent.args))
1153
+ .onChange(async (value) => {
1154
+ this.plugin.settings.customAgents[index].args =
1155
+ this.parseArgs(value);
1156
+ await this.plugin.saveSettings();
1157
+ });
1158
+ text.inputEl.rows = 3;
1159
+ });
1160
+
1161
+ new Setting(blockEl)
1162
+ .setName("Environment variables")
1163
+ .setDesc(
1164
+ "Enter KEY=VALUE pairs, one per line. (Stored as plain text)",
1165
+ )
1166
+ .addTextArea((text) => {
1167
+ text.setPlaceholder("TOKEN=...")
1168
+ .setValue(this.formatEnv(agent.env))
1169
+ .onChange(async (value) => {
1170
+ this.plugin.settings.customAgents[index].env =
1171
+ this.parseEnv(value);
1172
+ await this.plugin.saveSettings();
1173
+ });
1174
+ text.inputEl.rows = 3;
1175
+ });
1176
+ }
1177
+
1178
+ private generateCustomAgentDisplayName(): string {
1179
+ const base = "Custom agent";
1180
+ const existing = new Set<string>();
1181
+ existing.add(
1182
+ this.plugin.settings.claude.displayName ||
1183
+ this.plugin.settings.claude.id,
1184
+ );
1185
+ existing.add(
1186
+ this.plugin.settings.codex.displayName ||
1187
+ this.plugin.settings.codex.id,
1188
+ );
1189
+ existing.add(
1190
+ this.plugin.settings.gemini.displayName ||
1191
+ this.plugin.settings.gemini.id,
1192
+ );
1193
+ for (const item of this.plugin.settings.customAgents) {
1194
+ existing.add(item.displayName || item.id);
1195
+ }
1196
+ if (!existing.has(base)) {
1197
+ return base;
1198
+ }
1199
+ let counter = 2;
1200
+ let candidate = `${base} ${counter}`;
1201
+ while (existing.has(candidate)) {
1202
+ counter += 1;
1203
+ candidate = `${base} ${counter}`;
1204
+ }
1205
+ return candidate;
1206
+ }
1207
+
1208
+ // Create a readable ID for new custom agents and avoid collisions
1209
+ private generateCustomAgentId(): string {
1210
+ const base = "custom-agent";
1211
+ const existing = new Set(
1212
+ this.plugin.settings.customAgents.map((item) => item.id),
1213
+ );
1214
+ if (!existing.has(base)) {
1215
+ return base;
1216
+ }
1217
+ let counter = 2;
1218
+ let candidate = `${base}-${counter}`;
1219
+ while (existing.has(candidate)) {
1220
+ counter += 1;
1221
+ candidate = `${base}-${counter}`;
1222
+ }
1223
+ return candidate;
1224
+ }
1225
+
1226
+ /**
1227
+ * Renders a copyable npm install command hint below a Path setting.
1228
+ */
1229
+ private addInstallHint(containerEl: HTMLElement, npmPackage: string): void {
1230
+ const command = `npm install -g ${npmPackage}@latest`;
1231
+ const frag = createFragment();
1232
+ frag.appendText("Not installed? Run in terminal: ");
1233
+ frag.createEl("code", { text: command });
1234
+ new Setting(containerEl).setDesc(frag).addButton((btn) => {
1235
+ btn.setButtonText("Copy").onClick(() => {
1236
+ void navigator.clipboard.writeText(command).then(
1237
+ () => {
1238
+ btn.setButtonText("Copied!");
1239
+ window.setTimeout(() => {
1240
+ btn.setButtonText("Copy");
1241
+ }, 1500);
1242
+ },
1243
+ () => undefined,
1244
+ );
1245
+ });
1246
+ });
1247
+ }
1248
+
1249
+ /**
1250
+ * Shared helper: adds an "Auto-detect" button to a Path setting.
1251
+ * Calls `resolveCommandPath(commandName)` and, on success, writes the
1252
+ * resolved absolute path via `onResolved`, then re-renders the tab.
1253
+ */
1254
+ private addAutoDetectButton(
1255
+ setting: import("obsidian").Setting,
1256
+ commandName: string,
1257
+ onResolved: (path: string) => Promise<void>,
1258
+ ): void {
1259
+ setting.addButton((btn) => {
1260
+ const isWsl = Platform.isWin && this.plugin.settings.windowsWslMode;
1261
+ const lookupCmd = Platform.isWin && !isWsl ? "where" : "which";
1262
+ btn.setButtonText("Auto-detect")
1263
+ .setTooltip(
1264
+ `Run \`${lookupCmd} ${commandName}\` to find the path`,
1265
+ )
1266
+ .onClick(async () => {
1267
+ btn.setButtonText("Detecting…");
1268
+ btn.setDisabled(true);
1269
+ try {
1270
+ const found = isWsl
1271
+ ? await resolveCommandPathInWsl(
1272
+ commandName,
1273
+ this.plugin.settings
1274
+ .windowsWslDistribution || undefined,
1275
+ )
1276
+ : await resolveCommandPath(commandName);
1277
+ if (found) {
1278
+ await onResolved(found);
1279
+ this.display();
1280
+ } else {
1281
+ btn.setButtonText("Not found");
1282
+ window.setTimeout(() => {
1283
+ btn.setButtonText("Auto-detect");
1284
+ btn.setDisabled(false);
1285
+ }, 2000);
1286
+ }
1287
+ } catch {
1288
+ btn.setButtonText("Error");
1289
+ window.setTimeout(() => {
1290
+ btn.setButtonText("Auto-detect");
1291
+ btn.setDisabled(false);
1292
+ }, 2000);
1293
+ }
1294
+ });
1295
+ });
1296
+ }
1297
+
1298
+ private formatArgs(args: string[]): string {
1299
+ return args.join("\n");
1300
+ }
1301
+
1302
+ private parseArgs(value: string): string[] {
1303
+ return value
1304
+ .split(/\r?\n/)
1305
+ .map((line) => line.trim())
1306
+ .filter((line) => line.length > 0);
1307
+ }
1308
+
1309
+ private formatEnv(env: AgentEnvVar[]): string {
1310
+ return env
1311
+ .map((entry) => `${entry.key}=${entry.value ?? ""}`)
1312
+ .join("\n");
1313
+ }
1314
+
1315
+ private parseEnv(value: string): AgentEnvVar[] {
1316
+ const envVars: AgentEnvVar[] = [];
1317
+
1318
+ for (const line of value.split(/\r?\n/)) {
1319
+ const trimmed = line.trim();
1320
+ if (!trimmed) {
1321
+ continue;
1322
+ }
1323
+ const delimiter = trimmed.indexOf("=");
1324
+ if (delimiter === -1) {
1325
+ continue;
1326
+ }
1327
+ const key = trimmed.slice(0, delimiter).trim();
1328
+ const envValue = trimmed.slice(delimiter + 1).trim();
1329
+ if (!key) {
1330
+ continue;
1331
+ }
1332
+ envVars.push({ key, value: envValue });
1333
+ }
1334
+
1335
+ return normalizeEnvVars(envVars);
1336
+ }
1337
+ }