feat(settings): support declarative settings search

This commit is contained in:
murashit 2026-07-17 11:06:00 +09:00
parent 7dfc023360
commit ec3bcd0312
7 changed files with 510 additions and 20 deletions

View file

@ -18,16 +18,20 @@ export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionS
</SettingsGroup>
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__archived-threads-section">
<SettingsHeading dynamic name="Archived threads" />
{state.contentAvailable ? (
<ArchivedThreadList state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
<ArchivedThreadsContent state={state} />
</SettingsGroup>
</>
);
}
export function ArchivedThreadsContent({ state }: { state: ArchivedThreadSectionState }): UiNode {
return state.contentAvailable ? (
<ArchivedThreadList state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null;
}
function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<SettingsItems>

View file

@ -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<string, string>;
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;

View file

@ -13,7 +13,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__helper-section">
<SettingsHeading name="Panel helpers" />
<SettingsItems>
<ModelEffortSetting
<ModelEffortControl
name="Automatic thread naming"
desc="Model and effort used when Codex Panel generates thread names."
modelValue={state.threadNamingModel}
@ -22,7 +22,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
onModelChange={state.onThreadNamingModelChange}
onEffortChange={state.onThreadNamingEffortChange}
/>
<ModelEffortSetting
<ModelEffortControl
name="Selection rewrite"
desc="Model and effort used by Rewrite selection."
modelValue={state.rewriteSelectionModel}
@ -37,7 +37,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
);
}
function ModelEffortSetting({
export function ModelEffortControl({
name,
desc,
modelValue,
@ -45,6 +45,7 @@ function ModelEffortSetting({
models,
onModelChange,
onEffortChange,
controlsOnly = false,
}: {
name: string;
desc: string;
@ -53,10 +54,11 @@ function ModelEffortSetting({
models: readonly ModelMetadata[];
onModelChange: (value: string | null) => void;
onEffortChange: (value: ReasoningEffort | null) => void;
controlsOnly?: boolean;
}): UiNode {
const efforts = reasoningEffortsForSelectedModel(models, modelValue);
return (
<SettingRow name={name} desc={desc}>
const controls = (
<>
<ObsidianDropdown
value={modelValue ?? CODEX_DEFAULT_VALUE}
onChange={(value) => {
@ -71,6 +73,13 @@ function ModelEffortSetting({
}}
options={reasoningEffortSelectOptions(efforts, effortValue)}
/>
</>
);
return controlsOnly ? (
controls
) : (
<SettingRow name={name} desc={desc}>
{controls}
</SettingRow>
);
}

View file

@ -9,15 +9,19 @@ export function HookSection({ state }: { state: HookSectionState }): UiNode {
return (
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__hook-section">
<SettingsHeading dynamic name="Codex hooks" />
{state.contentAvailable ? (
<Hooks state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
<HooksContent state={state} />
</SettingsGroup>
);
}
export function HooksContent({ state }: { state: HookSectionState }): UiNode {
return state.contentAvailable ? (
<Hooks state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null;
}
function Hooks({ state }: { state: HookSectionState }): UiNode {
return (
<>

View file

@ -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<void> = 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, () => (
<IconButton
icon="refresh-cw"
label={this.dynamicSections.isLoading() ? "Refreshing Codex details" : "Refresh Codex details"}
className="clickable-icon codex-panel-settings__refresh-button"
disabled={this.dynamicSections.isLoading()}
onClick={() => 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, () => (
<ObsidianCommitTextInput
key={this.settingsShellRevision}
value={this.plugin.settings.codexPath}
placeholder={DEFAULT_CODEX_PATH}
normalizeValue={(value) => 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 (
<ModelEffortControl
name="Automatic thread naming"
desc="Model and effort used when Codex Panel generates thread names."
modelValue={helper.threadNamingModel}
effortValue={helper.threadNamingEffort}
models={helper.models}
onModelChange={helper.onThreadNamingModelChange}
onEffortChange={helper.onThreadNamingEffortChange}
controlsOnly
/>
);
}),
},
{
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 (
<ModelEffortControl
name="Selection rewrite"
desc="Model and effort used by Rewrite selection."
modelValue={helper.rewriteSelectionModel}
effortValue={helper.rewriteSelectionEffort}
models={helper.models}
onModelChange={helper.onRewriteSelectionModelChange}
onEffortChange={helper.onRewriteSelectionEffortChange}
controlsOnly
/>
);
}),
},
],
},
{
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, () => (
<ObsidianCommitTextInput
key={this.settingsShellRevision}
value={this.plugin.settings.attachmentFolder}
placeholder={DEFAULT_ATTACHMENT_FOLDER}
normalizeValue={(value) => 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, () => <ArchivedThreadsContent state={this.settingsSectionsState().archived} />),
},
],
},
{
type: "group",
heading: "Codex hooks",
cls: "codex-panel-settings__dynamic-section",
items: [
{
name: "Codex hooks content",
searchable: false,
render: (setting) => this.renderDeclarativeSection(setting, () => <HooksContent state={this.settingsSectionsState().hooks} />),
},
],
},
];
}
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<void> {
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<void>;
}): DeclarativeSettingDefinition {
return {
name: options.name,
desc: options.desc,
render: (setting) =>
this.renderDeclarativeControl(setting, () => (
<ObsidianCommitTextInput
key={this.settingsShellRevision}
value={options.value()}
placeholder={options.placeholder}
normalizeValue={options.normalizeValue}
onCommit={(value) => 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();
}
}

View file

@ -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;
}

View file

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