diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index 20fb88f..21b6d4c 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -1,19 +1,31 @@ -import type { App, SettingDefinitionItem } from 'obsidian'; -import { Notice, PluginSettingTab, Setting } from 'obsidian'; +import type { App, SettingDefinitionItem, SettingGroup } from 'obsidian'; +import { Notice, PluginSettingTab, requireApiVersion, Setting } from 'obsidian'; import type { ChatViewPlacement } from '../../core/types/settings'; import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n/i18n'; import type { Locale } from '../../i18n/types'; import type QoderianPlugin from '../../main'; +import { getQoderSettings, updateQoderSettings } from '../../qoder/config/settings'; import { buildNavMappingText, parseNavMappings } from './keyboard-navigation'; import { McpSettingsManager } from './ui/mcp-settings-manager'; import { + getCliPathDescription, + renderBangBashControl, + renderMcpSection, + renderPluginsSection, + renderQoderCliPathControl, renderQoderCliPathSetting, renderQoderSettingsTab, + renderSlashCommandsSection, + renderSubagentsSection, } from './ui/qoder-settings-tab'; +/** Keys whose edits affect the prompt and require a debounced service restart. */ +const PROMPT_SETTING_KEYS = new Set(['userName', 'systemPrompt', 'mediaFolder']); + export class QoderianSettingTab extends PluginSettingTab { plugin: QoderianPlugin; + private promptRestartTimer: number | null = null; constructor(app: App, plugin: QoderianPlugin) { super(app, plugin); @@ -21,43 +33,140 @@ export class QoderianSettingTab extends PluginSettingTab { } /** - * Declarative mirror of display() for Obsidian's settings search (1.13+). - * Rendering stays imperative in display(); these definitions only feed the - * search index, so every entry is a plain name/desc row grouped under the - * same headings the page shows. + * Declarative settings for Obsidian 1.13.0+. When this returns a + * non-empty array, Obsidian renders the tab from these definitions and + * never calls display(); simple values bind through getControlValue / + * setControlValue, while rows with custom validation or rich components + * use `render` callbacks that share the imperative builders with the + * pre-1.13 display() fallback below. + * + * The new-API usages below only execute on Obsidian >= 1.13 (Obsidian + * never invokes this method on older versions) and are guarded with + * requireApiVersion() so they stay compatible with the lower + * minAppVersion. */ getSettingDefinitions(): SettingDefinitionItem[] { setLocale(this.plugin.settings.locale as Locale); + const settingsBag = this.plugin.settings as unknown as Record; + const qoderSettings = getQoderSettings(settingsBag); + + const localeOptions: Record = {}; + for (const locale of getAvailableLocales()) { + localeOptions[locale] = getLocaleDisplayName(locale); + } + + const modelOptions: Record = {}; + for (const model of this.plugin.qoderServices.modelConfig.getModelOptions(settingsBag)) { + modelOptions[model.value] = model.label; + } + return [ { type: 'group', heading: t('settings.setup'), items: [ - { name: t('settings.cliPath.name'), desc: t('settings.cliPath.desc') }, + { + name: t('settings.cliPath.name'), + desc: getCliPathDescription(), + render: (setting, group) => { + let injected: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + injected = renderQoderCliPathControl(setting, host, { plugin: this.plugin }); + }); + return () => { injected?.remove(); }; + }, + }, ], }, - { name: t('settings.language.name'), desc: t('settings.language.desc') }, + { + name: t('settings.language.name'), + desc: t('settings.language.desc'), + control: { + type: 'dropdown', + key: 'locale', + options: localeOptions, + defaultValue: this.plugin.settings.locale, + validate: (value: string) => { + const locales: string[] = getAvailableLocales(); + if (!locales.includes(value)) { + return t('common.error'); + } + }, + }, + }, { type: 'group', heading: t('settings.display'), items: [ - { name: t('settings.maxTabs.name'), desc: t('settings.maxTabs.desc') }, - { name: t('settings.chatViewPlacement.name'), desc: t('settings.chatViewPlacement.desc') }, - { name: t('settings.enableAutoScroll.name'), desc: t('settings.enableAutoScroll.desc') }, - { name: t('settings.deferMathRenderingDuringStreaming.name'), desc: t('settings.deferMathRenderingDuringStreaming.desc') }, - { name: t('settings.expandFileEditsByDefault.name'), desc: t('settings.expandFileEditsByDefault.desc') }, + { + name: t('settings.maxTabs.name'), + desc: t('settings.maxTabs.desc'), + render: (setting, group) => { + let injected: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + injected = this.renderMaxTabsControl(setting, host); + }); + return () => { injected?.remove(); }; + }, + }, + { + name: t('settings.chatViewPlacement.name'), + desc: t('settings.chatViewPlacement.desc'), + render: (setting) => this.renderChatViewPlacementControl(setting), + }, + { + name: t('settings.enableAutoScroll.name'), + desc: t('settings.enableAutoScroll.desc'), + control: { + type: 'toggle', + key: 'enableAutoScroll', + defaultValue: true, + }, + }, + { + name: t('settings.deferMathRenderingDuringStreaming.name'), + desc: t('settings.deferMathRenderingDuringStreaming.desc'), + control: { + type: 'toggle', + key: 'deferMathRenderingDuringStreaming', + defaultValue: true, + }, + }, + { + name: t('settings.expandFileEditsByDefault.name'), + desc: t('settings.expandFileEditsByDefault.desc'), + control: { + type: 'toggle', + key: 'expandFileEditsByDefault', + defaultValue: false, + }, + }, ], }, { type: 'group', heading: t('settings.conversations'), items: [ - { name: t('settings.autoTitle.name'), desc: t('settings.autoTitle.desc') }, + { + name: t('settings.autoTitle.name'), + desc: t('settings.autoTitle.desc'), + control: { + type: 'toggle', + key: 'enableAutoTitleGeneration', + defaultValue: false, + }, + }, { name: t('settings.titleModel.name'), desc: t('settings.titleModel.desc'), visible: () => this.plugin.settings.enableAutoTitleGeneration, + control: { + type: 'dropdown', + key: 'titleGenerationModel', + options: modelOptions, + defaultValue: 'auto', + }, }, ], }, @@ -65,66 +174,289 @@ export class QoderianSettingTab extends PluginSettingTab { type: 'group', heading: t('settings.content'), items: [ - { name: t('settings.userName.name'), desc: t('settings.userName.desc') }, - { name: t('settings.systemPrompt.name'), desc: t('settings.systemPrompt.desc') }, - { name: t('settings.excludedTags.name'), desc: t('settings.excludedTags.desc') }, - { name: t('settings.mediaFolder.name'), desc: t('settings.mediaFolder.desc') }, + { + name: t('settings.userName.name'), + desc: t('settings.userName.desc'), + control: { + type: 'text', + key: 'userName', + placeholder: t('settings.userName.name'), + defaultValue: '', + }, + }, + { + name: t('settings.systemPrompt.name'), + desc: t('settings.systemPrompt.desc'), + control: { + type: 'textarea', + key: 'systemPrompt', + placeholder: t('settings.systemPrompt.name'), + defaultValue: '', + rows: 6, + }, + }, + { + name: t('settings.excludedTags.name'), + desc: t('settings.excludedTags.desc'), + control: { + type: 'textarea', + key: 'excludedTags', + placeholder: 'System\nprivate\ndraft', + defaultValue: '', + rows: 4, + }, + }, + { + name: t('settings.mediaFolder.name'), + desc: t('settings.mediaFolder.desc'), + control: { + type: 'text', + key: 'mediaFolder', + placeholder: 'Attachments', + defaultValue: '', + }, + }, ], }, { type: 'group', heading: t('settings.input'), items: [ - { name: t('settings.requireCommandOrControlEnterToSend.name'), desc: t('settings.requireCommandOrControlEnterToSend.desc') }, - { name: t('settings.navMappings.name'), desc: t('settings.navMappings.desc') }, + { + name: t('settings.requireCommandOrControlEnterToSend.name'), + desc: t('settings.requireCommandOrControlEnterToSend.desc'), + control: { + type: 'toggle', + key: 'requireCommandOrControlEnterToSend', + defaultValue: false, + }, + }, + { + name: t('settings.navMappings.name'), + desc: t('settings.navMappings.desc'), + render: (setting) => this.renderNavMappingsControl(setting), + }, ], }, { type: 'group', heading: t('settings.safety'), items: [ - { name: t('settings.loadUserSettings.name'), desc: t('settings.loadUserSettings.desc') }, + { + name: t('settings.loadUserSettings.name'), + desc: t('settings.loadUserSettings.desc'), + control: { + type: 'toggle', + key: 'loadUserSettings', + defaultValue: qoderSettings.loadUserSettings, + }, + }, ], }, { type: 'group', heading: t('settings.slashCommands.name'), + cls: 'qoderian-slash-commands-group', items: [ - { name: t('settings.slashCommands.commands'), desc: t('settings.slashCommands.commandsDesc') }, - { name: t('settings.slashCommands.skills'), desc: t('settings.slashCommands.skillsDesc') }, + { + name: t('settings.slashCommands.name'), + aliases: [ + t('settings.slashCommands.commands'), + t('settings.slashCommands.skills'), + ], + render: (setting, group) => { + let wrapper: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + wrapper = host.createDiv({ + cls: 'qoderian-slash-commands-container', + }); + renderSlashCommandsSection(wrapper, { plugin: this.plugin }); + }); + return () => { wrapper?.remove(); }; + }, + }, ], }, { type: 'group', heading: t('settings.subagents.name'), + cls: 'qoderian-agents-group', items: [ - { name: t('settings.subagents.name'), desc: t('settings.subagents.desc') }, + { + name: t('settings.subagents.name'), + desc: t('settings.subagents.desc'), + render: (setting, group) => { + let wrapper: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + wrapper = host.createDiv({ + cls: 'qoderian-agents-container', + }); + renderSubagentsSection(wrapper, { plugin: this.plugin }); + }); + return () => { wrapper?.remove(); }; + }, + }, ], }, { type: 'group', heading: t('settings.mcpServers.name'), + cls: 'qoderian-mcp-group', items: [ - { name: t('settings.mcpServers.name'), desc: t('settings.mcpServers.desc') }, + { + name: t('settings.mcpServers.name'), + desc: t('settings.mcpServers.desc'), + render: (setting, group) => { + let wrapper: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + wrapper = host.createDiv({ + cls: 'qoderian-mcp-container', + }); + renderMcpSection(wrapper, { + plugin: this.plugin, + renderMcpSettings: (target, storage) => { + new McpSettingsManager(target, { + app: this.plugin.app, + mcpStorage: storage, + broadcastMcpReload: async () => { + for (const view of this.plugin.getAllViews()) { + await view.getTabManager()?.broadcastToAllTabs( + (service) => service.reloadMcpServers(), + ); + } + }, + }); + }, + }); + }); + return () => { wrapper?.remove(); }; + }, + }, ], }, { type: 'group', heading: t('settings.plugins.name'), + cls: 'qoderian-plugins-group', items: [ - { name: t('settings.plugins.name'), desc: t('settings.plugins.desc') }, + { + name: t('settings.plugins.name'), + desc: t('settings.plugins.desc'), + render: (setting, group) => { + let wrapper: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + wrapper = host.createDiv({ + cls: 'qoderian-plugins-container', + }); + renderPluginsSection(wrapper, { plugin: this.plugin }); + }); + return () => { wrapper?.remove(); }; + }, + }, ], }, { type: 'group', heading: t('settings.experimental'), items: [ - { name: t('settings.enableBangBash.name'), desc: t('settings.enableBangBash.desc') }, + { + name: t('settings.enableBangBash.name'), + desc: t('settings.enableBangBash.desc'), + render: (setting, group) => { + let injected: HTMLElement | null = null; + this.deferIntoList(group, (host) => { + injected = renderBangBashControl(setting, host, { plugin: this.plugin }); + }); + return () => { injected?.remove(); }; + }, + }, ], }, ]; } + getControlValue(key: string): unknown { + if (requireApiVersion('1.13.0')) { + if (key === 'excludedTags') { + return this.plugin.settings.excludedTags.join('\n'); + } + if (key === 'loadUserSettings') { + const settingsBag = this.plugin.settings as unknown as Record; + return getQoderSettings(settingsBag).loadUserSettings; + } + if (key === 'titleGenerationModel') { + return this.plugin.settings.titleGenerationModel || 'auto'; + } + return super.getControlValue(key); + } + // Unreachable: Obsidian < 1.13 never calls getControlValue. + return undefined; + } + + setControlValue(key: string, value: unknown): void | Promise { + if (requireApiVersion('1.13.0')) { + if (key === 'excludedTags') { + this.plugin.settings.excludedTags = String(value) + .split(/\r?\n/) + .map((entry) => entry.trim().replace(/^#/, '')) + .filter((entry) => entry.length > 0); + return this.plugin.saveSettings(); + } + + if (key === 'loadUserSettings') { + const settingsBag = this.plugin.settings as unknown as Record; + updateQoderSettings(settingsBag, { loadUserSettings: Boolean(value) }); + return this.plugin.saveSettings(); + } + + if (key === 'mediaFolder') { + return super.setControlValue(key, String(value).trim()); + } + + const result = super.setControlValue(key, value); + + if (key === 'locale') { + setLocale(this.plugin.settings.locale as Locale); + this.update(); + } else if (key === 'maxTabs') { + for (const view of this.plugin.getAllViews()) { + view.refreshTabControls(); + } + } else if (key === 'enableAutoTitleGeneration') { + this.refreshDomState(); + } else if (PROMPT_SETTING_KEYS.has(key)) { + this.schedulePromptRestart(); + } + + return result; + } + // Unreachable: Obsidian < 1.13 never calls setControlValue. + } + + /** + * The declarative renderer reconciles `group.listEl` synchronously after + * every `render` callback returns, removing children it does not own. + * Deferring to a microtask lets injected content (validation messages, + * rich section managers) survive that reconciliation. The guard covers + * `listEl`, which only exists on newer Obsidian versions. + */ + private deferIntoList( + group: SettingGroup, + mount: (host: HTMLElement) => void, + ): void { + if (requireApiVersion('1.13.0')) { + const listEl = group.listEl; + if (listEl) { + queueMicrotask(() => mount(listEl)); + } + } + } + + /** + * Imperative renderer for Obsidian versions older than 1.13.0, which + * never consult getSettingDefinitions(). Kept as the documented fallback + * and sharing the same builders as the declarative render rows. + */ display(): void { const { containerEl } = this; containerEl.empty(); @@ -188,53 +520,12 @@ export class QoderianSettingTab extends PluginSettingTab { const maxTabsSetting = new Setting(container) .setName(t('settings.maxTabs.name')) .setDesc(t('settings.maxTabs.desc')); + this.renderMaxTabsControl(maxTabsSetting, container); - const maxTabsWarningEl = container.createDiv({ - cls: 'qoderian-max-tabs-warning qoderian-setting-validation qoderian-setting-validation-warning qoderian-hidden', - }); - maxTabsWarningEl.setText(t('settings.maxTabs.warning')); - - const updateMaxTabsWarning = (value: number): void => { - maxTabsWarningEl.toggleClass('qoderian-hidden', value <= 5); - }; - - maxTabsSetting.addSlider((slider) => { - slider - .setLimits(3, 10, 1) - .setValue(this.plugin.settings.maxTabs ?? 3) - .onChange(async (value) => { - this.plugin.settings.maxTabs = value; - await this.plugin.saveSettings(); - updateMaxTabsWarning(value); - for (const view of this.plugin.getAllViews()) { - view.refreshTabControls(); - } - }); - updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 3); - }); - - new Setting(container) + const placementSetting = new Setting(container) .setName(t('settings.chatViewPlacement.name')) - .setDesc(t('settings.chatViewPlacement.desc')) - .addDropdown((dropdown) => { - dropdown - .addOption('right-sidebar', t('settings.chatViewPlacement.rightSidebar')) - .addOption('left-sidebar', t('settings.chatViewPlacement.leftSidebar')) - .addOption('main-tab', t('settings.chatViewPlacement.mainTab')) - .setValue(this.plugin.settings.chatViewPlacement) - .onChange(async (value) => { - const previousPlacement = this.plugin.settings.chatViewPlacement; - try { - await this.plugin.updateChatViewPlacement(value as ChatViewPlacement); - } catch (error) { - dropdown.setValue(previousPlacement); - const message = error instanceof Error - ? error.message - : 'Could not move the Qoderian view.'; - new Notice(message); - } - }); - }); + .setDesc(t('settings.chatViewPlacement.desc')); + this.renderChatViewPlacementControl(placementSetting); new Setting(container) .setName(t('settings.enableAutoScroll.name')) @@ -381,60 +672,116 @@ export class QoderianSettingTab extends PluginSettingTab { }); }); - new Setting(container) + const navMappingsSetting = new Setting(container) .setName(t('settings.navMappings.name')) - .setDesc(t('settings.navMappings.desc')) - .addTextArea((text) => { - let pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); - let saveTimeout: number | null = null; + .setDesc(t('settings.navMappings.desc')); + this.renderNavMappingsControl(navMappingsSetting); + } - const commitValue = async (showError: boolean): Promise => { - if (saveTimeout !== null) { - window.clearTimeout(saveTimeout); - saveTimeout = null; - } + /** Builds the max tabs slider with its over-limit warning. */ + private renderMaxTabsControl(setting: Setting, warningHost: HTMLElement): HTMLElement { + const maxTabsWarningEl = warningHost.createDiv({ + cls: 'qoderian-max-tabs-warning qoderian-setting-validation qoderian-setting-validation-warning qoderian-hidden', + }); + maxTabsWarningEl.setText(t('settings.maxTabs.warning')); - const result = parseNavMappings(pendingValue); - if (!result.settings) { - if (showError) { - new Notice(`${t('common.error')}: ${result.error}`); - pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); - text.setValue(pendingValue); - } - return; - } + const updateMaxTabsWarning = (value: number): void => { + maxTabsWarningEl.toggleClass('qoderian-hidden', value <= 5); + }; - this.plugin.settings.keyboardNavigation.scrollUpKey = result.settings.scrollUp; - this.plugin.settings.keyboardNavigation.scrollDownKey = result.settings.scrollDown; - this.plugin.settings.keyboardNavigation.focusInputKey = result.settings.focusInput; + setting.addSlider((slider) => { + slider + .setLimits(3, 10, 1) + .setValue(this.plugin.settings.maxTabs ?? 3) + .onChange(async (value) => { + this.plugin.settings.maxTabs = value; await this.plugin.saveSettings(); - pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); - text.setValue(pendingValue); - }; + updateMaxTabsWarning(value); + for (const view of this.plugin.getAllViews()) { + view.refreshTabControls(); + } + }); + updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 3); + }); - const scheduleSave = (): void => { - if (saveTimeout !== null) { - window.clearTimeout(saveTimeout); + return maxTabsWarningEl; + } + + /** Builds the chat view placement dropdown with error rollback. */ + private renderChatViewPlacementControl(setting: Setting): void { + setting.addDropdown((dropdown) => { + dropdown + .addOption('right-sidebar', t('settings.chatViewPlacement.rightSidebar')) + .addOption('left-sidebar', t('settings.chatViewPlacement.leftSidebar')) + .addOption('main-tab', t('settings.chatViewPlacement.mainTab')) + .setValue(this.plugin.settings.chatViewPlacement) + .onChange(async (value) => { + const previousPlacement = this.plugin.settings.chatViewPlacement; + try { + await this.plugin.updateChatViewPlacement(value as ChatViewPlacement); + } catch (error) { + dropdown.setValue(previousPlacement); + const message = error instanceof Error + ? error.message + : 'Could not move the Qoderian view.'; + new Notice(message); } - saveTimeout = window.setTimeout(() => { - void commitValue(false); - }, 500); - }; + }); + }); + } - text - .setPlaceholder('Map w scrollup\nmap s scrolldown\nmap i focusinput') - .setValue(pendingValue) - .onChange((value) => { - pendingValue = value; - scheduleSave(); - }); + /** Builds the keyboard navigation mappings editor with debounced validation. */ + private renderNavMappingsControl(setting: Setting): void { + setting.addTextArea((text) => { + let pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + let saveTimeout: number | null = null; - text.inputEl.rows = 3; - text.inputEl.addEventListener('blur', () => { - void commitValue(true); + const commitValue = async (showError: boolean): Promise => { + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + saveTimeout = null; + } + + const result = parseNavMappings(pendingValue); + if (!result.settings) { + if (showError) { + new Notice(`${t('common.error')}: ${result.error}`); + pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + text.setValue(pendingValue); + } + return; + } + + this.plugin.settings.keyboardNavigation.scrollUpKey = result.settings.scrollUp; + this.plugin.settings.keyboardNavigation.scrollDownKey = result.settings.scrollDown; + this.plugin.settings.keyboardNavigation.focusInputKey = result.settings.focusInput; + await this.plugin.saveSettings(); + pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + text.setValue(pendingValue); + }; + + const scheduleSave = (): void => { + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + } + saveTimeout = window.setTimeout(() => { + void commitValue(false); + }, 500); + }; + + text + .setPlaceholder('Map w scrollup\nmap s scrolldown\nmap i focusinput') + .setValue(pendingValue) + .onChange((value) => { + pendingValue = value; + scheduleSave(); }); - }); + text.inputEl.rows = 3; + text.inputEl.addEventListener('blur', () => { + void commitValue(true); + }); + }); } private renderTitleModelSetting(container: HTMLElement): void { @@ -462,6 +809,17 @@ export class QoderianSettingTab extends PluginSettingTab { }); } + /** Debounced service restart so prompt edits apply after typing settles. */ + private schedulePromptRestart(): void { + if (this.promptRestartTimer !== null) { + window.clearTimeout(this.promptRestartTimer); + } + this.promptRestartTimer = window.setTimeout(() => { + this.promptRestartTimer = null; + void this.restartServiceForPromptChange(); + }, 1000); + } + private async restartServiceForPromptChange(): Promise { const view = this.plugin.getView(); const tabManager = view?.getTabManager(); diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index 691a382..bfdab6f 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -23,27 +23,53 @@ export interface QoderCliPathSettingContext { plugin: QoderianPlugin; } -/** Renders the host-specific CLI path at the top of the complete settings page. */ -export function renderQoderCliPathSetting( - container: HTMLElement, +export interface SlashCommandsSectionContext { + plugin: QoderianPlugin; +} + +export interface SubagentsSectionContext { + plugin: QoderianPlugin; +} + +export interface McpSectionContext { + plugin: QoderianPlugin; + renderMcpSettings(container: HTMLElement, storage: AppMcpStorage): void; +} + +export interface PluginsSectionContext { + plugin: QoderianPlugin; +} + +export interface BangBashSectionContext { + plugin: QoderianPlugin; +} + +/** Builds the CLI path description for the current platform. */ +export function getCliPathDescription(): string { + const platformDesc = process.platform === 'win32' + ? t('settings.cliPath.descWindows') + : t('settings.cliPath.descUnix'); + return `${t('settings.cliPath.desc')} ${platformDesc}`; +} + +/** + * Attaches the host-specific CLI path input to `setting` and its validation + * message to `validationHost`. Shared by the imperative display() fallback + * and the 1.13+ declarative render row. Returns the injected validation + * element so declarative callers can remove it on teardown. + */ +export function renderQoderCliPathControl( + setting: Setting, + validationHost: HTMLElement, context: QoderCliPathSettingContext, -): void { +): HTMLElement { const qoderWorkspace = context.plugin.qoderServices; const settingsBag = context.plugin.settings as unknown as Record; const qoderSettings = getQoderSettings(settingsBag); - new Setting(container).setName(t('settings.setup')).setHeading(); const hostnameKey = getHostnameKey(); - const platformDesc = process.platform === 'win32' - ? t('settings.cliPath.descWindows') - : t('settings.cliPath.descUnix'); - const cliPathDescription = `${t('settings.cliPath.desc')} ${platformDesc}`; - const cliPathSetting = new Setting(container) - .setName(t('settings.cliPath.name')) - .setDesc(cliPathDescription); - - const validationEl = container.createDiv({ + const validationEl = validationHost.createDiv({ cls: 'qoderian-cli-path-validation qoderian-setting-validation qoderian-setting-validation-error qoderian-hidden', }); @@ -109,7 +135,7 @@ export function renderQoderCliPathSetting( return true; }; - cliPathSetting.addText((text) => { + setting.addText((text) => { const placeholder = process.platform === 'win32' ? 'C:\\Users\\\\.local\\bin\\qodercli.exe' : '~/.local/bin/qodercli'; @@ -125,10 +151,122 @@ export function renderQoderCliPathSetting( updateCliPathValidation(currentValue, text.inputEl); }); + + return validationEl; +} + +/** Renders the "Setup" heading plus the CLI path row (imperative fallback). */ +export function renderQoderCliPathSetting( + container: HTMLElement, + context: QoderCliPathSettingContext, +): void { + new Setting(container).setName(t('settings.setup')).setHeading(); + const cliPathSetting = new Setting(container) + .setName(t('settings.cliPath.name')) + .setDesc(getCliPathDescription()); + renderQoderCliPathControl(cliPathSetting, container, context); +} + +/** Renders the slash commands/skills manager into `container`. */ +export function renderSlashCommandsSection( + container: HTMLElement, + context: SlashCommandsSectionContext, +): void { + new CommandSkillSettings( + container, + context.plugin.app, + context.plugin.qoderServices.commandCatalog, + ); +} + +/** Renders the subagents manager into `container`. */ +export function renderSubagentsSection( + container: HTMLElement, + context: SubagentsSectionContext, +): void { + const qoderWorkspace = context.plugin.qoderServices; + const settingsBag = context.plugin.settings as unknown as Record; + new AgentSettings(container, { + app: context.plugin.app, + agentCatalog: qoderWorkspace.agentCatalog, + agentStorage: qoderWorkspace.agentStorage, + modelOptions: qoderWorkspace.modelConfig.getModelOptions(settingsBag), + }); +} + +/** Renders the MCP servers manager into `container`. */ +export function renderMcpSection( + container: HTMLElement, + context: McpSectionContext, +): void { + context.renderMcpSettings(container, context.plugin.qoderServices.mcpStorage); +} + +/** Renders the plugins manager into `container`. */ +export function renderPluginsSection( + container: HTMLElement, + context: PluginsSectionContext, +): void { + const qoderWorkspace = context.plugin.qoderServices; + new PluginSettingsManager(container, { + pluginManager: qoderWorkspace.pluginManager, + agentCatalog: qoderWorkspace.agentCatalog, + restartTabs: async () => { + const view = context.plugin.getView(); + const tabManager = view?.getTabManager(); + if (!tabManager) { + return; + } + + await tabManager.broadcastToAllTabs( + async (service) => { await service.ensureReady({ force: true }); }, + ); + }, + }); +} + +/** + * Attaches the `!bash` toggle to `setting` and its validation message to + * `validationHost`. Shared by the imperative display() fallback and the + * 1.13+ declarative render row. Returns the injected validation element so + * declarative callers can remove it on teardown. + */ +export function renderBangBashControl( + setting: Setting, + validationHost: HTMLElement, + context: BangBashSectionContext, +): HTMLElement { + const settingsBag = context.plugin.settings as unknown as Record; + const qoderSettings = getQoderSettings(settingsBag); + + const validationEl = validationHost.createDiv({ + cls: 'qoderian-bang-bash-validation qoderian-setting-validation qoderian-setting-validation-error qoderian-hidden', + }); + + setting.addToggle((toggle) => + toggle + .setValue(qoderSettings.enableBangBash) + .onChange(async (value) => { + validationEl.toggleClass('qoderian-hidden', true); + if (value) { + const { findNodeExecutable, getEnhancedPath } = await import('../../../core/env/environment'); + const nodePath = findNodeExecutable(getEnhancedPath()); + if (!nodePath) { + validationEl.setText(t('settings.enableBangBash.validation.noNode')); + validationEl.toggleClass('qoderian-hidden', false); + toggle.setValue(false); + return; + } + } + updateQoderSettings(settingsBag, { enableBangBash: value }); + await context.plugin.saveSettings(); + }) + ); + + return validationEl; } export function renderQoderSettingsTab(container: HTMLElement, context: QoderSettingsTabContext): void { - const qoderWorkspace = context.plugin.qoderServices; const settingsBag = context.plugin.settings as unknown as Record; const qoderSettings = getQoderSettings(settingsBag); @@ -153,11 +291,7 @@ export function renderQoderSettingsTab(container: HTMLElement, context: QoderSet new Setting(container).setName(t('settings.slashCommands.name')).setHeading(); const slashCommandsContainer = container.createDiv({ cls: 'qoderian-slash-commands-container' }); - new CommandSkillSettings( - slashCommandsContainer, - context.plugin.app, - qoderWorkspace.commandCatalog, - ); + renderSlashCommandsSection(slashCommandsContainer, context); // --- Subagents --- @@ -170,12 +304,7 @@ export function renderQoderSettingsTab(container: HTMLElement, context: QoderSet }); const agentsContainer = container.createDiv({ cls: 'qoderian-agents-container' }); - new AgentSettings(agentsContainer, { - app: context.plugin.app, - agentCatalog: qoderWorkspace.agentCatalog, - agentStorage: qoderWorkspace.agentStorage, - modelOptions: qoderWorkspace.modelConfig.getModelOptions(settingsBag), - }); + renderSubagentsSection(agentsContainer, context); // --- MCP Servers --- @@ -188,7 +317,7 @@ export function renderQoderSettingsTab(container: HTMLElement, context: QoderSet }); const mcpContainer = container.createDiv({ cls: 'qoderian-mcp-container' }); - context.renderMcpSettings(mcpContainer, qoderWorkspace.mcpStorage); + renderMcpSection(mcpContainer, context); // --- Plugins --- @@ -201,50 +330,14 @@ export function renderQoderSettingsTab(container: HTMLElement, context: QoderSet }); const pluginsContainer = container.createDiv({ cls: 'qoderian-plugins-container' }); - new PluginSettingsManager(pluginsContainer, { - pluginManager: qoderWorkspace.pluginManager, - agentCatalog: qoderWorkspace.agentCatalog, - restartTabs: async () => { - const view = context.plugin.getView(); - const tabManager = view?.getTabManager(); - if (!tabManager) { - return; - } - - await tabManager.broadcastToAllTabs( - async (service) => { await service.ensureReady({ force: true }); }, - ); - }, - }); + renderPluginsSection(pluginsContainer, context); // --- Experimental --- new Setting(container).setName(t('settings.experimental')).setHeading(); - new Setting(container) + const bangBashSetting = new Setting(container) .setName(t('settings.enableBangBash.name')) - .setDesc(t('settings.enableBangBash.desc')) - .addToggle((toggle) => - toggle - .setValue(qoderSettings.enableBangBash) - .onChange(async (value) => { - bangBashValidationEl.toggleClass('qoderian-hidden', true); - if (value) { - const { findNodeExecutable, getEnhancedPath } = await import('../../../core/env/environment'); - const nodePath = findNodeExecutable(getEnhancedPath()); - if (!nodePath) { - bangBashValidationEl.setText(t('settings.enableBangBash.validation.noNode')); - bangBashValidationEl.toggleClass('qoderian-hidden', false); - toggle.setValue(false); - return; - } - } - updateQoderSettings(settingsBag, { enableBangBash: value }); - await context.plugin.saveSettings(); - }) - ); - - const bangBashValidationEl = container.createDiv({ - cls: 'qoderian-bang-bash-validation qoderian-setting-validation qoderian-setting-validation-error qoderian-hidden', - }); + .setDesc(t('settings.enableBangBash.desc')); + renderBangBashControl(bangBashSetting, container, context); }