diff --git a/eslint.config.mjs b/eslint.config.mjs
index 012305bf..4165226e 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -194,12 +194,14 @@ const uiRootBridgeFiles = [
"src/features/chat/ui/turn-diff/view.ts",
"src/features/selection-rewrite/popover.tsx",
"src/features/threads-view/renderer.tsx",
+ "src/settings/render.tsx",
];
const nonChatImperativeDomBridgeFiles = [
"src/features/selection-rewrite/popover.tsx",
"src/features/thread-picker/modal.ts",
"src/features/threads-view/renderer.tsx",
- "src/settings/dynamic-sections.ts",
+ "src/settings/dynamic-sections.tsx",
+ "src/settings/render.tsx",
"src/settings/tab.ts",
"src/shared/diff/render.ts",
"src/shared/ui/components.tsx",
diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts
deleted file mode 100644
index eb1fe084..00000000
--- a/src/settings/dynamic-sections.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import { Setting } from "obsidian";
-
-import type { HookItem } from "../domain/catalog/metadata";
-import type { Thread } from "../domain/threads/model";
-import { shortThreadId } from "../utils";
-import { archivedThreadDisplayTitle } from "./archived-thread-title";
-
-export interface ArchivedThreadSectionState {
- exportEnabled: boolean;
- exportFolderTemplate: string;
- exportFilenameTemplate: string;
- exportTags: string;
- threads: readonly Thread[];
- loaded: boolean;
- loading: boolean;
- status: string;
- deleteConfirmThreadId: string | null;
- onExportEnabledChange(enabled: boolean): void;
- onExportFolderTemplateChange(value: string): void;
- onExportFilenameTemplateChange(value: string): void;
- onExportTagsChange(value: string): void;
- onRestore(threadId: string): void;
- onStartDelete(threadId: string): void;
- onDelete(threadId: string): void;
-}
-
-export interface HookSectionState {
- hooks: readonly HookItem[];
- warnings: readonly string[];
- errors: readonly string[];
- loaded: boolean;
- loading: boolean;
- status: string;
- onTrust(hook: HookItem): void;
- onToggleEnabled(hook: HookItem, enabled: boolean): void;
-}
-
-export function renderHookSection(containerEl: HTMLElement, state: HookSectionState): void {
- const section = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
- new Setting(section)
- .setClass("codex-panel-settings__dynamic-section-heading")
- .setHeading()
- .setName("Hook status")
- .setDesc("Review discovered hooks, trust changes, and turn hooks on or off.");
-
- if (state.loaded) {
- renderHooks(section, state);
- } else if (!state.loading && state.status) {
- section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: state.status });
- }
-}
-
-export function renderArchivedThreadSection(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
- const section = containerEl.createDiv({
- cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
- });
- new Setting(section)
- .setClass("codex-panel-settings__dynamic-section-heading")
- .setHeading()
- .setName("Thread archiving")
- .setDesc(
- "Choose the default archive behavior and configure saved thread notes. Thread lists offer both archive choices; slash commands use the default.",
- );
-
- renderArchiveExportSettings(section, state);
-
- if (state.loaded && state.threads.length === 0) {
- section.createEl("p", {
- cls: "setting-item-description codex-panel-settings__dynamic-section-status",
- text: "No archived threads.",
- });
- } else if (state.loaded) {
- renderArchivedThreadList(section, state);
- } else if (!state.loading && state.status) {
- section.createEl("p", {
- cls: "setting-item-description codex-panel-settings__dynamic-section-status",
- text: state.status,
- });
- }
-}
-
-function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
- new Setting(containerEl)
- .setName("Save note by default")
- .setDesc(
- "When on, the default archive action saves a markdown note before archiving. When off, the default archives without saving. If saving fails, the thread stays active. Frontmatter includes title, thread_id, created, and optional tags.",
- )
- .addToggle((toggle) => {
- toggle.setValue(state.exportEnabled).onChange((value) => {
- state.onExportEnabledChange(value);
- });
- });
-
- new Setting(containerEl)
- .setName("Saved note folder")
- .setDesc("Vault-relative folder for saved thread notes. The folder is created when needed.")
- .addText((text) => {
- text
- .setPlaceholder("Codex archives")
- .setValue(state.exportFolderTemplate)
- .onChange((value) => {
- state.onExportFolderTemplateChange(value);
- });
- });
-
- new Setting(containerEl)
- .setName("Saved note filename")
- .setDesc("Filename template. Variables: {{date}}, {{time}}, {{title}}, {{id}}, {{shortId}}. Existing files get a numeric suffix.")
- .addText((text) => {
- text
- .setPlaceholder("{{date}} {{time}} {{title}} {{shortId}}.md")
- .setValue(state.exportFilenameTemplate)
- .onChange((value) => {
- state.onExportFilenameTemplateChange(value);
- });
- });
-
- new Setting(containerEl)
- .setName("Saved note tags")
- .setDesc("Comma-separated fixed tags for saved notes. Leave empty to omit tags.")
- .addText((text) => {
- text
- .setPlaceholder("Codex, archive")
- .setValue(state.exportTags)
- .onChange((value) => {
- state.onExportTagsChange(value);
- });
- });
-}
-
-function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
- containerEl.createEl("p", {
- cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
- text: "Restore archived threads, or permanently delete archived threads you no longer need.",
- });
- const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
- for (const thread of state.threads) {
- const title = archivedThreadDisplayTitle(thread);
- const deleteConfirming = state.deleteConfirmThreadId === thread.id;
- const setting = new Setting(list)
- .setClass("codex-panel-settings__dynamic-row")
- .setName(title)
- .setDesc(
- deleteConfirming
- ? "Permanently delete this archived thread? This cannot be undone."
- : `Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`,
- );
- if (!deleteConfirming) {
- setting.addExtraButton((button) => {
- button.setIcon("rotate-ccw").onClick(() => {
- state.onRestore(thread.id);
- });
- button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
- button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
- });
- }
- setting.addExtraButton((button) => {
- button.setIcon(deleteConfirming ? "check" : "shredder").onClick(() => {
- if (deleteConfirming) {
- state.onDelete(thread.id);
- } else {
- state.onStartDelete(thread.id);
- }
- });
- button.extraSettingsEl.addClass(
- deleteConfirming ? "codex-panel-settings__archived-delete-confirm" : "codex-panel-settings__archived-delete",
- );
- button.extraSettingsEl.setAttr("aria-label", deleteConfirming ? `Confirm permanent delete ${title}` : `Delete ${title}`);
- });
- setting.settingEl.addClass("codex-panel-settings__archived-row");
- if (deleteConfirming) setting.settingEl.addClass("codex-panel-settings__archived-row--delete-confirming");
- }
-}
-
-function renderHooks(containerEl: HTMLElement, state: HookSectionState): void {
- if (state.hooks.length === 0) {
- containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks found for this vault root." });
- } else {
- const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
- for (const hook of state.hooks) {
- renderHookRow(list, hook, state);
- }
- }
-
- for (const warning of state.warnings) {
- containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
- }
- for (const error of state.errors) {
- containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
- }
-}
-
-function renderHookRow(list: HTMLElement, hook: HookItem, state: HookSectionState): void {
- const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
- const hookName = firstNonEmptyString(hook.statusMessage, hook.command, hook.matcher, hook.eventName);
- const setting = new Setting(list)
- .setClass("codex-panel-settings__dynamic-row")
- .setName(hookName)
- .setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
- .addButton((button) => {
- button
- .setButtonText("Trust")
- .setDisabled(state.loading || !canTrust)
- .onClick(() => {
- state.onTrust(hook);
- });
- })
- .addButton((button) => {
- button
- .setButtonText(hook.enabled ? "Disable" : "Enable")
- .setDisabled(state.loading || hook.isManaged)
- .onClick(() => {
- state.onToggleEnabled(hook, !hook.enabled);
- });
- });
- setting.settingEl.addClass("codex-panel-settings__hook-row");
- setting.descEl.createDiv({
- cls: "codex-panel-settings__hook-hash",
- text: hook.currentHash,
- });
-}
-
-function firstNonEmptyString(...values: (string | null | undefined)[]): string {
- return (
- values.find((value): value is string => typeof value === "string" && value.length > 0) ??
- values.find((value): value is string => typeof value === "string") ??
- ""
- );
-}
-
-function formatThreadDate(timestamp: number): string {
- if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
- return new Date(timestamp * 1000).toLocaleString(undefined, {
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
-}
diff --git a/src/settings/dynamic-sections.tsx b/src/settings/dynamic-sections.tsx
new file mode 100644
index 00000000..3202d448
--- /dev/null
+++ b/src/settings/dynamic-sections.tsx
@@ -0,0 +1,512 @@
+import type { ComponentChild as UiNode, TargetedMouseEvent, TargetedPointerEvent } from "preact";
+
+import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
+import type { Thread } from "../domain/threads/model";
+import { IconButton } from "../shared/ui/components";
+import { shortThreadId } from "../utils";
+import { archivedThreadDisplayTitle } from "./archived-thread-title";
+
+const CODEX_DEFAULT_VALUE = "__codex-default__";
+
+interface HelperSettingsState {
+ threadNamingModel: string | null;
+ threadNamingEffort: ReasoningEffort | null;
+ rewriteSelectionModel: string | null;
+ rewriteSelectionEffort: ReasoningEffort | null;
+ models: readonly ModelMetadata[];
+ modelLoadFailed: boolean;
+ modelStatus: string;
+ onThreadNamingModelChange: (value: string | null) => void;
+ onThreadNamingEffortChange: (value: ReasoningEffort | null) => void;
+ onRewriteSelectionModelChange: (value: string | null) => void;
+ onRewriteSelectionEffortChange: (value: ReasoningEffort | null) => void;
+}
+
+interface ArchivedThreadSectionState {
+ exportEnabled: boolean;
+ exportFolderTemplate: string;
+ exportFilenameTemplate: string;
+ exportTags: string;
+ threads: readonly Thread[];
+ loaded: boolean;
+ loading: boolean;
+ status: string;
+ deleteConfirmThreadId: string | null;
+ onExportEnabledChange: (enabled: boolean) => void;
+ onExportFolderTemplateChange: (value: string) => void;
+ onExportFilenameTemplateChange: (value: string) => void;
+ onExportTagsChange: (value: string) => void;
+ onRestore: (threadId: string) => void;
+ onStartDelete: (threadId: string) => void;
+ onDelete: (threadId: string) => void;
+}
+
+interface HookSectionState {
+ hooks: readonly HookItem[];
+ warnings: readonly string[];
+ errors: readonly string[];
+ loaded: boolean;
+ loading: boolean;
+ status: string;
+ onTrust: (hook: HookItem) => void;
+ onToggleEnabled: (hook: HookItem, enabled: boolean) => void;
+}
+
+export interface SettingsDynamicSectionsState {
+ helper: HelperSettingsState;
+ archived: ArchivedThreadSectionState;
+ hooks: HookSectionState;
+}
+
+export function SettingsDynamicSections({ state }: { state: SettingsDynamicSectionsState }): UiNode {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function HelperSettingsSection({ state }: { state: HelperSettingsState }): UiNode {
+ return (
+
+
+
+
+ {state.modelLoadFailed ? {state.modelStatus}
: null}
+
+ );
+}
+
+function ModelEffortSetting({
+ name,
+ desc,
+ modelValue,
+ effortValue,
+ models,
+ onModelChange,
+ onEffortChange,
+}: {
+ name: string;
+ desc: string;
+ modelValue: string | null;
+ effortValue: ReasoningEffort | null;
+ models: readonly ModelMetadata[];
+ onModelChange: (value: string | null) => void;
+ onEffortChange: (value: ReasoningEffort | null) => void;
+}): UiNode {
+ const efforts = effortOptions(models, modelValue);
+ return (
+
+ {
+ onModelChange(value === CODEX_DEFAULT_VALUE ? null : value);
+ }}
+ >
+ {modelOptions(models, modelValue).map((option) => (
+
+ ))}
+
+ {
+ onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value);
+ }}
+ >
+ {reasoningEffortOptions(efforts, effortValue).map((option) => (
+
+ ))}
+
+
+ );
+}
+
+function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode {
+ return (
+
+
+
+ {state.loaded && state.threads.length === 0 ? (
+ No archived threads.
+ ) : state.loaded ? (
+
+ ) : !state.loading && state.status ? (
+ {state.status}
+ ) : null}
+
+ );
+}
+
+function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode {
+ return (
+ <>
+
+ {
+ state.onExportEnabledChange(checked);
+ }}
+ />
+
+
+ {
+ state.onExportFolderTemplateChange(value);
+ }}
+ />
+
+
+ {
+ state.onExportFilenameTemplateChange(value);
+ }}
+ />
+
+
+ {
+ state.onExportTagsChange(value);
+ }}
+ />
+
+ >
+ );
+}
+
+function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): UiNode {
+ return (
+ <>
+
+ Restore archived threads, or permanently delete archived threads you no longer need.
+
+
+ {state.threads.map((thread) => (
+
+ ))}
+
+ >
+ );
+}
+
+function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedThreadSectionState }): UiNode {
+ const title = archivedThreadDisplayTitle(thread);
+ const deleteConfirming = state.deleteConfirmThreadId === thread.id;
+ return (
+
+ {!deleteConfirming ? (
+ {
+ state.onRestore(thread.id);
+ }}
+ />
+ ) : null}
+ {
+ if (deleteConfirming) {
+ state.onDelete(thread.id);
+ } else {
+ state.onStartDelete(thread.id);
+ }
+ }}
+ />
+
+ );
+}
+
+function HookSection({ state }: { state: HookSectionState }): UiNode {
+ return (
+
+
+ {state.loaded ? (
+
+ ) : !state.loading && state.status ? (
+ {state.status}
+ ) : null}
+
+ );
+}
+
+function Hooks({ state }: { state: HookSectionState }): UiNode {
+ return (
+ <>
+ {state.hooks.length === 0 ? (
+ No hooks found for this vault root.
+ ) : (
+
+ {state.hooks.map((hook) => (
+
+ ))}
+
+ )}
+ {state.warnings.map((warning) => (
+
+ {warning}
+
+ ))}
+ {state.errors.map((error) => (
+
+ {error}
+
+ ))}
+ >
+ );
+}
+
+function HookRow({ hook, state }: { hook: HookItem; state: HookSectionState }): UiNode {
+ const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
+ const hookName = firstNonEmptyString(hook.statusMessage, hook.command, hook.matcher, hook.eventName);
+ return (
+ {hook.currentHash}}
+ >
+
+
+
+ );
+}
+
+function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode {
+ return (
+
+
+
+
{name}
+ {desc ?? null}
+
+
+
+
+ );
+}
+
+function SettingRow({
+ name,
+ desc,
+ className = "",
+ extraInfo,
+ children,
+}: {
+ name: string;
+ desc: string;
+ className?: string;
+ extraInfo?: UiNode;
+ children: UiNode;
+}): UiNode {
+ return (
+
+
+
+
{name}
+ {desc}
+ {extraInfo}
+
+
+
{children}
+
+ );
+}
+
+function SettingsIconButton({
+ icon,
+ label,
+ className,
+ onClick,
+}: {
+ icon: string;
+ label: string;
+ className: string;
+ onClick: () => void;
+}): UiNode {
+ return (
+ ) => {
+ event.stopPropagation();
+ }}
+ onClick={(event: TargetedMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ }}
+ />
+ );
+}
+
+function SelectControl({ value, onChange, children }: { value: string; onChange: (value: string) => void; children: UiNode }): UiNode {
+ return (
+
+ );
+}
+
+function TextControl({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (value: string) => void }): UiNode {
+ return (
+ {
+ onChange(event.currentTarget.value);
+ }}
+ />
+ );
+}
+
+function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
+ return (
+ {
+ if (event.target !== event.currentTarget) return;
+ onChange(!checked);
+ }}
+ >
+ {
+ onChange(event.currentTarget.checked);
+ }}
+ />
+
+ );
+}
+
+function modelOptions(models: readonly ModelMetadata[], current: string | null): { value: string; label: string }[] {
+ const options = [{ value: CODEX_DEFAULT_VALUE, label: "Codex default" }];
+ if (current && !models.some((model) => model.model === current || model.id === current)) {
+ options.push({ value: current, label: `${current} (saved)` });
+ }
+ for (const model of models) {
+ options.push({ value: model.model, label: model.model });
+ }
+ return options;
+}
+
+function effortOptions(models: readonly ModelMetadata[], modelIdOrName: string | null): ReasoningEffort[] {
+ const model = selectedModel(models, modelIdOrName);
+ return model ? supportedEffortsForModelMetadata(model) : [];
+}
+
+function reasoningEffortOptions(efforts: readonly ReasoningEffort[], current: ReasoningEffort | null): { value: string; label: string }[] {
+ const options = [{ value: CODEX_DEFAULT_VALUE, label: "Codex default" }];
+ if (current && !efforts.includes(current)) {
+ options.push({ value: current, label: `${current} (saved)` });
+ }
+ for (const effort of efforts) {
+ options.push({ value: effort, label: effort });
+ }
+ return options;
+}
+
+function selectedModel(models: readonly ModelMetadata[], modelIdOrName: string | null): ModelMetadata | null {
+ if (!modelIdOrName) return null;
+ return models.find((model) => model.model === modelIdOrName || model.id === modelIdOrName) ?? null;
+}
+
+function supportedEffortsForModelMetadata(model: ModelMetadata): ReasoningEffort[] {
+ return [...model.supportedReasoningEfforts];
+}
+
+function firstNonEmptyString(...values: (string | null | undefined)[]): string {
+ return (
+ values.find((value): value is string => typeof value === "string" && value.length > 0) ??
+ values.find((value): value is string => typeof value === "string") ??
+ ""
+ );
+}
+
+function formatThreadDate(timestamp: number): string {
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
+ return new Date(timestamp * 1000).toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
diff --git a/src/settings/render.tsx b/src/settings/render.tsx
new file mode 100644
index 00000000..fcfd79c0
--- /dev/null
+++ b/src/settings/render.tsx
@@ -0,0 +1,10 @@
+import { renderUiRoot, unmountUiRoot } from "../shared/ui/ui-root";
+import { SettingsDynamicSections, type SettingsDynamicSectionsState } from "./dynamic-sections";
+
+export function renderSettingsDynamicSections(container: HTMLElement, state: SettingsDynamicSectionsState): void {
+ renderUiRoot(container, );
+}
+
+export function unmountSettingsDynamicSections(container: HTMLElement | null): void {
+ unmountUiRoot(container);
+}
diff --git a/src/settings/tab.ts b/src/settings/tab.ts
index 297a62b6..b4f0827f 100644
--- a/src/settings/tab.ts
+++ b/src/settings/tab.ts
@@ -1,10 +1,11 @@
import { type App, Notice, type Plugin, PluginSettingTab, Setting, setIcon } from "obsidian";
import { DEFAULT_CODEX_PATH } from "../constants";
+import type { ReasoningEffort } from "../domain/catalog/metadata";
import { SettingsDynamicDataController, type SettingsDynamicDataHost } from "./dynamic-data-controller";
-import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections";
+import type { SettingsDynamicSectionsState } from "./dynamic-sections";
+import { renderSettingsDynamicSections, unmountSettingsDynamicSections } from "./render";
-const CODEX_DEFAULT_VALUE = "__codex-default__";
const SETTINGS_INTRO_TEXT =
"Codex Panel stores only panel preferences. Models, sandboxing, approvals, MCP servers, hooks, and network access still come from Codex config.";
const SEND_SHORTCUT_LABELS = {
@@ -19,6 +20,7 @@ function renderSettingsHeading(containerEl: HTMLElement, name: string): void {
export class CodexPanelSettingTab extends PluginSettingTab {
private readonly dynamicData: SettingsDynamicDataController;
private archivedDeleteConfirmThreadId: string | null = null;
+ private dynamicSectionsRoot: HTMLElement | null = null;
private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => {
if (!this.archivedDeleteConfirmThreadId) return;
const target = event.target;
@@ -28,7 +30,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
if (deleteConfirm && this.containerEl.contains(deleteConfirm)) return;
}
this.archivedDeleteConfirmThreadId = null;
- this.renderSettingsTab({ autoLoadCodexData: false });
+ this.renderDynamicSections();
};
constructor(
@@ -39,7 +41,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
super(app, owner);
this.dynamicData = new SettingsDynamicDataController(plugin, {
display: () => {
- this.display();
+ this.renderDynamicSections();
},
notify: (message) => {
new Notice(message);
@@ -56,11 +58,15 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
this.archivedDeleteConfirmThreadId = null;
this.dynamicData.dispose();
+ unmountSettingsDynamicSections(this.dynamicSectionsRoot);
+ this.dynamicSectionsRoot = null;
super.hide();
}
private renderSettingsTab(options: { autoLoadCodexData: boolean }): void {
const { containerEl } = this;
+ unmountSettingsDynamicSections(this.dynamicSectionsRoot);
+ this.dynamicSectionsRoot = null;
containerEl.empty();
containerEl.addClass("codex-panel-settings");
containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
@@ -68,7 +74,8 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.renderHeaderActions(containerEl, SETTINGS_INTRO_TEXT);
this.renderPanelPreferenceSections(containerEl);
- this.renderCodexDynamicSections(containerEl);
+ this.dynamicSectionsRoot = containerEl.createDiv({ cls: "codex-panel-settings__preact-sections" });
+ this.renderDynamicSections();
if (options.autoLoadCodexData) this.maybeAutoLoadSettingsData();
}
@@ -80,21 +87,17 @@ export class CodexPanelSettingTab extends PluginSettingTab {
.setName("Codex executable")
.setDesc("Path used to start `codex app-server`. Use an absolute path if Obsidian cannot find `codex`.")
.addText((text) => {
- text
- .setPlaceholder(DEFAULT_CODEX_PATH)
- .setValue(this.plugin.settings.codexPath)
- .onChange(async (value) => {
- const codexPath = value.trim() || DEFAULT_CODEX_PATH;
- const codexPathChanged = codexPath !== this.plugin.settings.codexPath;
- this.plugin.settings.codexPath = codexPath;
- await this.plugin.saveSettings();
- if (codexPathChanged) {
- this.dynamicData.resetSettingsDataContext();
- this.plugin.appServerData.notifyContextChanged();
- this.plugin.refreshOpenViews();
- this.renderSettingsTab({ autoLoadCodexData: false });
- }
- });
+ text.setPlaceholder(DEFAULT_CODEX_PATH).setValue(this.plugin.settings.codexPath);
+ const commitCodexPath = () => {
+ void this.setCodexPath(text.inputEl.value);
+ };
+ text.inputEl.addEventListener("blur", commitCodexPath);
+ text.inputEl.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ commitCodexPath();
+ text.inputEl.blur();
+ });
});
new Setting(configSection)
.setName("Show chat toolbar")
@@ -132,133 +135,69 @@ export class CodexPanelSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
-
- const helperSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__helper-section" });
- renderSettingsHeading(helperSection, "Codex helpers");
- new Setting(helperSection)
- .setName("Automatic thread naming")
- .setDesc("Choose the model and reasoning effort used to suggest thread names.")
- .addDropdown((dropdown) => {
- const current = this.plugin.settings.threadNamingModel;
- const options = this.dynamicData.modelMetadata();
- dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default");
- if (current && !options.some((model) => model.model === current || model.id === current)) {
- dropdown.addOption(current, `${current} (saved)`);
- }
- for (const model of options) {
- dropdown.addOption(model.model, model.model);
- }
- dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => {
- this.plugin.settings.threadNamingModel = value === CODEX_DEFAULT_VALUE ? null : value;
- if (!this.dynamicData.namingEffortSupported(this.plugin.settings.threadNamingEffort)) {
- this.plugin.settings.threadNamingEffort = null;
- }
- await this.plugin.saveSettings();
- this.display();
- });
- })
- .addDropdown((dropdown) => {
- const current = this.plugin.settings.threadNamingEffort;
- const options = this.dynamicData.effortOptions(this.plugin.settings.threadNamingModel);
- dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default");
- if (current && !options.includes(current)) {
- dropdown.addOption(current, `${current} (saved)`);
- }
- for (const effort of options) {
- dropdown.addOption(effort, effort);
- }
- dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => {
- this.plugin.settings.threadNamingEffort = value === CODEX_DEFAULT_VALUE ? null : value;
- await this.plugin.saveSettings();
- });
- });
-
- new Setting(helperSection)
- .setName("Selection rewrite")
- .setDesc("Choose the model and reasoning effort used by rewrite selection.")
- .addDropdown((dropdown) => {
- const current = this.plugin.settings.rewriteSelectionModel;
- const options = this.dynamicData.modelMetadata();
- dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default");
- if (current && !options.some((model) => model.model === current || model.id === current)) {
- dropdown.addOption(current, `${current} (saved)`);
- }
- for (const model of options) {
- dropdown.addOption(model.model, model.model);
- }
- dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => {
- this.plugin.settings.rewriteSelectionModel = value === CODEX_DEFAULT_VALUE ? null : value;
- if (!this.dynamicData.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) {
- this.plugin.settings.rewriteSelectionEffort = null;
- }
- await this.plugin.saveSettings();
- this.display();
- });
- })
- .addDropdown((dropdown) => {
- const current = this.plugin.settings.rewriteSelectionEffort;
- const options = this.dynamicData.effortOptions(this.plugin.settings.rewriteSelectionModel);
- dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default");
- if (current && !options.includes(current)) {
- dropdown.addOption(current, `${current} (saved)`);
- }
- for (const effort of options) {
- dropdown.addOption(effort, effort);
- }
- dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => {
- this.plugin.settings.rewriteSelectionEffort = value === CODEX_DEFAULT_VALUE ? null : value;
- await this.plugin.saveSettings();
- });
- });
- const dynamicData = this.dynamicData.snapshot();
- if (dynamicData.modelsLifecycle.kind === "failed") {
- configSection.createEl("p", {
- cls: "setting-item-description codex-panel-settings__section-status",
- text: dynamicData.modelsLifecycle.status,
- });
- }
}
- private renderCodexDynamicSections(containerEl: HTMLElement): void {
- const dynamicData = this.dynamicData.snapshot();
- renderArchivedThreadSection(containerEl, {
- exportEnabled: this.plugin.settings.archiveExportEnabled,
- exportFolderTemplate: this.plugin.settings.archiveExportFolderTemplate,
- exportFilenameTemplate: this.plugin.settings.archiveExportFilenameTemplate,
- exportTags: this.plugin.settings.archiveExportTags,
- threads: dynamicData.archivedThreads,
- loaded: dynamicData.archivedThreadsLifecycle.kind === "loaded",
- loading: dynamicData.archivedThreadsLifecycle.kind === "loading",
- status: dynamicData.archivedThreadsLifecycle.status,
- deleteConfirmThreadId: this.archivedDeleteConfirmThreadId,
- onExportEnabledChange: (enabled) => void this.setArchiveExportEnabled(enabled),
- onExportFolderTemplateChange: (value) => void this.setArchiveExportFolderTemplate(value),
- onExportFilenameTemplateChange: (value) => void this.setArchiveExportFilenameTemplate(value),
- onExportTagsChange: (value) => void this.setArchiveExportTags(value),
- onRestore: (threadId) => {
- this.archivedDeleteConfirmThreadId = null;
- void this.dynamicData.restoreArchivedThread(threadId);
- },
- onStartDelete: (threadId) => {
- this.archivedDeleteConfirmThreadId = threadId;
- this.renderSettingsTab({ autoLoadCodexData: false });
- },
- onDelete: (threadId) => {
- this.archivedDeleteConfirmThreadId = null;
- void this.dynamicData.deleteArchivedThread(threadId);
- },
- });
+ private renderDynamicSections(): void {
+ if (!this.dynamicSectionsRoot) return;
+ renderSettingsDynamicSections(this.dynamicSectionsRoot, this.dynamicSectionsState());
+ }
- renderHookSection(containerEl, {
- hooks: dynamicData.hooks,
- warnings: dynamicData.hookWarnings,
- errors: dynamicData.hookErrors,
- loaded: dynamicData.hooksLifecycle.kind === "loaded",
- loading: dynamicData.hooksLifecycle.kind === "loading",
- status: dynamicData.hooksLifecycle.status,
- onTrust: (hook) => void this.dynamicData.trustHook(hook),
- onToggleEnabled: (hook, enabled) => void this.dynamicData.setHookEnabled(hook, enabled),
- });
+ private dynamicSectionsState(): SettingsDynamicSectionsState {
+ const dynamicData = this.dynamicData.snapshot();
+ return {
+ helper: {
+ threadNamingModel: this.plugin.settings.threadNamingModel,
+ threadNamingEffort: this.plugin.settings.threadNamingEffort,
+ rewriteSelectionModel: this.plugin.settings.rewriteSelectionModel,
+ rewriteSelectionEffort: this.plugin.settings.rewriteSelectionEffort,
+ models: this.dynamicData.modelMetadata(),
+ modelLoadFailed: dynamicData.modelsLifecycle.kind === "failed",
+ modelStatus: dynamicData.modelsLifecycle.status,
+ onThreadNamingModelChange: (value) => void this.setThreadNamingModel(value),
+ onThreadNamingEffortChange: (value) => void this.setThreadNamingEffort(value),
+ onRewriteSelectionModelChange: (value) => void this.setRewriteSelectionModel(value),
+ onRewriteSelectionEffortChange: (value) => void this.setRewriteSelectionEffort(value),
+ },
+ archived: {
+ exportEnabled: this.plugin.settings.archiveExportEnabled,
+ exportFolderTemplate: this.plugin.settings.archiveExportFolderTemplate,
+ exportFilenameTemplate: this.plugin.settings.archiveExportFilenameTemplate,
+ exportTags: this.plugin.settings.archiveExportTags,
+ threads: dynamicData.archivedThreads,
+ loaded: dynamicData.archivedThreadsLifecycle.kind === "loaded",
+ loading: dynamicData.archivedThreadsLifecycle.kind === "loading",
+ status: dynamicData.archivedThreadsLifecycle.status,
+ deleteConfirmThreadId: this.archivedDeleteConfirmThreadId,
+ onExportEnabledChange: (enabled) => void this.setArchiveExportEnabled(enabled),
+ onExportFolderTemplateChange: (value) => void this.setArchiveExportFolderTemplate(value),
+ onExportFilenameTemplateChange: (value) => void this.setArchiveExportFilenameTemplate(value),
+ onExportTagsChange: (value) => void this.setArchiveExportTags(value),
+ onRestore: (threadId) => {
+ this.archivedDeleteConfirmThreadId = null;
+ this.renderDynamicSections();
+ void this.dynamicData.restoreArchivedThread(threadId);
+ },
+ onStartDelete: (threadId) => {
+ this.archivedDeleteConfirmThreadId = threadId;
+ this.renderDynamicSections();
+ },
+ onDelete: (threadId) => {
+ this.archivedDeleteConfirmThreadId = null;
+ this.renderDynamicSections();
+ void this.dynamicData.deleteArchivedThread(threadId);
+ },
+ },
+ hooks: {
+ hooks: dynamicData.hooks,
+ warnings: dynamicData.hookWarnings,
+ errors: dynamicData.hookErrors,
+ loaded: dynamicData.hooksLifecycle.kind === "loaded",
+ loading: dynamicData.hooksLifecycle.kind === "loading",
+ status: dynamicData.hooksLifecycle.status,
+ onTrust: (hook) => void this.dynamicData.trustHook(hook),
+ onToggleEnabled: (hook, enabled) => void this.dynamicData.setHookEnabled(hook, enabled),
+ },
+ };
}
private renderHeaderActions(containerEl: HTMLElement, introText: string): void {
@@ -283,10 +222,21 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.dynamicData.maybeAutoLoadSettingsData();
}
+ private async setCodexPath(value: string): Promise {
+ const codexPath = value.trim() || DEFAULT_CODEX_PATH;
+ if (codexPath === this.plugin.settings.codexPath) return;
+ this.plugin.settings.codexPath = codexPath;
+ await this.plugin.saveSettings();
+ this.dynamicData.resetSettingsDataContext();
+ this.plugin.appServerData.notifyContextChanged();
+ this.plugin.refreshOpenViews();
+ this.renderSettingsTab({ autoLoadCodexData: false });
+ }
+
private async setArchiveExportEnabled(enabled: boolean): Promise {
this.plugin.settings.archiveExportEnabled = enabled;
await this.plugin.saveSettings();
- this.display();
+ this.renderDynamicSections();
}
private async setArchiveExportFolderTemplate(value: string): Promise {
@@ -303,6 +253,34 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.plugin.settings.archiveExportTags = value.trim();
await this.plugin.saveSettings();
}
+
+ private async setThreadNamingModel(value: string | null): Promise {
+ this.plugin.settings.threadNamingModel = value;
+ if (!this.dynamicData.namingEffortSupported(this.plugin.settings.threadNamingEffort)) {
+ this.plugin.settings.threadNamingEffort = null;
+ }
+ await this.plugin.saveSettings();
+ this.renderDynamicSections();
+ }
+
+ private async setThreadNamingEffort(value: ReasoningEffort | null): Promise {
+ this.plugin.settings.threadNamingEffort = value;
+ await this.plugin.saveSettings();
+ }
+
+ private async setRewriteSelectionModel(value: string | null): Promise {
+ this.plugin.settings.rewriteSelectionModel = value;
+ if (!this.dynamicData.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) {
+ this.plugin.settings.rewriteSelectionEffort = null;
+ }
+ await this.plugin.saveSettings();
+ this.renderDynamicSections();
+ }
+
+ private async setRewriteSelectionEffort(value: ReasoningEffort | null): Promise {
+ this.plugin.settings.rewriteSelectionEffort = value;
+ await this.plugin.saveSettings();
+ }
}
export interface CodexPanelSettingTabHost extends SettingsDynamicDataHost {
diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts
index c4d2715f..a346db83 100644
--- a/tests/settings/settings-tab.test.ts
+++ b/tests/settings/settings-tab.test.ts
@@ -151,6 +151,10 @@ describe("settings tab", () => {
expect(folder.getAttribute("aria-label")).toBeNull();
expect(filename.getAttribute("aria-label")).toBeNull();
expect(tags.getAttribute("aria-label")).toBeNull();
+ expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
+ expect(folder.type).toBe("text");
+ expect(filename.type).toBe("text");
+ expect(tags.type).toBe("text");
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
@@ -232,6 +236,13 @@ describe("settings tab", () => {
codexInput.dispatchEvent(new Event("change"));
await flushPromises();
+ expect(saveSettings).not.toHaveBeenCalled();
+ expect(notifyContextChanged).not.toHaveBeenCalled();
+ expect(refreshOpenViews).not.toHaveBeenCalled();
+
+ codexInput.dispatchEvent(new FocusEvent("blur"));
+ await flushPromises();
+
expect(saveSettings).toHaveBeenCalledOnce();
expect(notifyContextChanged).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
@@ -447,6 +458,8 @@ describe("settings tab", () => {
expect(selectOptions(tab, "Automatic thread naming", 1)).toEqual(["Codex default", "saved-custom-effort (saved)", "extreme"]);
expect(selectOptions(tab, "Selection rewrite", 1)).toEqual(["Codex default", "extreme"]);
+ expect(selectForSetting(tab, "Automatic thread naming")?.classList.contains("dropdown")).toBe(true);
+ expect(selectForSetting(tab, "Selection rewrite")?.classList.contains("dropdown")).toBe(true);
});
it("keeps successful sections when one settings data request fails", async () => {
@@ -525,6 +538,39 @@ describe("settings tab", () => {
expect(client.deleteThread).not.toHaveBeenCalled();
});
+ it("keeps the settings shell mounted while dynamic sections update", async () => {
+ const saveSettings = vi.fn().mockResolvedValue(undefined);
+ const client = settingsClient({
+ threads: [appServerThread({ id: "thread-archived", preview: "Archived thread" })],
+ });
+ withShortLivedAppServerClientMock.mockImplementation(
+ (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client),
+ );
+ const tab = newSettingsTab({ saveSettings });
+
+ tab.display();
+ await flushPromises();
+
+ const codexInput = inputForSetting(tab, "Codex executable");
+ const shortcut = selectForSetting(tab, "Send shortcut");
+ const archiveToggle = inputForSetting(tab, "Save note by default");
+ if (!codexInput || !shortcut || !archiveToggle) throw new Error("Missing settings controls");
+
+ archiveToggle.checked = true;
+ archiveToggle.dispatchEvent(new Event("change"));
+ await flushPromises();
+
+ expect(saveSettings).toHaveBeenCalledOnce();
+ expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
+ expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
+
+ clickButtonByLabel(tab, "Delete Archived thread");
+
+ expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
+ expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
+ expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
+ });
+
it("permanently deletes an archived thread from the confirmed settings row", async () => {
const invalidateThreadsFromOpenSurface = vi.fn();
const client = settingsClient({
@@ -539,6 +585,7 @@ describe("settings tab", () => {
await flushPromises();
clickButtonByLabel(tab, "Delete Archived thread");
+ pointerDownButtonByLabel(tab, "Confirm permanent delete Archived thread");
clickButtonByLabel(tab, "Confirm permanent delete Archived thread");
await flushPromises();
@@ -730,7 +777,7 @@ async function flushPromises(): Promise {
}
function settingNames(tab: CodexPanelSettingTab): string[] {
- return Array.from(tab.containerEl.children).flatMap((element) => {
+ return Array.from(settingsSectionRoots(tab)).flatMap((element) => {
if (element.classList.contains("setting-item")) {
return [element.querySelector(".setting-item-name")?.textContent ?? ""];
}
@@ -746,6 +793,13 @@ function settingNames(tab: CodexPanelSettingTab): string[] {
});
}
+function settingsSectionRoots(tab: CodexPanelSettingTab): Element[] {
+ return Array.from(tab.containerEl.children).flatMap((element) => {
+ if (element.classList.contains("codex-panel-settings__preact-sections")) return Array.from(element.children);
+ return [element];
+ });
+}
+
function settingDesc(tab: CodexPanelSettingTab, name: string): string {
const setting = Array.from(tab.containerEl.querySelectorAll(".setting-item")).find(
(element) => element.querySelector(".setting-item-name")?.textContent === name,
@@ -762,9 +816,17 @@ function buttonLabels(tab: CodexPanelSettingTab): string[] {
}
function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
+ buttonByLabel(tab, label).click();
+}
+
+function pointerDownButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
+ buttonByLabel(tab, label).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
+}
+
+function buttonByLabel(tab: CodexPanelSettingTab, label: string): HTMLButtonElement {
const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.ariaLabel === label);
if (!button) throw new Error(`Could not find button: ${label}`);
- button.click();
+ return button;
}
function inputForSetting(tab: CodexPanelSettingTab, name: string): HTMLInputElement | null {