From 2dd60433404bc864bb34c2ac7739f8ac414c01bc Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 18 May 2026 11:55:43 +0900 Subject: [PATCH] Add rewrite selection runtime settings --- src/editor-rewrite/command.ts | 1 - src/editor-rewrite/modal.ts | 19 +---- src/editor-rewrite/model.ts | 3 - src/editor-rewrite/prompt.ts | 19 +---- src/panel/thread-naming.ts | 16 +--- src/runtime/model.ts | 28 +++++++ src/settings/model.ts | 14 +++- src/settings/tab.ts | 83 +++++++++++++++------ tests/editor-rewrite/editor-rewrite.test.ts | 13 +--- tests/settings/settings-tab.test.ts | 3 + tests/settings/settings.test.ts | 17 +++++ 11 files changed, 132 insertions(+), 84 deletions(-) diff --git a/src/editor-rewrite/command.ts b/src/editor-rewrite/command.ts index ea3a2c7f..ec53e752 100644 --- a/src/editor-rewrite/command.ts +++ b/src/editor-rewrite/command.ts @@ -34,7 +34,6 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH }, originalText, noteText: editor.getValue(), - contextMode: "note", instruction: "", status: "editing-prompt", streamText: "", diff --git a/src/editor-rewrite/modal.ts b/src/editor-rewrite/modal.ts index 560be501..022a5917 100644 --- a/src/editor-rewrite/modal.ts +++ b/src/editor-rewrite/modal.ts @@ -2,7 +2,7 @@ import { Modal, Notice, type App, type Editor } from "obsidian"; import { renderUnifiedDiff } from "../ui/turn-diff"; import { buildSelectionUnifiedDiff } from "./diff"; -import { canApplyRewrite, type RewriteContextMode, type RewriteSession } from "./model"; +import { canApplyRewrite, type RewriteSession } from "./model"; import { buildRewritePrompt } from "./prompt"; import { runRewriteSelection } from "./runner"; @@ -15,7 +15,6 @@ export interface RewriteSelectionModalOptions { export class RewriteSelectionModal extends Modal { private instructionEl: HTMLTextAreaElement | null = null; - private contextEl: HTMLSelectElement | null = null; private generateButton: HTMLButtonElement | null = null; private applyButton: HTMLButtonElement | null = null; private statusEl: HTMLElement | null = null; @@ -43,16 +42,6 @@ export class RewriteSelectionModal extends Modal { this.instructionEl.oninput = () => this.syncControls(); const controls = contentEl.createDiv({ cls: "codex-panel-rewrite__controls" }); - const contextLabel = controls.createEl("label", { cls: "codex-panel-rewrite__context" }); - contextLabel.createSpan({ text: "Context" }); - this.contextEl = contextLabel.createEl("select"); - this.addContextOption("note", "Selection + note context"); - this.addContextOption("selection", "Selection only"); - this.contextEl.value = this.options.session.contextMode === "selection" ? "selection" : "note"; - this.contextEl.onchange = () => { - this.options.session.contextMode = this.contextEl?.value === "selection" ? "selection" : "note"; - }; - const actions = controls.createDiv({ cls: "codex-panel-rewrite__actions" }); this.generateButton = actions.createEl("button", { text: "Generate", attr: { type: "button" } }); this.generateButton.onclick = () => void this.generate(); @@ -83,10 +72,6 @@ export class RewriteSelectionModal extends Modal { this.contentEl.empty(); } - private addContextOption(value: Exclude, label: string): void { - this.contextEl?.createEl("option", { text: label, attr: { value } }); - } - private async generate(): Promise { const instruction = this.instructionEl?.value.trim() ?? ""; if (!instruction) { @@ -96,7 +81,6 @@ export class RewriteSelectionModal extends Modal { } this.options.session.instruction = instruction; - this.options.session.contextMode = this.contextEl?.value === "selection" ? "selection" : "note"; this.options.session.status = "generating"; this.options.session.streamText = ""; this.options.session.replacementText = null; @@ -163,7 +147,6 @@ export class RewriteSelectionModal extends Modal { const generating = this.options.session.status === "generating"; const hasInstruction = Boolean(this.instructionEl?.value.trim()); if (this.instructionEl) this.instructionEl.disabled = generating; - if (this.contextEl) this.contextEl.disabled = generating; if (this.generateButton) this.generateButton.disabled = generating || !hasInstruction; if (this.applyButton) this.applyButton.disabled = generating || this.options.session.replacementText === null; } diff --git a/src/editor-rewrite/model.ts b/src/editor-rewrite/model.ts index 605ddebb..525d5c86 100644 --- a/src/editor-rewrite/model.ts +++ b/src/editor-rewrite/model.ts @@ -1,7 +1,5 @@ import type { EditorPosition } from "obsidian"; -export type RewriteContextMode = "selection" | "note" | "note-and-thread"; - export type RewriteStatus = "editing-prompt" | "generating" | "preview" | "applied" | "cancelled" | "failed"; export interface RewriteSession { @@ -12,7 +10,6 @@ export interface RewriteSession { }; originalText: string; noteText: string; - contextMode: RewriteContextMode; instruction: string; status: RewriteStatus; streamText: string; diff --git a/src/editor-rewrite/prompt.ts b/src/editor-rewrite/prompt.ts index 367a8d0e..ce630912 100644 --- a/src/editor-rewrite/prompt.ts +++ b/src/editor-rewrite/prompt.ts @@ -1,4 +1,4 @@ -import type { RewriteContextMode, RewriteSession } from "./model"; +import type { RewriteSession } from "./model"; const MAX_NOTE_CONTEXT_CHARS = 20_000; @@ -25,32 +25,19 @@ export function buildRewritePrompt(session: RewriteSession): string { "Target:", `- File: ${session.filePath}`, `- Selection: ${positionLabel(session.targetRange.from)} to ${positionLabel(session.targetRange.to)}`, - `- Context mode: ${contextModeLabel(session.contextMode)}`, + "- Context mode: Selection + note context", "", "User instruction:", session.instruction, "", "Selected text:", fenced(session.originalText), - ...noteContextLines(session), - ].join("\n"); -} - -function noteContextLines(session: RewriteSession): string[] { - if (session.contextMode === "selection") return []; - return [ "", "Current note context:", fenced(truncateNoteContext(session.noteText)), "", "Reminder: use the note context only to make the selected-text replacement coherent.", - ]; -} - -function contextModeLabel(mode: RewriteContextMode): string { - if (mode === "selection") return "Selection only"; - if (mode === "note") return "Selection + note context"; - return "Selection + note context + active thread"; + ].join("\n"); } function positionLabel(position: { line: number; ch: number }): string { diff --git a/src/panel/thread-naming.ts b/src/panel/thread-naming.ts index cb88519f..de592546 100644 --- a/src/panel/thread-naming.ts +++ b/src/panel/thread-naming.ts @@ -8,7 +8,7 @@ import type { SortDirection } from "../generated/app-server/v2/SortDirection"; import type { ThreadItem } from "../generated/app-server/v2/ThreadItem"; import type { Turn } from "../generated/app-server/v2/Turn"; import { inputToText, truncate } from "../utils"; -import { findModelByIdOrName, supportedEffortsForModel } from "../runtime/model"; +import { runtimeOverride, validatedRuntimeOverride } from "../runtime/model"; const NAMING_SERVICE_NAME = "codex-panel-naming"; const NAMING_TIMEOUT_MS = 60_000; @@ -214,21 +214,11 @@ export interface NamingRuntime { } export function namingRuntime(settings: ThreadNamingRuntimeSettings): NamingRuntime { - return { - ...(settings.threadNamingModel ? { model: settings.threadNamingModel } : {}), - ...(settings.threadNamingEffort ? { effort: settings.threadNamingEffort } : {}), - }; + return runtimeOverride({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }); } export function validatedNamingRuntime(settings: ThreadNamingRuntimeSettings, models: Model[]): NamingRuntime { - const runtime = namingRuntime(settings); - if (!runtime.model || !runtime.effort) return runtime; - - const model = findModelByIdOrName(models, runtime.model); - if (!model) return runtime; - - const supportedEfforts = new Set(supportedEffortsForModel(model)); - return supportedEfforts.has(runtime.effort) ? runtime : { model: runtime.model }; + return validatedRuntimeOverride({ model: settings.threadNamingModel, effort: settings.threadNamingEffort }, models); } function turnWithCollectedItems(turn: Turn, items: ThreadItem[]): Turn { diff --git a/src/runtime/model.ts b/src/runtime/model.ts index 4700623c..499e07a0 100644 --- a/src/runtime/model.ts +++ b/src/runtime/model.ts @@ -26,3 +26,31 @@ export function supportedEffortsForModel(model: Model | null): ReasoningEffort[] const efforts = model?.supportedReasoningEfforts.map((option) => option.reasoningEffort).filter(isReasoningEffort) ?? []; return efforts.length > 0 ? efforts : REASONING_EFFORTS; } + +export interface RuntimeOverrideSettings { + model: string | null; + effort: ReasoningEffort | null; +} + +export interface RuntimeOverride { + model?: string; + effort?: ReasoningEffort; +} + +export function runtimeOverride(settings: RuntimeOverrideSettings): RuntimeOverride { + return { + ...(settings.model ? { model: settings.model } : {}), + ...(settings.effort ? { effort: settings.effort } : {}), + }; +} + +export function validatedRuntimeOverride(settings: RuntimeOverrideSettings, models: Model[]): RuntimeOverride { + const runtime = runtimeOverride(settings); + if (!runtime.model || !runtime.effort) return runtime; + + const model = findModelByIdOrName(models, runtime.model); + if (!model) return runtime; + + const supportedEfforts = new Set(supportedEffortsForModel(model)); + return supportedEfforts.has(runtime.effort) ? runtime : { model: runtime.model }; +} diff --git a/src/settings/model.ts b/src/settings/model.ts index a0cbbe08..b85128c3 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -8,6 +8,8 @@ export interface CodexPanelSettings { codexPath: string; threadNamingModel: string | null; threadNamingEffort: ReasoningEffort | null; + rewriteSelectionModel: string | null; + rewriteSelectionEffort: ReasoningEffort | null; sendShortcut: SendShortcut; } @@ -17,6 +19,8 @@ export const DEFAULT_SETTINGS: CodexPanelSettings = { codexPath: DEFAULT_CODEX_PATH, threadNamingModel: null, threadNamingEffort: null, + rewriteSelectionModel: null, + rewriteSelectionEffort: null, sendShortcut: "enter", }; @@ -26,6 +30,8 @@ export function normalizeSettings(data: unknown): CodexPanelSettings { codexPath: stringOrDefault(record.codexPath, DEFAULT_CODEX_PATH).trim() || DEFAULT_CODEX_PATH, threadNamingModel: threadNamingModelOrDefault(record.threadNamingModel), threadNamingEffort: reasoningEffortOrDefault(record.threadNamingEffort), + rewriteSelectionModel: modelOrDefault(record.rewriteSelectionModel), + rewriteSelectionEffort: reasoningEffortOrDefault(record.rewriteSelectionEffort), sendShortcut: sendShortcutOrDefault(record.sendShortcut), }; } @@ -33,10 +39,12 @@ export function normalizeSettings(data: unknown): CodexPanelSettings { export function settingsMatchNormalizedData(data: unknown, settings: CodexPanelSettings): boolean { const record = asRecord(data); return ( - Object.keys(record).length === 4 && + Object.keys(record).length === 6 && record.codexPath === settings.codexPath && record.threadNamingModel === settings.threadNamingModel && record.threadNamingEffort === settings.threadNamingEffort && + record.rewriteSelectionModel === settings.rewriteSelectionModel && + record.rewriteSelectionEffort === settings.rewriteSelectionEffort && record.sendShortcut === settings.sendShortcut ); } @@ -50,6 +58,10 @@ function stringOrDefault(value: unknown, fallback: string): string { } function threadNamingModelOrDefault(value: unknown): string | null { + return modelOrDefault(value); +} + +function modelOrDefault(value: unknown): string | null { if (typeof value !== "string") return DEFAULT_SETTINGS.threadNamingModel; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 8f1e1fed..34ed74e0 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -33,9 +33,9 @@ export class CodexPanelSettingTab extends PluginSettingTab { private hooksLoaded = false; private hooksLoading = false; private hooksStatus = ""; - private namingModels: Model[] = []; - private namingModelsLoading = false; - private namingModelsStatus = ""; + private models: Model[] = []; + private modelsLoading = false; + private modelsStatus = ""; constructor( app: App, @@ -102,7 +102,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { .setDesc("Model and reasoning effort used only for automatic thread titles.") .addDropdown((dropdown) => { const current = this.plugin.settings.threadNamingModel; - const options = this.namingModelOptions(); + const options = this.modelOptions(); dropdown.selectEl.ariaLabel = "Thread naming model"; dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); if (current && !options.some((model) => model.model === current || model.id === current)) { @@ -122,7 +122,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { }) .addDropdown((dropdown) => { const current = this.plugin.settings.threadNamingEffort; - const options = this.namingEffortOptions(); + const options = this.effortOptions(this.plugin.settings.threadNamingModel); dropdown.selectEl.ariaLabel = "Thread naming effort"; dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); for (const effort of options) { @@ -133,10 +133,47 @@ export class CodexPanelSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); - if (this.namingModelsLoading || (this.namingModelsStatus && !this.namingModelsStatus.startsWith("Loaded "))) { + + new Setting(configSection) + .setName("Rewrite selection model") + .setDesc("Model and reasoning effort used only for selection rewrites.") + .addDropdown((dropdown) => { + const current = this.plugin.settings.rewriteSelectionModel; + const options = this.modelOptions(); + dropdown.selectEl.ariaLabel = "Rewrite selection model"; + 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.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.effortOptions(this.plugin.settings.rewriteSelectionModel); + dropdown.selectEl.ariaLabel = "Rewrite selection effort"; + dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); + for (const effort of options) { + dropdown.addOption(effort, effort); + } + dropdown.setValue(current && options.includes(current) ? current : CODEX_DEFAULT_VALUE).onChange(async (value) => { + this.plugin.settings.rewriteSelectionEffort = value === CODEX_DEFAULT_VALUE ? null : (value as ReasoningEffort); + await this.plugin.saveSettings(); + }); + }); + if (this.modelsLoading || (this.modelsStatus && !this.modelsStatus.startsWith("Loaded "))) { configSection.createEl("p", { cls: "setting-item-description codex-panel-settings__section-status", - text: this.namingModelsStatus || "Loading models...", + text: this.modelsStatus || "Loading models...", }); } @@ -170,10 +207,10 @@ export class CodexPanelSettingTab extends PluginSettingTab { private async refreshSettingsData(): Promise { this.settingsDataLoading = true; - this.namingModelsLoading = true; + this.modelsLoading = true; this.archivedThreadsLoading = true; this.hooksLoading = true; - this.namingModelsStatus = "Loading models..."; + this.modelsStatus = "Loading models..."; this.archivedThreadsStatus = "Loading archived threads..."; this.hooksStatus = "Loading hooks..."; this.display(); @@ -183,11 +220,11 @@ export class CodexPanelSettingTab extends PluginSettingTab { const result = await this.withSettingsSession((client) => loadSettingsData(client, this.plugin.vaultPath)); if (result.models.ok) { - this.namingModels = result.models.data; - this.namingModelsStatus = result.models.status; + this.models = result.models.data; + this.modelsStatus = result.models.status; } else { failedCount += 1; - this.namingModelsStatus = result.models.status; + this.modelsStatus = result.models.status; } if (result.hooks.ok) { @@ -212,12 +249,12 @@ export class CodexPanelSettingTab extends PluginSettingTab { } catch (error) { failedCount = 3; const message = errorMessage(error); - this.namingModelsStatus = `Could not load models: ${message}`; + this.modelsStatus = `Could not load models: ${message}`; this.hooksStatus = `Could not load hooks: ${message}`; this.archivedThreadsStatus = `Could not load archived threads: ${message}`; } finally { this.settingsDataLoading = false; - this.namingModelsLoading = false; + this.modelsLoading = false; this.archivedThreadsLoading = false; this.hooksLoading = false; if (failedCount > 0) { @@ -302,20 +339,24 @@ export class CodexPanelSettingTab extends PluginSettingTab { return withAppServerSession(this.plugin.settings.codexPath, this.plugin.vaultPath, operation); } - private namingModelOptions(): Model[] { - return sortedAvailableModels(this.namingModels); + private modelOptions(): Model[] { + return sortedAvailableModels(this.models); } - private namingEffortOptions(): ReasoningEffort[] { - const model = this.selectedNamingModel(); + private effortOptions(modelIdOrName: string | null): ReasoningEffort[] { + const model = this.selectedModel(modelIdOrName); return model ? supportedEffortsForModel(model) : REASONING_EFFORTS; } private namingEffortSupported(effort: ReasoningEffort | null): boolean { - return !effort || this.namingEffortOptions().includes(effort); + return !effort || this.effortOptions(this.plugin.settings.threadNamingModel).includes(effort); } - private selectedNamingModel(): Model | null { - return findModelByIdOrName(this.namingModels, this.plugin.settings.threadNamingModel); + private rewriteSelectionEffortSupported(effort: ReasoningEffort | null): boolean { + return !effort || this.effortOptions(this.plugin.settings.rewriteSelectionModel).includes(effort); + } + + private selectedModel(modelIdOrName: string | null): Model | null { + return findModelByIdOrName(this.models, modelIdOrName); } } diff --git a/tests/editor-rewrite/editor-rewrite.test.ts b/tests/editor-rewrite/editor-rewrite.test.ts index 0db22631..8fedcb63 100644 --- a/tests/editor-rewrite/editor-rewrite.test.ts +++ b/tests/editor-rewrite/editor-rewrite.test.ts @@ -30,16 +30,8 @@ describe("editor rewrite output", () => { }); describe("editor rewrite prompt", () => { - it("omits note context in selection-only mode", () => { - const prompt = buildRewritePrompt(session({ contextMode: "selection" })); - - expect(prompt).toContain("Selected text:"); - expect(prompt).toContain("Rewrite this sentence."); - expect(prompt).not.toContain("Current note context:"); - }); - - it("includes note context in note mode", () => { - const prompt = buildRewritePrompt(session({ contextMode: "note" })); + it("always includes note context with the selected text", () => { + const prompt = buildRewritePrompt(session()); expect(prompt).toContain("Context mode: Selection + note context"); expect(prompt).toContain("Current note context:"); @@ -84,7 +76,6 @@ function session(overrides: Partial = {}): RewriteSession { }, originalText: "Rewrite this sentence.", noteText: "# Heading\n\nRewrite this sentence.\n\nNext paragraph.", - contextMode: "note", instruction: "Make it clearer.", status: "editing-prompt", streamText: "", diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index f3331056..3ba1fb26 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -95,6 +95,7 @@ describe("settings tab", () => { "Codex executable", "Send shortcut", "Thread naming model", + "Rewrite selection model", "Hook status", "Archived thread list", ]); @@ -285,6 +286,8 @@ function newSettingsTab(options: { saveSettings?: () => Promise; sendShort codexPath: "codex", threadNamingModel: null, threadNamingEffort: null, + rewriteSelectionModel: null, + rewriteSelectionEffort: null, sendShortcut: options.sendShortcut ?? "enter", }, vaultPath: "/vault", diff --git a/tests/settings/settings.test.ts b/tests/settings/settings.test.ts index 17440bb9..c3983aa8 100644 --- a/tests/settings/settings.test.ts +++ b/tests/settings/settings.test.ts @@ -19,6 +19,8 @@ describe("settings", () => { codexPath: "/usr/local/bin/codex", threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low", + rewriteSelectionModel: "gpt-5.4-mini", + rewriteSelectionEffort: "minimal", sendShortcut: "mod-enter", model: "gpt-5.5", sandboxMode: "workspace-write", @@ -30,6 +32,8 @@ describe("settings", () => { codexPath: "/usr/local/bin/codex", threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low", + rewriteSelectionModel: "gpt-5.4-mini", + rewriteSelectionEffort: "minimal", sendShortcut: "mod-enter", }; @@ -46,6 +50,8 @@ describe("settings", () => { codexPath: "/usr/local/bin/codex", threadNamingModel: "gpt-5.4-mini", threadNamingEffort: "low", + rewriteSelectionModel: "gpt-5.4-mini", + rewriteSelectionEffort: "minimal", sendShortcut: "mod-enter", }); expect(settingsMatchNormalizedData({ ...settings }, settings)).toBe(true); @@ -64,6 +70,17 @@ describe("settings", () => { }); }); + it("normalizes current rewrite selection runtime settings", () => { + expect(normalizeSettings({ rewriteSelectionModel: " gpt-5.4-mini ", rewriteSelectionEffort: "minimal" })).toMatchObject({ + rewriteSelectionModel: "gpt-5.4-mini", + rewriteSelectionEffort: "minimal", + }); + expect(normalizeSettings({ rewriteSelectionModel: 1, rewriteSelectionEffort: "invalid" })).toMatchObject({ + rewriteSelectionModel: DEFAULT_SETTINGS.rewriteSelectionModel, + rewriteSelectionEffort: DEFAULT_SETTINGS.rewriteSelectionEffort, + }); + }); + it("normalizes the send shortcut", () => { expect(normalizeSettings({ sendShortcut: "mod-enter" }).sendShortcut).toBe("mod-enter"); expect(normalizeSettings({ sendShortcut: "invalid" }).sendShortcut).toBe(DEFAULT_SETTINGS.sendShortcut);