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 [];