Add rewrite selection runtime settings

This commit is contained in:
murashit 2026-05-18 11:55:43 +09:00
parent 084cdd4573
commit 2dd6043340
11 changed files with 132 additions and 84 deletions

View file

@ -34,7 +34,6 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH
},
originalText,
noteText: editor.getValue(),
contextMode: "note",
instruction: "",
status: "editing-prompt",
streamText: "",

View file

@ -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<RewriteContextMode, "note-and-thread">, label: string): void {
this.contextEl?.createEl("option", { text: label, attr: { value } });
}
private async generate(): Promise<void> {
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;
}

View file

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

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

@ -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<void> {
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);
}
}

View file

@ -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> = {}): RewriteSession {
},
originalText: "Rewrite this sentence.",
noteText: "# Heading\n\nRewrite this sentence.\n\nNext paragraph.",
contextMode: "note",
instruction: "Make it clearer.",
status: "editing-prompt",
streamText: "",

View file

@ -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<void>; sendShort
codexPath: "codex",
threadNamingModel: null,
threadNamingEffort: null,
rewriteSelectionModel: null,
rewriteSelectionEffort: null,
sendShortcut: options.sendShortcut ?? "enter",
},
vaultPath: "/vault",

View file

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