diff --git a/src/settings/archived-section.tsx b/src/settings/archived-section.tsx index 70db498b..34b4cc9f 100644 --- a/src/settings/archived-section.tsx +++ b/src/settings/archived-section.tsx @@ -18,16 +18,20 @@ export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionS - {state.contentAvailable ? ( - - ) : !state.loading && state.status ? ( -

{state.status}

- ) : null} +
); } +export function ArchivedThreadsContent({ state }: { state: ArchivedThreadSectionState }): UiNode { + return state.contentAvailable ? ( + + ) : !state.loading && state.status ? ( +

{state.status}

+ ) : null; +} + function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode { return ( diff --git a/src/settings/declarative-settings.compat.ts b/src/settings/declarative-settings.compat.ts new file mode 100644 index 00000000..503ef746 --- /dev/null +++ b/src/settings/declarative-settings.compat.ts @@ -0,0 +1,39 @@ +import type { Setting, SettingGroup } from "obsidian"; + +// Obsidian 1.13 declarative-settings subset used while the plugin continues to +// compile against its 1.12 runtime baseline. Remove this compatibility surface +// when minAppVersion and the obsidian type package move to 1.13 or later. +interface DeclarativeSettingBase { + name: string; + desc?: string | DocumentFragment; + aliases?: string[]; + searchable?: boolean | (() => boolean); + visible?: boolean | (() => boolean); +} + +type DeclarativeSettingControl = + | { type: "toggle"; key: string; defaultValue?: boolean; disabled?: boolean | (() => boolean) } + | { + type: "dropdown"; + key: string; + defaultValue?: string; + options: Record; + disabled?: boolean | (() => boolean); + }; + +export type DeclarativeSettingDefinition = DeclarativeSettingBase & + ( + | { control: DeclarativeSettingControl; render?: never; action?: never } + | { render: (setting: Setting, group: SettingGroup) => undefined | (() => void); control?: never; action?: never } + | { control?: never; render?: never; action?: never } + ); + +interface DeclarativeSettingGroup { + type: "group"; + heading?: string; + cls?: string; + items?: DeclarativeSettingDefinition[]; + visible?: boolean | (() => boolean); +} + +export type DeclarativeSettingDefinitionItem = DeclarativeSettingDefinition | DeclarativeSettingGroup; diff --git a/src/settings/helper-section.tsx b/src/settings/helper-section.tsx index a7fd01a9..af51c96b 100644 --- a/src/settings/helper-section.tsx +++ b/src/settings/helper-section.tsx @@ -13,7 +13,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState }) - - void; onEffortChange: (value: ReasoningEffort | null) => void; + controlsOnly?: boolean; }): UiNode { const efforts = reasoningEffortsForSelectedModel(models, modelValue); - return ( - + const controls = ( + <> { @@ -71,6 +73,13 @@ function ModelEffortSetting({ }} options={reasoningEffortSelectOptions(efforts, effortValue)} /> + + ); + return controlsOnly ? ( + controls + ) : ( + + {controls} ); } diff --git a/src/settings/hook-section.tsx b/src/settings/hook-section.tsx index d74bb8c6..40a35a63 100644 --- a/src/settings/hook-section.tsx +++ b/src/settings/hook-section.tsx @@ -9,15 +9,19 @@ export function HookSection({ state }: { state: HookSectionState }): UiNode { return ( - {state.contentAvailable ? ( - - ) : !state.loading && state.status ? ( -

{state.status}

- ) : null} +
); } +export function HooksContent({ state }: { state: HookSectionState }): UiNode { + return state.contentAvailable ? ( + + ) : !state.loading && state.status ? ( +

{state.status}

+ ) : null; +} + function Hooks({ state }: { state: HookSectionState }): UiNode { return ( <> diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index 5ae3f0ee..242f6f3c 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -1,10 +1,16 @@ -import { type App, Notice, type Plugin, PluginSettingTab } from "obsidian"; +import { type App, Notice, type Plugin, PluginSettingTab, type Setting } from "obsidian"; +import type { ComponentChild as UiNode } from "preact"; import { DEFAULT_CODEX_PATH } from "../constants"; import type { ReasoningEffort } from "../domain/catalog/metadata"; import { listenDomEvent } from "../shared/dom/events.dom"; import { renderUiRoot, unmountUiRoot } from "../shared/dom/preact-root.dom"; +import { IconButton, ObsidianCommitTextInput } from "../shared/obsidian/components.obsidian"; +import { ArchivedThreadsContent } from "./archived-section"; +import type { DeclarativeSettingDefinition, DeclarativeSettingDefinitionItem } from "./declarative-settings.compat"; import { SettingsDynamicSectionsController } from "./dynamic-sections-controller"; +import { ModelEffortControl } from "./helper-section"; +import { HooksContent } from "./hook-section"; import type { CodexPanelSettingTabHost } from "./host"; import type { CodexPanelSettings } from "./model"; import { DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, DEFAULT_ATTACHMENT_FOLDER } from "./model"; @@ -12,14 +18,16 @@ import type { SettingsSectionsState } from "./section-state"; import { SettingsTabShell } from "./tab-shell"; const SETTINGS_INTRO_TEXT = "Codex Panel stores panel preferences only. Runtime settings still come from Codex."; +const ARCHIVE_EXPORT_TAGS_PLACEHOLDER = "codex, archive"; export class CodexPanelSettingTab extends PluginSettingTab { private readonly dynamicSections: SettingsDynamicSectionsController; - private displayed = false; + private renderMode: "hidden" | "legacy" | "declarative" = "hidden"; private settingsShellRevision = 0; private settingsMutationQueue: Promise = Promise.resolve(); private archivedDeleteConfirmThreadId: string | null = null; private disposeOutsidePointer: (() => void) | null = null; + private readonly declarativeIslandRefreshers = new Set<() => void>(); private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => { if (!this.archivedDeleteConfirmThreadId) return; const target = event.target; @@ -49,13 +57,236 @@ export class CodexPanelSettingTab extends PluginSettingTab { } display(): void { - this.displayed = true; + this.renderMode = "legacy"; this.dynamicSections.activate(); this.renderSettingsTab({ autoLoadDynamicSections: true }); } + // Obsidian 1.13+ discovers this method at runtime and skips display(). The + // local return type mirrors the 1.13 API while the project retains 1.12 types. + getSettingDefinitions(): DeclarativeSettingDefinitionItem[] { + return [ + { + name: "Codex details", + desc: SETTINGS_INTRO_TEXT, + searchable: false, + render: (setting) => + this.renderDeclarativeControl(setting, () => ( + void this.dynamicSections.refreshDynamicSections()} + /> + )), + }, + { + name: "Codex executable", + desc: "Command used to start `codex app-server`. Use an absolute path when Obsidian cannot find `codex`.", + render: (setting) => + this.renderDeclarativeControl(setting, () => ( + value.trim() || DEFAULT_CODEX_PATH} + onCommit={(value) => void this.setCodexPath(value)} + /> + )), + }, + { + name: "Show chat toolbar", + desc: "Shows the toolbar above chat panels.", + control: { type: "toggle", key: "showToolbar" }, + }, + { + type: "group", + heading: "Panel helpers", + cls: "codex-panel-settings__section", + items: [ + { + name: "Automatic thread naming", + desc: "Model and effort used when Codex Panel generates thread names.", + render: (setting) => + this.renderDeclarativeControl(setting, () => { + const helper = this.settingsSectionsState().helper; + return ( + + ); + }), + }, + { + name: "Selection rewrite", + desc: "Model and effort used by Rewrite selection.", + render: (setting) => + this.renderDeclarativeControl(setting, () => { + const helper = this.settingsSectionsState().helper; + setting.setDesc( + helper.modelLoadFailed + ? `Model and effort used by Rewrite selection. ${helper.modelStatus}` + : "Model and effort used by Rewrite selection.", + ); + return ( + + ); + }), + }, + ], + }, + { + type: "group", + heading: "Composer", + cls: "codex-panel-settings__section", + items: [ + { + name: "Send shortcut", + desc: "Controls whether Enter or Cmd/Ctrl+Enter sends composer-style inputs. Shift+Enter adds a newline.", + control: { + type: "dropdown", + key: "sendShortcut", + defaultValue: "enter", + options: { enter: "Enter", "mod-enter": "Cmd/Ctrl+Enter" }, + }, + }, + { + name: "Scroll conversation from composer line edges", + desc: "Lets Up/Ctrl+P and Down/Ctrl+N scroll the conversation from composer line edges.", + control: { type: "toggle", key: "scrollThreadFromComposerEdges" }, + }, + { + name: "Reference active file on send", + desc: "Adds the active file as context on each send without changing the prompt text.", + control: { type: "toggle", key: "referenceActiveNoteOnSend" }, + }, + { + name: "Attachment folder", + desc: "Vault-relative folder for files pasted or dropped into composer inputs.", + render: (setting) => + this.renderDeclarativeControl(setting, () => ( + value.trim() || DEFAULT_ATTACHMENT_FOLDER} + onCommit={(value) => void this.setAttachmentFolder(value)} + /> + )), + }, + ], + }, + { + type: "group", + heading: "Thread archiving", + cls: "codex-panel-settings__dynamic-section", + items: [ + { + name: "Save note by default", + desc: "Makes Save and archive thread the default archive action.", + control: { type: "toggle", key: "archiveExportEnabled" }, + }, + this.commitTextDefinition({ + name: "Saved note folder", + desc: "Vault-relative folder for archived thread notes.", + value: () => this.plugin.settings.archiveExportFolderTemplate, + placeholder: DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, + normalizeValue: (value) => value.trim() || DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, + onCommit: (value) => this.setArchiveExportFolderTemplate(value), + }), + this.commitTextDefinition({ + name: "Saved note filename", + desc: "Filename template. Supports {{date}}, {{time}}, {{title}}, {{id}}, and {{shortId}}.", + value: () => this.plugin.settings.archiveExportFilenameTemplate, + placeholder: DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, + normalizeValue: (value) => value.trim() || DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, + onCommit: (value) => this.setArchiveExportFilenameTemplate(value), + }), + this.commitTextDefinition({ + name: "Saved note tags", + desc: "Comma-separated tags added to saved thread notes.", + value: () => this.plugin.settings.archiveExportTags, + placeholder: ARCHIVE_EXPORT_TAGS_PLACEHOLDER, + normalizeValue: (value) => value.trim(), + onCommit: (value) => this.setArchiveExportTags(value), + }), + ], + }, + { + type: "group", + heading: "Archived threads", + cls: "codex-panel-settings__dynamic-section", + items: [ + { + name: "Archived threads content", + searchable: false, + render: (setting) => + this.renderDeclarativeSection(setting, () => ), + }, + ], + }, + { + type: "group", + heading: "Codex hooks", + cls: "codex-panel-settings__dynamic-section", + items: [ + { + name: "Codex hooks content", + searchable: false, + render: (setting) => this.renderDeclarativeSection(setting, () => ), + }, + ], + }, + ]; + } + + getControlValue(key: string): unknown { + if (!(key in this.plugin.settings)) return undefined; + return this.plugin.settings[key as keyof CodexPanelSettings]; + } + + async setControlValue(key: string, value: unknown): Promise { + switch (key) { + case "showToolbar": + if (typeof value === "boolean") await this.setShowToolbar(value); + return; + case "sendShortcut": + if (value === "enter" || value === "mod-enter") await this.setSendShortcut(value); + return; + case "scrollThreadFromComposerEdges": + if (typeof value === "boolean") await this.setScrollThreadFromComposerEdges(value); + return; + case "referenceActiveNoteOnSend": + if (typeof value === "boolean") await this.setReferenceActiveNoteOnSend(value); + return; + case "archiveExportEnabled": + if (typeof value === "boolean") await this.setArchiveExportEnabled(value); + return; + default: + throw new Error(`Unknown declarative setting key: ${key}`); + } + } + override hide(): void { - this.displayed = false; + this.renderMode = "hidden"; + this.declarativeIslandRefreshers.clear(); this.disposeOutsidePointer?.(); this.disposeOutsidePointer = null; this.archivedDeleteConfirmThreadId = null; @@ -75,6 +306,65 @@ export class CodexPanelSettingTab extends PluginSettingTab { if (options.autoLoadDynamicSections) this.maybeAutoLoadDynamicSections(); } + private beginDeclarativeDisplay(): void { + if (this.renderMode === "declarative") return; + this.renderMode = "declarative"; + this.containerEl.addClass("codex-panel-settings"); + this.disposeOutsidePointer?.(); + this.disposeOutsidePointer = listenDomEvent(this.containerEl, "pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer); + this.dynamicSections.activate(); + queueMicrotask(() => { + if (this.renderMode === "declarative") this.maybeAutoLoadDynamicSections(); + }); + } + + private renderDeclarativeControl(setting: Setting, renderNode: () => UiNode): () => void { + return this.renderDeclarativeIsland(setting.controlEl, renderNode); + } + + private renderDeclarativeSection(setting: Setting, renderNode: () => UiNode): () => void { + setting.setClass("codex-panel-settings__declarative-section-island"); + setting.settingEl.empty(); + return this.renderDeclarativeIsland(setting.settingEl, renderNode); + } + + private renderDeclarativeIsland(container: HTMLElement, renderNode: () => UiNode): () => void { + this.beginDeclarativeDisplay(); + const refresh = (): void => { + renderUiRoot(container, renderNode()); + }; + this.declarativeIslandRefreshers.add(refresh); + refresh(); + return () => { + this.declarativeIslandRefreshers.delete(refresh); + unmountUiRoot(container); + }; + } + + private commitTextDefinition(options: { + name: string; + desc: string; + value: () => string; + placeholder: string; + normalizeValue: (value: string) => string; + onCommit: (value: string) => Promise; + }): DeclarativeSettingDefinition { + return { + name: options.name, + desc: options.desc, + render: (setting) => + this.renderDeclarativeControl(setting, () => ( + void options.onCommit(value)} + /> + )), + }; + } + private renderSettingsShell(): void { renderUiRoot( this.containerEl, @@ -311,6 +601,11 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private requestRender(): void { - if (this.displayed) this.renderSettingsShell(); + if (this.renderMode === "hidden") return; + if (this.renderMode === "declarative") { + for (const refresh of this.declarativeIslandRefreshers) refresh(); + return; + } + this.renderSettingsShell(); } } diff --git a/src/styles/60-settings.css b/src/styles/60-settings.css index 126f1255..620642c3 100644 --- a/src/styles/60-settings.css +++ b/src/styles/60-settings.css @@ -28,6 +28,17 @@ overflow: auto; } +.setting-item.codex-panel-settings__declarative-section-island { + display: block; + margin: 0; + padding: 0; + border: 0; +} + +.setting-item.codex-panel-settings__declarative-section-island:empty { + display: none; +} + .codex-panel-settings__dynamic-section-heading + .codex-panel-settings__dynamic-list { margin-top: 0; } diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 24b9711e..6be21eba 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -1,7 +1,9 @@ // @vitest-environment jsdom +import { Setting } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; +import type { DeclarativeSettingDefinition, DeclarativeSettingDefinitionItem } from "../../src/settings/declarative-settings.compat"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; import { DEFAULT_SETTINGS } from "../../src/settings/model"; import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian"; @@ -40,6 +42,108 @@ describe("settings tab", () => { notices.length = 0; }); + it("exposes every persistent setting to Obsidian 1.13 search without loading dynamic data", () => { + const tab = newSettingsTab(); + + const definitions = tab.getSettingDefinitions(); + + expect(withShortLivedAppServerClientMock).not.toHaveBeenCalled(); + expect(declarativeDefinitionNames(definitions)).toEqual([ + "Codex details", + "Codex executable", + "Show chat toolbar", + "Panel helpers", + "Automatic thread naming", + "Selection rewrite", + "Composer", + "Send shortcut", + "Scroll conversation from composer line edges", + "Reference active file on send", + "Attachment folder", + "Thread archiving", + "Save note by default", + "Saved note folder", + "Saved note filename", + "Saved note tags", + "Archived threads", + "Archived threads content", + "Codex hooks", + "Codex hooks content", + ]); + expect(declarativeDefinitionByName(definitions, "Codex details")?.searchable).toBe(false); + }); + + it("routes declarative controls through the existing settings publication boundary", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const refreshOpenViews = vi.fn(); + const tab = newSettingsTab({ saveSettings, refreshOpenViews }); + + expect(tab.getControlValue("showToolbar")).toBe(true); + await tab.setControlValue("showToolbar", false); + + expect(saveSettings).toHaveBeenCalledOnce(); + expect(refreshOpenViews).toHaveBeenCalledOnce(); + expect(tab.getControlValue("showToolbar")).toBe(false); + }); + + it("starts dynamic loading from declarative rendering instead of definition indexing", async () => { + const client = settingsClient(); + useShortLivedClients(client); + const tab = newSettingsTab(); + const header = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex details"); + if (!header?.render) throw new Error("Missing declarative Codex details renderer"); + const container = document.createElement("div"); + + header.render(new Setting(container), {} as never); + expect(withShortLivedAppServerClientMock).not.toHaveBeenCalled(); + + await flushPromises(); + + expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce(); + expect(container.querySelector("button")?.ariaLabel).toBe("Refresh Codex details"); + }); + + it("preserves an active declarative text island while dynamic sections refresh", async () => { + const client = settingsClient(); + useShortLivedClients(client); + const tab = newSettingsTab(); + const executable = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex executable"); + if (!executable?.render) throw new Error("Missing declarative Codex executable renderer"); + const container = document.createElement("div"); + + executable.render(new Setting(container), {} as never); + const input = container.querySelector("input"); + if (!input) throw new Error("Missing declarative Codex executable input"); + input.focus(); + input.value = "/draft/codex"; + + await flushPromises(); + + expect(container.querySelector("input")).toBe(input); + expect(input.value).toBe("/draft/codex"); + }); + + it("rolls back a declarative text island after publication fails", async () => { + const client = settingsClient(); + useShortLivedClients(client); + const tab = newSettingsTab({ saveSettings: vi.fn().mockRejectedValue(new Error("disk full")) }); + const executable = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex executable"); + if (!executable?.render) throw new Error("Missing declarative Codex executable renderer"); + const container = document.createElement("div"); + + executable.render(new Setting(container), {} as never); + const input = container.querySelector("input"); + if (!input) throw new Error("Missing declarative Codex executable input"); + input.value = "/failed/codex"; + input.dispatchEvent(new Event("blur")); + + await flushPromises(); + + expect(container.querySelector("input")).not.toBe(input); + expect(container.querySelector("input")?.value).toBe(DEFAULT_SETTINGS.codexPath); + expect(notices).toContain("Failed to save Codex Panel settings: disk full"); + }); + it("auto-loads dynamic sections once and keeps one global refresh button", async () => { const client = settingsClient(); const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")])); @@ -658,6 +762,30 @@ function newSettingsTab(options: SettingsTabHostOptions = {}): CodexPanelSetting return new CodexPanelSettingTab({} as never, {} as never, settingsTabHost(options)); } +function declarativeDefinitionNames(definitions: DeclarativeSettingDefinitionItem[]): string[] { + return definitions.flatMap((definition) => { + if ("type" in definition) { + return [...(definition.heading ? [definition.heading] : []), ...declarativeDefinitionNames(definition.items ?? [])]; + } + return [definition.name]; + }); +} + +function declarativeDefinitionByName( + definitions: DeclarativeSettingDefinitionItem[], + name: string, +): DeclarativeSettingDefinition | undefined { + for (const definition of definitions) { + if ("type" in definition) { + const nested = declarativeDefinitionByName(definition.items ?? [], name); + if (nested) return nested; + } else if (definition.name === name) { + return definition; + } + } + return undefined; +} + function settingNames(tab: CodexPanelSettingTab): string[] { return Array.from(settingsSectionRoots(tab)).flatMap((element) => { if (element.classList.contains("codex-panel-settings__header")) return [];