rait-09_obsidian-agent-client/src/components/settings/AgentClientSettingTab.ts

1208 lines
36 KiB
TypeScript

import {
App,
PluginSettingTab,
Setting,
DropdownComponent,
Platform,
} from "obsidian";
import type AgentClientPlugin from "../../plugin";
import type {
CustomAgentSettings,
AgentEnvVar,
ChatViewLocation,
} from "../../plugin";
import { normalizeEnvVars } from "../../shared/settings-utils";
import {
CHAT_FONT_SIZE_MAX,
CHAT_FONT_SIZE_MIN,
parseChatFontSize,
} from "../../shared/display-settings";
export class AgentClientSettingTab extends PluginSettingTab {
plugin: AgentClientPlugin;
private agentSelector: DropdownComponent | null = null;
private unsubscribe: (() => void) | null = null;
constructor(app: App, plugin: AgentClientPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
this.agentSelector = null;
// Cleanup previous subscription if exists
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
// Documentation link
const docContainer = containerEl.createDiv({
cls: "agent-client-doc-link",
});
docContainer.createSpan({ text: "Need help? Check out the " });
docContainer.createEl("a", {
text: "documentation",
href: "https://rait-09.github.io/obsidian-agent-client/",
});
docContainer.createSpan({ text: "." });
// ─────────────────────────────────────────────────────────────────────
// Top-level settings (no header)
// ─────────────────────────────────────────────────────────────────────
this.renderAgentSelector(containerEl);
// Subscribe to settings changes to update agent dropdown
this.unsubscribe = this.plugin.settingsStore.subscribe(() => {
this.updateAgentDropdown();
});
// Also update immediately on display to sync with current settings
this.updateAgentDropdown();
new Setting(containerEl)
.setName("Node.js path")
.setDesc(
'Absolute path to Node.js executable. On macOS/Linux, use "which node", and on Windows, use "where node" to find it.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to node")
.setValue(this.plugin.settings.nodePath)
.onChange(async (value) => {
this.plugin.settings.nodePath = value.trim();
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Send message shortcut")
.setDesc(
"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).",
)
.addDropdown((dropdown) =>
dropdown
.addOption(
"enter",
"Enter to send, Shift+Enter for newline",
)
.addOption(
"cmd-enter",
"Cmd/Ctrl+Enter to send, Enter for newline",
)
.setValue(this.plugin.settings.sendMessageShortcut)
.onChange(async (value) => {
this.plugin.settings.sendMessageShortcut = value as
| "enter"
| "cmd-enter";
await this.plugin.saveSettings();
}),
);
// ─────────────────────────────────────────────────────────────────────
// Mentions
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Mentions").setHeading();
new Setting(containerEl)
.setName("Auto-mention active note")
.setDesc(
"Include the current note in your messages automatically. The agent will have access to its content without typing @notename.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoMentionActiveNote)
.onChange(async (value) => {
this.plugin.settings.autoMentionActiveNote = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Max note length")
.setDesc(
"Maximum characters per mentioned note. Notes longer than this will be truncated.",
)
.addText((text) =>
text
.setPlaceholder("10000")
.setValue(
String(
this.plugin.settings.displaySettings.maxNoteLength,
),
)
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 1) {
this.plugin.settings.displaySettings.maxNoteLength =
num;
await this.plugin.saveSettings();
}
}),
);
new Setting(containerEl)
.setName("Max selection length")
.setDesc(
"Maximum characters for text selection in auto-mention. Selections longer than this will be truncated.",
)
.addText((text) =>
text
.setPlaceholder("10000")
.setValue(
String(
this.plugin.settings.displaySettings
.maxSelectionLength,
),
)
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 1) {
this.plugin.settings.displaySettings.maxSelectionLength =
num;
await this.plugin.saveSettings();
}
}),
);
// ─────────────────────────────────────────────────────────────────────
// Display
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Display").setHeading();
new Setting(containerEl)
.setName("Chat view location")
.setDesc("Where to open new chat views")
.addDropdown((dropdown) =>
dropdown
.addOption("right-tab", "Right pane (tabs)")
.addOption("editor-tab", "Editor area (tabs)")
.addOption("editor-split", "Editor area (split)")
.setValue(this.plugin.settings.chatViewLocation)
.onChange(async (value) => {
this.plugin.settings.chatViewLocation =
value as ChatViewLocation;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Chat font size")
.setDesc(
`Adjust the font size of the chat message area (${CHAT_FONT_SIZE_MIN}-${CHAT_FONT_SIZE_MAX}px).`,
)
.addText((text) => {
const getCurrentDisplayValue = (): string => {
const currentFontSize =
this.plugin.settings.displaySettings.fontSize;
return currentFontSize === null ? "" : String(currentFontSize);
};
const persistChatFontSize = async (
fontSize: number | null,
): Promise<void> => {
if (this.plugin.settings.displaySettings.fontSize === fontSize) {
return;
}
const nextSettings = {
...this.plugin.settings,
displaySettings: {
...this.plugin.settings.displaySettings,
fontSize,
},
};
await this.plugin.saveSettingsAndNotify(nextSettings);
};
text
.setPlaceholder(
`${CHAT_FONT_SIZE_MIN}-${CHAT_FONT_SIZE_MAX}`,
)
.setValue(getCurrentDisplayValue())
.onChange(async (value) => {
if (value.trim().length === 0) {
await persistChatFontSize(null);
return;
}
const trimmedValue = value.trim();
if (!/^-?\d+$/.test(trimmedValue)) {
return;
}
const numericValue = Number.parseInt(trimmedValue, 10);
if (
numericValue < CHAT_FONT_SIZE_MIN ||
numericValue > CHAT_FONT_SIZE_MAX
) {
return;
}
const parsedFontSize = parseChatFontSize(numericValue);
if (parsedFontSize === null) {
return;
}
const hasChanged =
this.plugin.settings.displaySettings.fontSize !==
parsedFontSize;
if (hasChanged) {
await persistChatFontSize(parsedFontSize);
}
});
text.inputEl.addEventListener("blur", () => {
const currentInputValue = text.getValue();
const parsedFontSize = parseChatFontSize(currentInputValue);
if (
currentInputValue.trim().length > 0 &&
parsedFontSize === null
) {
text.setValue(getCurrentDisplayValue());
return;
}
if (parsedFontSize !== null) {
text.setValue(String(parsedFontSize));
const hasChanged =
this.plugin.settings.displaySettings.fontSize !==
parsedFontSize;
if (hasChanged) {
void persistChatFontSize(parsedFontSize);
}
return;
}
text.setValue("");
});
});
new Setting(containerEl)
.setName("Show emojis")
.setDesc(
"Display emoji icons in tool calls, thoughts, plans, and terminal blocks.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.displaySettings.showEmojis)
.onChange(async (value) => {
this.plugin.settings.displaySettings.showEmojis = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Auto-collapse long diffs")
.setDesc(
"Automatically collapse diffs that exceed the line threshold.",
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.displaySettings.autoCollapseDiffs,
)
.onChange(async (value) => {
this.plugin.settings.displaySettings.autoCollapseDiffs =
value;
await this.plugin.saveSettings();
this.display();
}),
);
if (this.plugin.settings.displaySettings.autoCollapseDiffs) {
new Setting(containerEl)
.setName("Collapse threshold")
.setDesc(
"Diffs with more lines than this will be collapsed by default.",
)
.addText((text) =>
text
.setPlaceholder("10")
.setValue(
String(
this.plugin.settings.displaySettings
.diffCollapseThreshold,
),
)
.onChange(async (value) => {
const num = parseInt(value, 10);
if (!isNaN(num) && num > 0) {
this.plugin.settings.displaySettings.diffCollapseThreshold =
num;
await this.plugin.saveSettings();
}
}),
);
}
// ─────────────────────────────────────────────────────────────────────
// Floating chat
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Floating chat").setHeading();
new Setting(containerEl)
.setName("Show floating button")
.setDesc(
"Display a floating chat button that opens a draggable chat window.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showFloatingButton)
.onChange(async (value) => {
const wasEnabled =
this.plugin.settings.showFloatingButton;
this.plugin.settings.showFloatingButton = value;
await this.plugin.saveSettings();
// Handle dynamic toggle of floating chat
if (value && !wasEnabled) {
// Turning ON: create floating chat instance
this.plugin.openNewFloatingChat();
} else if (!value && wasEnabled) {
// Turning OFF: close all floating chat instances
const instances =
this.plugin.getFloatingChatInstances();
for (const instanceId of instances) {
this.plugin.closeFloatingChat(instanceId);
}
}
}),
);
new Setting(containerEl)
.setName("Floating button image")
.setDesc(
"URL or path to an image for the floating button. Leave empty for default icon.",
)
.addText((text) =>
text
.setPlaceholder("https://example.com/avatar.png")
.setValue(this.plugin.settings.floatingButtonImage)
.onChange(async (value) => {
this.plugin.settings.floatingButtonImage = value.trim();
await this.plugin.saveSettings();
}),
);
// ─────────────────────────────────────────────────────────────────────
// Permissions
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Permissions").setHeading();
new Setting(containerEl)
.setName("Auto-allow permissions")
.setDesc(
"Automatically allow all permission requests from agents. ⚠️ Use with caution - this gives agents full access to your system.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoAllowPermissions)
.onChange(async (value) => {
this.plugin.settings.autoAllowPermissions = value;
await this.plugin.saveSettings();
}),
);
// ─────────────────────────────────────────────────────────────────────
// Windows WSL Settings (Windows only)
// ─────────────────────────────────────────────────────────────────────
if (Platform.isWin) {
new Setting(containerEl)
.setName("Windows Subsystem for Linux")
.setHeading();
new Setting(containerEl)
.setName("Enable WSL mode")
.setDesc(
"Run agents inside Windows Subsystem for Linux. Recommended for agents like Codex that don't work well in native Windows environments.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.windowsWslMode)
.onChange(async (value) => {
this.plugin.settings.windowsWslMode = value;
await this.plugin.saveSettings();
this.display(); // Refresh to show/hide distribution setting
}),
);
if (this.plugin.settings.windowsWslMode) {
new Setting(containerEl)
.setName("WSL distribution")
.setDesc(
"Specify WSL distribution name (leave empty for default). Example: Ubuntu, Debian",
)
.addText((text) =>
text
.setPlaceholder("Leave empty for default")
.setValue(
this.plugin.settings.windowsWslDistribution ||
"",
)
.onChange(async (value) => {
this.plugin.settings.windowsWslDistribution =
value.trim() || undefined;
await this.plugin.saveSettings();
}),
);
}
}
// ─────────────────────────────────────────────────────────────────────
// Agents
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Built-in agents").setHeading();
this.renderClaudeSettings(containerEl);
this.renderCodexSettings(containerEl);
this.renderGeminiSettings(containerEl);
new Setting(containerEl).setName("Custom agents").setHeading();
this.renderCustomAgents(containerEl);
// ─────────────────────────────────────────────────────────────────────
// Export
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Export").setHeading();
new Setting(containerEl)
.setName("Export folder")
.setDesc("Folder where chat exports will be saved")
.addText((text) =>
text
.setPlaceholder("Agent Client")
.setValue(this.plugin.settings.exportSettings.defaultFolder)
.onChange(async (value) => {
this.plugin.settings.exportSettings.defaultFolder =
value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Filename")
.setDesc(
"Template for exported filenames. Use {date} for date and {time} for time",
)
.addText((text) =>
text
.setPlaceholder("agent_client_{date}_{time}")
.setValue(
this.plugin.settings.exportSettings.filenameTemplate,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.filenameTemplate =
value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Frontmatter tag")
.setDesc(
"Tag to add to exported notes. Supports nested tags (e.g., projects/agent-client). Leave empty to disable.",
)
.addText((text) =>
text
.setPlaceholder("agent-client")
.setValue(
this.plugin.settings.exportSettings.frontmatterTag,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.frontmatterTag =
value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Include images")
.setDesc("Include images in exported markdown files")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.exportSettings.includeImages)
.onChange(async (value) => {
this.plugin.settings.exportSettings.includeImages =
value;
await this.plugin.saveSettings();
this.display();
}),
);
if (this.plugin.settings.exportSettings.includeImages) {
new Setting(containerEl)
.setName("Image location")
.setDesc("Where to save exported images")
.addDropdown((dropdown) =>
dropdown
.addOption(
"obsidian",
"Use Obsidian's attachment setting",
)
.addOption("custom", "Save to custom folder")
.addOption(
"base64",
"Embed as Base64 (not recommended)",
)
.setValue(
this.plugin.settings.exportSettings.imageLocation,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.imageLocation =
value as "obsidian" | "custom" | "base64";
await this.plugin.saveSettings();
this.display();
}),
);
if (
this.plugin.settings.exportSettings.imageLocation === "custom"
) {
new Setting(containerEl)
.setName("Custom image folder")
.setDesc(
"Folder path for exported images (relative to vault root)",
)
.addText((text) =>
text
.setPlaceholder("Agent Client")
.setValue(
this.plugin.settings.exportSettings
.imageCustomFolder,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.imageCustomFolder =
value;
await this.plugin.saveSettings();
}),
);
}
}
new Setting(containerEl)
.setName("Auto-export on new chat")
.setDesc(
"Automatically export the current chat when starting a new chat",
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.exportSettings.autoExportOnNewChat,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.autoExportOnNewChat =
value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Auto-export on close chat")
.setDesc(
"Automatically export the current chat when closing the chat view",
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.exportSettings
.autoExportOnCloseChat,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.autoExportOnCloseChat =
value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Open note after export")
.setDesc("Automatically open the exported note after exporting")
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.exportSettings.openFileAfterExport,
)
.onChange(async (value) => {
this.plugin.settings.exportSettings.openFileAfterExport =
value;
await this.plugin.saveSettings();
}),
);
// ─────────────────────────────────────────────────────────────────────
// Developer
// ─────────────────────────────────────────────────────────────────────
new Setting(containerEl).setName("Developer").setHeading();
new Setting(containerEl)
.setName("Debug mode")
.setDesc(
"Enable debug logging to console. Useful for development and troubleshooting.",
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.debugMode)
.onChange(async (value) => {
this.plugin.settings.debugMode = value;
await this.plugin.saveSettings();
}),
);
}
/**
* Update the agent dropdown when settings change.
* Only updates if the value is different to avoid infinite loops.
*/
private updateAgentDropdown(): void {
if (!this.agentSelector) {
return;
}
// Get latest settings from store snapshot
const settings = this.plugin.settingsStore.getSnapshot();
const currentValue = this.agentSelector.getValue();
// Only update if different to avoid triggering onChange
if (settings.defaultAgentId !== currentValue) {
this.agentSelector.setValue(settings.defaultAgentId);
}
}
/**
* Called when the settings tab is hidden.
* Clean up subscriptions to prevent memory leaks.
*/
hide(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
private renderAgentSelector(containerEl: HTMLElement) {
this.plugin.ensureDefaultAgentId();
new Setting(containerEl)
.setName("Default agent")
.setDesc("Choose which agent is used when opening a new chat view.")
.addDropdown((dropdown) => {
this.agentSelector = dropdown;
this.populateAgentDropdown(dropdown);
dropdown.setValue(this.plugin.settings.defaultAgentId);
dropdown.onChange(async (value) => {
const nextSettings = {
...this.plugin.settings,
defaultAgentId: value,
};
this.plugin.ensureDefaultAgentId();
await this.plugin.saveSettingsAndNotify(nextSettings);
});
});
}
private populateAgentDropdown(dropdown: DropdownComponent) {
dropdown.selectEl.empty();
for (const option of this.getAgentOptions()) {
dropdown.addOption(option.id, option.label);
}
}
private refreshAgentDropdown() {
if (!this.agentSelector) {
return;
}
this.populateAgentDropdown(this.agentSelector);
this.agentSelector.setValue(this.plugin.settings.defaultAgentId);
}
private getAgentOptions(): { id: string; label: string }[] {
const toOption = (id: string, displayName: string) => ({
id,
label: `${displayName} (${id})`,
});
const options: { id: string; label: string }[] = [
toOption(
this.plugin.settings.claude.id,
this.plugin.settings.claude.displayName ||
this.plugin.settings.claude.id,
),
toOption(
this.plugin.settings.codex.id,
this.plugin.settings.codex.displayName ||
this.plugin.settings.codex.id,
),
toOption(
this.plugin.settings.gemini.id,
this.plugin.settings.gemini.displayName ||
this.plugin.settings.gemini.id,
),
];
for (const agent of this.plugin.settings.customAgents) {
if (agent.id && agent.id.length > 0) {
const labelSource =
agent.displayName && agent.displayName.length > 0
? agent.displayName
: agent.id;
options.push(toOption(agent.id, labelSource));
}
}
const seen = new Set<string>();
return options.filter(({ id }) => {
if (seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
}
private renderGeminiSettings(sectionEl: HTMLElement) {
const gemini = this.plugin.settings.gemini;
new Setting(sectionEl)
.setName(gemini.displayName || "Gemini CLI")
.setHeading();
new Setting(sectionEl)
.setName("API key")
.setDesc(
"Gemini API key. Required if not logging in with a Google account. (Stored as plain text)",
)
.addText((text) => {
text.setPlaceholder("Enter your Gemini API key")
.setValue(gemini.apiKey)
.onChange(async (value) => {
this.plugin.settings.gemini.apiKey = value.trim();
await this.plugin.saveSettings();
});
text.inputEl.type = "password";
});
new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the Gemini CLI. On macOS/Linux, use "which gemini", and on Windows, use "where gemini" to find it.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to gemini")
.setValue(gemini.command)
.onChange(async (value) => {
this.plugin.settings.gemini.command = value.trim();
await this.plugin.saveSettings();
});
});
new Setting(sectionEl)
.setName("Arguments")
.setDesc(
'Enter one argument per line. Leave empty to run without arguments.(Currently, the Gemini CLI requires the "--experimental-acp" option.)',
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(this.formatArgs(gemini.args))
.onChange(async (value) => {
this.plugin.settings.gemini.args =
this.parseArgs(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
new Setting(sectionEl)
.setName("Environment variables")
.setDesc(
"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)",
)
.addTextArea((text) => {
text.setPlaceholder("GOOGLE_CLOUD_PROJECT=...")
.setValue(this.formatEnv(gemini.env))
.onChange(async (value) => {
this.plugin.settings.gemini.env = this.parseEnv(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
}
private renderClaudeSettings(sectionEl: HTMLElement) {
const claude = this.plugin.settings.claude;
new Setting(sectionEl)
.setName(claude.displayName || "Claude Code (ACP)")
.setHeading();
new Setting(sectionEl)
.setName("API key")
.setDesc(
"Anthropic API key. Required if not logging in with an Anthropic account. (Stored as plain text)",
)
.addText((text) => {
text.setPlaceholder("Enter your Anthropic API key")
.setValue(claude.apiKey)
.onChange(async (value) => {
this.plugin.settings.claude.apiKey = value.trim();
await this.plugin.saveSettings();
});
text.inputEl.type = "password";
});
new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the claude-code-acp. On macOS/Linux, use "which claude-code-acp", and on Windows, use "where claude-code-acp" to find it.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to claude-code-acp")
.setValue(claude.command)
.onChange(async (value) => {
this.plugin.settings.claude.command = value.trim();
await this.plugin.saveSettings();
});
});
new Setting(sectionEl)
.setName("Arguments")
.setDesc(
"Enter one argument per line. Leave empty to run without arguments.",
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(this.formatArgs(claude.args))
.onChange(async (value) => {
this.plugin.settings.claude.args =
this.parseArgs(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
new Setting(sectionEl)
.setName("Environment variables")
.setDesc(
"Enter KEY=VALUE pairs, one per line. ANTHROPIC_API_KEY is derived from the field above.",
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(this.formatEnv(claude.env))
.onChange(async (value) => {
this.plugin.settings.claude.env = this.parseEnv(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
}
private renderCodexSettings(sectionEl: HTMLElement) {
const codex = this.plugin.settings.codex;
new Setting(sectionEl)
.setName(codex.displayName || "Codex")
.setHeading();
new Setting(sectionEl)
.setName("API key")
.setDesc(
"OpenAI API key. Required if not logging in with an OpenAI account. (Stored as plain text)",
)
.addText((text) => {
text.setPlaceholder("Enter your OpenAI API key")
.setValue(codex.apiKey)
.onChange(async (value) => {
this.plugin.settings.codex.apiKey = value.trim();
await this.plugin.saveSettings();
});
text.inputEl.type = "password";
});
new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the codex-acp. On macOS/Linux, use "which codex-acp", and on Windows, use "where codex-acp" to find it.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to codex-acp")
.setValue(codex.command)
.onChange(async (value) => {
this.plugin.settings.codex.command = value.trim();
await this.plugin.saveSettings();
});
});
new Setting(sectionEl)
.setName("Arguments")
.setDesc(
"Enter one argument per line. Leave empty to run without arguments.",
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(this.formatArgs(codex.args))
.onChange(async (value) => {
this.plugin.settings.codex.args = this.parseArgs(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
new Setting(sectionEl)
.setName("Environment variables")
.setDesc(
"Enter KEY=VALUE pairs, one per line. OPENAI_API_KEY is derived from the field above.",
)
.addTextArea((text) => {
text.setPlaceholder("")
.setValue(this.formatEnv(codex.env))
.onChange(async (value) => {
this.plugin.settings.codex.env = this.parseEnv(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
}
private renderCustomAgents(containerEl: HTMLElement) {
if (this.plugin.settings.customAgents.length === 0) {
containerEl.createEl("p", {
text: "No custom agents configured yet.",
});
} else {
this.plugin.settings.customAgents.forEach((agent, index) => {
this.renderCustomAgent(containerEl, agent, index);
});
}
new Setting(containerEl).addButton((button) => {
button
.setButtonText("Add custom agent")
.setCta()
.onClick(async () => {
const newId = this.generateCustomAgentId();
const newDisplayName =
this.generateCustomAgentDisplayName();
this.plugin.settings.customAgents.push({
id: newId,
displayName: newDisplayName,
command: "",
args: [],
env: [],
});
this.plugin.ensureDefaultAgentId();
await this.plugin.saveSettings();
this.display();
});
});
}
private renderCustomAgent(
containerEl: HTMLElement,
agent: CustomAgentSettings,
index: number,
) {
const blockEl = containerEl.createDiv({
cls: "agent-client-custom-agent",
});
const idSetting = new Setting(blockEl)
.setName("Agent ID")
.setDesc("Unique identifier used to reference this agent.")
.addText((text) => {
text.setPlaceholder("custom-agent")
.setValue(agent.id)
.onChange(async (value) => {
const previousId =
this.plugin.settings.customAgents[index].id;
const trimmed = value.trim();
let nextId = trimmed;
if (nextId.length === 0) {
nextId = this.generateCustomAgentId();
text.setValue(nextId);
}
this.plugin.settings.customAgents[index].id = nextId;
if (
this.plugin.settings.defaultAgentId === previousId
) {
this.plugin.settings.defaultAgentId = nextId;
}
this.plugin.ensureDefaultAgentId();
await this.plugin.saveSettings();
this.refreshAgentDropdown();
});
});
idSetting.addExtraButton((button) => {
button
.setIcon("trash")
.setTooltip("Delete this agent")
.onClick(async () => {
this.plugin.settings.customAgents.splice(index, 1);
this.plugin.ensureDefaultAgentId();
await this.plugin.saveSettings();
this.display();
});
});
new Setting(blockEl)
.setName("Display name")
.setDesc("Shown in menus and headers.")
.addText((text) => {
text.setPlaceholder("Custom agent")
.setValue(agent.displayName || agent.id)
.onChange(async (value) => {
const trimmed = value.trim();
this.plugin.settings.customAgents[index].displayName =
trimmed.length > 0
? trimmed
: this.plugin.settings.customAgents[index].id;
await this.plugin.saveSettings();
this.refreshAgentDropdown();
});
});
new Setting(blockEl)
.setName("Path")
.setDesc("Absolute path to the custom agent.")
.addText((text) => {
text.setPlaceholder("Absolute path to custom agent")
.setValue(agent.command)
.onChange(async (value) => {
this.plugin.settings.customAgents[index].command =
value.trim();
await this.plugin.saveSettings();
});
});
new Setting(blockEl)
.setName("Arguments")
.setDesc(
"Enter one argument per line. Leave empty to run without arguments.",
)
.addTextArea((text) => {
text.setPlaceholder("--flag\n--another=value")
.setValue(this.formatArgs(agent.args))
.onChange(async (value) => {
this.plugin.settings.customAgents[index].args =
this.parseArgs(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
new Setting(blockEl)
.setName("Environment variables")
.setDesc(
"Enter KEY=VALUE pairs, one per line. (Stored as plain text)",
)
.addTextArea((text) => {
text.setPlaceholder("TOKEN=...")
.setValue(this.formatEnv(agent.env))
.onChange(async (value) => {
this.plugin.settings.customAgents[index].env =
this.parseEnv(value);
await this.plugin.saveSettings();
});
text.inputEl.rows = 3;
});
}
private generateCustomAgentDisplayName(): string {
const base = "Custom agent";
const existing = new Set<string>();
existing.add(
this.plugin.settings.claude.displayName ||
this.plugin.settings.claude.id,
);
existing.add(
this.plugin.settings.codex.displayName ||
this.plugin.settings.codex.id,
);
existing.add(
this.plugin.settings.gemini.displayName ||
this.plugin.settings.gemini.id,
);
for (const item of this.plugin.settings.customAgents) {
existing.add(item.displayName || item.id);
}
if (!existing.has(base)) {
return base;
}
let counter = 2;
let candidate = `${base} ${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `${base} ${counter}`;
}
return candidate;
}
// Create a readable ID for new custom agents and avoid collisions
private generateCustomAgentId(): string {
const base = "custom-agent";
const existing = new Set(
this.plugin.settings.customAgents.map((item) => item.id),
);
if (!existing.has(base)) {
return base;
}
let counter = 2;
let candidate = `${base}-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `${base}-${counter}`;
}
return candidate;
}
private formatArgs(args: string[]): string {
return args.join("\n");
}
private parseArgs(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
private formatEnv(env: AgentEnvVar[]): string {
return env
.map((entry) => `${entry.key}=${entry.value ?? ""}`)
.join("\n");
}
private parseEnv(value: string): AgentEnvVar[] {
const envVars: AgentEnvVar[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const delimiter = trimmed.indexOf("=");
if (delimiter === -1) {
continue;
}
const key = trimmed.slice(0, delimiter).trim();
const envValue = trimmed.slice(delimiter + 1).trim();
if (!key) {
continue;
}
envVars.push({ key, value: envValue });
}
return normalizeEnvVars(envVars);
}
}